affinity-sdk 0.9.5__py3-none-any.whl
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.
- affinity/__init__.py +139 -0
- affinity/cli/__init__.py +7 -0
- affinity/cli/click_compat.py +27 -0
- affinity/cli/commands/__init__.py +1 -0
- affinity/cli/commands/_entity_files_dump.py +219 -0
- affinity/cli/commands/_list_entry_fields.py +41 -0
- affinity/cli/commands/_v1_parsing.py +77 -0
- affinity/cli/commands/company_cmds.py +2139 -0
- affinity/cli/commands/completion_cmd.py +33 -0
- affinity/cli/commands/config_cmds.py +540 -0
- affinity/cli/commands/entry_cmds.py +33 -0
- affinity/cli/commands/field_cmds.py +413 -0
- affinity/cli/commands/interaction_cmds.py +875 -0
- affinity/cli/commands/list_cmds.py +3152 -0
- affinity/cli/commands/note_cmds.py +433 -0
- affinity/cli/commands/opportunity_cmds.py +1174 -0
- affinity/cli/commands/person_cmds.py +1980 -0
- affinity/cli/commands/query_cmd.py +444 -0
- affinity/cli/commands/relationship_strength_cmds.py +62 -0
- affinity/cli/commands/reminder_cmds.py +595 -0
- affinity/cli/commands/resolve_url_cmd.py +127 -0
- affinity/cli/commands/session_cmds.py +84 -0
- affinity/cli/commands/task_cmds.py +110 -0
- affinity/cli/commands/version_cmd.py +29 -0
- affinity/cli/commands/whoami_cmd.py +36 -0
- affinity/cli/config.py +108 -0
- affinity/cli/context.py +749 -0
- affinity/cli/csv_utils.py +195 -0
- affinity/cli/date_utils.py +42 -0
- affinity/cli/decorators.py +77 -0
- affinity/cli/errors.py +28 -0
- affinity/cli/field_utils.py +355 -0
- affinity/cli/formatters.py +551 -0
- affinity/cli/help_json.py +283 -0
- affinity/cli/logging.py +100 -0
- affinity/cli/main.py +261 -0
- affinity/cli/options.py +53 -0
- affinity/cli/paths.py +32 -0
- affinity/cli/progress.py +183 -0
- affinity/cli/query/__init__.py +163 -0
- affinity/cli/query/aggregates.py +357 -0
- affinity/cli/query/dates.py +194 -0
- affinity/cli/query/exceptions.py +147 -0
- affinity/cli/query/executor.py +1236 -0
- affinity/cli/query/filters.py +248 -0
- affinity/cli/query/models.py +333 -0
- affinity/cli/query/output.py +331 -0
- affinity/cli/query/parser.py +619 -0
- affinity/cli/query/planner.py +430 -0
- affinity/cli/query/progress.py +270 -0
- affinity/cli/query/schema.py +439 -0
- affinity/cli/render.py +1589 -0
- affinity/cli/resolve.py +222 -0
- affinity/cli/resolvers.py +249 -0
- affinity/cli/results.py +308 -0
- affinity/cli/runner.py +218 -0
- affinity/cli/serialization.py +65 -0
- affinity/cli/session_cache.py +276 -0
- affinity/cli/types.py +70 -0
- affinity/client.py +771 -0
- affinity/clients/__init__.py +19 -0
- affinity/clients/http.py +3664 -0
- affinity/clients/pipeline.py +165 -0
- affinity/compare.py +501 -0
- affinity/downloads.py +114 -0
- affinity/exceptions.py +615 -0
- affinity/filters.py +1128 -0
- affinity/hooks.py +198 -0
- affinity/inbound_webhooks.py +302 -0
- affinity/models/__init__.py +163 -0
- affinity/models/entities.py +798 -0
- affinity/models/pagination.py +513 -0
- affinity/models/rate_limit_snapshot.py +48 -0
- affinity/models/secondary.py +413 -0
- affinity/models/types.py +663 -0
- affinity/policies.py +40 -0
- affinity/progress.py +22 -0
- affinity/py.typed +0 -0
- affinity/services/__init__.py +42 -0
- affinity/services/companies.py +1286 -0
- affinity/services/lists.py +1892 -0
- affinity/services/opportunities.py +1330 -0
- affinity/services/persons.py +1348 -0
- affinity/services/rate_limits.py +173 -0
- affinity/services/tasks.py +193 -0
- affinity/services/v1_only.py +2445 -0
- affinity/types.py +83 -0
- affinity_sdk-0.9.5.dist-info/METADATA +622 -0
- affinity_sdk-0.9.5.dist-info/RECORD +92 -0
- affinity_sdk-0.9.5.dist-info/WHEEL +4 -0
- affinity_sdk-0.9.5.dist-info/entry_points.txt +2 -0
- affinity_sdk-0.9.5.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1330 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Opportunity service.
|
|
3
|
+
|
|
4
|
+
Opportunities can be retrieved via v2 endpoints, but full "row" data (fields)
|
|
5
|
+
is available via list entries.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import builtins
|
|
11
|
+
from collections.abc import AsyncIterator, Iterator, Sequence
|
|
12
|
+
from typing import TYPE_CHECKING, Any, Literal, NamedTuple
|
|
13
|
+
|
|
14
|
+
from ..exceptions import AffinityError
|
|
15
|
+
from ..models.entities import (
|
|
16
|
+
Company,
|
|
17
|
+
Opportunity,
|
|
18
|
+
OpportunityCreate,
|
|
19
|
+
OpportunityUpdate,
|
|
20
|
+
Person,
|
|
21
|
+
)
|
|
22
|
+
from ..models.pagination import (
|
|
23
|
+
AsyncPageIterator,
|
|
24
|
+
PageIterator,
|
|
25
|
+
PaginatedResponse,
|
|
26
|
+
PaginationInfo,
|
|
27
|
+
V1PaginatedResponse,
|
|
28
|
+
)
|
|
29
|
+
from ..models.types import CompanyId, ListId, OpportunityId, PersonId
|
|
30
|
+
from .lists import AsyncListEntryService, ListEntryService
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from ..clients.http import AsyncHTTPClient, HTTPClient
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class OpportunityAssociations(NamedTuple):
|
|
37
|
+
"""Person and company associations for an opportunity."""
|
|
38
|
+
|
|
39
|
+
person_ids: builtins.list[PersonId]
|
|
40
|
+
company_ids: builtins.list[CompanyId]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class OpportunityService:
|
|
44
|
+
"""
|
|
45
|
+
Service for managing opportunities.
|
|
46
|
+
|
|
47
|
+
Notes:
|
|
48
|
+
- V2 opportunity endpoints may return partial representations (e.g. name and
|
|
49
|
+
listId only). The SDK does not perform hidden follow-up calls to "complete"
|
|
50
|
+
an opportunity.
|
|
51
|
+
- For full opportunity row data (including list fields), use list entries
|
|
52
|
+
explicitly via `client.lists.entries(list_id)`.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(self, client: HTTPClient):
|
|
56
|
+
self._client = client
|
|
57
|
+
|
|
58
|
+
# =========================================================================
|
|
59
|
+
# Read Operations (V2 API by default)
|
|
60
|
+
# =========================================================================
|
|
61
|
+
|
|
62
|
+
def get(self, opportunity_id: OpportunityId) -> Opportunity:
|
|
63
|
+
"""
|
|
64
|
+
Get a single opportunity by ID.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
opportunity_id: The opportunity ID
|
|
68
|
+
|
|
69
|
+
Returns:
|
|
70
|
+
The opportunity representation returned by v2 (may be partial).
|
|
71
|
+
"""
|
|
72
|
+
data = self._client.get(f"/opportunities/{opportunity_id}")
|
|
73
|
+
return Opportunity.model_validate(data)
|
|
74
|
+
|
|
75
|
+
def get_details(self, opportunity_id: OpportunityId) -> Opportunity:
|
|
76
|
+
"""
|
|
77
|
+
Get a single opportunity by ID with a more complete representation.
|
|
78
|
+
|
|
79
|
+
Includes association IDs and (when present) list entries, which are not
|
|
80
|
+
always included in the default `get()` response.
|
|
81
|
+
|
|
82
|
+
See Also:
|
|
83
|
+
- :meth:`get_associated_person_ids`: Get just person IDs (single API call)
|
|
84
|
+
- :meth:`get_associated_people`: Get full Person objects
|
|
85
|
+
- :meth:`get_associated_company_ids`: Get just company IDs (single API call)
|
|
86
|
+
- :meth:`get_associated_companies`: Get full Company objects
|
|
87
|
+
- :meth:`get_associations`: Get both person and company IDs in one call
|
|
88
|
+
"""
|
|
89
|
+
# Uses the v1 endpoint because it returns a fuller payload (including
|
|
90
|
+
# association IDs and, when present, list entries).
|
|
91
|
+
data = self._client.get(f"/opportunities/{opportunity_id}", v1=True)
|
|
92
|
+
return Opportunity.model_validate(data)
|
|
93
|
+
|
|
94
|
+
def list(
|
|
95
|
+
self,
|
|
96
|
+
*,
|
|
97
|
+
ids: Sequence[OpportunityId] | None = None,
|
|
98
|
+
limit: int | None = None,
|
|
99
|
+
cursor: str | None = None,
|
|
100
|
+
) -> PaginatedResponse[Opportunity]:
|
|
101
|
+
"""
|
|
102
|
+
List all opportunities.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
ids: Specific opportunity IDs to fetch (batch lookup)
|
|
106
|
+
limit: Maximum number of results per page
|
|
107
|
+
cursor: Cursor to resume pagination (opaque; obtained from prior responses)
|
|
108
|
+
|
|
109
|
+
Returns the v2 opportunity representation (which may be partial).
|
|
110
|
+
For full opportunity row data, use list entries explicitly.
|
|
111
|
+
"""
|
|
112
|
+
if cursor is not None:
|
|
113
|
+
if ids is not None or limit is not None:
|
|
114
|
+
raise ValueError(
|
|
115
|
+
"Cannot combine 'cursor' with other parameters; cursor encodes all query "
|
|
116
|
+
"context. Start a new pagination sequence without a cursor to change "
|
|
117
|
+
"parameters."
|
|
118
|
+
)
|
|
119
|
+
data = self._client.get_url(cursor)
|
|
120
|
+
else:
|
|
121
|
+
params: dict[str, Any] = {}
|
|
122
|
+
if ids:
|
|
123
|
+
params["ids"] = [int(id_) for id_ in ids]
|
|
124
|
+
if limit is not None:
|
|
125
|
+
params["limit"] = limit
|
|
126
|
+
data = self._client.get("/opportunities", params=params or None)
|
|
127
|
+
|
|
128
|
+
return PaginatedResponse[Opportunity](
|
|
129
|
+
data=[Opportunity.model_validate(item) for item in data.get("data", [])],
|
|
130
|
+
pagination=PaginationInfo.model_validate(data.get("pagination", {})),
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
def pages(
|
|
134
|
+
self,
|
|
135
|
+
*,
|
|
136
|
+
ids: Sequence[OpportunityId] | None = None,
|
|
137
|
+
limit: int | None = None,
|
|
138
|
+
cursor: str | None = None,
|
|
139
|
+
) -> Iterator[PaginatedResponse[Opportunity]]:
|
|
140
|
+
"""
|
|
141
|
+
Iterate opportunity pages (not items), yielding `PaginatedResponse[Opportunity]`.
|
|
142
|
+
|
|
143
|
+
This is useful for ETL scripts that want checkpoint/resume via `page.next_cursor`.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
ids: Specific opportunity IDs to fetch (batch lookup)
|
|
147
|
+
limit: Maximum results per page
|
|
148
|
+
cursor: Cursor to resume pagination
|
|
149
|
+
"""
|
|
150
|
+
other_params = (ids, limit)
|
|
151
|
+
if cursor is not None and any(p is not None for p in other_params):
|
|
152
|
+
raise ValueError(
|
|
153
|
+
"Cannot combine 'cursor' with other parameters; cursor encodes all query context. "
|
|
154
|
+
"Start a new pagination sequence without a cursor to change parameters."
|
|
155
|
+
)
|
|
156
|
+
requested_cursor = cursor
|
|
157
|
+
page = self.list(cursor=cursor) if cursor is not None else self.list(ids=ids, limit=limit)
|
|
158
|
+
while True:
|
|
159
|
+
yield page
|
|
160
|
+
if not page.has_next:
|
|
161
|
+
return
|
|
162
|
+
next_cursor = page.next_cursor
|
|
163
|
+
if next_cursor is None or next_cursor == requested_cursor:
|
|
164
|
+
return
|
|
165
|
+
requested_cursor = next_cursor
|
|
166
|
+
page = self.list(cursor=next_cursor)
|
|
167
|
+
|
|
168
|
+
def all(
|
|
169
|
+
self,
|
|
170
|
+
*,
|
|
171
|
+
ids: Sequence[OpportunityId] | None = None,
|
|
172
|
+
) -> Iterator[Opportunity]:
|
|
173
|
+
"""
|
|
174
|
+
Iterate through all opportunities with automatic pagination.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
ids: Specific opportunity IDs to fetch (batch lookup)
|
|
178
|
+
"""
|
|
179
|
+
|
|
180
|
+
def fetch_page(next_url: str | None) -> PaginatedResponse[Opportunity]:
|
|
181
|
+
if next_url:
|
|
182
|
+
data = self._client.get_url(next_url)
|
|
183
|
+
return PaginatedResponse[Opportunity](
|
|
184
|
+
data=[Opportunity.model_validate(item) for item in data.get("data", [])],
|
|
185
|
+
pagination=PaginationInfo.model_validate(data.get("pagination", {})),
|
|
186
|
+
)
|
|
187
|
+
return self.list(ids=ids)
|
|
188
|
+
|
|
189
|
+
return PageIterator(fetch_page)
|
|
190
|
+
|
|
191
|
+
def iter(
|
|
192
|
+
self,
|
|
193
|
+
*,
|
|
194
|
+
ids: Sequence[OpportunityId] | None = None,
|
|
195
|
+
) -> Iterator[Opportunity]:
|
|
196
|
+
"""
|
|
197
|
+
Auto-paginate all opportunities.
|
|
198
|
+
|
|
199
|
+
Alias for `all()` (FR-006 public contract).
|
|
200
|
+
"""
|
|
201
|
+
return self.all(ids=ids)
|
|
202
|
+
|
|
203
|
+
# =========================================================================
|
|
204
|
+
# Search (V1 API)
|
|
205
|
+
# =========================================================================
|
|
206
|
+
|
|
207
|
+
def search(
|
|
208
|
+
self,
|
|
209
|
+
term: str | None = None,
|
|
210
|
+
*,
|
|
211
|
+
page_size: int | None = None,
|
|
212
|
+
page_token: str | None = None,
|
|
213
|
+
) -> V1PaginatedResponse[Opportunity]:
|
|
214
|
+
"""
|
|
215
|
+
Search for opportunities by name.
|
|
216
|
+
|
|
217
|
+
Uses V1 API for search functionality.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
term: Search term (matches opportunity name). If None, returns all.
|
|
221
|
+
page_size: Results per page (max 500)
|
|
222
|
+
page_token: Pagination token
|
|
223
|
+
|
|
224
|
+
Returns:
|
|
225
|
+
V1PaginatedResponse with opportunities and next_page_token
|
|
226
|
+
"""
|
|
227
|
+
params: dict[str, Any] = {}
|
|
228
|
+
if term:
|
|
229
|
+
params["term"] = term
|
|
230
|
+
if page_size:
|
|
231
|
+
params["page_size"] = page_size
|
|
232
|
+
if page_token:
|
|
233
|
+
params["page_token"] = page_token
|
|
234
|
+
|
|
235
|
+
data = self._client.get("/opportunities", params=params, v1=True)
|
|
236
|
+
items = [Opportunity.model_validate(o) for o in data.get("opportunities", [])]
|
|
237
|
+
return V1PaginatedResponse[Opportunity](
|
|
238
|
+
data=items,
|
|
239
|
+
next_page_token=data.get("next_page_token"),
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
def search_pages(
|
|
243
|
+
self,
|
|
244
|
+
term: str | None = None,
|
|
245
|
+
*,
|
|
246
|
+
page_size: int | None = None,
|
|
247
|
+
page_token: str | None = None,
|
|
248
|
+
) -> Iterator[V1PaginatedResponse[Opportunity]]:
|
|
249
|
+
"""
|
|
250
|
+
Iterate V1 opportunity-search result pages.
|
|
251
|
+
|
|
252
|
+
Useful for scripts that need checkpoint/resume via `next_page_token`.
|
|
253
|
+
|
|
254
|
+
Args:
|
|
255
|
+
term: Search term (matches opportunity name). If None, returns all.
|
|
256
|
+
page_size: Results per page (max 500)
|
|
257
|
+
page_token: Resume from this pagination token
|
|
258
|
+
|
|
259
|
+
Yields:
|
|
260
|
+
V1PaginatedResponse[Opportunity] for each page
|
|
261
|
+
"""
|
|
262
|
+
requested_token = page_token
|
|
263
|
+
page = self.search(term, page_size=page_size, page_token=page_token)
|
|
264
|
+
while True:
|
|
265
|
+
yield page
|
|
266
|
+
next_token = page.next_page_token
|
|
267
|
+
if not next_token or next_token == requested_token:
|
|
268
|
+
return
|
|
269
|
+
requested_token = next_token
|
|
270
|
+
page = self.search(term, page_size=page_size, page_token=next_token)
|
|
271
|
+
|
|
272
|
+
def search_all(
|
|
273
|
+
self,
|
|
274
|
+
term: str | None = None,
|
|
275
|
+
*,
|
|
276
|
+
page_size: int | None = None,
|
|
277
|
+
page_token: str | None = None,
|
|
278
|
+
) -> Iterator[Opportunity]:
|
|
279
|
+
"""
|
|
280
|
+
Iterate all V1 opportunity-search results with automatic pagination.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
term: Search term (matches opportunity name). If None, returns all.
|
|
284
|
+
page_size: Results per page (max 500)
|
|
285
|
+
page_token: Resume from this pagination token
|
|
286
|
+
|
|
287
|
+
Yields:
|
|
288
|
+
Each Opportunity individually
|
|
289
|
+
"""
|
|
290
|
+
for page in self.search_pages(term, page_size=page_size, page_token=page_token):
|
|
291
|
+
yield from page.data
|
|
292
|
+
|
|
293
|
+
def resolve(
|
|
294
|
+
self,
|
|
295
|
+
*,
|
|
296
|
+
name: str,
|
|
297
|
+
list_id: ListId,
|
|
298
|
+
limit: int | None = None,
|
|
299
|
+
) -> Opportunity | None:
|
|
300
|
+
"""
|
|
301
|
+
Find a single opportunity by exact name within a specific list.
|
|
302
|
+
|
|
303
|
+
Notes:
|
|
304
|
+
- Opportunities are list-scoped; a list id is required.
|
|
305
|
+
- This iterates list-entry pages client-side (no dedicated search endpoint).
|
|
306
|
+
- If multiple matches exist, returns the first match in server-provided order.
|
|
307
|
+
"""
|
|
308
|
+
name = name.strip()
|
|
309
|
+
if not name:
|
|
310
|
+
raise ValueError("Name cannot be empty")
|
|
311
|
+
name_lower = name.lower()
|
|
312
|
+
|
|
313
|
+
entries = ListEntryService(self._client, list_id)
|
|
314
|
+
for page in entries.pages(limit=limit):
|
|
315
|
+
for entry in page.data:
|
|
316
|
+
entity = entry.entity
|
|
317
|
+
if isinstance(entity, Opportunity) and entity.name.lower() == name_lower:
|
|
318
|
+
return entity
|
|
319
|
+
return None
|
|
320
|
+
|
|
321
|
+
def resolve_all(
|
|
322
|
+
self,
|
|
323
|
+
*,
|
|
324
|
+
name: str,
|
|
325
|
+
list_id: ListId,
|
|
326
|
+
limit: int | None = None,
|
|
327
|
+
) -> builtins.list[Opportunity]:
|
|
328
|
+
"""
|
|
329
|
+
Find all opportunities matching a name within a specific list.
|
|
330
|
+
|
|
331
|
+
Notes:
|
|
332
|
+
- Opportunities are list-scoped; a list id is required.
|
|
333
|
+
- This iterates list-entry pages client-side (no dedicated search endpoint).
|
|
334
|
+
"""
|
|
335
|
+
name = name.strip()
|
|
336
|
+
if not name:
|
|
337
|
+
raise ValueError("Name cannot be empty")
|
|
338
|
+
name_lower = name.lower()
|
|
339
|
+
matches: builtins.list[Opportunity] = []
|
|
340
|
+
|
|
341
|
+
entries = ListEntryService(self._client, list_id)
|
|
342
|
+
for page in entries.pages(limit=limit):
|
|
343
|
+
for entry in page.data:
|
|
344
|
+
entity = entry.entity
|
|
345
|
+
if isinstance(entity, Opportunity) and entity.name.lower() == name_lower:
|
|
346
|
+
matches.append(entity)
|
|
347
|
+
return matches
|
|
348
|
+
|
|
349
|
+
# =========================================================================
|
|
350
|
+
# Write Operations (V1 API)
|
|
351
|
+
# =========================================================================
|
|
352
|
+
|
|
353
|
+
def create(self, data: OpportunityCreate) -> Opportunity:
|
|
354
|
+
"""
|
|
355
|
+
Create a new opportunity.
|
|
356
|
+
|
|
357
|
+
The opportunity will be added to the specified list.
|
|
358
|
+
|
|
359
|
+
Args:
|
|
360
|
+
data: Opportunity creation data including list_id and name
|
|
361
|
+
|
|
362
|
+
Returns:
|
|
363
|
+
The created opportunity
|
|
364
|
+
"""
|
|
365
|
+
payload = data.model_dump(by_alias=True, mode="json", exclude_none=True)
|
|
366
|
+
if not data.person_ids:
|
|
367
|
+
payload.pop("person_ids", None)
|
|
368
|
+
if not data.company_ids:
|
|
369
|
+
payload.pop("organization_ids", None)
|
|
370
|
+
|
|
371
|
+
result = self._client.post("/opportunities", json=payload, v1=True)
|
|
372
|
+
return Opportunity.model_validate(result)
|
|
373
|
+
|
|
374
|
+
def update(self, opportunity_id: OpportunityId, data: OpportunityUpdate) -> Opportunity:
|
|
375
|
+
"""
|
|
376
|
+
Update an existing opportunity.
|
|
377
|
+
|
|
378
|
+
Note: When provided, `person_ids` and `company_ids` replace the existing
|
|
379
|
+
values. To add or remove associations safely, pass the full desired arrays.
|
|
380
|
+
"""
|
|
381
|
+
payload = data.model_dump(
|
|
382
|
+
by_alias=True,
|
|
383
|
+
mode="json",
|
|
384
|
+
exclude_unset=True,
|
|
385
|
+
exclude_none=True,
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
# Uses the v1 endpoint; its PUT semantics replace association arrays.
|
|
389
|
+
result = self._client.put(f"/opportunities/{opportunity_id}", json=payload, v1=True)
|
|
390
|
+
return Opportunity.model_validate(result)
|
|
391
|
+
|
|
392
|
+
def delete(self, opportunity_id: OpportunityId) -> bool:
|
|
393
|
+
"""
|
|
394
|
+
Delete an opportunity.
|
|
395
|
+
|
|
396
|
+
This removes the opportunity and all associated list entries.
|
|
397
|
+
|
|
398
|
+
Args:
|
|
399
|
+
opportunity_id: The opportunity to delete
|
|
400
|
+
|
|
401
|
+
Returns:
|
|
402
|
+
True if successful
|
|
403
|
+
"""
|
|
404
|
+
result = self._client.delete(f"/opportunities/{opportunity_id}", v1=True)
|
|
405
|
+
return bool(result.get("success", False))
|
|
406
|
+
|
|
407
|
+
# =========================================================================
|
|
408
|
+
# Association Methods (V1 API)
|
|
409
|
+
# =========================================================================
|
|
410
|
+
|
|
411
|
+
def get_associated_person_ids(
|
|
412
|
+
self,
|
|
413
|
+
opportunity_id: OpportunityId,
|
|
414
|
+
*,
|
|
415
|
+
max_results: int | None = None,
|
|
416
|
+
) -> builtins.list[PersonId]:
|
|
417
|
+
"""
|
|
418
|
+
Get associated person IDs for an opportunity.
|
|
419
|
+
|
|
420
|
+
V1-only: V2 does not expose opportunity -> person associations.
|
|
421
|
+
Uses GET `/opportunities/{id}` (V1) and returns `person_ids`.
|
|
422
|
+
|
|
423
|
+
Args:
|
|
424
|
+
opportunity_id: The opportunity ID
|
|
425
|
+
max_results: Maximum number of person IDs to return
|
|
426
|
+
|
|
427
|
+
Returns:
|
|
428
|
+
List of PersonId values associated with this opportunity
|
|
429
|
+
|
|
430
|
+
See Also:
|
|
431
|
+
- :meth:`get_associated_people`: Returns full Person objects
|
|
432
|
+
- :meth:`get_associations`: Get both person and company IDs in one call
|
|
433
|
+
"""
|
|
434
|
+
data = self._client.get(f"/opportunities/{opportunity_id}", v1=True)
|
|
435
|
+
# Defensive: V1 returns directly (not wrapped), but handle potential wrapper
|
|
436
|
+
# for consistency with CompanyService pattern that handles "organization" wrapper
|
|
437
|
+
opportunity = data.get("opportunity") if isinstance(data, dict) else None
|
|
438
|
+
source = opportunity if isinstance(opportunity, dict) else data
|
|
439
|
+
person_ids = None
|
|
440
|
+
if isinstance(source, dict):
|
|
441
|
+
person_ids = source.get("person_ids") or source.get("personIds")
|
|
442
|
+
|
|
443
|
+
if not isinstance(person_ids, list):
|
|
444
|
+
return []
|
|
445
|
+
|
|
446
|
+
ids = [PersonId(int(pid)) for pid in person_ids if pid is not None]
|
|
447
|
+
if max_results is not None and max_results >= 0:
|
|
448
|
+
return ids[:max_results]
|
|
449
|
+
return ids
|
|
450
|
+
|
|
451
|
+
def get_associated_people(
|
|
452
|
+
self,
|
|
453
|
+
opportunity_id: OpportunityId,
|
|
454
|
+
*,
|
|
455
|
+
max_results: int | None = None,
|
|
456
|
+
) -> builtins.list[Person]:
|
|
457
|
+
"""
|
|
458
|
+
Get associated people for an opportunity.
|
|
459
|
+
|
|
460
|
+
V1-only exception. Performs one V1 request to get person IDs,
|
|
461
|
+
then one V1 request per person to fetch full Person objects.
|
|
462
|
+
|
|
463
|
+
Args:
|
|
464
|
+
opportunity_id: The opportunity ID
|
|
465
|
+
max_results: Maximum number of people to return
|
|
466
|
+
|
|
467
|
+
Returns:
|
|
468
|
+
List of Person objects associated with this opportunity
|
|
469
|
+
|
|
470
|
+
Note:
|
|
471
|
+
For large associations (N > 50), be aware of rate limits.
|
|
472
|
+
This method makes 1 + N API calls where N is the number of people.
|
|
473
|
+
"""
|
|
474
|
+
person_ids = self.get_associated_person_ids(opportunity_id, max_results=max_results)
|
|
475
|
+
|
|
476
|
+
people: builtins.list[Person] = []
|
|
477
|
+
for person_id in person_ids:
|
|
478
|
+
# Use V1 for consistency with CompanyService pattern
|
|
479
|
+
data = self._client.get(f"/persons/{person_id}", v1=True)
|
|
480
|
+
people.append(Person.model_validate(data))
|
|
481
|
+
return people
|
|
482
|
+
|
|
483
|
+
def get_associated_company_ids(
|
|
484
|
+
self,
|
|
485
|
+
opportunity_id: OpportunityId,
|
|
486
|
+
*,
|
|
487
|
+
max_results: int | None = None,
|
|
488
|
+
) -> builtins.list[CompanyId]:
|
|
489
|
+
"""
|
|
490
|
+
Get associated company IDs for an opportunity.
|
|
491
|
+
|
|
492
|
+
V1-only: V2 does not expose opportunity -> company associations.
|
|
493
|
+
Uses GET `/opportunities/{id}` (V1) and returns `organization_ids`.
|
|
494
|
+
|
|
495
|
+
Args:
|
|
496
|
+
opportunity_id: The opportunity ID
|
|
497
|
+
max_results: Maximum number of company IDs to return
|
|
498
|
+
|
|
499
|
+
Returns:
|
|
500
|
+
List of CompanyId values associated with this opportunity
|
|
501
|
+
|
|
502
|
+
See Also:
|
|
503
|
+
- :meth:`get_associated_companies`: Returns full Company objects
|
|
504
|
+
- :meth:`get_associations`: Get both person and company IDs in one call
|
|
505
|
+
"""
|
|
506
|
+
data = self._client.get(f"/opportunities/{opportunity_id}", v1=True)
|
|
507
|
+
# Defensive: V1 returns directly (not wrapped), but handle potential wrapper
|
|
508
|
+
# for consistency with CompanyService pattern that handles "organization" wrapper
|
|
509
|
+
opportunity = data.get("opportunity") if isinstance(data, dict) else None
|
|
510
|
+
source = opportunity if isinstance(opportunity, dict) else data
|
|
511
|
+
company_ids = None
|
|
512
|
+
if isinstance(source, dict):
|
|
513
|
+
company_ids = source.get("organization_ids") or source.get("organizationIds")
|
|
514
|
+
|
|
515
|
+
if not isinstance(company_ids, list):
|
|
516
|
+
return []
|
|
517
|
+
|
|
518
|
+
ids = [CompanyId(int(cid)) for cid in company_ids if cid is not None]
|
|
519
|
+
if max_results is not None and max_results >= 0:
|
|
520
|
+
return ids[:max_results]
|
|
521
|
+
return ids
|
|
522
|
+
|
|
523
|
+
def get_associated_companies(
|
|
524
|
+
self,
|
|
525
|
+
opportunity_id: OpportunityId,
|
|
526
|
+
*,
|
|
527
|
+
max_results: int | None = None,
|
|
528
|
+
) -> builtins.list[Company]:
|
|
529
|
+
"""
|
|
530
|
+
Get associated companies for an opportunity.
|
|
531
|
+
|
|
532
|
+
V1-only exception. Performs one V1 request to get company IDs,
|
|
533
|
+
then one V1 request per company to fetch full Company objects.
|
|
534
|
+
|
|
535
|
+
Args:
|
|
536
|
+
opportunity_id: The opportunity ID
|
|
537
|
+
max_results: Maximum number of companies to return
|
|
538
|
+
|
|
539
|
+
Returns:
|
|
540
|
+
List of Company objects associated with this opportunity
|
|
541
|
+
|
|
542
|
+
Note:
|
|
543
|
+
For large associations (N > 50), be aware of rate limits.
|
|
544
|
+
This method makes 1 + N API calls where N is the number of companies.
|
|
545
|
+
"""
|
|
546
|
+
company_ids = self.get_associated_company_ids(opportunity_id, max_results=max_results)
|
|
547
|
+
|
|
548
|
+
companies: builtins.list[Company] = []
|
|
549
|
+
for company_id in company_ids:
|
|
550
|
+
# V1 uses "organizations" terminology
|
|
551
|
+
data = self._client.get(f"/organizations/{company_id}", v1=True)
|
|
552
|
+
companies.append(Company.model_validate(data))
|
|
553
|
+
return companies
|
|
554
|
+
|
|
555
|
+
def get_associations(
|
|
556
|
+
self,
|
|
557
|
+
opportunity_id: OpportunityId,
|
|
558
|
+
) -> OpportunityAssociations:
|
|
559
|
+
"""
|
|
560
|
+
Get both person and company associations in a single V1 call.
|
|
561
|
+
|
|
562
|
+
Use this when you need both types of associations to avoid
|
|
563
|
+
duplicate API calls from separate get_associated_*_ids() calls.
|
|
564
|
+
|
|
565
|
+
Args:
|
|
566
|
+
opportunity_id: The opportunity ID
|
|
567
|
+
|
|
568
|
+
Returns:
|
|
569
|
+
OpportunityAssociations named tuple with person_ids and company_ids
|
|
570
|
+
|
|
571
|
+
Example:
|
|
572
|
+
assoc = client.opportunities.get_associations(opp_id)
|
|
573
|
+
print(assoc.person_ids) # IDE autocomplete works
|
|
574
|
+
print(assoc.company_ids) # IDE autocomplete works
|
|
575
|
+
"""
|
|
576
|
+
data = self._client.get(f"/opportunities/{opportunity_id}", v1=True)
|
|
577
|
+
# Defensive: V1 returns directly (not wrapped), but handle potential wrapper
|
|
578
|
+
opportunity = data.get("opportunity") if isinstance(data, dict) else None
|
|
579
|
+
source = opportunity if isinstance(opportunity, dict) else data
|
|
580
|
+
|
|
581
|
+
person_ids: builtins.list[PersonId] = []
|
|
582
|
+
company_ids: builtins.list[CompanyId] = []
|
|
583
|
+
|
|
584
|
+
if isinstance(source, dict):
|
|
585
|
+
raw_person_ids = source.get("person_ids") or source.get("personIds")
|
|
586
|
+
raw_company_ids = source.get("organization_ids") or source.get("organizationIds")
|
|
587
|
+
|
|
588
|
+
if isinstance(raw_person_ids, list):
|
|
589
|
+
person_ids = [PersonId(int(pid)) for pid in raw_person_ids if pid is not None]
|
|
590
|
+
if isinstance(raw_company_ids, list):
|
|
591
|
+
company_ids = [CompanyId(int(cid)) for cid in raw_company_ids if cid is not None]
|
|
592
|
+
|
|
593
|
+
return OpportunityAssociations(person_ids=person_ids, company_ids=company_ids)
|
|
594
|
+
|
|
595
|
+
def get_associated_person_ids_batch(
|
|
596
|
+
self,
|
|
597
|
+
opportunity_ids: Sequence[OpportunityId],
|
|
598
|
+
*,
|
|
599
|
+
on_error: Literal["raise", "skip"] = "raise",
|
|
600
|
+
) -> dict[OpportunityId, builtins.list[PersonId]]:
|
|
601
|
+
"""
|
|
602
|
+
Get person associations for multiple opportunities.
|
|
603
|
+
|
|
604
|
+
Makes one V1 API call per opportunity. Useful for iterating list entries
|
|
605
|
+
where V2 returns empty person_ids.
|
|
606
|
+
|
|
607
|
+
Args:
|
|
608
|
+
opportunity_ids: Sequence of opportunity IDs to fetch
|
|
609
|
+
on_error: How to handle errors - "raise" (default) or "skip" failed IDs
|
|
610
|
+
|
|
611
|
+
Returns:
|
|
612
|
+
Dict mapping opportunity_id -> list of person_ids
|
|
613
|
+
|
|
614
|
+
Raises:
|
|
615
|
+
AffinityError: If on_error="raise" and any fetch fails. The exception
|
|
616
|
+
includes the failing opportunity_id in its context.
|
|
617
|
+
|
|
618
|
+
Example:
|
|
619
|
+
# Get all persons from an opportunity list
|
|
620
|
+
opp_ids = [entry.entity.id for entry in client.lists.entries(list_id).all()]
|
|
621
|
+
associations = client.opportunities.get_associated_person_ids_batch(opp_ids)
|
|
622
|
+
all_person_ids = set()
|
|
623
|
+
for person_ids in associations.values():
|
|
624
|
+
all_person_ids.update(person_ids)
|
|
625
|
+
"""
|
|
626
|
+
result: dict[OpportunityId, builtins.list[PersonId]] = {}
|
|
627
|
+
for opp_id in opportunity_ids:
|
|
628
|
+
try:
|
|
629
|
+
result[opp_id] = self.get_associated_person_ids(opp_id)
|
|
630
|
+
except AffinityError:
|
|
631
|
+
# Re-raise SDK errors directly - they already have good context
|
|
632
|
+
if on_error == "raise":
|
|
633
|
+
raise
|
|
634
|
+
# skip: continue without this opportunity
|
|
635
|
+
except Exception as e:
|
|
636
|
+
if on_error == "raise":
|
|
637
|
+
# Wrap non-SDK errors with context, preserving chain
|
|
638
|
+
raise AffinityError(
|
|
639
|
+
f"Failed to get associations for opportunity {opp_id}: {e}"
|
|
640
|
+
) from e
|
|
641
|
+
# skip: continue without this opportunity
|
|
642
|
+
return result
|
|
643
|
+
|
|
644
|
+
def get_associated_company_ids_batch(
|
|
645
|
+
self,
|
|
646
|
+
opportunity_ids: Sequence[OpportunityId],
|
|
647
|
+
*,
|
|
648
|
+
on_error: Literal["raise", "skip"] = "raise",
|
|
649
|
+
) -> dict[OpportunityId, builtins.list[CompanyId]]:
|
|
650
|
+
"""
|
|
651
|
+
Get company associations for multiple opportunities.
|
|
652
|
+
|
|
653
|
+
Makes one V1 API call per opportunity. Useful for iterating list entries
|
|
654
|
+
where V2 returns empty company_ids.
|
|
655
|
+
|
|
656
|
+
Args:
|
|
657
|
+
opportunity_ids: Sequence of opportunity IDs to fetch
|
|
658
|
+
on_error: How to handle errors - "raise" (default) or "skip" failed IDs
|
|
659
|
+
|
|
660
|
+
Returns:
|
|
661
|
+
Dict mapping opportunity_id -> list of company_ids
|
|
662
|
+
|
|
663
|
+
Raises:
|
|
664
|
+
AffinityError: If on_error="raise" and any fetch fails. The exception
|
|
665
|
+
includes the failing opportunity_id in its context.
|
|
666
|
+
|
|
667
|
+
Example:
|
|
668
|
+
# Get all companies from an opportunity list
|
|
669
|
+
opp_ids = [entry.entity.id for entry in client.lists.entries(list_id).all()]
|
|
670
|
+
associations = client.opportunities.get_associated_company_ids_batch(opp_ids)
|
|
671
|
+
all_company_ids = set()
|
|
672
|
+
for company_ids in associations.values():
|
|
673
|
+
all_company_ids.update(company_ids)
|
|
674
|
+
"""
|
|
675
|
+
result: dict[OpportunityId, builtins.list[CompanyId]] = {}
|
|
676
|
+
for opp_id in opportunity_ids:
|
|
677
|
+
try:
|
|
678
|
+
result[opp_id] = self.get_associated_company_ids(opp_id)
|
|
679
|
+
except AffinityError:
|
|
680
|
+
# Re-raise SDK errors directly - they already have good context
|
|
681
|
+
if on_error == "raise":
|
|
682
|
+
raise
|
|
683
|
+
# skip: continue without this opportunity
|
|
684
|
+
except Exception as e:
|
|
685
|
+
if on_error == "raise":
|
|
686
|
+
# Wrap non-SDK errors with context, preserving chain
|
|
687
|
+
raise AffinityError(
|
|
688
|
+
f"Failed to get company associations for opportunity {opp_id}: {e}"
|
|
689
|
+
) from e
|
|
690
|
+
# skip: continue without this opportunity
|
|
691
|
+
return result
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
class AsyncOpportunityService:
|
|
695
|
+
"""Async version of OpportunityService (TR-009)."""
|
|
696
|
+
|
|
697
|
+
def __init__(self, client: AsyncHTTPClient):
|
|
698
|
+
self._client = client
|
|
699
|
+
|
|
700
|
+
async def get(self, opportunity_id: OpportunityId) -> Opportunity:
|
|
701
|
+
"""
|
|
702
|
+
Get a single opportunity by ID.
|
|
703
|
+
|
|
704
|
+
Args:
|
|
705
|
+
opportunity_id: The opportunity ID
|
|
706
|
+
|
|
707
|
+
Returns:
|
|
708
|
+
The opportunity representation returned by v2 (may be partial).
|
|
709
|
+
"""
|
|
710
|
+
data = await self._client.get(f"/opportunities/{opportunity_id}")
|
|
711
|
+
return Opportunity.model_validate(data)
|
|
712
|
+
|
|
713
|
+
async def get_details(self, opportunity_id: OpportunityId) -> Opportunity:
|
|
714
|
+
"""
|
|
715
|
+
Get a single opportunity by ID with a more complete representation.
|
|
716
|
+
|
|
717
|
+
Includes association IDs and (when present) list entries, which are not
|
|
718
|
+
always included in the default `get()` response.
|
|
719
|
+
|
|
720
|
+
See Also:
|
|
721
|
+
- :meth:`get_associated_person_ids`: Get just person IDs (single API call)
|
|
722
|
+
- :meth:`get_associated_people`: Get full Person objects
|
|
723
|
+
- :meth:`get_associated_company_ids`: Get just company IDs (single API call)
|
|
724
|
+
- :meth:`get_associated_companies`: Get full Company objects
|
|
725
|
+
- :meth:`get_associations`: Get both person and company IDs in one call
|
|
726
|
+
"""
|
|
727
|
+
# Uses the v1 endpoint because it returns a fuller payload (including
|
|
728
|
+
# association IDs and, when present, list entries).
|
|
729
|
+
data = await self._client.get(f"/opportunities/{opportunity_id}", v1=True)
|
|
730
|
+
return Opportunity.model_validate(data)
|
|
731
|
+
|
|
732
|
+
async def list(
|
|
733
|
+
self,
|
|
734
|
+
*,
|
|
735
|
+
ids: Sequence[OpportunityId] | None = None,
|
|
736
|
+
limit: int | None = None,
|
|
737
|
+
cursor: str | None = None,
|
|
738
|
+
) -> PaginatedResponse[Opportunity]:
|
|
739
|
+
"""
|
|
740
|
+
List all opportunities.
|
|
741
|
+
|
|
742
|
+
Args:
|
|
743
|
+
ids: Specific opportunity IDs to fetch (batch lookup)
|
|
744
|
+
limit: Maximum number of results per page
|
|
745
|
+
cursor: Cursor to resume pagination (opaque; obtained from prior responses)
|
|
746
|
+
|
|
747
|
+
Returns the v2 opportunity representation (which may be partial).
|
|
748
|
+
For full opportunity row data, use list entries explicitly.
|
|
749
|
+
"""
|
|
750
|
+
if cursor is not None:
|
|
751
|
+
if ids is not None or limit is not None:
|
|
752
|
+
raise ValueError(
|
|
753
|
+
"Cannot combine 'cursor' with other parameters; cursor encodes all query "
|
|
754
|
+
"context. Start a new pagination sequence without a cursor to change "
|
|
755
|
+
"parameters."
|
|
756
|
+
)
|
|
757
|
+
data = await self._client.get_url(cursor)
|
|
758
|
+
else:
|
|
759
|
+
params: dict[str, Any] = {}
|
|
760
|
+
if ids:
|
|
761
|
+
params["ids"] = [int(id_) for id_ in ids]
|
|
762
|
+
if limit is not None:
|
|
763
|
+
params["limit"] = limit
|
|
764
|
+
data = await self._client.get("/opportunities", params=params or None)
|
|
765
|
+
|
|
766
|
+
return PaginatedResponse[Opportunity](
|
|
767
|
+
data=[Opportunity.model_validate(item) for item in data.get("data", [])],
|
|
768
|
+
pagination=PaginationInfo.model_validate(data.get("pagination", {})),
|
|
769
|
+
)
|
|
770
|
+
|
|
771
|
+
async def pages(
|
|
772
|
+
self,
|
|
773
|
+
*,
|
|
774
|
+
ids: Sequence[OpportunityId] | None = None,
|
|
775
|
+
limit: int | None = None,
|
|
776
|
+
cursor: str | None = None,
|
|
777
|
+
) -> AsyncIterator[PaginatedResponse[Opportunity]]:
|
|
778
|
+
"""
|
|
779
|
+
Iterate opportunity pages (not items), yielding `PaginatedResponse[Opportunity]`.
|
|
780
|
+
|
|
781
|
+
This is useful for ETL scripts that want checkpoint/resume via `page.next_cursor`.
|
|
782
|
+
|
|
783
|
+
Args:
|
|
784
|
+
ids: Specific opportunity IDs to fetch (batch lookup)
|
|
785
|
+
limit: Maximum results per page
|
|
786
|
+
cursor: Cursor to resume pagination
|
|
787
|
+
"""
|
|
788
|
+
other_params = (ids, limit)
|
|
789
|
+
if cursor is not None and any(p is not None for p in other_params):
|
|
790
|
+
raise ValueError(
|
|
791
|
+
"Cannot combine 'cursor' with other parameters; cursor encodes all query context. "
|
|
792
|
+
"Start a new pagination sequence without a cursor to change parameters."
|
|
793
|
+
)
|
|
794
|
+
requested_cursor = cursor
|
|
795
|
+
page = (
|
|
796
|
+
await self.list(cursor=cursor)
|
|
797
|
+
if cursor is not None
|
|
798
|
+
else await self.list(ids=ids, limit=limit)
|
|
799
|
+
)
|
|
800
|
+
while True:
|
|
801
|
+
yield page
|
|
802
|
+
if not page.has_next:
|
|
803
|
+
return
|
|
804
|
+
next_cursor = page.next_cursor
|
|
805
|
+
if next_cursor is None or next_cursor == requested_cursor:
|
|
806
|
+
return
|
|
807
|
+
requested_cursor = next_cursor
|
|
808
|
+
page = await self.list(cursor=next_cursor)
|
|
809
|
+
|
|
810
|
+
def all(
|
|
811
|
+
self,
|
|
812
|
+
*,
|
|
813
|
+
ids: Sequence[OpportunityId] | None = None,
|
|
814
|
+
) -> AsyncIterator[Opportunity]:
|
|
815
|
+
"""
|
|
816
|
+
Iterate through all opportunities with automatic pagination.
|
|
817
|
+
|
|
818
|
+
Args:
|
|
819
|
+
ids: Specific opportunity IDs to fetch (batch lookup)
|
|
820
|
+
"""
|
|
821
|
+
|
|
822
|
+
async def fetch_page(next_url: str | None) -> PaginatedResponse[Opportunity]:
|
|
823
|
+
if next_url:
|
|
824
|
+
data = await self._client.get_url(next_url)
|
|
825
|
+
return PaginatedResponse[Opportunity](
|
|
826
|
+
data=[Opportunity.model_validate(item) for item in data.get("data", [])],
|
|
827
|
+
pagination=PaginationInfo.model_validate(data.get("pagination", {})),
|
|
828
|
+
)
|
|
829
|
+
return await self.list(ids=ids)
|
|
830
|
+
|
|
831
|
+
return AsyncPageIterator(fetch_page)
|
|
832
|
+
|
|
833
|
+
def iter(
|
|
834
|
+
self,
|
|
835
|
+
*,
|
|
836
|
+
ids: Sequence[OpportunityId] | None = None,
|
|
837
|
+
) -> AsyncIterator[Opportunity]:
|
|
838
|
+
"""
|
|
839
|
+
Auto-paginate all opportunities.
|
|
840
|
+
|
|
841
|
+
Alias for `all()` (FR-006 public contract).
|
|
842
|
+
"""
|
|
843
|
+
return self.all(ids=ids)
|
|
844
|
+
|
|
845
|
+
# =========================================================================
|
|
846
|
+
# Search (V1 API)
|
|
847
|
+
# =========================================================================
|
|
848
|
+
|
|
849
|
+
async def search(
|
|
850
|
+
self,
|
|
851
|
+
term: str | None = None,
|
|
852
|
+
*,
|
|
853
|
+
page_size: int | None = None,
|
|
854
|
+
page_token: str | None = None,
|
|
855
|
+
) -> V1PaginatedResponse[Opportunity]:
|
|
856
|
+
"""
|
|
857
|
+
Search for opportunities by name.
|
|
858
|
+
|
|
859
|
+
Uses V1 API for search functionality.
|
|
860
|
+
|
|
861
|
+
Args:
|
|
862
|
+
term: Search term (matches opportunity name). If None, returns all.
|
|
863
|
+
page_size: Results per page (max 500)
|
|
864
|
+
page_token: Pagination token
|
|
865
|
+
|
|
866
|
+
Returns:
|
|
867
|
+
V1PaginatedResponse with opportunities and next_page_token
|
|
868
|
+
"""
|
|
869
|
+
params: dict[str, Any] = {}
|
|
870
|
+
if term:
|
|
871
|
+
params["term"] = term
|
|
872
|
+
if page_size:
|
|
873
|
+
params["page_size"] = page_size
|
|
874
|
+
if page_token:
|
|
875
|
+
params["page_token"] = page_token
|
|
876
|
+
|
|
877
|
+
data = await self._client.get("/opportunities", params=params, v1=True)
|
|
878
|
+
items = [Opportunity.model_validate(o) for o in data.get("opportunities", [])]
|
|
879
|
+
return V1PaginatedResponse[Opportunity](
|
|
880
|
+
data=items,
|
|
881
|
+
next_page_token=data.get("next_page_token"),
|
|
882
|
+
)
|
|
883
|
+
|
|
884
|
+
async def search_pages(
|
|
885
|
+
self,
|
|
886
|
+
term: str | None = None,
|
|
887
|
+
*,
|
|
888
|
+
page_size: int | None = None,
|
|
889
|
+
page_token: str | None = None,
|
|
890
|
+
) -> AsyncIterator[V1PaginatedResponse[Opportunity]]:
|
|
891
|
+
"""
|
|
892
|
+
Iterate V1 opportunity-search result pages.
|
|
893
|
+
|
|
894
|
+
Useful for scripts that need checkpoint/resume via `next_page_token`.
|
|
895
|
+
|
|
896
|
+
Args:
|
|
897
|
+
term: Search term (matches opportunity name). If None, returns all.
|
|
898
|
+
page_size: Results per page (max 500)
|
|
899
|
+
page_token: Resume from this pagination token
|
|
900
|
+
|
|
901
|
+
Yields:
|
|
902
|
+
V1PaginatedResponse[Opportunity] for each page
|
|
903
|
+
"""
|
|
904
|
+
requested_token = page_token
|
|
905
|
+
page = await self.search(term, page_size=page_size, page_token=page_token)
|
|
906
|
+
while True:
|
|
907
|
+
yield page
|
|
908
|
+
next_token = page.next_page_token
|
|
909
|
+
if not next_token or next_token == requested_token:
|
|
910
|
+
return
|
|
911
|
+
requested_token = next_token
|
|
912
|
+
page = await self.search(term, page_size=page_size, page_token=next_token)
|
|
913
|
+
|
|
914
|
+
async def search_all(
|
|
915
|
+
self,
|
|
916
|
+
term: str | None = None,
|
|
917
|
+
*,
|
|
918
|
+
page_size: int | None = None,
|
|
919
|
+
page_token: str | None = None,
|
|
920
|
+
) -> AsyncIterator[Opportunity]:
|
|
921
|
+
"""
|
|
922
|
+
Iterate all V1 opportunity-search results with automatic pagination.
|
|
923
|
+
|
|
924
|
+
Args:
|
|
925
|
+
term: Search term (matches opportunity name). If None, returns all.
|
|
926
|
+
page_size: Results per page (max 500)
|
|
927
|
+
page_token: Resume from this pagination token
|
|
928
|
+
|
|
929
|
+
Yields:
|
|
930
|
+
Each Opportunity individually
|
|
931
|
+
"""
|
|
932
|
+
async for page in self.search_pages(term, page_size=page_size, page_token=page_token):
|
|
933
|
+
for opp in page.data:
|
|
934
|
+
yield opp
|
|
935
|
+
|
|
936
|
+
async def resolve(
|
|
937
|
+
self,
|
|
938
|
+
*,
|
|
939
|
+
name: str,
|
|
940
|
+
list_id: ListId,
|
|
941
|
+
limit: int | None = None,
|
|
942
|
+
) -> Opportunity | None:
|
|
943
|
+
"""
|
|
944
|
+
Find a single opportunity by exact name within a specific list.
|
|
945
|
+
|
|
946
|
+
Notes:
|
|
947
|
+
- Opportunities are list-scoped; a list id is required.
|
|
948
|
+
- This iterates list-entry pages client-side (no dedicated search endpoint).
|
|
949
|
+
- If multiple matches exist, returns the first match in server-provided order.
|
|
950
|
+
"""
|
|
951
|
+
name = name.strip()
|
|
952
|
+
if not name:
|
|
953
|
+
raise ValueError("Name cannot be empty")
|
|
954
|
+
name_lower = name.lower()
|
|
955
|
+
|
|
956
|
+
entries = AsyncListEntryService(self._client, list_id)
|
|
957
|
+
async for page in entries.pages(limit=limit):
|
|
958
|
+
for entry in page.data:
|
|
959
|
+
entity = entry.entity
|
|
960
|
+
if isinstance(entity, Opportunity) and entity.name.lower() == name_lower:
|
|
961
|
+
return entity
|
|
962
|
+
return None
|
|
963
|
+
|
|
964
|
+
async def resolve_all(
|
|
965
|
+
self,
|
|
966
|
+
*,
|
|
967
|
+
name: str,
|
|
968
|
+
list_id: ListId,
|
|
969
|
+
limit: int | None = None,
|
|
970
|
+
) -> builtins.list[Opportunity]:
|
|
971
|
+
"""
|
|
972
|
+
Find all opportunities matching a name within a specific list.
|
|
973
|
+
|
|
974
|
+
Notes:
|
|
975
|
+
- Opportunities are list-scoped; a list id is required.
|
|
976
|
+
- This iterates list-entry pages client-side (no dedicated search endpoint).
|
|
977
|
+
"""
|
|
978
|
+
name = name.strip()
|
|
979
|
+
if not name:
|
|
980
|
+
raise ValueError("Name cannot be empty")
|
|
981
|
+
name_lower = name.lower()
|
|
982
|
+
matches: builtins.list[Opportunity] = []
|
|
983
|
+
|
|
984
|
+
entries = AsyncListEntryService(self._client, list_id)
|
|
985
|
+
async for page in entries.pages(limit=limit):
|
|
986
|
+
for entry in page.data:
|
|
987
|
+
entity = entry.entity
|
|
988
|
+
if isinstance(entity, Opportunity) and entity.name.lower() == name_lower:
|
|
989
|
+
matches.append(entity)
|
|
990
|
+
return matches
|
|
991
|
+
|
|
992
|
+
# =========================================================================
|
|
993
|
+
# Write Operations (V1 API)
|
|
994
|
+
# =========================================================================
|
|
995
|
+
|
|
996
|
+
async def create(self, data: OpportunityCreate) -> Opportunity:
|
|
997
|
+
"""
|
|
998
|
+
Create a new opportunity.
|
|
999
|
+
|
|
1000
|
+
The opportunity will be added to the specified list.
|
|
1001
|
+
|
|
1002
|
+
Args:
|
|
1003
|
+
data: Opportunity creation data including list_id and name
|
|
1004
|
+
|
|
1005
|
+
Returns:
|
|
1006
|
+
The created opportunity
|
|
1007
|
+
"""
|
|
1008
|
+
payload = data.model_dump(by_alias=True, mode="json", exclude_none=True)
|
|
1009
|
+
if not data.person_ids:
|
|
1010
|
+
payload.pop("person_ids", None)
|
|
1011
|
+
if not data.company_ids:
|
|
1012
|
+
payload.pop("organization_ids", None)
|
|
1013
|
+
|
|
1014
|
+
result = await self._client.post("/opportunities", json=payload, v1=True)
|
|
1015
|
+
return Opportunity.model_validate(result)
|
|
1016
|
+
|
|
1017
|
+
async def update(self, opportunity_id: OpportunityId, data: OpportunityUpdate) -> Opportunity:
|
|
1018
|
+
"""
|
|
1019
|
+
Update an existing opportunity.
|
|
1020
|
+
|
|
1021
|
+
Note: When provided, `person_ids` and `company_ids` replace the existing
|
|
1022
|
+
values. To add or remove associations safely, pass the full desired arrays.
|
|
1023
|
+
"""
|
|
1024
|
+
payload = data.model_dump(
|
|
1025
|
+
by_alias=True,
|
|
1026
|
+
mode="json",
|
|
1027
|
+
exclude_unset=True,
|
|
1028
|
+
exclude_none=True,
|
|
1029
|
+
)
|
|
1030
|
+
|
|
1031
|
+
# Uses the v1 endpoint; its PUT semantics replace association arrays.
|
|
1032
|
+
result = await self._client.put(f"/opportunities/{opportunity_id}", json=payload, v1=True)
|
|
1033
|
+
return Opportunity.model_validate(result)
|
|
1034
|
+
|
|
1035
|
+
async def delete(self, opportunity_id: OpportunityId) -> bool:
|
|
1036
|
+
"""
|
|
1037
|
+
Delete an opportunity.
|
|
1038
|
+
|
|
1039
|
+
This removes the opportunity and all associated list entries.
|
|
1040
|
+
|
|
1041
|
+
Args:
|
|
1042
|
+
opportunity_id: The opportunity to delete
|
|
1043
|
+
|
|
1044
|
+
Returns:
|
|
1045
|
+
True if successful
|
|
1046
|
+
"""
|
|
1047
|
+
result = await self._client.delete(f"/opportunities/{opportunity_id}", v1=True)
|
|
1048
|
+
return bool(result.get("success", False))
|
|
1049
|
+
|
|
1050
|
+
# =========================================================================
|
|
1051
|
+
# Association Methods (V1 API)
|
|
1052
|
+
# =========================================================================
|
|
1053
|
+
|
|
1054
|
+
async def get_associated_person_ids(
|
|
1055
|
+
self,
|
|
1056
|
+
opportunity_id: OpportunityId,
|
|
1057
|
+
*,
|
|
1058
|
+
max_results: int | None = None,
|
|
1059
|
+
) -> builtins.list[PersonId]:
|
|
1060
|
+
"""
|
|
1061
|
+
Get associated person IDs for an opportunity.
|
|
1062
|
+
|
|
1063
|
+
V1-only: V2 does not expose opportunity -> person associations.
|
|
1064
|
+
Uses GET `/opportunities/{id}` (V1) and returns `person_ids`.
|
|
1065
|
+
|
|
1066
|
+
Args:
|
|
1067
|
+
opportunity_id: The opportunity ID
|
|
1068
|
+
max_results: Maximum number of person IDs to return
|
|
1069
|
+
|
|
1070
|
+
Returns:
|
|
1071
|
+
List of PersonId values associated with this opportunity
|
|
1072
|
+
|
|
1073
|
+
See Also:
|
|
1074
|
+
- :meth:`get_associated_people`: Returns full Person objects
|
|
1075
|
+
- :meth:`get_associations`: Get both person and company IDs in one call
|
|
1076
|
+
"""
|
|
1077
|
+
data = await self._client.get(f"/opportunities/{opportunity_id}", v1=True)
|
|
1078
|
+
# Defensive: V1 returns directly (not wrapped), but handle potential wrapper
|
|
1079
|
+
opportunity = data.get("opportunity") if isinstance(data, dict) else None
|
|
1080
|
+
source = opportunity if isinstance(opportunity, dict) else data
|
|
1081
|
+
person_ids = None
|
|
1082
|
+
if isinstance(source, dict):
|
|
1083
|
+
person_ids = source.get("person_ids") or source.get("personIds")
|
|
1084
|
+
|
|
1085
|
+
if not isinstance(person_ids, list):
|
|
1086
|
+
return []
|
|
1087
|
+
|
|
1088
|
+
ids = [PersonId(int(pid)) for pid in person_ids if pid is not None]
|
|
1089
|
+
if max_results is not None and max_results >= 0:
|
|
1090
|
+
return ids[:max_results]
|
|
1091
|
+
return ids
|
|
1092
|
+
|
|
1093
|
+
async def get_associated_people(
|
|
1094
|
+
self,
|
|
1095
|
+
opportunity_id: OpportunityId,
|
|
1096
|
+
*,
|
|
1097
|
+
max_results: int | None = None,
|
|
1098
|
+
) -> builtins.list[Person]:
|
|
1099
|
+
"""
|
|
1100
|
+
Get associated people for an opportunity.
|
|
1101
|
+
|
|
1102
|
+
V1-only exception. Performs one V1 request to get person IDs,
|
|
1103
|
+
then one V1 request per person to fetch full Person objects.
|
|
1104
|
+
|
|
1105
|
+
Args:
|
|
1106
|
+
opportunity_id: The opportunity ID
|
|
1107
|
+
max_results: Maximum number of people to return
|
|
1108
|
+
|
|
1109
|
+
Returns:
|
|
1110
|
+
List of Person objects associated with this opportunity
|
|
1111
|
+
|
|
1112
|
+
Note:
|
|
1113
|
+
For large associations (N > 50), be aware of rate limits.
|
|
1114
|
+
This method makes 1 + N API calls where N is the number of people.
|
|
1115
|
+
"""
|
|
1116
|
+
person_ids = await self.get_associated_person_ids(opportunity_id, max_results=max_results)
|
|
1117
|
+
|
|
1118
|
+
people: builtins.list[Person] = []
|
|
1119
|
+
for person_id in person_ids:
|
|
1120
|
+
data = await self._client.get(f"/persons/{person_id}", v1=True)
|
|
1121
|
+
people.append(Person.model_validate(data))
|
|
1122
|
+
return people
|
|
1123
|
+
|
|
1124
|
+
async def get_associated_company_ids(
|
|
1125
|
+
self,
|
|
1126
|
+
opportunity_id: OpportunityId,
|
|
1127
|
+
*,
|
|
1128
|
+
max_results: int | None = None,
|
|
1129
|
+
) -> builtins.list[CompanyId]:
|
|
1130
|
+
"""
|
|
1131
|
+
Get associated company IDs for an opportunity.
|
|
1132
|
+
|
|
1133
|
+
V1-only: V2 does not expose opportunity -> company associations.
|
|
1134
|
+
Uses GET `/opportunities/{id}` (V1) and returns `organization_ids`.
|
|
1135
|
+
|
|
1136
|
+
Args:
|
|
1137
|
+
opportunity_id: The opportunity ID
|
|
1138
|
+
max_results: Maximum number of company IDs to return
|
|
1139
|
+
|
|
1140
|
+
Returns:
|
|
1141
|
+
List of CompanyId values associated with this opportunity
|
|
1142
|
+
|
|
1143
|
+
See Also:
|
|
1144
|
+
- :meth:`get_associated_companies`: Returns full Company objects
|
|
1145
|
+
- :meth:`get_associations`: Get both person and company IDs in one call
|
|
1146
|
+
"""
|
|
1147
|
+
data = await self._client.get(f"/opportunities/{opportunity_id}", v1=True)
|
|
1148
|
+
# Defensive: V1 returns directly (not wrapped), but handle potential wrapper
|
|
1149
|
+
opportunity = data.get("opportunity") if isinstance(data, dict) else None
|
|
1150
|
+
source = opportunity if isinstance(opportunity, dict) else data
|
|
1151
|
+
company_ids = None
|
|
1152
|
+
if isinstance(source, dict):
|
|
1153
|
+
company_ids = source.get("organization_ids") or source.get("organizationIds")
|
|
1154
|
+
|
|
1155
|
+
if not isinstance(company_ids, list):
|
|
1156
|
+
return []
|
|
1157
|
+
|
|
1158
|
+
ids = [CompanyId(int(cid)) for cid in company_ids if cid is not None]
|
|
1159
|
+
if max_results is not None and max_results >= 0:
|
|
1160
|
+
return ids[:max_results]
|
|
1161
|
+
return ids
|
|
1162
|
+
|
|
1163
|
+
async def get_associated_companies(
|
|
1164
|
+
self,
|
|
1165
|
+
opportunity_id: OpportunityId,
|
|
1166
|
+
*,
|
|
1167
|
+
max_results: int | None = None,
|
|
1168
|
+
) -> builtins.list[Company]:
|
|
1169
|
+
"""
|
|
1170
|
+
Get associated companies for an opportunity.
|
|
1171
|
+
|
|
1172
|
+
V1-only exception. Performs one V1 request to get company IDs,
|
|
1173
|
+
then one V1 request per company to fetch full Company objects.
|
|
1174
|
+
|
|
1175
|
+
Args:
|
|
1176
|
+
opportunity_id: The opportunity ID
|
|
1177
|
+
max_results: Maximum number of companies to return
|
|
1178
|
+
|
|
1179
|
+
Returns:
|
|
1180
|
+
List of Company objects associated with this opportunity
|
|
1181
|
+
|
|
1182
|
+
Note:
|
|
1183
|
+
For large associations (N > 50), be aware of rate limits.
|
|
1184
|
+
This method makes 1 + N API calls where N is the number of companies.
|
|
1185
|
+
"""
|
|
1186
|
+
company_ids = await self.get_associated_company_ids(opportunity_id, max_results=max_results)
|
|
1187
|
+
|
|
1188
|
+
companies: builtins.list[Company] = []
|
|
1189
|
+
for company_id in company_ids:
|
|
1190
|
+
data = await self._client.get(f"/organizations/{company_id}", v1=True)
|
|
1191
|
+
companies.append(Company.model_validate(data))
|
|
1192
|
+
return companies
|
|
1193
|
+
|
|
1194
|
+
async def get_associations(
|
|
1195
|
+
self,
|
|
1196
|
+
opportunity_id: OpportunityId,
|
|
1197
|
+
) -> OpportunityAssociations:
|
|
1198
|
+
"""
|
|
1199
|
+
Get both person and company associations in a single V1 call.
|
|
1200
|
+
|
|
1201
|
+
Use this when you need both types of associations to avoid
|
|
1202
|
+
duplicate API calls from separate get_associated_*_ids() calls.
|
|
1203
|
+
|
|
1204
|
+
Args:
|
|
1205
|
+
opportunity_id: The opportunity ID
|
|
1206
|
+
|
|
1207
|
+
Returns:
|
|
1208
|
+
OpportunityAssociations named tuple with person_ids and company_ids
|
|
1209
|
+
|
|
1210
|
+
Example:
|
|
1211
|
+
assoc = await client.opportunities.get_associations(opp_id)
|
|
1212
|
+
print(assoc.person_ids) # IDE autocomplete works
|
|
1213
|
+
print(assoc.company_ids) # IDE autocomplete works
|
|
1214
|
+
"""
|
|
1215
|
+
data = await self._client.get(f"/opportunities/{opportunity_id}", v1=True)
|
|
1216
|
+
# Defensive: V1 returns directly (not wrapped), but handle potential wrapper
|
|
1217
|
+
opportunity = data.get("opportunity") if isinstance(data, dict) else None
|
|
1218
|
+
source = opportunity if isinstance(opportunity, dict) else data
|
|
1219
|
+
|
|
1220
|
+
person_ids: builtins.list[PersonId] = []
|
|
1221
|
+
company_ids: builtins.list[CompanyId] = []
|
|
1222
|
+
|
|
1223
|
+
if isinstance(source, dict):
|
|
1224
|
+
raw_person_ids = source.get("person_ids") or source.get("personIds")
|
|
1225
|
+
raw_company_ids = source.get("organization_ids") or source.get("organizationIds")
|
|
1226
|
+
|
|
1227
|
+
if isinstance(raw_person_ids, list):
|
|
1228
|
+
person_ids = [PersonId(int(pid)) for pid in raw_person_ids if pid is not None]
|
|
1229
|
+
if isinstance(raw_company_ids, list):
|
|
1230
|
+
company_ids = [CompanyId(int(cid)) for cid in raw_company_ids if cid is not None]
|
|
1231
|
+
|
|
1232
|
+
return OpportunityAssociations(person_ids=person_ids, company_ids=company_ids)
|
|
1233
|
+
|
|
1234
|
+
async def get_associated_person_ids_batch(
|
|
1235
|
+
self,
|
|
1236
|
+
opportunity_ids: Sequence[OpportunityId],
|
|
1237
|
+
*,
|
|
1238
|
+
on_error: Literal["raise", "skip"] = "raise",
|
|
1239
|
+
) -> dict[OpportunityId, builtins.list[PersonId]]:
|
|
1240
|
+
"""
|
|
1241
|
+
Get person associations for multiple opportunities.
|
|
1242
|
+
|
|
1243
|
+
Makes one V1 API call per opportunity. Useful for iterating list entries
|
|
1244
|
+
where V2 returns empty person_ids.
|
|
1245
|
+
|
|
1246
|
+
Args:
|
|
1247
|
+
opportunity_ids: Sequence of opportunity IDs to fetch
|
|
1248
|
+
on_error: How to handle errors - "raise" (default) or "skip" failed IDs
|
|
1249
|
+
|
|
1250
|
+
Returns:
|
|
1251
|
+
Dict mapping opportunity_id -> list of person_ids
|
|
1252
|
+
|
|
1253
|
+
Raises:
|
|
1254
|
+
AffinityError: If on_error="raise" and any fetch fails. The exception
|
|
1255
|
+
includes the failing opportunity_id in its context.
|
|
1256
|
+
|
|
1257
|
+
Example:
|
|
1258
|
+
# Get all persons from an opportunity list
|
|
1259
|
+
opp_ids = [entry.entity.id async for entry in client.lists.entries(list_id).all()]
|
|
1260
|
+
associations = await client.opportunities.get_associated_person_ids_batch(opp_ids)
|
|
1261
|
+
all_person_ids = set()
|
|
1262
|
+
for person_ids in associations.values():
|
|
1263
|
+
all_person_ids.update(person_ids)
|
|
1264
|
+
"""
|
|
1265
|
+
result: dict[OpportunityId, builtins.list[PersonId]] = {}
|
|
1266
|
+
for opp_id in opportunity_ids:
|
|
1267
|
+
try:
|
|
1268
|
+
result[opp_id] = await self.get_associated_person_ids(opp_id)
|
|
1269
|
+
except AffinityError:
|
|
1270
|
+
# Re-raise SDK errors directly - they already have good context
|
|
1271
|
+
if on_error == "raise":
|
|
1272
|
+
raise
|
|
1273
|
+
# skip: continue without this opportunity
|
|
1274
|
+
except Exception as e:
|
|
1275
|
+
if on_error == "raise":
|
|
1276
|
+
# Wrap non-SDK errors with context, preserving chain
|
|
1277
|
+
raise AffinityError(
|
|
1278
|
+
f"Failed to get associations for opportunity {opp_id}: {e}"
|
|
1279
|
+
) from e
|
|
1280
|
+
# skip: continue without this opportunity
|
|
1281
|
+
return result
|
|
1282
|
+
|
|
1283
|
+
async def get_associated_company_ids_batch(
|
|
1284
|
+
self,
|
|
1285
|
+
opportunity_ids: Sequence[OpportunityId],
|
|
1286
|
+
*,
|
|
1287
|
+
on_error: Literal["raise", "skip"] = "raise",
|
|
1288
|
+
) -> dict[OpportunityId, builtins.list[CompanyId]]:
|
|
1289
|
+
"""
|
|
1290
|
+
Get company associations for multiple opportunities.
|
|
1291
|
+
|
|
1292
|
+
Makes one V1 API call per opportunity. Useful for iterating list entries
|
|
1293
|
+
where V2 returns empty company_ids.
|
|
1294
|
+
|
|
1295
|
+
Args:
|
|
1296
|
+
opportunity_ids: Sequence of opportunity IDs to fetch
|
|
1297
|
+
on_error: How to handle errors - "raise" (default) or "skip" failed IDs
|
|
1298
|
+
|
|
1299
|
+
Returns:
|
|
1300
|
+
Dict mapping opportunity_id -> list of company_ids
|
|
1301
|
+
|
|
1302
|
+
Raises:
|
|
1303
|
+
AffinityError: If on_error="raise" and any fetch fails. The exception
|
|
1304
|
+
includes the failing opportunity_id in its context.
|
|
1305
|
+
|
|
1306
|
+
Example:
|
|
1307
|
+
# Get all companies from an opportunity list
|
|
1308
|
+
opp_ids = [entry.entity.id async for entry in client.lists.entries(list_id).all()]
|
|
1309
|
+
associations = await client.opportunities.get_associated_company_ids_batch(opp_ids)
|
|
1310
|
+
all_company_ids = set()
|
|
1311
|
+
for company_ids in associations.values():
|
|
1312
|
+
all_company_ids.update(company_ids)
|
|
1313
|
+
"""
|
|
1314
|
+
result: dict[OpportunityId, builtins.list[CompanyId]] = {}
|
|
1315
|
+
for opp_id in opportunity_ids:
|
|
1316
|
+
try:
|
|
1317
|
+
result[opp_id] = await self.get_associated_company_ids(opp_id)
|
|
1318
|
+
except AffinityError:
|
|
1319
|
+
# Re-raise SDK errors directly - they already have good context
|
|
1320
|
+
if on_error == "raise":
|
|
1321
|
+
raise
|
|
1322
|
+
# skip: continue without this opportunity
|
|
1323
|
+
except Exception as e:
|
|
1324
|
+
if on_error == "raise":
|
|
1325
|
+
# Wrap non-SDK errors with context, preserving chain
|
|
1326
|
+
raise AffinityError(
|
|
1327
|
+
f"Failed to get company associations for opportunity {opp_id}: {e}"
|
|
1328
|
+
) from e
|
|
1329
|
+
# skip: continue without this opportunity
|
|
1330
|
+
return result
|