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,1348 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Person service.
|
|
3
|
+
|
|
4
|
+
Provides operations for managing persons (contacts) in Affinity.
|
|
5
|
+
Uses V2 API for reading, V1 API for writing.
|
|
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
|
|
13
|
+
|
|
14
|
+
from ..exceptions import BetaEndpointDisabledError
|
|
15
|
+
from ..filters import FilterExpression
|
|
16
|
+
from ..models.entities import (
|
|
17
|
+
FieldMetadata,
|
|
18
|
+
ListEntry,
|
|
19
|
+
ListSummary,
|
|
20
|
+
Person,
|
|
21
|
+
PersonCreate,
|
|
22
|
+
PersonUpdate,
|
|
23
|
+
)
|
|
24
|
+
from ..models.pagination import (
|
|
25
|
+
AsyncPageIterator,
|
|
26
|
+
PageIterator,
|
|
27
|
+
PaginatedResponse,
|
|
28
|
+
PaginationInfo,
|
|
29
|
+
V1PaginatedResponse,
|
|
30
|
+
)
|
|
31
|
+
from ..models.secondary import MergeTask
|
|
32
|
+
from ..models.types import AnyFieldId, CompanyId, FieldType, OpportunityId, PersonId
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from ..clients.http import AsyncHTTPClient, HTTPClient
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _person_matches(person: Person, *, email: str | None, name: str | None) -> bool:
|
|
39
|
+
if email:
|
|
40
|
+
email_lower = email.lower()
|
|
41
|
+
if person.primary_email and person.primary_email.lower() == email_lower:
|
|
42
|
+
return True
|
|
43
|
+
if person.emails:
|
|
44
|
+
for addr in person.emails:
|
|
45
|
+
if addr.lower() == email_lower:
|
|
46
|
+
return True
|
|
47
|
+
if name:
|
|
48
|
+
name_lower = name.lower()
|
|
49
|
+
full_name = f"{person.first_name or ''} {person.last_name or ''}".strip()
|
|
50
|
+
if full_name.lower() == name_lower:
|
|
51
|
+
return True
|
|
52
|
+
return False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# V1 → V2 key mapping for person responses
|
|
56
|
+
_V1_TO_V2_KEYS = {
|
|
57
|
+
"first_name": "firstName",
|
|
58
|
+
"last_name": "lastName",
|
|
59
|
+
"primary_email_address": "primaryEmailAddress",
|
|
60
|
+
"email_addresses": "emailAddresses",
|
|
61
|
+
"organization_ids": "organizationIds",
|
|
62
|
+
"opportunity_ids": "opportunityIds",
|
|
63
|
+
"current_organization_ids": "currentOrganizationIds",
|
|
64
|
+
"list_entry_id": "listEntryId",
|
|
65
|
+
"interaction_dates": "interactionDates",
|
|
66
|
+
"created_at": "createdAt",
|
|
67
|
+
"updated_at": "updatedAt",
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _normalize_v1_person_response(data: dict[str, Any]) -> dict[str, Any]:
|
|
72
|
+
"""
|
|
73
|
+
Normalize V1 person response to match V2 schema.
|
|
74
|
+
|
|
75
|
+
V1 uses snake_case (first_name), V2 uses camelCase (firstName).
|
|
76
|
+
The Person model uses aliases, so we need consistent key names.
|
|
77
|
+
|
|
78
|
+
Implementation notes:
|
|
79
|
+
- Maps snake_case keys to camelCase as needed
|
|
80
|
+
- Strips unknown keys to avoid Pydantic strict mode errors
|
|
81
|
+
- Handles nested field_values entries appropriately
|
|
82
|
+
- V1 may include field values for deleted fields; these are preserved
|
|
83
|
+
"""
|
|
84
|
+
result: dict[str, Any] = {}
|
|
85
|
+
|
|
86
|
+
for key, value in data.items():
|
|
87
|
+
# Skip field_values - handled separately
|
|
88
|
+
if key == "field_values":
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
# Map known V1 keys to V2
|
|
92
|
+
if key in _V1_TO_V2_KEYS:
|
|
93
|
+
result[_V1_TO_V2_KEYS[key]] = value
|
|
94
|
+
else:
|
|
95
|
+
# Keep as-is (id, type, emails, etc. are same in both)
|
|
96
|
+
result[key] = value
|
|
97
|
+
|
|
98
|
+
return result
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class PersonService:
|
|
102
|
+
"""
|
|
103
|
+
Service for managing persons (contacts).
|
|
104
|
+
|
|
105
|
+
Uses V2 API for efficient reading with field selection,
|
|
106
|
+
V1 API for create/update/delete operations.
|
|
107
|
+
"""
|
|
108
|
+
|
|
109
|
+
def __init__(self, client: HTTPClient):
|
|
110
|
+
self._client = client
|
|
111
|
+
|
|
112
|
+
# =========================================================================
|
|
113
|
+
# Read Operations (V2 API)
|
|
114
|
+
# =========================================================================
|
|
115
|
+
|
|
116
|
+
def list(
|
|
117
|
+
self,
|
|
118
|
+
*,
|
|
119
|
+
ids: Sequence[PersonId] | None = None,
|
|
120
|
+
field_ids: Sequence[AnyFieldId] | None = None,
|
|
121
|
+
field_types: Sequence[FieldType] | None = None,
|
|
122
|
+
filter: str | FilterExpression | None = None,
|
|
123
|
+
limit: int | None = None,
|
|
124
|
+
cursor: str | None = None,
|
|
125
|
+
) -> PaginatedResponse[Person]:
|
|
126
|
+
"""
|
|
127
|
+
Get a page of persons.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
ids: Specific person IDs to fetch (batch lookup)
|
|
131
|
+
field_ids: Specific field IDs to include in response
|
|
132
|
+
field_types: Field types to include
|
|
133
|
+
filter: V2 filter expression string, or a FilterExpression built via `affinity.F`
|
|
134
|
+
limit: Maximum number of results
|
|
135
|
+
cursor: Cursor to resume pagination (opaque; obtained from prior responses)
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
Paginated response with persons
|
|
139
|
+
"""
|
|
140
|
+
if cursor is not None:
|
|
141
|
+
if any(p is not None for p in (ids, field_ids, field_types, filter, limit)):
|
|
142
|
+
raise ValueError(
|
|
143
|
+
"Cannot combine 'cursor' with other parameters; cursor encodes all query "
|
|
144
|
+
"context. Start a new pagination sequence without a cursor to change "
|
|
145
|
+
"parameters."
|
|
146
|
+
)
|
|
147
|
+
data = self._client.get_url(cursor)
|
|
148
|
+
else:
|
|
149
|
+
params: dict[str, Any] = {}
|
|
150
|
+
if ids:
|
|
151
|
+
params["ids"] = [int(id_) for id_ in ids]
|
|
152
|
+
if field_ids:
|
|
153
|
+
params["fieldIds"] = [str(field_id) for field_id in field_ids]
|
|
154
|
+
if field_types:
|
|
155
|
+
params["fieldTypes"] = [field_type.value for field_type in field_types]
|
|
156
|
+
if filter is not None:
|
|
157
|
+
filter_text = str(filter).strip()
|
|
158
|
+
if filter_text:
|
|
159
|
+
params["filter"] = filter_text
|
|
160
|
+
if limit:
|
|
161
|
+
params["limit"] = limit
|
|
162
|
+
data = self._client.get("/persons", params=params or None)
|
|
163
|
+
|
|
164
|
+
return PaginatedResponse[Person](
|
|
165
|
+
data=[Person.model_validate(p) for p in data.get("data", [])],
|
|
166
|
+
pagination=PaginationInfo.model_validate(data.get("pagination", {})),
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
def pages(
|
|
170
|
+
self,
|
|
171
|
+
*,
|
|
172
|
+
ids: Sequence[PersonId] | None = None,
|
|
173
|
+
field_ids: Sequence[AnyFieldId] | None = None,
|
|
174
|
+
field_types: Sequence[FieldType] | None = None,
|
|
175
|
+
filter: str | FilterExpression | None = None,
|
|
176
|
+
limit: int | None = None,
|
|
177
|
+
cursor: str | None = None,
|
|
178
|
+
) -> Iterator[PaginatedResponse[Person]]:
|
|
179
|
+
"""
|
|
180
|
+
Iterate person pages (not items), yielding `PaginatedResponse[Person]`.
|
|
181
|
+
|
|
182
|
+
Useful for ETL scripts that need checkpoint/resume via `page.next_cursor`.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
ids: Specific person IDs to fetch (batch lookup)
|
|
186
|
+
field_ids: Specific field IDs to include in response
|
|
187
|
+
field_types: Field types to include
|
|
188
|
+
filter: V2 filter expression string or FilterExpression
|
|
189
|
+
limit: Maximum results per page
|
|
190
|
+
cursor: Cursor to resume pagination
|
|
191
|
+
|
|
192
|
+
Yields:
|
|
193
|
+
PaginatedResponse[Person] for each page
|
|
194
|
+
"""
|
|
195
|
+
other_params = (ids, field_ids, field_types, filter, limit)
|
|
196
|
+
if cursor is not None and any(p is not None for p in other_params):
|
|
197
|
+
raise ValueError(
|
|
198
|
+
"Cannot combine 'cursor' with other parameters; cursor encodes all query context. "
|
|
199
|
+
"Start a new pagination sequence without a cursor to change parameters."
|
|
200
|
+
)
|
|
201
|
+
requested_cursor = cursor
|
|
202
|
+
page = (
|
|
203
|
+
self.list(cursor=cursor)
|
|
204
|
+
if cursor is not None
|
|
205
|
+
else self.list(
|
|
206
|
+
ids=ids, field_ids=field_ids, field_types=field_types, filter=filter, limit=limit
|
|
207
|
+
)
|
|
208
|
+
)
|
|
209
|
+
while True:
|
|
210
|
+
yield page
|
|
211
|
+
if not page.has_next:
|
|
212
|
+
return
|
|
213
|
+
next_cursor = page.next_cursor
|
|
214
|
+
if next_cursor is None or next_cursor == requested_cursor:
|
|
215
|
+
return
|
|
216
|
+
requested_cursor = next_cursor
|
|
217
|
+
page = self.list(cursor=next_cursor)
|
|
218
|
+
|
|
219
|
+
def all(
|
|
220
|
+
self,
|
|
221
|
+
*,
|
|
222
|
+
ids: Sequence[PersonId] | None = None,
|
|
223
|
+
field_ids: Sequence[AnyFieldId] | None = None,
|
|
224
|
+
field_types: Sequence[FieldType] | None = None,
|
|
225
|
+
filter: str | FilterExpression | None = None,
|
|
226
|
+
) -> Iterator[Person]:
|
|
227
|
+
"""
|
|
228
|
+
Iterate through all persons with automatic pagination.
|
|
229
|
+
|
|
230
|
+
Args:
|
|
231
|
+
ids: Specific person IDs to fetch (batch lookup)
|
|
232
|
+
field_ids: Specific field IDs to include
|
|
233
|
+
field_types: Field types to include
|
|
234
|
+
filter: V2 filter expression
|
|
235
|
+
|
|
236
|
+
Yields:
|
|
237
|
+
Person objects
|
|
238
|
+
"""
|
|
239
|
+
|
|
240
|
+
def fetch_page(next_url: str | None) -> PaginatedResponse[Person]:
|
|
241
|
+
if next_url:
|
|
242
|
+
data = self._client.get_url(next_url)
|
|
243
|
+
return PaginatedResponse[Person](
|
|
244
|
+
data=[Person.model_validate(p) for p in data.get("data", [])],
|
|
245
|
+
pagination=PaginationInfo.model_validate(data.get("pagination", {})),
|
|
246
|
+
)
|
|
247
|
+
return self.list(
|
|
248
|
+
ids=ids,
|
|
249
|
+
field_ids=field_ids,
|
|
250
|
+
field_types=field_types,
|
|
251
|
+
filter=filter,
|
|
252
|
+
)
|
|
253
|
+
|
|
254
|
+
return PageIterator(fetch_page)
|
|
255
|
+
|
|
256
|
+
def iter(
|
|
257
|
+
self,
|
|
258
|
+
*,
|
|
259
|
+
ids: Sequence[PersonId] | None = None,
|
|
260
|
+
field_ids: Sequence[AnyFieldId] | None = None,
|
|
261
|
+
field_types: Sequence[FieldType] | None = None,
|
|
262
|
+
filter: str | FilterExpression | None = None,
|
|
263
|
+
) -> Iterator[Person]:
|
|
264
|
+
"""
|
|
265
|
+
Auto-paginate all persons.
|
|
266
|
+
|
|
267
|
+
Alias for `all()` (FR-006 public contract).
|
|
268
|
+
"""
|
|
269
|
+
return self.all(ids=ids, field_ids=field_ids, field_types=field_types, filter=filter)
|
|
270
|
+
|
|
271
|
+
def get(
|
|
272
|
+
self,
|
|
273
|
+
person_id: PersonId,
|
|
274
|
+
*,
|
|
275
|
+
field_ids: Sequence[AnyFieldId] | None = None,
|
|
276
|
+
field_types: Sequence[FieldType] | None = None,
|
|
277
|
+
include_field_values: bool = False,
|
|
278
|
+
) -> Person:
|
|
279
|
+
"""
|
|
280
|
+
Get a single person by ID.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
person_id: The person ID
|
|
284
|
+
field_ids: Specific field IDs to include (V2 API)
|
|
285
|
+
field_types: Field types to include (V2 API)
|
|
286
|
+
include_field_values: If True, use V1 API to fetch embedded field values.
|
|
287
|
+
This saves one API call when you need both person info and field values.
|
|
288
|
+
Note: V1 response is ~2-3x larger than V2; consider this when fetching
|
|
289
|
+
many persons. V1 may include field values for deleted fields.
|
|
290
|
+
|
|
291
|
+
Returns:
|
|
292
|
+
Person object with requested field data.
|
|
293
|
+
When include_field_values=True, the Person will have a `field_values`
|
|
294
|
+
attribute containing the list of FieldValue objects.
|
|
295
|
+
"""
|
|
296
|
+
if include_field_values:
|
|
297
|
+
# Use V1 API which returns field values embedded
|
|
298
|
+
data = self._client.get(f"/persons/{person_id}", v1=True)
|
|
299
|
+
|
|
300
|
+
# Extract field_values before normalization
|
|
301
|
+
field_values_data = data.get("field_values", [])
|
|
302
|
+
|
|
303
|
+
# Normalize V1 response to V2 schema
|
|
304
|
+
normalized = _normalize_v1_person_response(data)
|
|
305
|
+
person = Person.model_validate(normalized)
|
|
306
|
+
|
|
307
|
+
# Attach field_values as an attribute
|
|
308
|
+
# Using object.__setattr__ to bypass Pydantic's frozen/immutable if needed
|
|
309
|
+
object.__setattr__(person, "field_values", field_values_data)
|
|
310
|
+
|
|
311
|
+
return person
|
|
312
|
+
|
|
313
|
+
# Standard V2 path
|
|
314
|
+
params: dict[str, Any] = {}
|
|
315
|
+
if field_ids:
|
|
316
|
+
params["fieldIds"] = [str(field_id) for field_id in field_ids]
|
|
317
|
+
if field_types:
|
|
318
|
+
params["fieldTypes"] = [field_type.value for field_type in field_types]
|
|
319
|
+
|
|
320
|
+
data = self._client.get(
|
|
321
|
+
f"/persons/{person_id}",
|
|
322
|
+
params=params or None,
|
|
323
|
+
)
|
|
324
|
+
return Person.model_validate(data)
|
|
325
|
+
|
|
326
|
+
def get_list_entries(
|
|
327
|
+
self,
|
|
328
|
+
person_id: PersonId,
|
|
329
|
+
) -> PaginatedResponse[ListEntry]:
|
|
330
|
+
"""Get all list entries for a person across all lists."""
|
|
331
|
+
data = self._client.get(f"/persons/{person_id}/list-entries")
|
|
332
|
+
|
|
333
|
+
return PaginatedResponse[ListEntry](
|
|
334
|
+
data=[ListEntry.model_validate(e) for e in data.get("data", [])],
|
|
335
|
+
pagination=PaginationInfo.model_validate(data.get("pagination", {})),
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
def get_lists(
|
|
339
|
+
self,
|
|
340
|
+
person_id: PersonId,
|
|
341
|
+
) -> PaginatedResponse[ListSummary]:
|
|
342
|
+
"""Get all lists that contain this person."""
|
|
343
|
+
data = self._client.get(f"/persons/{person_id}/lists")
|
|
344
|
+
|
|
345
|
+
return PaginatedResponse[ListSummary](
|
|
346
|
+
data=[ListSummary.model_validate(item) for item in data.get("data", [])],
|
|
347
|
+
pagination=PaginationInfo.model_validate(data.get("pagination", {})),
|
|
348
|
+
)
|
|
349
|
+
|
|
350
|
+
def get_fields(
|
|
351
|
+
self,
|
|
352
|
+
*,
|
|
353
|
+
field_types: Sequence[FieldType] | None = None,
|
|
354
|
+
) -> builtins.list[FieldMetadata]:
|
|
355
|
+
"""
|
|
356
|
+
Get metadata about person fields.
|
|
357
|
+
|
|
358
|
+
Cached for performance.
|
|
359
|
+
"""
|
|
360
|
+
params: dict[str, Any] = {}
|
|
361
|
+
if field_types:
|
|
362
|
+
params["fieldTypes"] = [field_type.value for field_type in field_types]
|
|
363
|
+
|
|
364
|
+
data = self._client.get(
|
|
365
|
+
"/persons/fields",
|
|
366
|
+
params=params or None,
|
|
367
|
+
cache_key=f"person_fields:{','.join(field_types or [])}",
|
|
368
|
+
cache_ttl=300,
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
return [FieldMetadata.model_validate(f) for f in data.get("data", [])]
|
|
372
|
+
|
|
373
|
+
# =========================================================================
|
|
374
|
+
# Associations (V1 API)
|
|
375
|
+
# =========================================================================
|
|
376
|
+
|
|
377
|
+
def get_associated_company_ids(
|
|
378
|
+
self,
|
|
379
|
+
person_id: PersonId,
|
|
380
|
+
*,
|
|
381
|
+
max_results: int | None = None,
|
|
382
|
+
) -> builtins.list[CompanyId]:
|
|
383
|
+
"""
|
|
384
|
+
Get associated company IDs for a person.
|
|
385
|
+
|
|
386
|
+
V1-only: V2 does not expose person -> company associations directly.
|
|
387
|
+
Uses GET `/persons/{id}` (V1) and returns `organization_ids`.
|
|
388
|
+
|
|
389
|
+
Args:
|
|
390
|
+
person_id: The person ID
|
|
391
|
+
max_results: Maximum number of company IDs to return
|
|
392
|
+
|
|
393
|
+
Returns:
|
|
394
|
+
List of CompanyId values associated with this person
|
|
395
|
+
|
|
396
|
+
Note:
|
|
397
|
+
The Person model already has `company_ids` populated from V1's
|
|
398
|
+
`organizationIds` field. This method provides API parity with
|
|
399
|
+
`CompanyService.get_associated_person_ids()`.
|
|
400
|
+
"""
|
|
401
|
+
data = self._client.get(f"/persons/{person_id}", v1=True)
|
|
402
|
+
# Defensive: handle potential {"person": {...}} wrapper
|
|
403
|
+
# (consistent with CompanyService.get_associated_person_ids pattern)
|
|
404
|
+
person = data.get("person") if isinstance(data, dict) else None
|
|
405
|
+
source = person if isinstance(person, dict) else data
|
|
406
|
+
org_ids = None
|
|
407
|
+
if isinstance(source, dict):
|
|
408
|
+
org_ids = source.get("organization_ids") or source.get("organizationIds")
|
|
409
|
+
|
|
410
|
+
if not isinstance(org_ids, list):
|
|
411
|
+
return []
|
|
412
|
+
|
|
413
|
+
ids = [CompanyId(int(cid)) for cid in org_ids if cid is not None]
|
|
414
|
+
if max_results is not None and max_results >= 0:
|
|
415
|
+
return ids[:max_results]
|
|
416
|
+
return ids
|
|
417
|
+
|
|
418
|
+
def get_associated_opportunity_ids(
|
|
419
|
+
self,
|
|
420
|
+
person_id: PersonId,
|
|
421
|
+
*,
|
|
422
|
+
max_results: int | None = None,
|
|
423
|
+
) -> builtins.list[OpportunityId]:
|
|
424
|
+
"""
|
|
425
|
+
Get associated opportunity IDs for a person.
|
|
426
|
+
|
|
427
|
+
V1-only: V2 does not expose person -> opportunity associations directly.
|
|
428
|
+
Uses GET `/persons/{id}` (V1) and returns `opportunity_ids`.
|
|
429
|
+
|
|
430
|
+
Args:
|
|
431
|
+
person_id: The person ID
|
|
432
|
+
max_results: Maximum number of opportunity IDs to return
|
|
433
|
+
|
|
434
|
+
Returns:
|
|
435
|
+
List of OpportunityId values associated with this person
|
|
436
|
+
|
|
437
|
+
Note:
|
|
438
|
+
The Person model already has `opportunity_ids` populated from V1's
|
|
439
|
+
`opportunityIds` field. This method provides API parity with
|
|
440
|
+
`OpportunityService.get_associated_person_ids()`.
|
|
441
|
+
"""
|
|
442
|
+
data = self._client.get(f"/persons/{person_id}", v1=True)
|
|
443
|
+
# Defensive: handle potential {"person": {...}} wrapper
|
|
444
|
+
person = data.get("person") if isinstance(data, dict) else None
|
|
445
|
+
source = person if isinstance(person, dict) else data
|
|
446
|
+
opp_ids = None
|
|
447
|
+
if isinstance(source, dict):
|
|
448
|
+
opp_ids = source.get("opportunity_ids") or source.get("opportunityIds")
|
|
449
|
+
|
|
450
|
+
if not isinstance(opp_ids, list):
|
|
451
|
+
return []
|
|
452
|
+
|
|
453
|
+
ids = [OpportunityId(int(oid)) for oid in opp_ids if oid is not None]
|
|
454
|
+
if max_results is not None and max_results >= 0:
|
|
455
|
+
return ids[:max_results]
|
|
456
|
+
return ids
|
|
457
|
+
|
|
458
|
+
# =========================================================================
|
|
459
|
+
# Search (V1 API)
|
|
460
|
+
# =========================================================================
|
|
461
|
+
|
|
462
|
+
def search(
|
|
463
|
+
self,
|
|
464
|
+
term: str,
|
|
465
|
+
*,
|
|
466
|
+
with_interaction_dates: bool = False,
|
|
467
|
+
with_interaction_persons: bool = False,
|
|
468
|
+
with_opportunities: bool = False,
|
|
469
|
+
page_size: int | None = None,
|
|
470
|
+
page_token: str | None = None,
|
|
471
|
+
) -> V1PaginatedResponse[Person]:
|
|
472
|
+
"""
|
|
473
|
+
Search for persons by name or email.
|
|
474
|
+
|
|
475
|
+
Uses V1 API for search functionality.
|
|
476
|
+
|
|
477
|
+
Args:
|
|
478
|
+
term: Search term (name or email)
|
|
479
|
+
with_interaction_dates: Include interaction date data
|
|
480
|
+
with_interaction_persons: Include persons for interactions
|
|
481
|
+
with_opportunities: Include associated opportunity IDs
|
|
482
|
+
page_size: Results per page (max 500)
|
|
483
|
+
page_token: Pagination token
|
|
484
|
+
|
|
485
|
+
Returns:
|
|
486
|
+
Dict with 'persons' and 'next_page_token'
|
|
487
|
+
"""
|
|
488
|
+
params: dict[str, Any] = {"term": term}
|
|
489
|
+
if with_interaction_dates:
|
|
490
|
+
params["with_interaction_dates"] = True
|
|
491
|
+
if with_interaction_persons:
|
|
492
|
+
params["with_interaction_persons"] = True
|
|
493
|
+
if with_opportunities:
|
|
494
|
+
params["with_opportunities"] = True
|
|
495
|
+
if page_size:
|
|
496
|
+
params["page_size"] = page_size
|
|
497
|
+
if page_token:
|
|
498
|
+
params["page_token"] = page_token
|
|
499
|
+
|
|
500
|
+
data = self._client.get("/persons", params=params, v1=True)
|
|
501
|
+
items = [Person.model_validate(p) for p in data.get("persons", [])]
|
|
502
|
+
return V1PaginatedResponse[Person](
|
|
503
|
+
data=items,
|
|
504
|
+
next_page_token=data.get("next_page_token"),
|
|
505
|
+
)
|
|
506
|
+
|
|
507
|
+
def search_pages(
|
|
508
|
+
self,
|
|
509
|
+
term: str,
|
|
510
|
+
*,
|
|
511
|
+
with_interaction_dates: bool = False,
|
|
512
|
+
with_interaction_persons: bool = False,
|
|
513
|
+
with_opportunities: bool = False,
|
|
514
|
+
page_size: int | None = None,
|
|
515
|
+
page_token: str | None = None,
|
|
516
|
+
) -> Iterator[V1PaginatedResponse[Person]]:
|
|
517
|
+
"""
|
|
518
|
+
Iterate V1 person-search result pages.
|
|
519
|
+
|
|
520
|
+
Useful for scripts that need checkpoint/resume via `next_page_token`.
|
|
521
|
+
|
|
522
|
+
Args:
|
|
523
|
+
term: Search term (name or email)
|
|
524
|
+
with_interaction_dates: Include interaction date data
|
|
525
|
+
with_interaction_persons: Include persons for interactions
|
|
526
|
+
with_opportunities: Include associated opportunity IDs
|
|
527
|
+
page_size: Results per page (max 500)
|
|
528
|
+
page_token: Resume from this pagination token
|
|
529
|
+
|
|
530
|
+
Yields:
|
|
531
|
+
V1PaginatedResponse[Person] for each page
|
|
532
|
+
"""
|
|
533
|
+
requested_token = page_token
|
|
534
|
+
page = self.search(
|
|
535
|
+
term,
|
|
536
|
+
with_interaction_dates=with_interaction_dates,
|
|
537
|
+
with_interaction_persons=with_interaction_persons,
|
|
538
|
+
with_opportunities=with_opportunities,
|
|
539
|
+
page_size=page_size,
|
|
540
|
+
page_token=page_token,
|
|
541
|
+
)
|
|
542
|
+
while True:
|
|
543
|
+
yield page
|
|
544
|
+
next_token = page.next_page_token
|
|
545
|
+
if not next_token or next_token == requested_token:
|
|
546
|
+
return
|
|
547
|
+
requested_token = next_token
|
|
548
|
+
page = self.search(
|
|
549
|
+
term,
|
|
550
|
+
with_interaction_dates=with_interaction_dates,
|
|
551
|
+
with_interaction_persons=with_interaction_persons,
|
|
552
|
+
with_opportunities=with_opportunities,
|
|
553
|
+
page_size=page_size,
|
|
554
|
+
page_token=next_token,
|
|
555
|
+
)
|
|
556
|
+
|
|
557
|
+
def search_all(
|
|
558
|
+
self,
|
|
559
|
+
term: str,
|
|
560
|
+
*,
|
|
561
|
+
with_interaction_dates: bool = False,
|
|
562
|
+
with_interaction_persons: bool = False,
|
|
563
|
+
with_opportunities: bool = False,
|
|
564
|
+
page_size: int | None = None,
|
|
565
|
+
page_token: str | None = None,
|
|
566
|
+
) -> Iterator[Person]:
|
|
567
|
+
"""
|
|
568
|
+
Iterate all V1 person-search results with automatic pagination.
|
|
569
|
+
|
|
570
|
+
Args:
|
|
571
|
+
term: Search term (name or email)
|
|
572
|
+
with_interaction_dates: Include interaction date data
|
|
573
|
+
with_interaction_persons: Include persons for interactions
|
|
574
|
+
with_opportunities: Include associated opportunity IDs
|
|
575
|
+
page_size: Results per page (max 500)
|
|
576
|
+
page_token: Resume from this pagination token
|
|
577
|
+
|
|
578
|
+
Yields:
|
|
579
|
+
Person objects matching the search term
|
|
580
|
+
"""
|
|
581
|
+
for page in self.search_pages(
|
|
582
|
+
term,
|
|
583
|
+
with_interaction_dates=with_interaction_dates,
|
|
584
|
+
with_interaction_persons=with_interaction_persons,
|
|
585
|
+
with_opportunities=with_opportunities,
|
|
586
|
+
page_size=page_size,
|
|
587
|
+
page_token=page_token,
|
|
588
|
+
):
|
|
589
|
+
yield from page.data
|
|
590
|
+
|
|
591
|
+
def resolve(
|
|
592
|
+
self,
|
|
593
|
+
*,
|
|
594
|
+
email: str | None = None,
|
|
595
|
+
name: str | None = None,
|
|
596
|
+
) -> Person | None:
|
|
597
|
+
"""
|
|
598
|
+
Find a single person by email or name.
|
|
599
|
+
|
|
600
|
+
This is a convenience helper that searches and returns the first exact match,
|
|
601
|
+
or None if not found. Uses V1 search internally.
|
|
602
|
+
|
|
603
|
+
Args:
|
|
604
|
+
email: Email address to search for
|
|
605
|
+
name: Person name to search for (first + last)
|
|
606
|
+
|
|
607
|
+
Returns:
|
|
608
|
+
The matching Person, or None if not found
|
|
609
|
+
|
|
610
|
+
Raises:
|
|
611
|
+
ValueError: If neither email nor name is provided
|
|
612
|
+
|
|
613
|
+
Note:
|
|
614
|
+
This auto-paginates V1 search results until a match is found.
|
|
615
|
+
If multiple matches are found, returns the first one. For full
|
|
616
|
+
disambiguation, use resolve_all() or search() directly.
|
|
617
|
+
"""
|
|
618
|
+
if not email and not name:
|
|
619
|
+
raise ValueError("Must provide either email or name")
|
|
620
|
+
|
|
621
|
+
term = email or name or ""
|
|
622
|
+
for page in self.search_pages(term, page_size=10):
|
|
623
|
+
for person in page.data:
|
|
624
|
+
if _person_matches(person, email=email, name=name):
|
|
625
|
+
return person
|
|
626
|
+
|
|
627
|
+
return None
|
|
628
|
+
|
|
629
|
+
def resolve_all(
|
|
630
|
+
self,
|
|
631
|
+
*,
|
|
632
|
+
email: str | None = None,
|
|
633
|
+
name: str | None = None,
|
|
634
|
+
) -> builtins.list[Person]:
|
|
635
|
+
"""
|
|
636
|
+
Find all persons matching an email or name.
|
|
637
|
+
|
|
638
|
+
Notes:
|
|
639
|
+
- This auto-paginates V1 search results to collect exact matches.
|
|
640
|
+
- Unlike resolve(), this returns every match in server-provided order.
|
|
641
|
+
"""
|
|
642
|
+
if not email and not name:
|
|
643
|
+
raise ValueError("Must provide either email or name")
|
|
644
|
+
|
|
645
|
+
term = email or name or ""
|
|
646
|
+
matches: builtins.list[Person] = []
|
|
647
|
+
for page in self.search_pages(term, page_size=10):
|
|
648
|
+
for person in page.data:
|
|
649
|
+
if _person_matches(person, email=email, name=name):
|
|
650
|
+
matches.append(person)
|
|
651
|
+
return matches
|
|
652
|
+
|
|
653
|
+
# =========================================================================
|
|
654
|
+
# Write Operations (V1 API)
|
|
655
|
+
# =========================================================================
|
|
656
|
+
|
|
657
|
+
def create(self, data: PersonCreate) -> Person:
|
|
658
|
+
"""
|
|
659
|
+
Create a new person.
|
|
660
|
+
|
|
661
|
+
Raises:
|
|
662
|
+
ValidationError: If email conflicts with existing person
|
|
663
|
+
"""
|
|
664
|
+
payload = data.model_dump(by_alias=True, mode="json")
|
|
665
|
+
if not data.company_ids:
|
|
666
|
+
payload.pop("organization_ids", None)
|
|
667
|
+
|
|
668
|
+
result = self._client.post("/persons", json=payload, v1=True)
|
|
669
|
+
|
|
670
|
+
if self._client.cache:
|
|
671
|
+
self._client.cache.invalidate_prefix("person")
|
|
672
|
+
|
|
673
|
+
return Person.model_validate(result)
|
|
674
|
+
|
|
675
|
+
def update(
|
|
676
|
+
self,
|
|
677
|
+
person_id: PersonId,
|
|
678
|
+
data: PersonUpdate,
|
|
679
|
+
) -> Person:
|
|
680
|
+
"""
|
|
681
|
+
Update an existing person.
|
|
682
|
+
|
|
683
|
+
Note: To add emails/organizations, include existing values plus new ones.
|
|
684
|
+
"""
|
|
685
|
+
payload = data.model_dump(
|
|
686
|
+
by_alias=True,
|
|
687
|
+
mode="json",
|
|
688
|
+
exclude_unset=True,
|
|
689
|
+
exclude_none=True,
|
|
690
|
+
)
|
|
691
|
+
|
|
692
|
+
result = self._client.put(
|
|
693
|
+
f"/persons/{person_id}",
|
|
694
|
+
json=payload,
|
|
695
|
+
v1=True,
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
if self._client.cache:
|
|
699
|
+
self._client.cache.invalidate_prefix("person")
|
|
700
|
+
|
|
701
|
+
return Person.model_validate(result)
|
|
702
|
+
|
|
703
|
+
def delete(self, person_id: PersonId) -> bool:
|
|
704
|
+
"""Delete a person (also deletes associated field values)."""
|
|
705
|
+
result = self._client.delete(f"/persons/{person_id}", v1=True)
|
|
706
|
+
|
|
707
|
+
if self._client.cache:
|
|
708
|
+
self._client.cache.invalidate_prefix("person")
|
|
709
|
+
|
|
710
|
+
return bool(result.get("success", False))
|
|
711
|
+
|
|
712
|
+
# =========================================================================
|
|
713
|
+
# Merge Operations (V2 BETA)
|
|
714
|
+
# =========================================================================
|
|
715
|
+
|
|
716
|
+
def merge(
|
|
717
|
+
self,
|
|
718
|
+
primary_id: PersonId,
|
|
719
|
+
duplicate_id: PersonId,
|
|
720
|
+
) -> str:
|
|
721
|
+
"""
|
|
722
|
+
Merge a duplicate person into a primary person.
|
|
723
|
+
|
|
724
|
+
Returns a task URL to check merge status.
|
|
725
|
+
"""
|
|
726
|
+
if not self._client.enable_beta_endpoints:
|
|
727
|
+
raise BetaEndpointDisabledError(
|
|
728
|
+
"Person merge is a beta endpoint; set enable_beta_endpoints=True to use it."
|
|
729
|
+
)
|
|
730
|
+
result = self._client.post(
|
|
731
|
+
"/person-merges",
|
|
732
|
+
json={
|
|
733
|
+
"primaryPersonId": int(primary_id),
|
|
734
|
+
"duplicatePersonId": int(duplicate_id),
|
|
735
|
+
},
|
|
736
|
+
)
|
|
737
|
+
return str(result.get("taskUrl", ""))
|
|
738
|
+
|
|
739
|
+
def get_merge_status(self, task_id: str) -> MergeTask:
|
|
740
|
+
"""Check the status of a merge operation."""
|
|
741
|
+
data = self._client.get(f"/tasks/person-merges/{task_id}")
|
|
742
|
+
return MergeTask.model_validate(data)
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
class AsyncPersonService:
|
|
746
|
+
"""Async version of PersonService."""
|
|
747
|
+
|
|
748
|
+
def __init__(self, client: AsyncHTTPClient):
|
|
749
|
+
self._client = client
|
|
750
|
+
|
|
751
|
+
async def list(
|
|
752
|
+
self,
|
|
753
|
+
*,
|
|
754
|
+
ids: Sequence[PersonId] | None = None,
|
|
755
|
+
field_ids: Sequence[AnyFieldId] | None = None,
|
|
756
|
+
field_types: Sequence[FieldType] | None = None,
|
|
757
|
+
filter: str | FilterExpression | None = None,
|
|
758
|
+
limit: int | None = None,
|
|
759
|
+
cursor: str | None = None,
|
|
760
|
+
) -> PaginatedResponse[Person]:
|
|
761
|
+
"""
|
|
762
|
+
Get a page of persons.
|
|
763
|
+
|
|
764
|
+
Args:
|
|
765
|
+
ids: Specific person IDs to fetch (batch lookup)
|
|
766
|
+
field_ids: Specific field IDs to include in response
|
|
767
|
+
field_types: Field types to include
|
|
768
|
+
filter: V2 filter expression string, or a FilterExpression built via `affinity.F`
|
|
769
|
+
limit: Maximum number of results
|
|
770
|
+
cursor: Cursor to resume pagination (opaque; obtained from prior responses)
|
|
771
|
+
|
|
772
|
+
Returns:
|
|
773
|
+
Paginated response with persons
|
|
774
|
+
"""
|
|
775
|
+
if cursor is not None:
|
|
776
|
+
if any(p is not None for p in (ids, field_ids, field_types, filter, limit)):
|
|
777
|
+
raise ValueError(
|
|
778
|
+
"Cannot combine 'cursor' with other parameters; cursor encodes all query "
|
|
779
|
+
"context. Start a new pagination sequence without a cursor to change "
|
|
780
|
+
"parameters."
|
|
781
|
+
)
|
|
782
|
+
data = await self._client.get_url(cursor)
|
|
783
|
+
else:
|
|
784
|
+
params: dict[str, Any] = {}
|
|
785
|
+
if ids:
|
|
786
|
+
params["ids"] = [int(id_) for id_ in ids]
|
|
787
|
+
if field_ids:
|
|
788
|
+
params["fieldIds"] = [str(field_id) for field_id in field_ids]
|
|
789
|
+
if field_types:
|
|
790
|
+
params["fieldTypes"] = [field_type.value for field_type in field_types]
|
|
791
|
+
if filter is not None:
|
|
792
|
+
filter_text = str(filter).strip()
|
|
793
|
+
if filter_text:
|
|
794
|
+
params["filter"] = filter_text
|
|
795
|
+
if limit:
|
|
796
|
+
params["limit"] = limit
|
|
797
|
+
data = await self._client.get("/persons", params=params or None)
|
|
798
|
+
|
|
799
|
+
return PaginatedResponse[Person](
|
|
800
|
+
data=[Person.model_validate(p) for p in data.get("data", [])],
|
|
801
|
+
pagination=PaginationInfo.model_validate(data.get("pagination", {})),
|
|
802
|
+
)
|
|
803
|
+
|
|
804
|
+
async def pages(
|
|
805
|
+
self,
|
|
806
|
+
*,
|
|
807
|
+
ids: Sequence[PersonId] | None = None,
|
|
808
|
+
field_ids: Sequence[AnyFieldId] | None = None,
|
|
809
|
+
field_types: Sequence[FieldType] | None = None,
|
|
810
|
+
filter: str | FilterExpression | None = None,
|
|
811
|
+
limit: int | None = None,
|
|
812
|
+
cursor: str | None = None,
|
|
813
|
+
) -> AsyncIterator[PaginatedResponse[Person]]:
|
|
814
|
+
"""
|
|
815
|
+
Iterate person pages (not items), yielding `PaginatedResponse[Person]`.
|
|
816
|
+
|
|
817
|
+
Useful for ETL scripts that need checkpoint/resume via `page.next_cursor`.
|
|
818
|
+
|
|
819
|
+
Args:
|
|
820
|
+
ids: Specific person IDs to fetch (batch lookup)
|
|
821
|
+
field_ids: Specific field IDs to include in response
|
|
822
|
+
field_types: Field types to include
|
|
823
|
+
filter: V2 filter expression string or FilterExpression
|
|
824
|
+
limit: Maximum results per page
|
|
825
|
+
cursor: Cursor to resume pagination
|
|
826
|
+
|
|
827
|
+
Yields:
|
|
828
|
+
PaginatedResponse[Person] for each page
|
|
829
|
+
"""
|
|
830
|
+
other_params = (ids, field_ids, field_types, filter, limit)
|
|
831
|
+
if cursor is not None and any(p is not None for p in other_params):
|
|
832
|
+
raise ValueError(
|
|
833
|
+
"Cannot combine 'cursor' with other parameters; cursor encodes all query context. "
|
|
834
|
+
"Start a new pagination sequence without a cursor to change parameters."
|
|
835
|
+
)
|
|
836
|
+
requested_cursor = cursor
|
|
837
|
+
if cursor is not None:
|
|
838
|
+
page = await self.list(cursor=cursor)
|
|
839
|
+
else:
|
|
840
|
+
page = await self.list(
|
|
841
|
+
ids=ids,
|
|
842
|
+
field_ids=field_ids,
|
|
843
|
+
field_types=field_types,
|
|
844
|
+
filter=filter,
|
|
845
|
+
limit=limit,
|
|
846
|
+
)
|
|
847
|
+
while True:
|
|
848
|
+
yield page
|
|
849
|
+
if not page.has_next:
|
|
850
|
+
return
|
|
851
|
+
next_cursor = page.next_cursor
|
|
852
|
+
if next_cursor is None or next_cursor == requested_cursor:
|
|
853
|
+
return
|
|
854
|
+
requested_cursor = next_cursor
|
|
855
|
+
page = await self.list(cursor=next_cursor)
|
|
856
|
+
|
|
857
|
+
def all(
|
|
858
|
+
self,
|
|
859
|
+
*,
|
|
860
|
+
ids: Sequence[PersonId] | None = None,
|
|
861
|
+
field_ids: Sequence[AnyFieldId] | None = None,
|
|
862
|
+
field_types: Sequence[FieldType] | None = None,
|
|
863
|
+
filter: str | FilterExpression | None = None,
|
|
864
|
+
) -> AsyncIterator[Person]:
|
|
865
|
+
"""
|
|
866
|
+
Iterate through all persons with automatic pagination.
|
|
867
|
+
|
|
868
|
+
Args:
|
|
869
|
+
ids: Specific person IDs to fetch (batch lookup)
|
|
870
|
+
field_ids: Specific field IDs to include
|
|
871
|
+
field_types: Field types to include
|
|
872
|
+
filter: V2 filter expression
|
|
873
|
+
|
|
874
|
+
Yields:
|
|
875
|
+
Person objects
|
|
876
|
+
"""
|
|
877
|
+
|
|
878
|
+
async def fetch_page(next_url: str | None) -> PaginatedResponse[Person]:
|
|
879
|
+
if next_url:
|
|
880
|
+
data = await self._client.get_url(next_url)
|
|
881
|
+
return PaginatedResponse[Person](
|
|
882
|
+
data=[Person.model_validate(p) for p in data.get("data", [])],
|
|
883
|
+
pagination=PaginationInfo.model_validate(data.get("pagination", {})),
|
|
884
|
+
)
|
|
885
|
+
return await self.list(
|
|
886
|
+
ids=ids, field_ids=field_ids, field_types=field_types, filter=filter
|
|
887
|
+
)
|
|
888
|
+
|
|
889
|
+
return AsyncPageIterator(fetch_page)
|
|
890
|
+
|
|
891
|
+
def iter(
|
|
892
|
+
self,
|
|
893
|
+
*,
|
|
894
|
+
ids: Sequence[PersonId] | None = None,
|
|
895
|
+
field_ids: Sequence[AnyFieldId] | None = None,
|
|
896
|
+
field_types: Sequence[FieldType] | None = None,
|
|
897
|
+
filter: str | FilterExpression | None = None,
|
|
898
|
+
) -> AsyncIterator[Person]:
|
|
899
|
+
"""
|
|
900
|
+
Auto-paginate all persons.
|
|
901
|
+
|
|
902
|
+
Alias for `all()` (FR-006 public contract).
|
|
903
|
+
"""
|
|
904
|
+
return self.all(ids=ids, field_ids=field_ids, field_types=field_types, filter=filter)
|
|
905
|
+
|
|
906
|
+
async def get(
|
|
907
|
+
self,
|
|
908
|
+
person_id: PersonId,
|
|
909
|
+
*,
|
|
910
|
+
field_ids: Sequence[AnyFieldId] | None = None,
|
|
911
|
+
field_types: Sequence[FieldType] | None = None,
|
|
912
|
+
include_field_values: bool = False,
|
|
913
|
+
) -> Person:
|
|
914
|
+
"""
|
|
915
|
+
Get a single person by ID.
|
|
916
|
+
|
|
917
|
+
Args:
|
|
918
|
+
person_id: The person ID
|
|
919
|
+
field_ids: Specific field IDs to include (V2 API)
|
|
920
|
+
field_types: Field types to include (V2 API)
|
|
921
|
+
include_field_values: If True, use V1 API to fetch embedded field values.
|
|
922
|
+
This saves one API call when you need both person info and field values.
|
|
923
|
+
Note: V1 response is ~2-3x larger than V2; consider this when fetching
|
|
924
|
+
many persons. V1 may include field values for deleted fields.
|
|
925
|
+
|
|
926
|
+
Returns:
|
|
927
|
+
Person object with requested field data.
|
|
928
|
+
When include_field_values=True, the Person will have a `field_values`
|
|
929
|
+
attribute containing the list of FieldValue objects.
|
|
930
|
+
"""
|
|
931
|
+
if include_field_values:
|
|
932
|
+
# Use V1 API which returns field values embedded
|
|
933
|
+
data = await self._client.get(f"/persons/{person_id}", v1=True)
|
|
934
|
+
|
|
935
|
+
# Extract field_values before normalization
|
|
936
|
+
field_values_data = data.get("field_values", [])
|
|
937
|
+
|
|
938
|
+
# Normalize V1 response to V2 schema
|
|
939
|
+
normalized = _normalize_v1_person_response(data)
|
|
940
|
+
person = Person.model_validate(normalized)
|
|
941
|
+
|
|
942
|
+
# Attach field_values as an attribute
|
|
943
|
+
object.__setattr__(person, "field_values", field_values_data)
|
|
944
|
+
|
|
945
|
+
return person
|
|
946
|
+
|
|
947
|
+
# Standard V2 path
|
|
948
|
+
params: dict[str, Any] = {}
|
|
949
|
+
if field_ids:
|
|
950
|
+
params["fieldIds"] = [str(field_id) for field_id in field_ids]
|
|
951
|
+
if field_types:
|
|
952
|
+
params["fieldTypes"] = [field_type.value for field_type in field_types]
|
|
953
|
+
|
|
954
|
+
data = await self._client.get(f"/persons/{person_id}", params=params or None)
|
|
955
|
+
return Person.model_validate(data)
|
|
956
|
+
|
|
957
|
+
async def get_list_entries(
|
|
958
|
+
self,
|
|
959
|
+
person_id: PersonId,
|
|
960
|
+
) -> PaginatedResponse[ListEntry]:
|
|
961
|
+
"""Get all list entries for a person across all lists."""
|
|
962
|
+
data = await self._client.get(f"/persons/{person_id}/list-entries")
|
|
963
|
+
|
|
964
|
+
return PaginatedResponse[ListEntry](
|
|
965
|
+
data=[ListEntry.model_validate(e) for e in data.get("data", [])],
|
|
966
|
+
pagination=PaginationInfo.model_validate(data.get("pagination", {})),
|
|
967
|
+
)
|
|
968
|
+
|
|
969
|
+
async def get_lists(
|
|
970
|
+
self,
|
|
971
|
+
person_id: PersonId,
|
|
972
|
+
) -> PaginatedResponse[ListSummary]:
|
|
973
|
+
"""Get all lists that contain this person."""
|
|
974
|
+
data = await self._client.get(f"/persons/{person_id}/lists")
|
|
975
|
+
|
|
976
|
+
return PaginatedResponse[ListSummary](
|
|
977
|
+
data=[ListSummary.model_validate(item) for item in data.get("data", [])],
|
|
978
|
+
pagination=PaginationInfo.model_validate(data.get("pagination", {})),
|
|
979
|
+
)
|
|
980
|
+
|
|
981
|
+
async def get_fields(
|
|
982
|
+
self,
|
|
983
|
+
*,
|
|
984
|
+
field_types: Sequence[FieldType] | None = None,
|
|
985
|
+
) -> builtins.list[FieldMetadata]:
|
|
986
|
+
"""
|
|
987
|
+
Get metadata about person fields.
|
|
988
|
+
|
|
989
|
+
Cached for performance.
|
|
990
|
+
"""
|
|
991
|
+
params: dict[str, Any] = {}
|
|
992
|
+
if field_types:
|
|
993
|
+
params["fieldTypes"] = [field_type.value for field_type in field_types]
|
|
994
|
+
|
|
995
|
+
data = await self._client.get(
|
|
996
|
+
"/persons/fields",
|
|
997
|
+
params=params or None,
|
|
998
|
+
cache_key=f"person_fields:{','.join(field_types or [])}",
|
|
999
|
+
cache_ttl=300,
|
|
1000
|
+
)
|
|
1001
|
+
|
|
1002
|
+
return [FieldMetadata.model_validate(f) for f in data.get("data", [])]
|
|
1003
|
+
|
|
1004
|
+
# =========================================================================
|
|
1005
|
+
# Associations (V1 API)
|
|
1006
|
+
# =========================================================================
|
|
1007
|
+
|
|
1008
|
+
async def get_associated_company_ids(
|
|
1009
|
+
self,
|
|
1010
|
+
person_id: PersonId,
|
|
1011
|
+
*,
|
|
1012
|
+
max_results: int | None = None,
|
|
1013
|
+
) -> builtins.list[CompanyId]:
|
|
1014
|
+
"""
|
|
1015
|
+
Get associated company IDs for a person.
|
|
1016
|
+
|
|
1017
|
+
V1-only: V2 does not expose person -> company associations directly.
|
|
1018
|
+
Uses GET `/persons/{id}` (V1) and returns `organization_ids`.
|
|
1019
|
+
|
|
1020
|
+
Args:
|
|
1021
|
+
person_id: The person ID
|
|
1022
|
+
max_results: Maximum number of company IDs to return
|
|
1023
|
+
|
|
1024
|
+
Returns:
|
|
1025
|
+
List of CompanyId values associated with this person
|
|
1026
|
+
|
|
1027
|
+
Note:
|
|
1028
|
+
The Person model already has `company_ids` populated from V1's
|
|
1029
|
+
`organizationIds` field. This method provides API parity with
|
|
1030
|
+
`CompanyService.get_associated_person_ids()`.
|
|
1031
|
+
"""
|
|
1032
|
+
data = await self._client.get(f"/persons/{person_id}", v1=True)
|
|
1033
|
+
# Defensive: handle potential {"person": {...}} wrapper
|
|
1034
|
+
# (consistent with CompanyService.get_associated_person_ids pattern)
|
|
1035
|
+
person = data.get("person") if isinstance(data, dict) else None
|
|
1036
|
+
source = person if isinstance(person, dict) else data
|
|
1037
|
+
org_ids = None
|
|
1038
|
+
if isinstance(source, dict):
|
|
1039
|
+
org_ids = source.get("organization_ids") or source.get("organizationIds")
|
|
1040
|
+
|
|
1041
|
+
if not isinstance(org_ids, list):
|
|
1042
|
+
return []
|
|
1043
|
+
|
|
1044
|
+
ids = [CompanyId(int(cid)) for cid in org_ids if cid is not None]
|
|
1045
|
+
if max_results is not None and max_results >= 0:
|
|
1046
|
+
return ids[:max_results]
|
|
1047
|
+
return ids
|
|
1048
|
+
|
|
1049
|
+
async def get_associated_opportunity_ids(
|
|
1050
|
+
self,
|
|
1051
|
+
person_id: PersonId,
|
|
1052
|
+
*,
|
|
1053
|
+
max_results: int | None = None,
|
|
1054
|
+
) -> builtins.list[OpportunityId]:
|
|
1055
|
+
"""
|
|
1056
|
+
Get associated opportunity IDs for a person.
|
|
1057
|
+
|
|
1058
|
+
V1-only: V2 does not expose person -> opportunity associations directly.
|
|
1059
|
+
Uses GET `/persons/{id}` (V1) and returns `opportunity_ids`.
|
|
1060
|
+
|
|
1061
|
+
Args:
|
|
1062
|
+
person_id: The person ID
|
|
1063
|
+
max_results: Maximum number of opportunity IDs to return
|
|
1064
|
+
|
|
1065
|
+
Returns:
|
|
1066
|
+
List of OpportunityId values associated with this person
|
|
1067
|
+
|
|
1068
|
+
Note:
|
|
1069
|
+
The Person model already has `opportunity_ids` populated from V1's
|
|
1070
|
+
`opportunityIds` field. This method provides API parity with
|
|
1071
|
+
`OpportunityService.get_associated_person_ids()`.
|
|
1072
|
+
"""
|
|
1073
|
+
data = await self._client.get(f"/persons/{person_id}", v1=True)
|
|
1074
|
+
# Defensive: handle potential {"person": {...}} wrapper
|
|
1075
|
+
person = data.get("person") if isinstance(data, dict) else None
|
|
1076
|
+
source = person if isinstance(person, dict) else data
|
|
1077
|
+
opp_ids = None
|
|
1078
|
+
if isinstance(source, dict):
|
|
1079
|
+
opp_ids = source.get("opportunity_ids") or source.get("opportunityIds")
|
|
1080
|
+
|
|
1081
|
+
if not isinstance(opp_ids, list):
|
|
1082
|
+
return []
|
|
1083
|
+
|
|
1084
|
+
ids = [OpportunityId(int(oid)) for oid in opp_ids if oid is not None]
|
|
1085
|
+
if max_results is not None and max_results >= 0:
|
|
1086
|
+
return ids[:max_results]
|
|
1087
|
+
return ids
|
|
1088
|
+
|
|
1089
|
+
# =========================================================================
|
|
1090
|
+
# Search (V1 API)
|
|
1091
|
+
# =========================================================================
|
|
1092
|
+
|
|
1093
|
+
async def search(
|
|
1094
|
+
self,
|
|
1095
|
+
term: str,
|
|
1096
|
+
*,
|
|
1097
|
+
with_interaction_dates: bool = False,
|
|
1098
|
+
with_interaction_persons: bool = False,
|
|
1099
|
+
with_opportunities: bool = False,
|
|
1100
|
+
page_size: int | None = None,
|
|
1101
|
+
page_token: str | None = None,
|
|
1102
|
+
) -> V1PaginatedResponse[Person]:
|
|
1103
|
+
"""
|
|
1104
|
+
Search for persons by name or email.
|
|
1105
|
+
|
|
1106
|
+
Uses V1 API for search functionality.
|
|
1107
|
+
"""
|
|
1108
|
+
params: dict[str, Any] = {"term": term}
|
|
1109
|
+
if with_interaction_dates:
|
|
1110
|
+
params["with_interaction_dates"] = True
|
|
1111
|
+
if with_interaction_persons:
|
|
1112
|
+
params["with_interaction_persons"] = True
|
|
1113
|
+
if with_opportunities:
|
|
1114
|
+
params["with_opportunities"] = True
|
|
1115
|
+
if page_size:
|
|
1116
|
+
params["page_size"] = page_size
|
|
1117
|
+
if page_token:
|
|
1118
|
+
params["page_token"] = page_token
|
|
1119
|
+
|
|
1120
|
+
data = await self._client.get("/persons", params=params, v1=True)
|
|
1121
|
+
items = [Person.model_validate(p) for p in data.get("persons", [])]
|
|
1122
|
+
return V1PaginatedResponse[Person](
|
|
1123
|
+
data=items,
|
|
1124
|
+
next_page_token=data.get("next_page_token"),
|
|
1125
|
+
)
|
|
1126
|
+
|
|
1127
|
+
async def search_pages(
|
|
1128
|
+
self,
|
|
1129
|
+
term: str,
|
|
1130
|
+
*,
|
|
1131
|
+
with_interaction_dates: bool = False,
|
|
1132
|
+
with_interaction_persons: bool = False,
|
|
1133
|
+
with_opportunities: bool = False,
|
|
1134
|
+
page_size: int | None = None,
|
|
1135
|
+
page_token: str | None = None,
|
|
1136
|
+
) -> AsyncIterator[V1PaginatedResponse[Person]]:
|
|
1137
|
+
"""
|
|
1138
|
+
Iterate V1 person-search result pages.
|
|
1139
|
+
|
|
1140
|
+
Useful for scripts that need checkpoint/resume via `next_page_token`.
|
|
1141
|
+
|
|
1142
|
+
Args:
|
|
1143
|
+
term: Search term (name or email)
|
|
1144
|
+
with_interaction_dates: Include interaction date data
|
|
1145
|
+
with_interaction_persons: Include persons for interactions
|
|
1146
|
+
with_opportunities: Include associated opportunity IDs
|
|
1147
|
+
page_size: Results per page (max 500)
|
|
1148
|
+
page_token: Resume from this pagination token
|
|
1149
|
+
|
|
1150
|
+
Yields:
|
|
1151
|
+
V1PaginatedResponse[Person] for each page
|
|
1152
|
+
"""
|
|
1153
|
+
requested_token = page_token
|
|
1154
|
+
page = await self.search(
|
|
1155
|
+
term,
|
|
1156
|
+
with_interaction_dates=with_interaction_dates,
|
|
1157
|
+
with_interaction_persons=with_interaction_persons,
|
|
1158
|
+
with_opportunities=with_opportunities,
|
|
1159
|
+
page_size=page_size,
|
|
1160
|
+
page_token=page_token,
|
|
1161
|
+
)
|
|
1162
|
+
while True:
|
|
1163
|
+
yield page
|
|
1164
|
+
next_token = page.next_page_token
|
|
1165
|
+
if not next_token or next_token == requested_token:
|
|
1166
|
+
return
|
|
1167
|
+
requested_token = next_token
|
|
1168
|
+
page = await self.search(
|
|
1169
|
+
term,
|
|
1170
|
+
with_interaction_dates=with_interaction_dates,
|
|
1171
|
+
with_interaction_persons=with_interaction_persons,
|
|
1172
|
+
with_opportunities=with_opportunities,
|
|
1173
|
+
page_size=page_size,
|
|
1174
|
+
page_token=next_token,
|
|
1175
|
+
)
|
|
1176
|
+
|
|
1177
|
+
async def search_all(
|
|
1178
|
+
self,
|
|
1179
|
+
term: str,
|
|
1180
|
+
*,
|
|
1181
|
+
with_interaction_dates: bool = False,
|
|
1182
|
+
with_interaction_persons: bool = False,
|
|
1183
|
+
with_opportunities: bool = False,
|
|
1184
|
+
page_size: int | None = None,
|
|
1185
|
+
page_token: str | None = None,
|
|
1186
|
+
) -> AsyncIterator[Person]:
|
|
1187
|
+
"""
|
|
1188
|
+
Iterate all V1 person-search results with automatic pagination.
|
|
1189
|
+
|
|
1190
|
+
Args:
|
|
1191
|
+
term: Search term (name or email)
|
|
1192
|
+
with_interaction_dates: Include interaction date data
|
|
1193
|
+
with_interaction_persons: Include persons for interactions
|
|
1194
|
+
with_opportunities: Include associated opportunity IDs
|
|
1195
|
+
page_size: Results per page (max 500)
|
|
1196
|
+
page_token: Resume from this pagination token
|
|
1197
|
+
|
|
1198
|
+
Yields:
|
|
1199
|
+
Person objects matching the search term
|
|
1200
|
+
"""
|
|
1201
|
+
async for page in self.search_pages(
|
|
1202
|
+
term,
|
|
1203
|
+
with_interaction_dates=with_interaction_dates,
|
|
1204
|
+
with_interaction_persons=with_interaction_persons,
|
|
1205
|
+
with_opportunities=with_opportunities,
|
|
1206
|
+
page_size=page_size,
|
|
1207
|
+
page_token=page_token,
|
|
1208
|
+
):
|
|
1209
|
+
for person in page.data:
|
|
1210
|
+
yield person
|
|
1211
|
+
|
|
1212
|
+
async def resolve(
|
|
1213
|
+
self,
|
|
1214
|
+
*,
|
|
1215
|
+
email: str | None = None,
|
|
1216
|
+
name: str | None = None,
|
|
1217
|
+
) -> Person | None:
|
|
1218
|
+
"""
|
|
1219
|
+
Find a single person by email or name.
|
|
1220
|
+
|
|
1221
|
+
This is a convenience helper that searches and returns the first exact match,
|
|
1222
|
+
or None if not found. Uses V1 search internally.
|
|
1223
|
+
"""
|
|
1224
|
+
if not email and not name:
|
|
1225
|
+
raise ValueError("Must provide either email or name")
|
|
1226
|
+
|
|
1227
|
+
term = email or name or ""
|
|
1228
|
+
async for page in self.search_pages(term, page_size=10):
|
|
1229
|
+
for person in page.data:
|
|
1230
|
+
if _person_matches(person, email=email, name=name):
|
|
1231
|
+
return person
|
|
1232
|
+
|
|
1233
|
+
return None
|
|
1234
|
+
|
|
1235
|
+
async def resolve_all(
|
|
1236
|
+
self,
|
|
1237
|
+
*,
|
|
1238
|
+
email: str | None = None,
|
|
1239
|
+
name: str | None = None,
|
|
1240
|
+
) -> builtins.list[Person]:
|
|
1241
|
+
"""
|
|
1242
|
+
Find all persons matching an email or name.
|
|
1243
|
+
|
|
1244
|
+
Notes:
|
|
1245
|
+
- This auto-paginates V1 search results to collect exact matches.
|
|
1246
|
+
- Unlike resolve(), this returns every match in server-provided order.
|
|
1247
|
+
"""
|
|
1248
|
+
if not email and not name:
|
|
1249
|
+
raise ValueError("Must provide either email or name")
|
|
1250
|
+
|
|
1251
|
+
term = email or name or ""
|
|
1252
|
+
matches: builtins.list[Person] = []
|
|
1253
|
+
async for page in self.search_pages(term, page_size=10):
|
|
1254
|
+
for person in page.data:
|
|
1255
|
+
if _person_matches(person, email=email, name=name):
|
|
1256
|
+
matches.append(person)
|
|
1257
|
+
return matches
|
|
1258
|
+
|
|
1259
|
+
# =========================================================================
|
|
1260
|
+
# Write Operations (V1 API)
|
|
1261
|
+
# =========================================================================
|
|
1262
|
+
|
|
1263
|
+
async def create(self, data: PersonCreate) -> Person:
|
|
1264
|
+
"""
|
|
1265
|
+
Create a new person.
|
|
1266
|
+
|
|
1267
|
+
Raises:
|
|
1268
|
+
ValidationError: If email conflicts with existing person
|
|
1269
|
+
"""
|
|
1270
|
+
payload = data.model_dump(by_alias=True, mode="json")
|
|
1271
|
+
if not data.company_ids:
|
|
1272
|
+
payload.pop("organization_ids", None)
|
|
1273
|
+
|
|
1274
|
+
result = await self._client.post("/persons", json=payload, v1=True)
|
|
1275
|
+
|
|
1276
|
+
if self._client.cache:
|
|
1277
|
+
self._client.cache.invalidate_prefix("person")
|
|
1278
|
+
|
|
1279
|
+
return Person.model_validate(result)
|
|
1280
|
+
|
|
1281
|
+
async def update(
|
|
1282
|
+
self,
|
|
1283
|
+
person_id: PersonId,
|
|
1284
|
+
data: PersonUpdate,
|
|
1285
|
+
) -> Person:
|
|
1286
|
+
"""
|
|
1287
|
+
Update an existing person.
|
|
1288
|
+
|
|
1289
|
+
Note: To add emails/organizations, include existing values plus new ones.
|
|
1290
|
+
"""
|
|
1291
|
+
payload = data.model_dump(
|
|
1292
|
+
by_alias=True,
|
|
1293
|
+
mode="json",
|
|
1294
|
+
exclude_unset=True,
|
|
1295
|
+
exclude_none=True,
|
|
1296
|
+
)
|
|
1297
|
+
|
|
1298
|
+
result = await self._client.put(
|
|
1299
|
+
f"/persons/{person_id}",
|
|
1300
|
+
json=payload,
|
|
1301
|
+
v1=True,
|
|
1302
|
+
)
|
|
1303
|
+
|
|
1304
|
+
if self._client.cache:
|
|
1305
|
+
self._client.cache.invalidate_prefix("person")
|
|
1306
|
+
|
|
1307
|
+
return Person.model_validate(result)
|
|
1308
|
+
|
|
1309
|
+
async def delete(self, person_id: PersonId) -> bool:
|
|
1310
|
+
"""Delete a person (also deletes associated field values)."""
|
|
1311
|
+
result = await self._client.delete(f"/persons/{person_id}", v1=True)
|
|
1312
|
+
|
|
1313
|
+
if self._client.cache:
|
|
1314
|
+
self._client.cache.invalidate_prefix("person")
|
|
1315
|
+
|
|
1316
|
+
return bool(result.get("success", False))
|
|
1317
|
+
|
|
1318
|
+
# =========================================================================
|
|
1319
|
+
# Merge Operations (V2 BETA)
|
|
1320
|
+
# =========================================================================
|
|
1321
|
+
|
|
1322
|
+
async def merge(
|
|
1323
|
+
self,
|
|
1324
|
+
primary_id: PersonId,
|
|
1325
|
+
duplicate_id: PersonId,
|
|
1326
|
+
) -> str:
|
|
1327
|
+
"""
|
|
1328
|
+
Merge a duplicate person into a primary person.
|
|
1329
|
+
|
|
1330
|
+
Returns a task URL to check merge status.
|
|
1331
|
+
"""
|
|
1332
|
+
if not self._client.enable_beta_endpoints:
|
|
1333
|
+
raise BetaEndpointDisabledError(
|
|
1334
|
+
"Person merge is a beta endpoint; set enable_beta_endpoints=True to use it."
|
|
1335
|
+
)
|
|
1336
|
+
result = await self._client.post(
|
|
1337
|
+
"/person-merges",
|
|
1338
|
+
json={
|
|
1339
|
+
"primaryPersonId": int(primary_id),
|
|
1340
|
+
"duplicatePersonId": int(duplicate_id),
|
|
1341
|
+
},
|
|
1342
|
+
)
|
|
1343
|
+
return str(result.get("taskUrl", ""))
|
|
1344
|
+
|
|
1345
|
+
async def get_merge_status(self, task_id: str) -> MergeTask:
|
|
1346
|
+
"""Check the status of a merge operation."""
|
|
1347
|
+
data = await self._client.get(f"/tasks/person-merges/{task_id}")
|
|
1348
|
+
return MergeTask.model_validate(data)
|