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,2445 @@
|
|
|
1
|
+
"""
|
|
2
|
+
V1-only services: Notes, Reminders, Webhooks, Interactions, Fields, and more.
|
|
3
|
+
|
|
4
|
+
These services wrap V1 API endpoints that don't have V2 equivalents.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import builtins
|
|
11
|
+
import mimetypes
|
|
12
|
+
from collections.abc import AsyncIterator, Iterator, Sequence
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import TYPE_CHECKING, Any, Literal, TypeVar, cast
|
|
16
|
+
|
|
17
|
+
import httpx
|
|
18
|
+
|
|
19
|
+
from ..downloads import AsyncDownloadedFile, DownloadedFile
|
|
20
|
+
from ..exceptions import AffinityError
|
|
21
|
+
from ..models.entities import (
|
|
22
|
+
FieldCreate,
|
|
23
|
+
FieldMetadata,
|
|
24
|
+
FieldValue,
|
|
25
|
+
FieldValueChange,
|
|
26
|
+
FieldValueCreate,
|
|
27
|
+
)
|
|
28
|
+
from ..models.pagination import V1PaginatedResponse
|
|
29
|
+
from ..models.secondary import (
|
|
30
|
+
EntityFile,
|
|
31
|
+
Interaction,
|
|
32
|
+
InteractionCreate,
|
|
33
|
+
InteractionUpdate,
|
|
34
|
+
Note,
|
|
35
|
+
NoteCreate,
|
|
36
|
+
NoteUpdate,
|
|
37
|
+
RelationshipStrength,
|
|
38
|
+
Reminder,
|
|
39
|
+
ReminderCreate,
|
|
40
|
+
ReminderUpdate,
|
|
41
|
+
WebhookCreate,
|
|
42
|
+
WebhookSubscription,
|
|
43
|
+
WebhookUpdate,
|
|
44
|
+
WhoAmI,
|
|
45
|
+
)
|
|
46
|
+
from ..models.types import (
|
|
47
|
+
AnyFieldId,
|
|
48
|
+
CompanyId,
|
|
49
|
+
EntityType,
|
|
50
|
+
FieldId,
|
|
51
|
+
FieldValueChangeAction,
|
|
52
|
+
FieldValueId,
|
|
53
|
+
FileId,
|
|
54
|
+
InteractionId,
|
|
55
|
+
InteractionType,
|
|
56
|
+
ListEntryId,
|
|
57
|
+
ListId,
|
|
58
|
+
NoteId,
|
|
59
|
+
OpportunityId,
|
|
60
|
+
PersonId,
|
|
61
|
+
ReminderIdType,
|
|
62
|
+
ReminderResetType,
|
|
63
|
+
ReminderStatus,
|
|
64
|
+
ReminderType,
|
|
65
|
+
UserId,
|
|
66
|
+
WebhookId,
|
|
67
|
+
field_id_to_v1_numeric,
|
|
68
|
+
to_v1_value_type_code,
|
|
69
|
+
)
|
|
70
|
+
from ..progress import ProgressCallback
|
|
71
|
+
|
|
72
|
+
if TYPE_CHECKING:
|
|
73
|
+
from ..clients.http import AsyncHTTPClient, HTTPClient
|
|
74
|
+
|
|
75
|
+
# TypeVar for default parameter in get_for_entity()
|
|
76
|
+
T = TypeVar("T")
|
|
77
|
+
|
|
78
|
+
# Sentinel for distinguishing None from "not provided" in get_for_entity()
|
|
79
|
+
_UNSET: Any = object()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _coerce_isoformat(payload: dict[str, Any], keys: tuple[str, ...]) -> None:
|
|
83
|
+
for key in keys:
|
|
84
|
+
value = payload.get(key)
|
|
85
|
+
if isinstance(value, datetime):
|
|
86
|
+
payload[key] = value.isoformat()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# =============================================================================
|
|
90
|
+
# Notes Service (V1 API)
|
|
91
|
+
# =============================================================================
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class NoteService:
|
|
95
|
+
"""
|
|
96
|
+
Service for managing notes.
|
|
97
|
+
|
|
98
|
+
V2 provides read-only access; use V1 for create/update/delete.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def __init__(self, client: HTTPClient):
|
|
102
|
+
self._client = client
|
|
103
|
+
|
|
104
|
+
def list(
|
|
105
|
+
self,
|
|
106
|
+
*,
|
|
107
|
+
person_id: PersonId | None = None,
|
|
108
|
+
company_id: CompanyId | None = None,
|
|
109
|
+
opportunity_id: OpportunityId | None = None,
|
|
110
|
+
creator_id: UserId | None = None,
|
|
111
|
+
page_size: int | None = None,
|
|
112
|
+
page_token: str | None = None,
|
|
113
|
+
) -> V1PaginatedResponse[Note]:
|
|
114
|
+
"""
|
|
115
|
+
Get notes filtered by entity or creator.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
person_id: Filter notes associated with this person
|
|
119
|
+
company_id: Filter notes associated with this company
|
|
120
|
+
opportunity_id: Filter notes associated with this opportunity
|
|
121
|
+
creator_id: Filter notes created by this user
|
|
122
|
+
page_size: Number of results per page
|
|
123
|
+
page_token: Pagination token from previous response
|
|
124
|
+
|
|
125
|
+
Returns:
|
|
126
|
+
V1PaginatedResponse with notes and next_page_token
|
|
127
|
+
"""
|
|
128
|
+
params: dict[str, Any] = {}
|
|
129
|
+
if person_id:
|
|
130
|
+
params["person_id"] = int(person_id)
|
|
131
|
+
if company_id:
|
|
132
|
+
params["organization_id"] = int(company_id)
|
|
133
|
+
if opportunity_id:
|
|
134
|
+
params["opportunity_id"] = int(opportunity_id)
|
|
135
|
+
if creator_id:
|
|
136
|
+
params["creator_id"] = int(creator_id)
|
|
137
|
+
if page_size:
|
|
138
|
+
params["page_size"] = page_size
|
|
139
|
+
if page_token:
|
|
140
|
+
params["page_token"] = page_token
|
|
141
|
+
|
|
142
|
+
data = self._client.get("/notes", params=params or None, v1=True)
|
|
143
|
+
items = data.get("notes", data.get("data", []))
|
|
144
|
+
if not isinstance(items, list):
|
|
145
|
+
items = []
|
|
146
|
+
return V1PaginatedResponse[Note](
|
|
147
|
+
data=[Note.model_validate(n) for n in items],
|
|
148
|
+
next_page_token=data.get("next_page_token") or data.get("nextPageToken"),
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
def get(self, note_id: NoteId) -> Note:
|
|
152
|
+
"""Get a single note by ID."""
|
|
153
|
+
data = self._client.get(f"/notes/{note_id}", v1=True)
|
|
154
|
+
return Note.model_validate(data)
|
|
155
|
+
|
|
156
|
+
def create(self, data: NoteCreate) -> Note:
|
|
157
|
+
"""
|
|
158
|
+
Create a new note.
|
|
159
|
+
|
|
160
|
+
Must be associated with at least one person, organization,
|
|
161
|
+
opportunity, or parent note (for replies).
|
|
162
|
+
"""
|
|
163
|
+
payload = data.model_dump(by_alias=True, mode="python", exclude_none=True)
|
|
164
|
+
_coerce_isoformat(payload, ("created_at",))
|
|
165
|
+
if not data.person_ids:
|
|
166
|
+
payload.pop("person_ids", None)
|
|
167
|
+
if not data.company_ids:
|
|
168
|
+
payload.pop("organization_ids", None)
|
|
169
|
+
if not data.opportunity_ids:
|
|
170
|
+
payload.pop("opportunity_ids", None)
|
|
171
|
+
|
|
172
|
+
result = self._client.post("/notes", json=payload, v1=True)
|
|
173
|
+
return Note.model_validate(result)
|
|
174
|
+
|
|
175
|
+
def update(self, note_id: NoteId, data: NoteUpdate) -> Note:
|
|
176
|
+
"""Update a note's content."""
|
|
177
|
+
payload = data.model_dump(mode="json", exclude_unset=True, exclude_none=True)
|
|
178
|
+
result = self._client.put(
|
|
179
|
+
f"/notes/{note_id}",
|
|
180
|
+
json=payload,
|
|
181
|
+
v1=True,
|
|
182
|
+
)
|
|
183
|
+
return Note.model_validate(result)
|
|
184
|
+
|
|
185
|
+
def delete(self, note_id: NoteId) -> bool:
|
|
186
|
+
"""Delete a note."""
|
|
187
|
+
result = self._client.delete(f"/notes/{note_id}", v1=True)
|
|
188
|
+
return bool(result.get("success", False))
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# =============================================================================
|
|
192
|
+
# Reminder Service (V1 API)
|
|
193
|
+
# =============================================================================
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class ReminderService:
|
|
197
|
+
"""
|
|
198
|
+
Service for managing reminders.
|
|
199
|
+
|
|
200
|
+
Reminders are V1-only in this SDK (create/update/delete via V1).
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
def __init__(self, client: HTTPClient):
|
|
204
|
+
self._client = client
|
|
205
|
+
|
|
206
|
+
def list(
|
|
207
|
+
self,
|
|
208
|
+
*,
|
|
209
|
+
person_id: PersonId | None = None,
|
|
210
|
+
company_id: CompanyId | None = None,
|
|
211
|
+
opportunity_id: OpportunityId | None = None,
|
|
212
|
+
creator_id: UserId | None = None,
|
|
213
|
+
owner_id: UserId | None = None,
|
|
214
|
+
completer_id: UserId | None = None,
|
|
215
|
+
type: ReminderType | None = None,
|
|
216
|
+
reset_type: ReminderResetType | None = None,
|
|
217
|
+
status: ReminderStatus | None = None,
|
|
218
|
+
due_before: datetime | None = None,
|
|
219
|
+
due_after: datetime | None = None,
|
|
220
|
+
page_size: int | None = None,
|
|
221
|
+
page_token: str | None = None,
|
|
222
|
+
) -> V1PaginatedResponse[Reminder]:
|
|
223
|
+
"""
|
|
224
|
+
Get reminders with optional filtering.
|
|
225
|
+
|
|
226
|
+
Args:
|
|
227
|
+
person_id: Filter reminders for this person
|
|
228
|
+
company_id: Filter reminders for this company
|
|
229
|
+
opportunity_id: Filter reminders for this opportunity
|
|
230
|
+
creator_id: Filter by reminder creator
|
|
231
|
+
owner_id: Filter by reminder owner (assignee)
|
|
232
|
+
completer_id: Filter by who completed the reminder
|
|
233
|
+
type: Filter by reminder type (ONE_TIME or RECURRING)
|
|
234
|
+
reset_type: Filter by reset type (FIXED_DATE, DATE_ADDED, or INTERACTION)
|
|
235
|
+
status: Filter by status (ACTIVE, SNOOZED, or COMPLETE)
|
|
236
|
+
due_before: Filter reminders due before this datetime
|
|
237
|
+
due_after: Filter reminders due after this datetime
|
|
238
|
+
page_size: Number of results per page
|
|
239
|
+
page_token: Pagination token from previous response
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
V1PaginatedResponse with reminders and next_page_token
|
|
243
|
+
"""
|
|
244
|
+
params: dict[str, Any] = {}
|
|
245
|
+
if person_id:
|
|
246
|
+
params["person_id"] = int(person_id)
|
|
247
|
+
if company_id:
|
|
248
|
+
params["organization_id"] = int(company_id)
|
|
249
|
+
if opportunity_id:
|
|
250
|
+
params["opportunity_id"] = int(opportunity_id)
|
|
251
|
+
if creator_id:
|
|
252
|
+
params["creator_id"] = int(creator_id)
|
|
253
|
+
if owner_id:
|
|
254
|
+
params["owner_id"] = int(owner_id)
|
|
255
|
+
if completer_id:
|
|
256
|
+
params["completer_id"] = int(completer_id)
|
|
257
|
+
if type is not None:
|
|
258
|
+
params["type"] = int(type)
|
|
259
|
+
if reset_type is not None:
|
|
260
|
+
params["reset_type"] = int(reset_type)
|
|
261
|
+
if status is not None:
|
|
262
|
+
params["status"] = int(status)
|
|
263
|
+
if due_before:
|
|
264
|
+
params["due_before"] = due_before.isoformat()
|
|
265
|
+
if due_after:
|
|
266
|
+
params["due_after"] = due_after.isoformat()
|
|
267
|
+
if page_size:
|
|
268
|
+
params["page_size"] = page_size
|
|
269
|
+
if page_token:
|
|
270
|
+
params["page_token"] = page_token
|
|
271
|
+
|
|
272
|
+
data = self._client.get("/reminders", params=params or None, v1=True)
|
|
273
|
+
items = data.get("reminders", data.get("data", []))
|
|
274
|
+
if not isinstance(items, list):
|
|
275
|
+
items = []
|
|
276
|
+
return V1PaginatedResponse[Reminder](
|
|
277
|
+
data=[Reminder.model_validate(r) for r in items],
|
|
278
|
+
next_page_token=data.get("next_page_token") or data.get("nextPageToken"),
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
def get(self, reminder_id: ReminderIdType) -> Reminder:
|
|
282
|
+
"""Get a single reminder."""
|
|
283
|
+
data = self._client.get(f"/reminders/{reminder_id}", v1=True)
|
|
284
|
+
return Reminder.model_validate(data)
|
|
285
|
+
|
|
286
|
+
def create(self, data: ReminderCreate) -> Reminder:
|
|
287
|
+
"""Create a new reminder."""
|
|
288
|
+
payload = data.model_dump(by_alias=True, mode="python", exclude_none=True)
|
|
289
|
+
_coerce_isoformat(payload, ("due_date",))
|
|
290
|
+
|
|
291
|
+
result = self._client.post("/reminders", json=payload, v1=True)
|
|
292
|
+
return Reminder.model_validate(result)
|
|
293
|
+
|
|
294
|
+
def update(self, reminder_id: ReminderIdType, data: ReminderUpdate) -> Reminder:
|
|
295
|
+
"""Update a reminder."""
|
|
296
|
+
payload = data.model_dump(
|
|
297
|
+
by_alias=True,
|
|
298
|
+
mode="python",
|
|
299
|
+
exclude_unset=True,
|
|
300
|
+
exclude_none=True,
|
|
301
|
+
)
|
|
302
|
+
_coerce_isoformat(payload, ("due_date",))
|
|
303
|
+
|
|
304
|
+
result = self._client.put(f"/reminders/{reminder_id}", json=payload, v1=True)
|
|
305
|
+
return Reminder.model_validate(result)
|
|
306
|
+
|
|
307
|
+
def delete(self, reminder_id: ReminderIdType) -> bool:
|
|
308
|
+
"""Delete a reminder."""
|
|
309
|
+
result = self._client.delete(f"/reminders/{reminder_id}", v1=True)
|
|
310
|
+
return bool(result.get("success", False))
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# =============================================================================
|
|
314
|
+
# Webhook Service (V1 API)
|
|
315
|
+
# =============================================================================
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
class WebhookService:
|
|
319
|
+
"""
|
|
320
|
+
Service for managing webhook subscriptions.
|
|
321
|
+
|
|
322
|
+
Note: Limited to 3 subscriptions per Affinity instance.
|
|
323
|
+
"""
|
|
324
|
+
|
|
325
|
+
def __init__(self, client: HTTPClient):
|
|
326
|
+
self._client = client
|
|
327
|
+
|
|
328
|
+
def list(self) -> builtins.list[WebhookSubscription]:
|
|
329
|
+
"""Get all webhook subscriptions."""
|
|
330
|
+
data = self._client.get("/webhook", v1=True)
|
|
331
|
+
return [WebhookSubscription.model_validate(w) for w in data.get("data", [])]
|
|
332
|
+
|
|
333
|
+
def get(self, webhook_id: WebhookId) -> WebhookSubscription:
|
|
334
|
+
"""Get a single webhook subscription."""
|
|
335
|
+
data = self._client.get(f"/webhook/{webhook_id}", v1=True)
|
|
336
|
+
return WebhookSubscription.model_validate(data)
|
|
337
|
+
|
|
338
|
+
def create(self, data: WebhookCreate) -> WebhookSubscription:
|
|
339
|
+
"""
|
|
340
|
+
Create a webhook subscription.
|
|
341
|
+
|
|
342
|
+
The webhook URL will receive a validation request.
|
|
343
|
+
"""
|
|
344
|
+
payload = data.model_dump(by_alias=True, mode="python", exclude_none=True)
|
|
345
|
+
_coerce_isoformat(payload, ("date",))
|
|
346
|
+
if not data.subscriptions:
|
|
347
|
+
payload.pop("subscriptions", None)
|
|
348
|
+
|
|
349
|
+
result = self._client.post("/webhook/subscribe", json=payload, v1=True)
|
|
350
|
+
return WebhookSubscription.model_validate(result)
|
|
351
|
+
|
|
352
|
+
def update(self, webhook_id: WebhookId, data: WebhookUpdate) -> WebhookSubscription:
|
|
353
|
+
"""Update a webhook subscription."""
|
|
354
|
+
payload = data.model_dump(
|
|
355
|
+
by_alias=True,
|
|
356
|
+
mode="json",
|
|
357
|
+
exclude_unset=True,
|
|
358
|
+
exclude_none=True,
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
result = self._client.put(f"/webhook/{webhook_id}", json=payload, v1=True)
|
|
362
|
+
return WebhookSubscription.model_validate(result)
|
|
363
|
+
|
|
364
|
+
def delete(self, webhook_id: WebhookId) -> bool:
|
|
365
|
+
"""Delete a webhook subscription."""
|
|
366
|
+
result = self._client.delete(f"/webhook/{webhook_id}", v1=True)
|
|
367
|
+
return bool(result.get("success", False))
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
# =============================================================================
|
|
371
|
+
# Interaction Service (V1 API)
|
|
372
|
+
# =============================================================================
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
class InteractionService:
|
|
376
|
+
"""
|
|
377
|
+
Service for managing interactions (meetings, calls, emails, chats).
|
|
378
|
+
|
|
379
|
+
V2 provides read-only metadata; V1 supports full CRUD.
|
|
380
|
+
"""
|
|
381
|
+
|
|
382
|
+
def __init__(self, client: HTTPClient):
|
|
383
|
+
self._client = client
|
|
384
|
+
|
|
385
|
+
def list(
|
|
386
|
+
self,
|
|
387
|
+
*,
|
|
388
|
+
type: InteractionType | None = None,
|
|
389
|
+
start_time: datetime | None = None,
|
|
390
|
+
end_time: datetime | None = None,
|
|
391
|
+
person_id: PersonId | None = None,
|
|
392
|
+
company_id: CompanyId | None = None,
|
|
393
|
+
opportunity_id: OpportunityId | None = None,
|
|
394
|
+
page_size: int | None = None,
|
|
395
|
+
page_token: str | None = None,
|
|
396
|
+
) -> V1PaginatedResponse[Interaction]:
|
|
397
|
+
"""
|
|
398
|
+
Get interactions with optional filtering.
|
|
399
|
+
|
|
400
|
+
The Affinity API requires:
|
|
401
|
+
- type: Interaction type (meeting, call, email, chat)
|
|
402
|
+
- start_time and end_time: Date range (max 1 year)
|
|
403
|
+
- One entity ID: person_id, company_id, or opportunity_id
|
|
404
|
+
|
|
405
|
+
Returns V1 paginated response with `data` and `next_page_token`.
|
|
406
|
+
"""
|
|
407
|
+
params: dict[str, Any] = {}
|
|
408
|
+
if type is not None:
|
|
409
|
+
params["type"] = int(type)
|
|
410
|
+
if start_time:
|
|
411
|
+
params["start_time"] = start_time.isoformat()
|
|
412
|
+
if end_time:
|
|
413
|
+
params["end_time"] = end_time.isoformat()
|
|
414
|
+
if person_id:
|
|
415
|
+
params["person_id"] = int(person_id)
|
|
416
|
+
if company_id:
|
|
417
|
+
params["organization_id"] = int(company_id)
|
|
418
|
+
if opportunity_id:
|
|
419
|
+
params["opportunity_id"] = int(opportunity_id)
|
|
420
|
+
if page_size:
|
|
421
|
+
params["page_size"] = page_size
|
|
422
|
+
if page_token:
|
|
423
|
+
params["page_token"] = page_token
|
|
424
|
+
|
|
425
|
+
data = self._client.get("/interactions", params=params or None, v1=True)
|
|
426
|
+
items: Any = None
|
|
427
|
+
if type is not None:
|
|
428
|
+
if int(type) in (int(InteractionType.MEETING), int(InteractionType.CALL)):
|
|
429
|
+
items = data.get("events")
|
|
430
|
+
elif int(type) == int(InteractionType.CHAT_MESSAGE):
|
|
431
|
+
items = data.get("chat_messages")
|
|
432
|
+
elif int(type) == int(InteractionType.EMAIL):
|
|
433
|
+
items = data.get("emails")
|
|
434
|
+
|
|
435
|
+
if items is None:
|
|
436
|
+
items = (
|
|
437
|
+
data.get("interactions")
|
|
438
|
+
or data.get("events")
|
|
439
|
+
or data.get("emails")
|
|
440
|
+
or data.get("chat_messages")
|
|
441
|
+
or data.get("data", [])
|
|
442
|
+
)
|
|
443
|
+
if not isinstance(items, list):
|
|
444
|
+
items = []
|
|
445
|
+
return V1PaginatedResponse[Interaction](
|
|
446
|
+
data=[Interaction.model_validate(i) for i in items],
|
|
447
|
+
next_page_token=data.get("next_page_token") or data.get("nextPageToken"),
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
def get(self, interaction_id: InteractionId, type: InteractionType) -> Interaction:
|
|
451
|
+
"""Get a single interaction by ID and type."""
|
|
452
|
+
data = self._client.get(
|
|
453
|
+
f"/interactions/{int(interaction_id)}",
|
|
454
|
+
params={"type": int(type)},
|
|
455
|
+
v1=True,
|
|
456
|
+
)
|
|
457
|
+
return Interaction.model_validate(data)
|
|
458
|
+
|
|
459
|
+
def create(self, data: InteractionCreate) -> Interaction:
|
|
460
|
+
"""Create a new interaction (manually logged)."""
|
|
461
|
+
payload = data.model_dump(by_alias=True, mode="python", exclude_none=True)
|
|
462
|
+
_coerce_isoformat(payload, ("date",))
|
|
463
|
+
|
|
464
|
+
result = self._client.post("/interactions", json=payload, v1=True)
|
|
465
|
+
return Interaction.model_validate(result)
|
|
466
|
+
|
|
467
|
+
def update(
|
|
468
|
+
self,
|
|
469
|
+
interaction_id: InteractionId,
|
|
470
|
+
type: InteractionType,
|
|
471
|
+
data: InteractionUpdate,
|
|
472
|
+
) -> Interaction:
|
|
473
|
+
"""Update an interaction."""
|
|
474
|
+
payload = data.model_dump(
|
|
475
|
+
by_alias=True,
|
|
476
|
+
mode="python",
|
|
477
|
+
exclude_unset=True,
|
|
478
|
+
exclude_none=True,
|
|
479
|
+
)
|
|
480
|
+
payload["type"] = int(type)
|
|
481
|
+
_coerce_isoformat(payload, ("date",))
|
|
482
|
+
|
|
483
|
+
result = self._client.put(
|
|
484
|
+
f"/interactions/{int(interaction_id)}",
|
|
485
|
+
json=payload,
|
|
486
|
+
v1=True,
|
|
487
|
+
)
|
|
488
|
+
return Interaction.model_validate(result)
|
|
489
|
+
|
|
490
|
+
def delete(self, interaction_id: InteractionId, type: InteractionType) -> bool:
|
|
491
|
+
"""Delete an interaction."""
|
|
492
|
+
result = self._client.delete(
|
|
493
|
+
f"/interactions/{int(interaction_id)}",
|
|
494
|
+
params={"type": int(type)},
|
|
495
|
+
v1=True,
|
|
496
|
+
)
|
|
497
|
+
return bool(result.get("success", False))
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
# =============================================================================
|
|
501
|
+
# Field Service (V1 API)
|
|
502
|
+
# =============================================================================
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
class FieldService:
|
|
506
|
+
"""
|
|
507
|
+
Service for managing custom fields.
|
|
508
|
+
|
|
509
|
+
Use V2 /fields endpoints for reading field metadata.
|
|
510
|
+
Use V1 for creating/deleting fields.
|
|
511
|
+
"""
|
|
512
|
+
|
|
513
|
+
def __init__(self, client: HTTPClient):
|
|
514
|
+
self._client = client
|
|
515
|
+
|
|
516
|
+
def list(
|
|
517
|
+
self,
|
|
518
|
+
*,
|
|
519
|
+
list_id: ListId | None = None,
|
|
520
|
+
entity_type: EntityType | None = None,
|
|
521
|
+
) -> list[FieldMetadata]:
|
|
522
|
+
"""
|
|
523
|
+
Get fields (V1 API).
|
|
524
|
+
|
|
525
|
+
For list/person/company field metadata, prefer the V2 read endpoints on the
|
|
526
|
+
corresponding services when available (e.g., `client.lists.get_fields(...)`).
|
|
527
|
+
"""
|
|
528
|
+
params: dict[str, Any] = {}
|
|
529
|
+
if list_id:
|
|
530
|
+
params["list_id"] = int(list_id)
|
|
531
|
+
if entity_type is not None:
|
|
532
|
+
params["entity_type"] = int(entity_type)
|
|
533
|
+
|
|
534
|
+
data = self._client.get("/fields", params=params or None, v1=True)
|
|
535
|
+
items = data.get("data", [])
|
|
536
|
+
if not isinstance(items, list):
|
|
537
|
+
items = []
|
|
538
|
+
return [FieldMetadata.model_validate(f) for f in items]
|
|
539
|
+
|
|
540
|
+
def create(self, data: FieldCreate) -> FieldMetadata:
|
|
541
|
+
"""Create a custom field."""
|
|
542
|
+
value_type_code = to_v1_value_type_code(value_type=data.value_type, raw=None)
|
|
543
|
+
if value_type_code is None:
|
|
544
|
+
raise ValueError(f"Field value_type has no V1 numeric mapping: {data.value_type!s}")
|
|
545
|
+
payload = data.model_dump(by_alias=True, mode="json", exclude_unset=True, exclude_none=True)
|
|
546
|
+
payload["entity_type"] = int(data.entity_type)
|
|
547
|
+
payload["value_type"] = value_type_code
|
|
548
|
+
for key in ("allows_multiple", "is_list_specific", "is_required"):
|
|
549
|
+
if not payload.get(key):
|
|
550
|
+
payload.pop(key, None)
|
|
551
|
+
|
|
552
|
+
result = self._client.post("/fields", json=payload, v1=True)
|
|
553
|
+
|
|
554
|
+
# Invalidate field caches
|
|
555
|
+
if self._client.cache:
|
|
556
|
+
self._client.cache.invalidate_prefix("field")
|
|
557
|
+
self._client.cache.invalidate_prefix("list_")
|
|
558
|
+
self._client.cache.invalidate_prefix("person_fields")
|
|
559
|
+
self._client.cache.invalidate_prefix("company_fields")
|
|
560
|
+
|
|
561
|
+
return FieldMetadata.model_validate(result)
|
|
562
|
+
|
|
563
|
+
def delete(self, field_id: FieldId) -> bool:
|
|
564
|
+
"""
|
|
565
|
+
Delete a custom field (V1 API).
|
|
566
|
+
|
|
567
|
+
Note: V1 deletes require numeric field IDs. The SDK accepts V2-style
|
|
568
|
+
`field-<digits>` IDs and converts them; enriched/relationship-intelligence
|
|
569
|
+
IDs are not supported.
|
|
570
|
+
"""
|
|
571
|
+
numeric_id = field_id_to_v1_numeric(field_id)
|
|
572
|
+
result = self._client.delete(f"/fields/{numeric_id}", v1=True)
|
|
573
|
+
|
|
574
|
+
# Invalidate field caches
|
|
575
|
+
if self._client.cache:
|
|
576
|
+
self._client.cache.invalidate_prefix("field")
|
|
577
|
+
self._client.cache.invalidate_prefix("list_")
|
|
578
|
+
self._client.cache.invalidate_prefix("person_fields")
|
|
579
|
+
self._client.cache.invalidate_prefix("company_fields")
|
|
580
|
+
|
|
581
|
+
return bool(result.get("success", False))
|
|
582
|
+
|
|
583
|
+
def exists(self, field_id: AnyFieldId) -> bool:
|
|
584
|
+
"""
|
|
585
|
+
Check if a field exists.
|
|
586
|
+
|
|
587
|
+
Useful for validation before setting field values.
|
|
588
|
+
|
|
589
|
+
Note: This fetches all fields and checks locally. If your code calls
|
|
590
|
+
exists() frequently in a loop, consider caching the result of fields.list()
|
|
591
|
+
yourself.
|
|
592
|
+
|
|
593
|
+
Args:
|
|
594
|
+
field_id: The field ID to check
|
|
595
|
+
|
|
596
|
+
Returns:
|
|
597
|
+
True if the field exists, False otherwise
|
|
598
|
+
|
|
599
|
+
Example:
|
|
600
|
+
if client.fields.exists(FieldId("field-123")):
|
|
601
|
+
client.field_values.create(...)
|
|
602
|
+
"""
|
|
603
|
+
target_id = FieldId(field_id) if not isinstance(field_id, FieldId) else field_id
|
|
604
|
+
fields = self.list()
|
|
605
|
+
return any(f.id == target_id for f in fields)
|
|
606
|
+
|
|
607
|
+
def get_by_name(self, name: str) -> FieldMetadata | None:
|
|
608
|
+
"""
|
|
609
|
+
Find a field by its display name.
|
|
610
|
+
|
|
611
|
+
Uses case-insensitive matching (casefold for i18n support).
|
|
612
|
+
|
|
613
|
+
Note: This fetches all fields and searches locally. If your code calls
|
|
614
|
+
get_by_name() frequently in a loop, consider caching the result of
|
|
615
|
+
fields.list() yourself.
|
|
616
|
+
|
|
617
|
+
Args:
|
|
618
|
+
name: The field display name to search for
|
|
619
|
+
|
|
620
|
+
Returns:
|
|
621
|
+
FieldMetadata if found, None otherwise
|
|
622
|
+
|
|
623
|
+
Example:
|
|
624
|
+
field = client.fields.get_by_name("Primary Email Status")
|
|
625
|
+
if field:
|
|
626
|
+
fv = client.field_values.get_for_entity(field.id, person_id=pid)
|
|
627
|
+
"""
|
|
628
|
+
fields = self.list()
|
|
629
|
+
name_folded = name.strip().casefold() # Strip whitespace, then casefold for i18n
|
|
630
|
+
for field in fields:
|
|
631
|
+
if field.name.casefold() == name_folded:
|
|
632
|
+
return field
|
|
633
|
+
return None
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
# =============================================================================
|
|
637
|
+
# Field Value Service (V1 API)
|
|
638
|
+
# =============================================================================
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
class FieldValueService:
|
|
642
|
+
"""
|
|
643
|
+
Service for managing field values.
|
|
644
|
+
|
|
645
|
+
For list entry field values, prefer ListEntryService.update_field_value().
|
|
646
|
+
Use this for global field values not tied to list entries.
|
|
647
|
+
"""
|
|
648
|
+
|
|
649
|
+
def __init__(self, client: HTTPClient):
|
|
650
|
+
self._client = client
|
|
651
|
+
|
|
652
|
+
def list(
|
|
653
|
+
self,
|
|
654
|
+
*,
|
|
655
|
+
person_id: PersonId | None = None,
|
|
656
|
+
company_id: CompanyId | None = None,
|
|
657
|
+
opportunity_id: OpportunityId | None = None,
|
|
658
|
+
list_entry_id: ListEntryId | None = None,
|
|
659
|
+
) -> list[FieldValue]:
|
|
660
|
+
"""
|
|
661
|
+
Get field values for an entity.
|
|
662
|
+
|
|
663
|
+
Exactly one of person_id, company_id, opportunity_id, or list_entry_id
|
|
664
|
+
must be provided.
|
|
665
|
+
|
|
666
|
+
Raises:
|
|
667
|
+
ValueError: If zero or multiple IDs are provided.
|
|
668
|
+
"""
|
|
669
|
+
provided = {
|
|
670
|
+
name: value
|
|
671
|
+
for name, value in (
|
|
672
|
+
("person_id", person_id),
|
|
673
|
+
("company_id", company_id),
|
|
674
|
+
("opportunity_id", opportunity_id),
|
|
675
|
+
("list_entry_id", list_entry_id),
|
|
676
|
+
)
|
|
677
|
+
if value is not None
|
|
678
|
+
}
|
|
679
|
+
if len(provided) == 0:
|
|
680
|
+
raise ValueError(
|
|
681
|
+
"field_values.list() requires exactly one entity ID. "
|
|
682
|
+
"Example: client.field_values.list(person_id=PersonId(123))"
|
|
683
|
+
)
|
|
684
|
+
if len(provided) > 1:
|
|
685
|
+
raise ValueError(
|
|
686
|
+
f"field_values.list() accepts only one entity ID, "
|
|
687
|
+
f"but received {len(provided)}: {', '.join(provided.keys())}. "
|
|
688
|
+
"Call list() separately for each entity."
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
params: dict[str, Any] = {}
|
|
692
|
+
if person_id is not None:
|
|
693
|
+
params["person_id"] = int(person_id)
|
|
694
|
+
if company_id is not None:
|
|
695
|
+
params["organization_id"] = int(company_id)
|
|
696
|
+
if opportunity_id is not None:
|
|
697
|
+
params["opportunity_id"] = int(opportunity_id)
|
|
698
|
+
if list_entry_id is not None:
|
|
699
|
+
params["list_entry_id"] = int(list_entry_id)
|
|
700
|
+
|
|
701
|
+
data = self._client.get("/field-values", params=params or None, v1=True)
|
|
702
|
+
items = data.get("data", [])
|
|
703
|
+
if not isinstance(items, list):
|
|
704
|
+
items = []
|
|
705
|
+
return [FieldValue.model_validate(v) for v in items]
|
|
706
|
+
|
|
707
|
+
def create(self, data: FieldValueCreate) -> FieldValue:
|
|
708
|
+
"""
|
|
709
|
+
Create a field value (V1 API).
|
|
710
|
+
|
|
711
|
+
Note: V1 writes require numeric field IDs. The SDK accepts V2-style
|
|
712
|
+
`field-<digits>` IDs and converts them; enriched/relationship-intelligence
|
|
713
|
+
IDs are not supported.
|
|
714
|
+
"""
|
|
715
|
+
payload = data.model_dump(by_alias=True, mode="json", exclude_unset=True, exclude_none=True)
|
|
716
|
+
payload["field_id"] = field_id_to_v1_numeric(data.field_id)
|
|
717
|
+
|
|
718
|
+
result = self._client.post("/field-values", json=payload, v1=True)
|
|
719
|
+
return FieldValue.model_validate(result)
|
|
720
|
+
|
|
721
|
+
def update(self, field_value_id: FieldValueId, value: Any) -> FieldValue:
|
|
722
|
+
"""Update a field value."""
|
|
723
|
+
result = self._client.put(
|
|
724
|
+
f"/field-values/{field_value_id}",
|
|
725
|
+
json={"value": value},
|
|
726
|
+
v1=True,
|
|
727
|
+
)
|
|
728
|
+
return FieldValue.model_validate(result)
|
|
729
|
+
|
|
730
|
+
def delete(self, field_value_id: FieldValueId) -> bool:
|
|
731
|
+
"""Delete a field value."""
|
|
732
|
+
result = self._client.delete(f"/field-values/{field_value_id}", v1=True)
|
|
733
|
+
return bool(result.get("success", False))
|
|
734
|
+
|
|
735
|
+
def get_for_entity(
|
|
736
|
+
self,
|
|
737
|
+
field_id: str | FieldId,
|
|
738
|
+
*,
|
|
739
|
+
person_id: PersonId | None = None,
|
|
740
|
+
company_id: CompanyId | None = None,
|
|
741
|
+
opportunity_id: OpportunityId | None = None,
|
|
742
|
+
list_entry_id: ListEntryId | None = None,
|
|
743
|
+
default: T = _UNSET,
|
|
744
|
+
) -> FieldValue | T | None:
|
|
745
|
+
"""
|
|
746
|
+
Get a specific field value for an entity.
|
|
747
|
+
|
|
748
|
+
Convenience method that fetches all field values and returns the one
|
|
749
|
+
matching field_id. Like dict.get(), returns None (or default) if not found.
|
|
750
|
+
|
|
751
|
+
Note: This still makes one API call to fetch all field values for the entity.
|
|
752
|
+
For entities with hundreds of field values, prefer using ``list()`` directly
|
|
753
|
+
if you need to inspect multiple fields.
|
|
754
|
+
|
|
755
|
+
Args:
|
|
756
|
+
field_id: The field to look up (accepts str or FieldId for convenience)
|
|
757
|
+
person_id: Person entity (exactly one entity ID required)
|
|
758
|
+
company_id: Company entity
|
|
759
|
+
opportunity_id: Opportunity entity
|
|
760
|
+
list_entry_id: List entry entity
|
|
761
|
+
default: Value to return if field not found (default: None)
|
|
762
|
+
|
|
763
|
+
Returns:
|
|
764
|
+
FieldValue if the field has a value, default otherwise.
|
|
765
|
+
Note: A FieldValue with ``.value is None`` still counts as "present" (explicit empty).
|
|
766
|
+
|
|
767
|
+
Example:
|
|
768
|
+
# Check if a person has a specific field value
|
|
769
|
+
status = client.field_values.get_for_entity(
|
|
770
|
+
"field-123", # or FieldId("field-123")
|
|
771
|
+
person_id=PersonId(456),
|
|
772
|
+
)
|
|
773
|
+
if status is None:
|
|
774
|
+
print("Field is empty")
|
|
775
|
+
else:
|
|
776
|
+
print(f"Value: {status.value}")
|
|
777
|
+
|
|
778
|
+
# With default value
|
|
779
|
+
status = client.field_values.get_for_entity(
|
|
780
|
+
"field-123",
|
|
781
|
+
person_id=PersonId(456),
|
|
782
|
+
default="N/A",
|
|
783
|
+
)
|
|
784
|
+
"""
|
|
785
|
+
all_values = self.list(
|
|
786
|
+
person_id=person_id,
|
|
787
|
+
company_id=company_id,
|
|
788
|
+
opportunity_id=opportunity_id,
|
|
789
|
+
list_entry_id=list_entry_id,
|
|
790
|
+
)
|
|
791
|
+
# Normalize field_id for comparison (handles both str and FieldId)
|
|
792
|
+
target_id = FieldId(field_id) if not isinstance(field_id, FieldId) else field_id
|
|
793
|
+
for fv in all_values:
|
|
794
|
+
if fv.field_id == target_id:
|
|
795
|
+
return fv
|
|
796
|
+
return None if default is _UNSET else default
|
|
797
|
+
|
|
798
|
+
def list_batch(
|
|
799
|
+
self,
|
|
800
|
+
person_ids: Sequence[PersonId] | None = None,
|
|
801
|
+
company_ids: Sequence[CompanyId] | None = None,
|
|
802
|
+
opportunity_ids: Sequence[OpportunityId] | None = None,
|
|
803
|
+
*,
|
|
804
|
+
on_error: Literal["raise", "skip"] = "raise",
|
|
805
|
+
) -> dict[PersonId | CompanyId | OpportunityId, builtins.list[FieldValue]]:
|
|
806
|
+
"""
|
|
807
|
+
Get field values for multiple entities.
|
|
808
|
+
|
|
809
|
+
**Performance note:** This makes one API call per entity (O(n) calls).
|
|
810
|
+
There is no server-side batch endpoint. Use this for convenience and
|
|
811
|
+
consistent error handling, not for performance optimization.
|
|
812
|
+
For parallelism, use the async client.
|
|
813
|
+
|
|
814
|
+
Args:
|
|
815
|
+
person_ids: Sequence of person IDs (mutually exclusive with others)
|
|
816
|
+
company_ids: Sequence of company IDs
|
|
817
|
+
opportunity_ids: Sequence of opportunity IDs
|
|
818
|
+
on_error: How to handle errors - "raise" (default) or "skip" failed IDs
|
|
819
|
+
|
|
820
|
+
Returns:
|
|
821
|
+
Dict mapping entity_id -> list of field values.
|
|
822
|
+
Note: Dict ordering is not guaranteed; do not rely on insertion order.
|
|
823
|
+
|
|
824
|
+
Example:
|
|
825
|
+
# Check which persons have a specific field set
|
|
826
|
+
fv_map = client.field_values.list_batch(person_ids=person_ids)
|
|
827
|
+
for person_id, field_values in fv_map.items():
|
|
828
|
+
has_status = any(fv.field_id == target_field for fv in field_values)
|
|
829
|
+
"""
|
|
830
|
+
# Validate exactly one sequence provided
|
|
831
|
+
provided = [
|
|
832
|
+
("person_ids", person_ids),
|
|
833
|
+
("company_ids", company_ids),
|
|
834
|
+
("opportunity_ids", opportunity_ids),
|
|
835
|
+
]
|
|
836
|
+
non_none = [(name, seq) for name, seq in provided if seq is not None]
|
|
837
|
+
if len(non_none) != 1:
|
|
838
|
+
raise ValueError("Exactly one of person_ids, company_ids, or opportunity_ids required")
|
|
839
|
+
|
|
840
|
+
name, ids = non_none[0]
|
|
841
|
+
result: dict[PersonId | CompanyId | OpportunityId, list[FieldValue]] = {}
|
|
842
|
+
|
|
843
|
+
for entity_id in ids:
|
|
844
|
+
try:
|
|
845
|
+
if name == "person_ids":
|
|
846
|
+
result[entity_id] = self.list(person_id=cast(PersonId, entity_id))
|
|
847
|
+
elif name == "company_ids":
|
|
848
|
+
result[entity_id] = self.list(company_id=cast(CompanyId, entity_id))
|
|
849
|
+
else:
|
|
850
|
+
result[entity_id] = self.list(opportunity_id=cast(OpportunityId, entity_id))
|
|
851
|
+
except AffinityError:
|
|
852
|
+
if on_error == "raise":
|
|
853
|
+
raise
|
|
854
|
+
# skip: continue without this entity
|
|
855
|
+
except Exception as e:
|
|
856
|
+
if on_error == "raise":
|
|
857
|
+
# Preserve status_code if available
|
|
858
|
+
status_code = getattr(e, "status_code", None)
|
|
859
|
+
raise AffinityError(
|
|
860
|
+
f"Failed to get field values for {name[:-1]} {entity_id}: {e}",
|
|
861
|
+
status_code=status_code,
|
|
862
|
+
) from e
|
|
863
|
+
|
|
864
|
+
return result
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
# =============================================================================
|
|
868
|
+
# Field Value Changes Service (V1 API)
|
|
869
|
+
# =============================================================================
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
class FieldValueChangesService:
|
|
873
|
+
"""Service for querying field value change history (V1 API)."""
|
|
874
|
+
|
|
875
|
+
def __init__(self, client: HTTPClient):
|
|
876
|
+
self._client = client
|
|
877
|
+
|
|
878
|
+
@staticmethod
|
|
879
|
+
def _validate_selector(
|
|
880
|
+
*,
|
|
881
|
+
person_id: PersonId | None,
|
|
882
|
+
company_id: CompanyId | None,
|
|
883
|
+
opportunity_id: OpportunityId | None,
|
|
884
|
+
list_entry_id: ListEntryId | None,
|
|
885
|
+
) -> None:
|
|
886
|
+
provided = [
|
|
887
|
+
name
|
|
888
|
+
for name, value in (
|
|
889
|
+
("person_id", person_id),
|
|
890
|
+
("company_id", company_id),
|
|
891
|
+
("opportunity_id", opportunity_id),
|
|
892
|
+
("list_entry_id", list_entry_id),
|
|
893
|
+
)
|
|
894
|
+
if value is not None
|
|
895
|
+
]
|
|
896
|
+
if len(provided) != 1:
|
|
897
|
+
joined = ", ".join(provided) if provided else "(none)"
|
|
898
|
+
raise ValueError(
|
|
899
|
+
"FieldValueChangesService.list() requires exactly one of: "
|
|
900
|
+
"person_id, company_id, opportunity_id, or list_entry_id; "
|
|
901
|
+
f"got {len(provided)}: {joined}"
|
|
902
|
+
)
|
|
903
|
+
|
|
904
|
+
def list(
|
|
905
|
+
self,
|
|
906
|
+
field_id: AnyFieldId,
|
|
907
|
+
*,
|
|
908
|
+
person_id: PersonId | None = None,
|
|
909
|
+
company_id: CompanyId | None = None,
|
|
910
|
+
opportunity_id: OpportunityId | None = None,
|
|
911
|
+
list_entry_id: ListEntryId | None = None,
|
|
912
|
+
action_type: FieldValueChangeAction | None = None,
|
|
913
|
+
) -> list[FieldValueChange]:
|
|
914
|
+
"""
|
|
915
|
+
Get field value changes for a specific field and entity.
|
|
916
|
+
|
|
917
|
+
This endpoint is not paginated. For large histories, use narrow filters.
|
|
918
|
+
V1 requires numeric field IDs; only `field-<digits>` values are convertible.
|
|
919
|
+
"""
|
|
920
|
+
self._validate_selector(
|
|
921
|
+
person_id=person_id,
|
|
922
|
+
company_id=company_id,
|
|
923
|
+
opportunity_id=opportunity_id,
|
|
924
|
+
list_entry_id=list_entry_id,
|
|
925
|
+
)
|
|
926
|
+
|
|
927
|
+
params: dict[str, Any] = {
|
|
928
|
+
"field_id": field_id_to_v1_numeric(field_id),
|
|
929
|
+
}
|
|
930
|
+
if person_id is not None:
|
|
931
|
+
params["person_id"] = int(person_id)
|
|
932
|
+
if company_id is not None:
|
|
933
|
+
params["organization_id"] = int(company_id)
|
|
934
|
+
if opportunity_id is not None:
|
|
935
|
+
params["opportunity_id"] = int(opportunity_id)
|
|
936
|
+
if list_entry_id is not None:
|
|
937
|
+
params["list_entry_id"] = int(list_entry_id)
|
|
938
|
+
if action_type is not None:
|
|
939
|
+
params["action_type"] = int(action_type)
|
|
940
|
+
|
|
941
|
+
data = self._client.get("/field-value-changes", params=params, v1=True)
|
|
942
|
+
items = data.get("data", [])
|
|
943
|
+
if not isinstance(items, list):
|
|
944
|
+
items = []
|
|
945
|
+
return [FieldValueChange.model_validate(item) for item in items]
|
|
946
|
+
|
|
947
|
+
def iter(
|
|
948
|
+
self,
|
|
949
|
+
field_id: AnyFieldId,
|
|
950
|
+
*,
|
|
951
|
+
person_id: PersonId | None = None,
|
|
952
|
+
company_id: CompanyId | None = None,
|
|
953
|
+
opportunity_id: OpportunityId | None = None,
|
|
954
|
+
list_entry_id: ListEntryId | None = None,
|
|
955
|
+
action_type: FieldValueChangeAction | None = None,
|
|
956
|
+
) -> Iterator[FieldValueChange]:
|
|
957
|
+
"""Iterate field value changes (convenience wrapper for list())."""
|
|
958
|
+
yield from self.list(
|
|
959
|
+
field_id,
|
|
960
|
+
person_id=person_id,
|
|
961
|
+
company_id=company_id,
|
|
962
|
+
opportunity_id=opportunity_id,
|
|
963
|
+
list_entry_id=list_entry_id,
|
|
964
|
+
action_type=action_type,
|
|
965
|
+
)
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
# =============================================================================
|
|
969
|
+
# Relationship Strength Service (V1 API)
|
|
970
|
+
# =============================================================================
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
class RelationshipStrengthService:
|
|
974
|
+
"""Service for querying relationship strengths."""
|
|
975
|
+
|
|
976
|
+
def __init__(self, client: HTTPClient):
|
|
977
|
+
self._client = client
|
|
978
|
+
|
|
979
|
+
def get(
|
|
980
|
+
self,
|
|
981
|
+
external_id: PersonId,
|
|
982
|
+
internal_id: UserId | None = None,
|
|
983
|
+
) -> list[RelationshipStrength]:
|
|
984
|
+
"""
|
|
985
|
+
Get relationship strength(s) for an external person.
|
|
986
|
+
|
|
987
|
+
Args:
|
|
988
|
+
external_id: External person to query
|
|
989
|
+
internal_id: Optional internal person for specific relationship
|
|
990
|
+
|
|
991
|
+
Returns:
|
|
992
|
+
List of relationship strengths (may be empty)
|
|
993
|
+
"""
|
|
994
|
+
params: dict[str, Any] = {"external_id": int(external_id)}
|
|
995
|
+
if internal_id:
|
|
996
|
+
params["internal_id"] = int(internal_id)
|
|
997
|
+
|
|
998
|
+
data = self._client.get("/relationships-strengths", params=params, v1=True)
|
|
999
|
+
items = data.get("data", [])
|
|
1000
|
+
if not isinstance(items, list):
|
|
1001
|
+
items = []
|
|
1002
|
+
return [RelationshipStrength.model_validate(r) for r in items]
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
# =============================================================================
|
|
1006
|
+
# Entity File Service (V1 API)
|
|
1007
|
+
# =============================================================================
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
class EntityFileService:
|
|
1011
|
+
"""Service for managing files attached to entities."""
|
|
1012
|
+
|
|
1013
|
+
def __init__(self, client: HTTPClient):
|
|
1014
|
+
self._client = client
|
|
1015
|
+
|
|
1016
|
+
def _validate_exactly_one_target(
|
|
1017
|
+
self,
|
|
1018
|
+
*,
|
|
1019
|
+
person_id: PersonId | None,
|
|
1020
|
+
company_id: CompanyId | None,
|
|
1021
|
+
opportunity_id: OpportunityId | None,
|
|
1022
|
+
) -> None:
|
|
1023
|
+
targets = [person_id, company_id, opportunity_id]
|
|
1024
|
+
count = sum(1 for t in targets if t is not None)
|
|
1025
|
+
if count == 1:
|
|
1026
|
+
return
|
|
1027
|
+
if count == 0:
|
|
1028
|
+
raise ValueError("Exactly one of person_id, company_id, or opportunity_id is required")
|
|
1029
|
+
raise ValueError("Only one of person_id, company_id, or opportunity_id may be provided")
|
|
1030
|
+
|
|
1031
|
+
def list(
|
|
1032
|
+
self,
|
|
1033
|
+
*,
|
|
1034
|
+
person_id: PersonId | None = None,
|
|
1035
|
+
company_id: CompanyId | None = None,
|
|
1036
|
+
opportunity_id: OpportunityId | None = None,
|
|
1037
|
+
page_size: int | None = None,
|
|
1038
|
+
page_token: str | None = None,
|
|
1039
|
+
) -> V1PaginatedResponse[EntityFile]:
|
|
1040
|
+
"""Get files attached to an entity."""
|
|
1041
|
+
self._validate_exactly_one_target(
|
|
1042
|
+
person_id=person_id,
|
|
1043
|
+
company_id=company_id,
|
|
1044
|
+
opportunity_id=opportunity_id,
|
|
1045
|
+
)
|
|
1046
|
+
params: dict[str, Any] = {}
|
|
1047
|
+
if person_id:
|
|
1048
|
+
params["person_id"] = int(person_id)
|
|
1049
|
+
if company_id:
|
|
1050
|
+
params["organization_id"] = int(company_id)
|
|
1051
|
+
if opportunity_id:
|
|
1052
|
+
params["opportunity_id"] = int(opportunity_id)
|
|
1053
|
+
if page_size:
|
|
1054
|
+
params["page_size"] = page_size
|
|
1055
|
+
if page_token:
|
|
1056
|
+
params["page_token"] = page_token
|
|
1057
|
+
|
|
1058
|
+
data = self._client.get("/entity-files", params=params or None, v1=True)
|
|
1059
|
+
items = (
|
|
1060
|
+
data.get("entity_files")
|
|
1061
|
+
or data.get("entityFiles")
|
|
1062
|
+
or data.get("files")
|
|
1063
|
+
or data.get("data", [])
|
|
1064
|
+
)
|
|
1065
|
+
if not isinstance(items, list):
|
|
1066
|
+
items = []
|
|
1067
|
+
return V1PaginatedResponse[EntityFile](
|
|
1068
|
+
data=[EntityFile.model_validate(f) for f in items],
|
|
1069
|
+
next_page_token=data.get("next_page_token") or data.get("nextPageToken"),
|
|
1070
|
+
)
|
|
1071
|
+
|
|
1072
|
+
def get(self, file_id: FileId) -> EntityFile:
|
|
1073
|
+
"""Get file metadata."""
|
|
1074
|
+
data = self._client.get(f"/entity-files/{file_id}", v1=True)
|
|
1075
|
+
return EntityFile.model_validate(data)
|
|
1076
|
+
|
|
1077
|
+
def download(
|
|
1078
|
+
self,
|
|
1079
|
+
file_id: FileId,
|
|
1080
|
+
*,
|
|
1081
|
+
timeout: httpx.Timeout | float | None = None,
|
|
1082
|
+
deadline_seconds: float | None = None,
|
|
1083
|
+
) -> bytes:
|
|
1084
|
+
"""Download file content."""
|
|
1085
|
+
return self._client.download_file(
|
|
1086
|
+
f"/entity-files/download/{file_id}",
|
|
1087
|
+
v1=True,
|
|
1088
|
+
timeout=timeout,
|
|
1089
|
+
deadline_seconds=deadline_seconds,
|
|
1090
|
+
)
|
|
1091
|
+
|
|
1092
|
+
def download_stream(
|
|
1093
|
+
self,
|
|
1094
|
+
file_id: FileId,
|
|
1095
|
+
*,
|
|
1096
|
+
chunk_size: int = 65_536,
|
|
1097
|
+
on_progress: ProgressCallback | None = None,
|
|
1098
|
+
timeout: httpx.Timeout | float | None = None,
|
|
1099
|
+
deadline_seconds: float | None = None,
|
|
1100
|
+
) -> Iterator[bytes]:
|
|
1101
|
+
"""Stream-download file content in chunks."""
|
|
1102
|
+
return self._client.stream_download(
|
|
1103
|
+
f"/entity-files/download/{file_id}",
|
|
1104
|
+
v1=True,
|
|
1105
|
+
chunk_size=chunk_size,
|
|
1106
|
+
on_progress=on_progress,
|
|
1107
|
+
timeout=timeout,
|
|
1108
|
+
deadline_seconds=deadline_seconds,
|
|
1109
|
+
)
|
|
1110
|
+
|
|
1111
|
+
def download_stream_with_info(
|
|
1112
|
+
self,
|
|
1113
|
+
file_id: FileId,
|
|
1114
|
+
*,
|
|
1115
|
+
chunk_size: int = 65_536,
|
|
1116
|
+
on_progress: ProgressCallback | None = None,
|
|
1117
|
+
timeout: httpx.Timeout | float | None = None,
|
|
1118
|
+
deadline_seconds: float | None = None,
|
|
1119
|
+
) -> DownloadedFile:
|
|
1120
|
+
"""
|
|
1121
|
+
Stream-download a file and return response metadata (headers/filename/size).
|
|
1122
|
+
|
|
1123
|
+
Notes:
|
|
1124
|
+
- `filename` is derived from `Content-Disposition` when present.
|
|
1125
|
+
- If the server does not provide a filename, callers can fall back to
|
|
1126
|
+
`files.get(file_id).name`.
|
|
1127
|
+
"""
|
|
1128
|
+
return self._client.stream_download_with_info(
|
|
1129
|
+
f"/entity-files/download/{file_id}",
|
|
1130
|
+
v1=True,
|
|
1131
|
+
chunk_size=chunk_size,
|
|
1132
|
+
on_progress=on_progress,
|
|
1133
|
+
timeout=timeout,
|
|
1134
|
+
deadline_seconds=deadline_seconds,
|
|
1135
|
+
)
|
|
1136
|
+
|
|
1137
|
+
def download_to(
|
|
1138
|
+
self,
|
|
1139
|
+
file_id: FileId,
|
|
1140
|
+
path: str | Path,
|
|
1141
|
+
*,
|
|
1142
|
+
overwrite: bool = False,
|
|
1143
|
+
chunk_size: int = 65_536,
|
|
1144
|
+
on_progress: ProgressCallback | None = None,
|
|
1145
|
+
timeout: httpx.Timeout | float | None = None,
|
|
1146
|
+
deadline_seconds: float | None = None,
|
|
1147
|
+
) -> Path:
|
|
1148
|
+
"""
|
|
1149
|
+
Download a file to disk.
|
|
1150
|
+
|
|
1151
|
+
Args:
|
|
1152
|
+
file_id: The entity file id
|
|
1153
|
+
path: Destination path
|
|
1154
|
+
overwrite: If False, raises FileExistsError when path exists
|
|
1155
|
+
chunk_size: Bytes per chunk
|
|
1156
|
+
|
|
1157
|
+
Returns:
|
|
1158
|
+
The destination path
|
|
1159
|
+
"""
|
|
1160
|
+
target = Path(path)
|
|
1161
|
+
if target.exists() and not overwrite:
|
|
1162
|
+
raise FileExistsError(str(target))
|
|
1163
|
+
|
|
1164
|
+
with target.open("wb") as f:
|
|
1165
|
+
for chunk in self.download_stream(
|
|
1166
|
+
file_id,
|
|
1167
|
+
chunk_size=chunk_size,
|
|
1168
|
+
on_progress=on_progress,
|
|
1169
|
+
timeout=timeout,
|
|
1170
|
+
deadline_seconds=deadline_seconds,
|
|
1171
|
+
):
|
|
1172
|
+
f.write(chunk)
|
|
1173
|
+
|
|
1174
|
+
return target
|
|
1175
|
+
|
|
1176
|
+
def upload(
|
|
1177
|
+
self,
|
|
1178
|
+
files: dict[str, Any],
|
|
1179
|
+
*,
|
|
1180
|
+
person_id: PersonId | None = None,
|
|
1181
|
+
company_id: CompanyId | None = None,
|
|
1182
|
+
opportunity_id: OpportunityId | None = None,
|
|
1183
|
+
) -> bool:
|
|
1184
|
+
"""
|
|
1185
|
+
Upload files to an entity.
|
|
1186
|
+
|
|
1187
|
+
Args:
|
|
1188
|
+
files: Dict of filename to file-like object
|
|
1189
|
+
person_id: Person to attach to
|
|
1190
|
+
company_id: Company to attach to
|
|
1191
|
+
opportunity_id: Opportunity to attach to
|
|
1192
|
+
|
|
1193
|
+
Returns:
|
|
1194
|
+
List of created file records
|
|
1195
|
+
"""
|
|
1196
|
+
self._validate_exactly_one_target(
|
|
1197
|
+
person_id=person_id,
|
|
1198
|
+
company_id=company_id,
|
|
1199
|
+
opportunity_id=opportunity_id,
|
|
1200
|
+
)
|
|
1201
|
+
data: dict[str, Any] = {}
|
|
1202
|
+
if person_id:
|
|
1203
|
+
data["person_id"] = int(person_id)
|
|
1204
|
+
if company_id:
|
|
1205
|
+
data["organization_id"] = int(company_id)
|
|
1206
|
+
if opportunity_id:
|
|
1207
|
+
data["opportunity_id"] = int(opportunity_id)
|
|
1208
|
+
|
|
1209
|
+
result = self._client.upload_file(
|
|
1210
|
+
"/entity-files",
|
|
1211
|
+
files=files,
|
|
1212
|
+
data=data,
|
|
1213
|
+
v1=True,
|
|
1214
|
+
)
|
|
1215
|
+
if "success" in result:
|
|
1216
|
+
return bool(result.get("success"))
|
|
1217
|
+
# If the API returns something else on success (e.g., created object),
|
|
1218
|
+
# treat any 2xx JSON response as success (4xx/5xx raise earlier).
|
|
1219
|
+
return True
|
|
1220
|
+
|
|
1221
|
+
def upload_path(
|
|
1222
|
+
self,
|
|
1223
|
+
path: str | Path,
|
|
1224
|
+
*,
|
|
1225
|
+
person_id: PersonId | None = None,
|
|
1226
|
+
company_id: CompanyId | None = None,
|
|
1227
|
+
opportunity_id: OpportunityId | None = None,
|
|
1228
|
+
filename: str | None = None,
|
|
1229
|
+
content_type: str | None = None,
|
|
1230
|
+
on_progress: ProgressCallback | None = None,
|
|
1231
|
+
) -> bool:
|
|
1232
|
+
"""
|
|
1233
|
+
Upload a file from disk.
|
|
1234
|
+
|
|
1235
|
+
Notes:
|
|
1236
|
+
- Returns only a boolean because the API returns `{"success": true}` for uploads.
|
|
1237
|
+
- Progress reporting is best-effort for uploads (start/end only).
|
|
1238
|
+
"""
|
|
1239
|
+
self._validate_exactly_one_target(
|
|
1240
|
+
person_id=person_id,
|
|
1241
|
+
company_id=company_id,
|
|
1242
|
+
opportunity_id=opportunity_id,
|
|
1243
|
+
)
|
|
1244
|
+
|
|
1245
|
+
p = Path(path)
|
|
1246
|
+
upload_filename = filename or p.name
|
|
1247
|
+
guessed, _ = mimetypes.guess_type(upload_filename)
|
|
1248
|
+
final_content_type = content_type or guessed or "application/octet-stream"
|
|
1249
|
+
total = p.stat().st_size
|
|
1250
|
+
|
|
1251
|
+
if on_progress:
|
|
1252
|
+
on_progress(0, total, phase="upload")
|
|
1253
|
+
|
|
1254
|
+
with p.open("rb") as f:
|
|
1255
|
+
ok = self.upload(
|
|
1256
|
+
files={"file": (upload_filename, f, final_content_type)},
|
|
1257
|
+
person_id=person_id,
|
|
1258
|
+
company_id=company_id,
|
|
1259
|
+
opportunity_id=opportunity_id,
|
|
1260
|
+
)
|
|
1261
|
+
|
|
1262
|
+
if on_progress:
|
|
1263
|
+
on_progress(total, total, phase="upload")
|
|
1264
|
+
|
|
1265
|
+
return ok
|
|
1266
|
+
|
|
1267
|
+
def upload_bytes(
|
|
1268
|
+
self,
|
|
1269
|
+
data: bytes,
|
|
1270
|
+
filename: str,
|
|
1271
|
+
*,
|
|
1272
|
+
person_id: PersonId | None = None,
|
|
1273
|
+
company_id: CompanyId | None = None,
|
|
1274
|
+
opportunity_id: OpportunityId | None = None,
|
|
1275
|
+
content_type: str | None = None,
|
|
1276
|
+
on_progress: ProgressCallback | None = None,
|
|
1277
|
+
) -> bool:
|
|
1278
|
+
"""
|
|
1279
|
+
Upload in-memory bytes as a file.
|
|
1280
|
+
|
|
1281
|
+
Notes:
|
|
1282
|
+
- Returns only a boolean because the API returns `{"success": true}` for uploads.
|
|
1283
|
+
- Progress reporting is best-effort for uploads (start/end only).
|
|
1284
|
+
"""
|
|
1285
|
+
self._validate_exactly_one_target(
|
|
1286
|
+
person_id=person_id,
|
|
1287
|
+
company_id=company_id,
|
|
1288
|
+
opportunity_id=opportunity_id,
|
|
1289
|
+
)
|
|
1290
|
+
|
|
1291
|
+
guessed, _ = mimetypes.guess_type(filename)
|
|
1292
|
+
final_content_type = content_type or guessed or "application/octet-stream"
|
|
1293
|
+
total = len(data)
|
|
1294
|
+
|
|
1295
|
+
if on_progress:
|
|
1296
|
+
on_progress(0, total, phase="upload")
|
|
1297
|
+
|
|
1298
|
+
ok = self.upload(
|
|
1299
|
+
files={"file": (filename, data, final_content_type)},
|
|
1300
|
+
person_id=person_id,
|
|
1301
|
+
company_id=company_id,
|
|
1302
|
+
opportunity_id=opportunity_id,
|
|
1303
|
+
)
|
|
1304
|
+
|
|
1305
|
+
if on_progress:
|
|
1306
|
+
on_progress(total, total, phase="upload")
|
|
1307
|
+
|
|
1308
|
+
return ok
|
|
1309
|
+
|
|
1310
|
+
def all(
|
|
1311
|
+
self,
|
|
1312
|
+
*,
|
|
1313
|
+
person_id: PersonId | None = None,
|
|
1314
|
+
company_id: CompanyId | None = None,
|
|
1315
|
+
opportunity_id: OpportunityId | None = None,
|
|
1316
|
+
) -> Iterator[EntityFile]:
|
|
1317
|
+
"""Iterate through all files for an entity with automatic pagination."""
|
|
1318
|
+
self._validate_exactly_one_target(
|
|
1319
|
+
person_id=person_id,
|
|
1320
|
+
company_id=company_id,
|
|
1321
|
+
opportunity_id=opportunity_id,
|
|
1322
|
+
)
|
|
1323
|
+
|
|
1324
|
+
page_token: str | None = None
|
|
1325
|
+
while True:
|
|
1326
|
+
page = self.list(
|
|
1327
|
+
person_id=person_id,
|
|
1328
|
+
company_id=company_id,
|
|
1329
|
+
opportunity_id=opportunity_id,
|
|
1330
|
+
page_token=page_token,
|
|
1331
|
+
)
|
|
1332
|
+
yield from page.data
|
|
1333
|
+
if not page.has_next:
|
|
1334
|
+
break
|
|
1335
|
+
page_token = page.next_page_token
|
|
1336
|
+
|
|
1337
|
+
def iter(
|
|
1338
|
+
self,
|
|
1339
|
+
*,
|
|
1340
|
+
person_id: PersonId | None = None,
|
|
1341
|
+
company_id: CompanyId | None = None,
|
|
1342
|
+
opportunity_id: OpportunityId | None = None,
|
|
1343
|
+
) -> Iterator[EntityFile]:
|
|
1344
|
+
"""Auto-paginate all files (alias for `all()`)."""
|
|
1345
|
+
return self.all(
|
|
1346
|
+
person_id=person_id,
|
|
1347
|
+
company_id=company_id,
|
|
1348
|
+
opportunity_id=opportunity_id,
|
|
1349
|
+
)
|
|
1350
|
+
|
|
1351
|
+
|
|
1352
|
+
# =============================================================================
|
|
1353
|
+
# Auth Service
|
|
1354
|
+
# =============================================================================
|
|
1355
|
+
|
|
1356
|
+
|
|
1357
|
+
class AuthService:
|
|
1358
|
+
"""Service for authentication info."""
|
|
1359
|
+
|
|
1360
|
+
def __init__(self, client: HTTPClient):
|
|
1361
|
+
self._client = client
|
|
1362
|
+
|
|
1363
|
+
def whoami(self) -> WhoAmI:
|
|
1364
|
+
"""Get info about current user and API key."""
|
|
1365
|
+
# V2 also has this endpoint
|
|
1366
|
+
data = self._client.get("/auth/whoami")
|
|
1367
|
+
return WhoAmI.model_validate(data)
|
|
1368
|
+
|
|
1369
|
+
# Note: rate limit handling is exposed via `client.rate_limits` (version-agnostic).
|
|
1370
|
+
|
|
1371
|
+
|
|
1372
|
+
# =============================================================================
|
|
1373
|
+
# Async V1-only services
|
|
1374
|
+
# =============================================================================
|
|
1375
|
+
|
|
1376
|
+
|
|
1377
|
+
class AsyncNoteService:
|
|
1378
|
+
"""
|
|
1379
|
+
Async service for managing notes (V1 API).
|
|
1380
|
+
|
|
1381
|
+
V2 provides read-only access; use V1 for create/update/delete.
|
|
1382
|
+
"""
|
|
1383
|
+
|
|
1384
|
+
def __init__(self, client: AsyncHTTPClient):
|
|
1385
|
+
self._client = client
|
|
1386
|
+
|
|
1387
|
+
async def list(
|
|
1388
|
+
self,
|
|
1389
|
+
*,
|
|
1390
|
+
person_id: PersonId | None = None,
|
|
1391
|
+
company_id: CompanyId | None = None,
|
|
1392
|
+
opportunity_id: OpportunityId | None = None,
|
|
1393
|
+
creator_id: UserId | None = None,
|
|
1394
|
+
page_size: int | None = None,
|
|
1395
|
+
page_token: str | None = None,
|
|
1396
|
+
) -> V1PaginatedResponse[Note]:
|
|
1397
|
+
params: dict[str, Any] = {}
|
|
1398
|
+
if person_id:
|
|
1399
|
+
params["person_id"] = int(person_id)
|
|
1400
|
+
if company_id:
|
|
1401
|
+
params["organization_id"] = int(company_id)
|
|
1402
|
+
if opportunity_id:
|
|
1403
|
+
params["opportunity_id"] = int(opportunity_id)
|
|
1404
|
+
if creator_id:
|
|
1405
|
+
params["creator_id"] = int(creator_id)
|
|
1406
|
+
if page_size:
|
|
1407
|
+
params["page_size"] = page_size
|
|
1408
|
+
if page_token:
|
|
1409
|
+
params["page_token"] = page_token
|
|
1410
|
+
|
|
1411
|
+
data = await self._client.get("/notes", params=params or None, v1=True)
|
|
1412
|
+
items = data.get("notes", data.get("data", []))
|
|
1413
|
+
if not isinstance(items, list):
|
|
1414
|
+
items = []
|
|
1415
|
+
return V1PaginatedResponse[Note](
|
|
1416
|
+
data=[Note.model_validate(n) for n in items],
|
|
1417
|
+
next_page_token=data.get("next_page_token") or data.get("nextPageToken"),
|
|
1418
|
+
)
|
|
1419
|
+
|
|
1420
|
+
async def get(self, note_id: NoteId) -> Note:
|
|
1421
|
+
data = await self._client.get(f"/notes/{note_id}", v1=True)
|
|
1422
|
+
return Note.model_validate(data)
|
|
1423
|
+
|
|
1424
|
+
async def create(self, data: NoteCreate) -> Note:
|
|
1425
|
+
payload = data.model_dump(by_alias=True, mode="python", exclude_none=True)
|
|
1426
|
+
_coerce_isoformat(payload, ("created_at",))
|
|
1427
|
+
if not data.person_ids:
|
|
1428
|
+
payload.pop("person_ids", None)
|
|
1429
|
+
if not data.company_ids:
|
|
1430
|
+
payload.pop("organization_ids", None)
|
|
1431
|
+
if not data.opportunity_ids:
|
|
1432
|
+
payload.pop("opportunity_ids", None)
|
|
1433
|
+
|
|
1434
|
+
result = await self._client.post("/notes", json=payload, v1=True)
|
|
1435
|
+
return Note.model_validate(result)
|
|
1436
|
+
|
|
1437
|
+
async def update(self, note_id: NoteId, data: NoteUpdate) -> Note:
|
|
1438
|
+
payload = data.model_dump(mode="json", exclude_unset=True, exclude_none=True)
|
|
1439
|
+
result = await self._client.put(
|
|
1440
|
+
f"/notes/{note_id}",
|
|
1441
|
+
json=payload,
|
|
1442
|
+
v1=True,
|
|
1443
|
+
)
|
|
1444
|
+
return Note.model_validate(result)
|
|
1445
|
+
|
|
1446
|
+
async def delete(self, note_id: NoteId) -> bool:
|
|
1447
|
+
result = await self._client.delete(f"/notes/{note_id}", v1=True)
|
|
1448
|
+
return bool(result.get("success", False))
|
|
1449
|
+
|
|
1450
|
+
|
|
1451
|
+
class AsyncReminderService:
|
|
1452
|
+
"""Async service for managing reminders (V1 API)."""
|
|
1453
|
+
|
|
1454
|
+
def __init__(self, client: AsyncHTTPClient):
|
|
1455
|
+
self._client = client
|
|
1456
|
+
|
|
1457
|
+
async def list(
|
|
1458
|
+
self,
|
|
1459
|
+
*,
|
|
1460
|
+
person_id: PersonId | None = None,
|
|
1461
|
+
company_id: CompanyId | None = None,
|
|
1462
|
+
opportunity_id: OpportunityId | None = None,
|
|
1463
|
+
creator_id: UserId | None = None,
|
|
1464
|
+
owner_id: UserId | None = None,
|
|
1465
|
+
completer_id: UserId | None = None,
|
|
1466
|
+
type: ReminderType | None = None,
|
|
1467
|
+
reset_type: ReminderResetType | None = None,
|
|
1468
|
+
status: ReminderStatus | None = None,
|
|
1469
|
+
due_before: datetime | None = None,
|
|
1470
|
+
due_after: datetime | None = None,
|
|
1471
|
+
page_size: int | None = None,
|
|
1472
|
+
page_token: str | None = None,
|
|
1473
|
+
) -> V1PaginatedResponse[Reminder]:
|
|
1474
|
+
params: dict[str, Any] = {}
|
|
1475
|
+
if person_id:
|
|
1476
|
+
params["person_id"] = int(person_id)
|
|
1477
|
+
if company_id:
|
|
1478
|
+
params["organization_id"] = int(company_id)
|
|
1479
|
+
if opportunity_id:
|
|
1480
|
+
params["opportunity_id"] = int(opportunity_id)
|
|
1481
|
+
if creator_id:
|
|
1482
|
+
params["creator_id"] = int(creator_id)
|
|
1483
|
+
if owner_id:
|
|
1484
|
+
params["owner_id"] = int(owner_id)
|
|
1485
|
+
if completer_id:
|
|
1486
|
+
params["completer_id"] = int(completer_id)
|
|
1487
|
+
if type is not None:
|
|
1488
|
+
params["type"] = int(type)
|
|
1489
|
+
if reset_type is not None:
|
|
1490
|
+
params["reset_type"] = int(reset_type)
|
|
1491
|
+
if status is not None:
|
|
1492
|
+
params["status"] = int(status)
|
|
1493
|
+
if due_before:
|
|
1494
|
+
params["due_before"] = due_before.isoformat()
|
|
1495
|
+
if due_after:
|
|
1496
|
+
params["due_after"] = due_after.isoformat()
|
|
1497
|
+
if page_size:
|
|
1498
|
+
params["page_size"] = page_size
|
|
1499
|
+
if page_token:
|
|
1500
|
+
params["page_token"] = page_token
|
|
1501
|
+
|
|
1502
|
+
data = await self._client.get("/reminders", params=params or None, v1=True)
|
|
1503
|
+
items = data.get("reminders", data.get("data", []))
|
|
1504
|
+
if not isinstance(items, list):
|
|
1505
|
+
items = []
|
|
1506
|
+
return V1PaginatedResponse[Reminder](
|
|
1507
|
+
data=[Reminder.model_validate(r) for r in items],
|
|
1508
|
+
next_page_token=data.get("next_page_token") or data.get("nextPageToken"),
|
|
1509
|
+
)
|
|
1510
|
+
|
|
1511
|
+
async def get(self, reminder_id: ReminderIdType) -> Reminder:
|
|
1512
|
+
data = await self._client.get(f"/reminders/{reminder_id}", v1=True)
|
|
1513
|
+
return Reminder.model_validate(data)
|
|
1514
|
+
|
|
1515
|
+
async def create(self, data: ReminderCreate) -> Reminder:
|
|
1516
|
+
payload = data.model_dump(by_alias=True, mode="python", exclude_none=True)
|
|
1517
|
+
_coerce_isoformat(payload, ("due_date",))
|
|
1518
|
+
|
|
1519
|
+
result = await self._client.post("/reminders", json=payload, v1=True)
|
|
1520
|
+
return Reminder.model_validate(result)
|
|
1521
|
+
|
|
1522
|
+
async def update(self, reminder_id: ReminderIdType, data: ReminderUpdate) -> Reminder:
|
|
1523
|
+
payload = data.model_dump(
|
|
1524
|
+
by_alias=True,
|
|
1525
|
+
mode="python",
|
|
1526
|
+
exclude_unset=True,
|
|
1527
|
+
exclude_none=True,
|
|
1528
|
+
)
|
|
1529
|
+
_coerce_isoformat(payload, ("due_date",))
|
|
1530
|
+
|
|
1531
|
+
result = await self._client.put(f"/reminders/{reminder_id}", json=payload, v1=True)
|
|
1532
|
+
return Reminder.model_validate(result)
|
|
1533
|
+
|
|
1534
|
+
async def delete(self, reminder_id: ReminderIdType) -> bool:
|
|
1535
|
+
result = await self._client.delete(f"/reminders/{reminder_id}", v1=True)
|
|
1536
|
+
return bool(result.get("success", False))
|
|
1537
|
+
|
|
1538
|
+
|
|
1539
|
+
class AsyncWebhookService:
|
|
1540
|
+
"""Async service for managing webhook subscriptions (V1 API)."""
|
|
1541
|
+
|
|
1542
|
+
def __init__(self, client: AsyncHTTPClient):
|
|
1543
|
+
self._client = client
|
|
1544
|
+
|
|
1545
|
+
async def list(self) -> builtins.list[WebhookSubscription]:
|
|
1546
|
+
data = await self._client.get("/webhook", v1=True)
|
|
1547
|
+
items = data.get("data", [])
|
|
1548
|
+
if not isinstance(items, list):
|
|
1549
|
+
items = []
|
|
1550
|
+
return [WebhookSubscription.model_validate(w) for w in items]
|
|
1551
|
+
|
|
1552
|
+
async def get(self, webhook_id: WebhookId) -> WebhookSubscription:
|
|
1553
|
+
data = await self._client.get(f"/webhook/{webhook_id}", v1=True)
|
|
1554
|
+
return WebhookSubscription.model_validate(data)
|
|
1555
|
+
|
|
1556
|
+
async def create(self, data: WebhookCreate) -> WebhookSubscription:
|
|
1557
|
+
payload = data.model_dump(by_alias=True, mode="json", exclude_none=True)
|
|
1558
|
+
if not data.subscriptions:
|
|
1559
|
+
payload.pop("subscriptions", None)
|
|
1560
|
+
result = await self._client.post("/webhook/subscribe", json=payload, v1=True)
|
|
1561
|
+
return WebhookSubscription.model_validate(result)
|
|
1562
|
+
|
|
1563
|
+
async def update(self, webhook_id: WebhookId, data: WebhookUpdate) -> WebhookSubscription:
|
|
1564
|
+
payload = data.model_dump(
|
|
1565
|
+
by_alias=True,
|
|
1566
|
+
mode="json",
|
|
1567
|
+
exclude_unset=True,
|
|
1568
|
+
exclude_none=True,
|
|
1569
|
+
)
|
|
1570
|
+
result = await self._client.put(f"/webhook/{webhook_id}", json=payload, v1=True)
|
|
1571
|
+
return WebhookSubscription.model_validate(result)
|
|
1572
|
+
|
|
1573
|
+
async def delete(self, webhook_id: WebhookId) -> bool:
|
|
1574
|
+
result = await self._client.delete(f"/webhook/{webhook_id}", v1=True)
|
|
1575
|
+
return bool(result.get("success", False))
|
|
1576
|
+
|
|
1577
|
+
|
|
1578
|
+
class AsyncInteractionService:
|
|
1579
|
+
"""Async service for managing interactions (V1 API)."""
|
|
1580
|
+
|
|
1581
|
+
def __init__(self, client: AsyncHTTPClient):
|
|
1582
|
+
self._client = client
|
|
1583
|
+
|
|
1584
|
+
async def list(
|
|
1585
|
+
self,
|
|
1586
|
+
*,
|
|
1587
|
+
type: InteractionType | None = None,
|
|
1588
|
+
start_time: datetime | None = None,
|
|
1589
|
+
end_time: datetime | None = None,
|
|
1590
|
+
person_id: PersonId | None = None,
|
|
1591
|
+
company_id: CompanyId | None = None,
|
|
1592
|
+
opportunity_id: OpportunityId | None = None,
|
|
1593
|
+
page_size: int | None = None,
|
|
1594
|
+
page_token: str | None = None,
|
|
1595
|
+
) -> V1PaginatedResponse[Interaction]:
|
|
1596
|
+
params: dict[str, Any] = {}
|
|
1597
|
+
if type is not None:
|
|
1598
|
+
params["type"] = int(type)
|
|
1599
|
+
if start_time:
|
|
1600
|
+
params["start_time"] = start_time.isoformat()
|
|
1601
|
+
if end_time:
|
|
1602
|
+
params["end_time"] = end_time.isoformat()
|
|
1603
|
+
if person_id:
|
|
1604
|
+
params["person_id"] = int(person_id)
|
|
1605
|
+
if company_id:
|
|
1606
|
+
params["organization_id"] = int(company_id)
|
|
1607
|
+
if opportunity_id:
|
|
1608
|
+
params["opportunity_id"] = int(opportunity_id)
|
|
1609
|
+
if page_size:
|
|
1610
|
+
params["page_size"] = page_size
|
|
1611
|
+
if page_token:
|
|
1612
|
+
params["page_token"] = page_token
|
|
1613
|
+
|
|
1614
|
+
data = await self._client.get("/interactions", params=params or None, v1=True)
|
|
1615
|
+
items: Any = None
|
|
1616
|
+
if type is not None:
|
|
1617
|
+
if int(type) in (int(InteractionType.MEETING), int(InteractionType.CALL)):
|
|
1618
|
+
items = data.get("events")
|
|
1619
|
+
elif int(type) == int(InteractionType.CHAT_MESSAGE):
|
|
1620
|
+
items = data.get("chat_messages")
|
|
1621
|
+
elif int(type) == int(InteractionType.EMAIL):
|
|
1622
|
+
items = data.get("emails")
|
|
1623
|
+
|
|
1624
|
+
if items is None:
|
|
1625
|
+
items = (
|
|
1626
|
+
data.get("interactions")
|
|
1627
|
+
or data.get("events")
|
|
1628
|
+
or data.get("emails")
|
|
1629
|
+
or data.get("chat_messages")
|
|
1630
|
+
or data.get("data", [])
|
|
1631
|
+
)
|
|
1632
|
+
if not isinstance(items, list):
|
|
1633
|
+
items = []
|
|
1634
|
+
return V1PaginatedResponse[Interaction](
|
|
1635
|
+
data=[Interaction.model_validate(i) for i in items],
|
|
1636
|
+
next_page_token=data.get("next_page_token") or data.get("nextPageToken"),
|
|
1637
|
+
)
|
|
1638
|
+
|
|
1639
|
+
async def get(self, interaction_id: InteractionId, type: InteractionType) -> Interaction:
|
|
1640
|
+
data = await self._client.get(
|
|
1641
|
+
f"/interactions/{interaction_id}",
|
|
1642
|
+
params={"type": int(type)},
|
|
1643
|
+
v1=True,
|
|
1644
|
+
)
|
|
1645
|
+
return Interaction.model_validate(data)
|
|
1646
|
+
|
|
1647
|
+
async def create(self, data: InteractionCreate) -> Interaction:
|
|
1648
|
+
payload = data.model_dump(by_alias=True, mode="python", exclude_none=True)
|
|
1649
|
+
_coerce_isoformat(payload, ("date",))
|
|
1650
|
+
|
|
1651
|
+
result = await self._client.post("/interactions", json=payload, v1=True)
|
|
1652
|
+
return Interaction.model_validate(result)
|
|
1653
|
+
|
|
1654
|
+
async def update(
|
|
1655
|
+
self,
|
|
1656
|
+
interaction_id: InteractionId,
|
|
1657
|
+
type: InteractionType,
|
|
1658
|
+
data: InteractionUpdate,
|
|
1659
|
+
) -> Interaction:
|
|
1660
|
+
payload = data.model_dump(
|
|
1661
|
+
by_alias=True,
|
|
1662
|
+
mode="python",
|
|
1663
|
+
exclude_unset=True,
|
|
1664
|
+
exclude_none=True,
|
|
1665
|
+
)
|
|
1666
|
+
payload["type"] = int(type)
|
|
1667
|
+
_coerce_isoformat(payload, ("date",))
|
|
1668
|
+
|
|
1669
|
+
result = await self._client.put(f"/interactions/{interaction_id}", json=payload, v1=True)
|
|
1670
|
+
return Interaction.model_validate(result)
|
|
1671
|
+
|
|
1672
|
+
async def delete(self, interaction_id: InteractionId, type: InteractionType) -> bool:
|
|
1673
|
+
result = await self._client.delete(
|
|
1674
|
+
f"/interactions/{interaction_id}",
|
|
1675
|
+
params={"type": int(type)},
|
|
1676
|
+
v1=True,
|
|
1677
|
+
)
|
|
1678
|
+
return bool(result.get("success", False))
|
|
1679
|
+
|
|
1680
|
+
|
|
1681
|
+
class AsyncFieldService:
|
|
1682
|
+
"""Async service for managing custom fields (V1 API)."""
|
|
1683
|
+
|
|
1684
|
+
def __init__(self, client: AsyncHTTPClient):
|
|
1685
|
+
self._client = client
|
|
1686
|
+
|
|
1687
|
+
async def list(
|
|
1688
|
+
self,
|
|
1689
|
+
*,
|
|
1690
|
+
list_id: ListId | None = None,
|
|
1691
|
+
entity_type: EntityType | None = None,
|
|
1692
|
+
) -> builtins.list[FieldMetadata]:
|
|
1693
|
+
params: dict[str, Any] = {}
|
|
1694
|
+
if list_id:
|
|
1695
|
+
params["list_id"] = int(list_id)
|
|
1696
|
+
if entity_type is not None:
|
|
1697
|
+
params["entity_type"] = int(entity_type)
|
|
1698
|
+
|
|
1699
|
+
data = await self._client.get("/fields", params=params or None, v1=True)
|
|
1700
|
+
items = data.get("data", [])
|
|
1701
|
+
if not isinstance(items, list):
|
|
1702
|
+
items = []
|
|
1703
|
+
return [FieldMetadata.model_validate(f) for f in items]
|
|
1704
|
+
|
|
1705
|
+
async def create(self, data: FieldCreate) -> FieldMetadata:
|
|
1706
|
+
value_type_code = to_v1_value_type_code(value_type=data.value_type, raw=None)
|
|
1707
|
+
if value_type_code is None:
|
|
1708
|
+
raise ValueError(f"Field value_type has no V1 numeric mapping: {data.value_type!s}")
|
|
1709
|
+
payload = data.model_dump(by_alias=True, mode="json", exclude_unset=True, exclude_none=True)
|
|
1710
|
+
payload["entity_type"] = int(data.entity_type)
|
|
1711
|
+
payload["value_type"] = value_type_code
|
|
1712
|
+
for key in ("allows_multiple", "is_list_specific", "is_required"):
|
|
1713
|
+
if not payload.get(key):
|
|
1714
|
+
payload.pop(key, None)
|
|
1715
|
+
|
|
1716
|
+
result = await self._client.post("/fields", json=payload, v1=True)
|
|
1717
|
+
|
|
1718
|
+
if self._client.cache:
|
|
1719
|
+
self._client.cache.invalidate_prefix("field")
|
|
1720
|
+
self._client.cache.invalidate_prefix("list_")
|
|
1721
|
+
self._client.cache.invalidate_prefix("person_fields")
|
|
1722
|
+
self._client.cache.invalidate_prefix("company_fields")
|
|
1723
|
+
|
|
1724
|
+
return FieldMetadata.model_validate(result)
|
|
1725
|
+
|
|
1726
|
+
async def delete(self, field_id: FieldId) -> bool:
|
|
1727
|
+
"""
|
|
1728
|
+
Delete a custom field (V1 API).
|
|
1729
|
+
|
|
1730
|
+
Note: V1 deletes require numeric field IDs. The SDK accepts V2-style
|
|
1731
|
+
`field-<digits>` IDs and converts them; enriched/relationship-intelligence
|
|
1732
|
+
IDs are not supported.
|
|
1733
|
+
"""
|
|
1734
|
+
numeric_id = field_id_to_v1_numeric(field_id)
|
|
1735
|
+
result = await self._client.delete(f"/fields/{numeric_id}", v1=True)
|
|
1736
|
+
|
|
1737
|
+
if self._client.cache:
|
|
1738
|
+
self._client.cache.invalidate_prefix("field")
|
|
1739
|
+
self._client.cache.invalidate_prefix("list_")
|
|
1740
|
+
self._client.cache.invalidate_prefix("person_fields")
|
|
1741
|
+
self._client.cache.invalidate_prefix("company_fields")
|
|
1742
|
+
|
|
1743
|
+
return bool(result.get("success", False))
|
|
1744
|
+
|
|
1745
|
+
async def exists(self, field_id: AnyFieldId) -> bool:
|
|
1746
|
+
"""
|
|
1747
|
+
Check if a field exists.
|
|
1748
|
+
|
|
1749
|
+
Useful for validation before setting field values.
|
|
1750
|
+
|
|
1751
|
+
Note: This fetches all fields and checks locally. If your code calls
|
|
1752
|
+
exists() frequently in a loop, consider caching the result of fields.list()
|
|
1753
|
+
yourself.
|
|
1754
|
+
|
|
1755
|
+
Args:
|
|
1756
|
+
field_id: The field ID to check
|
|
1757
|
+
|
|
1758
|
+
Returns:
|
|
1759
|
+
True if the field exists, False otherwise
|
|
1760
|
+
|
|
1761
|
+
Example:
|
|
1762
|
+
if await client.fields.exists(FieldId("field-123")):
|
|
1763
|
+
await client.field_values.create(...)
|
|
1764
|
+
"""
|
|
1765
|
+
target_id = FieldId(field_id) if not isinstance(field_id, FieldId) else field_id
|
|
1766
|
+
fields = await self.list()
|
|
1767
|
+
return any(f.id == target_id for f in fields)
|
|
1768
|
+
|
|
1769
|
+
async def get_by_name(self, name: str) -> FieldMetadata | None:
|
|
1770
|
+
"""
|
|
1771
|
+
Find a field by its display name.
|
|
1772
|
+
|
|
1773
|
+
Uses case-insensitive matching (casefold for i18n support).
|
|
1774
|
+
|
|
1775
|
+
Note: This fetches all fields and searches locally. If your code calls
|
|
1776
|
+
get_by_name() frequently in a loop, consider caching the result of
|
|
1777
|
+
fields.list() yourself.
|
|
1778
|
+
|
|
1779
|
+
Args:
|
|
1780
|
+
name: The field display name to search for
|
|
1781
|
+
|
|
1782
|
+
Returns:
|
|
1783
|
+
FieldMetadata if found, None otherwise
|
|
1784
|
+
|
|
1785
|
+
Example:
|
|
1786
|
+
field = await client.fields.get_by_name("Primary Email Status")
|
|
1787
|
+
if field:
|
|
1788
|
+
fv = await client.field_values.get_for_entity(field.id, person_id=pid)
|
|
1789
|
+
"""
|
|
1790
|
+
fields = await self.list()
|
|
1791
|
+
name_folded = name.strip().casefold() # Strip whitespace, then casefold for i18n
|
|
1792
|
+
for field in fields:
|
|
1793
|
+
if field.name.casefold() == name_folded:
|
|
1794
|
+
return field
|
|
1795
|
+
return None
|
|
1796
|
+
|
|
1797
|
+
|
|
1798
|
+
class AsyncFieldValueService:
|
|
1799
|
+
"""Async service for managing field values (V1 API)."""
|
|
1800
|
+
|
|
1801
|
+
def __init__(self, client: AsyncHTTPClient):
|
|
1802
|
+
self._client = client
|
|
1803
|
+
|
|
1804
|
+
async def list(
|
|
1805
|
+
self,
|
|
1806
|
+
*,
|
|
1807
|
+
person_id: PersonId | None = None,
|
|
1808
|
+
company_id: CompanyId | None = None,
|
|
1809
|
+
opportunity_id: OpportunityId | None = None,
|
|
1810
|
+
list_entry_id: ListEntryId | None = None,
|
|
1811
|
+
) -> builtins.list[FieldValue]:
|
|
1812
|
+
provided = {
|
|
1813
|
+
name: value
|
|
1814
|
+
for name, value in (
|
|
1815
|
+
("person_id", person_id),
|
|
1816
|
+
("company_id", company_id),
|
|
1817
|
+
("opportunity_id", opportunity_id),
|
|
1818
|
+
("list_entry_id", list_entry_id),
|
|
1819
|
+
)
|
|
1820
|
+
if value is not None
|
|
1821
|
+
}
|
|
1822
|
+
if len(provided) == 0:
|
|
1823
|
+
raise ValueError(
|
|
1824
|
+
"field_values.list() requires exactly one entity ID. "
|
|
1825
|
+
"Example: client.field_values.list(person_id=PersonId(123))"
|
|
1826
|
+
)
|
|
1827
|
+
if len(provided) > 1:
|
|
1828
|
+
raise ValueError(
|
|
1829
|
+
f"field_values.list() accepts only one entity ID, "
|
|
1830
|
+
f"but received {len(provided)}: {', '.join(provided.keys())}. "
|
|
1831
|
+
"Call list() separately for each entity."
|
|
1832
|
+
)
|
|
1833
|
+
|
|
1834
|
+
params: dict[str, Any] = {}
|
|
1835
|
+
if person_id is not None:
|
|
1836
|
+
params["person_id"] = int(person_id)
|
|
1837
|
+
if company_id is not None:
|
|
1838
|
+
params["organization_id"] = int(company_id)
|
|
1839
|
+
if opportunity_id is not None:
|
|
1840
|
+
params["opportunity_id"] = int(opportunity_id)
|
|
1841
|
+
if list_entry_id is not None:
|
|
1842
|
+
params["list_entry_id"] = int(list_entry_id)
|
|
1843
|
+
|
|
1844
|
+
data = await self._client.get("/field-values", params=params or None, v1=True)
|
|
1845
|
+
items = data.get("data", [])
|
|
1846
|
+
if not isinstance(items, list):
|
|
1847
|
+
items = []
|
|
1848
|
+
return [FieldValue.model_validate(v) for v in items]
|
|
1849
|
+
|
|
1850
|
+
async def create(self, data: FieldValueCreate) -> FieldValue:
|
|
1851
|
+
"""
|
|
1852
|
+
Create a field value (V1 API).
|
|
1853
|
+
|
|
1854
|
+
Note: V1 writes require numeric field IDs. The SDK accepts V2-style
|
|
1855
|
+
`field-<digits>` IDs and converts them; enriched/relationship-intelligence
|
|
1856
|
+
IDs are not supported.
|
|
1857
|
+
"""
|
|
1858
|
+
payload = data.model_dump(by_alias=True, mode="json", exclude_unset=True, exclude_none=True)
|
|
1859
|
+
payload["field_id"] = field_id_to_v1_numeric(data.field_id)
|
|
1860
|
+
|
|
1861
|
+
result = await self._client.post("/field-values", json=payload, v1=True)
|
|
1862
|
+
return FieldValue.model_validate(result)
|
|
1863
|
+
|
|
1864
|
+
async def update(self, field_value_id: FieldValueId, value: Any) -> FieldValue:
|
|
1865
|
+
result = await self._client.put(
|
|
1866
|
+
f"/field-values/{field_value_id}",
|
|
1867
|
+
json={"value": value},
|
|
1868
|
+
v1=True,
|
|
1869
|
+
)
|
|
1870
|
+
return FieldValue.model_validate(result)
|
|
1871
|
+
|
|
1872
|
+
async def delete(self, field_value_id: FieldValueId) -> bool:
|
|
1873
|
+
result = await self._client.delete(f"/field-values/{field_value_id}", v1=True)
|
|
1874
|
+
return bool(result.get("success", False))
|
|
1875
|
+
|
|
1876
|
+
async def get_for_entity(
|
|
1877
|
+
self,
|
|
1878
|
+
field_id: str | FieldId,
|
|
1879
|
+
*,
|
|
1880
|
+
person_id: PersonId | None = None,
|
|
1881
|
+
company_id: CompanyId | None = None,
|
|
1882
|
+
opportunity_id: OpportunityId | None = None,
|
|
1883
|
+
list_entry_id: ListEntryId | None = None,
|
|
1884
|
+
default: T = _UNSET,
|
|
1885
|
+
) -> FieldValue | T | None:
|
|
1886
|
+
"""
|
|
1887
|
+
Get a specific field value for an entity.
|
|
1888
|
+
|
|
1889
|
+
Convenience method that fetches all field values and returns the one
|
|
1890
|
+
matching field_id. Like dict.get(), returns None (or default) if not found.
|
|
1891
|
+
|
|
1892
|
+
Note: This still makes one API call to fetch all field values for the entity.
|
|
1893
|
+
For entities with hundreds of field values, prefer using ``list()`` directly
|
|
1894
|
+
if you need to inspect multiple fields.
|
|
1895
|
+
|
|
1896
|
+
Args:
|
|
1897
|
+
field_id: The field to look up (accepts str or FieldId for convenience)
|
|
1898
|
+
person_id: Person entity (exactly one entity ID required)
|
|
1899
|
+
company_id: Company entity
|
|
1900
|
+
opportunity_id: Opportunity entity
|
|
1901
|
+
list_entry_id: List entry entity
|
|
1902
|
+
default: Value to return if field not found (default: None)
|
|
1903
|
+
|
|
1904
|
+
Returns:
|
|
1905
|
+
FieldValue if the field has a value, default otherwise.
|
|
1906
|
+
Note: A FieldValue with ``.value is None`` still counts as "present" (explicit empty).
|
|
1907
|
+
|
|
1908
|
+
Example:
|
|
1909
|
+
# Check if a person has a specific field value
|
|
1910
|
+
status = await client.field_values.get_for_entity(
|
|
1911
|
+
"field-123", # or FieldId("field-123")
|
|
1912
|
+
person_id=PersonId(456),
|
|
1913
|
+
)
|
|
1914
|
+
if status is None:
|
|
1915
|
+
print("Field is empty")
|
|
1916
|
+
else:
|
|
1917
|
+
print(f"Value: {status.value}")
|
|
1918
|
+
|
|
1919
|
+
# With default value
|
|
1920
|
+
status = await client.field_values.get_for_entity(
|
|
1921
|
+
"field-123",
|
|
1922
|
+
person_id=PersonId(456),
|
|
1923
|
+
default="N/A",
|
|
1924
|
+
)
|
|
1925
|
+
"""
|
|
1926
|
+
all_values = await self.list(
|
|
1927
|
+
person_id=person_id,
|
|
1928
|
+
company_id=company_id,
|
|
1929
|
+
opportunity_id=opportunity_id,
|
|
1930
|
+
list_entry_id=list_entry_id,
|
|
1931
|
+
)
|
|
1932
|
+
# Normalize field_id for comparison (handles both str and FieldId)
|
|
1933
|
+
target_id = FieldId(field_id) if not isinstance(field_id, FieldId) else field_id
|
|
1934
|
+
for fv in all_values:
|
|
1935
|
+
if fv.field_id == target_id:
|
|
1936
|
+
return fv
|
|
1937
|
+
return None if default is _UNSET else default
|
|
1938
|
+
|
|
1939
|
+
async def list_batch(
|
|
1940
|
+
self,
|
|
1941
|
+
person_ids: Sequence[PersonId] | None = None,
|
|
1942
|
+
company_ids: Sequence[CompanyId] | None = None,
|
|
1943
|
+
opportunity_ids: Sequence[OpportunityId] | None = None,
|
|
1944
|
+
*,
|
|
1945
|
+
on_error: Literal["raise", "skip"] = "raise",
|
|
1946
|
+
concurrency: int | None = 10,
|
|
1947
|
+
) -> dict[PersonId | CompanyId | OpportunityId, builtins.list[FieldValue]]:
|
|
1948
|
+
"""
|
|
1949
|
+
Get field values for multiple entities concurrently.
|
|
1950
|
+
|
|
1951
|
+
Uses asyncio.gather() for concurrent API calls, bounded by semaphore.
|
|
1952
|
+
Significant speedup compared to sequential sync version.
|
|
1953
|
+
|
|
1954
|
+
Args:
|
|
1955
|
+
person_ids: Sequence of person IDs (mutually exclusive with others)
|
|
1956
|
+
company_ids: Sequence of company IDs
|
|
1957
|
+
opportunity_ids: Sequence of opportunity IDs
|
|
1958
|
+
on_error: How to handle errors - "raise" (default) or "skip" failed IDs
|
|
1959
|
+
concurrency: Maximum concurrent requests. Default 10. Set to None for unlimited.
|
|
1960
|
+
|
|
1961
|
+
Returns:
|
|
1962
|
+
Dict mapping entity_id -> list of field values.
|
|
1963
|
+
Note: Dict ordering is not guaranteed; do not rely on insertion order.
|
|
1964
|
+
|
|
1965
|
+
Example:
|
|
1966
|
+
# Check which persons have a specific field set
|
|
1967
|
+
fv_map = await client.field_values.list_batch(person_ids=person_ids)
|
|
1968
|
+
for person_id, field_values in fv_map.items():
|
|
1969
|
+
has_status = any(fv.field_id == target_field for fv in field_values)
|
|
1970
|
+
"""
|
|
1971
|
+
# Validate exactly one sequence provided
|
|
1972
|
+
provided = [
|
|
1973
|
+
("person_ids", person_ids),
|
|
1974
|
+
("company_ids", company_ids),
|
|
1975
|
+
("opportunity_ids", opportunity_ids),
|
|
1976
|
+
]
|
|
1977
|
+
non_none = [(name, seq) for name, seq in provided if seq is not None]
|
|
1978
|
+
if len(non_none) != 1:
|
|
1979
|
+
raise ValueError("Exactly one of person_ids, company_ids, or opportunity_ids required")
|
|
1980
|
+
|
|
1981
|
+
name, ids = non_none[0]
|
|
1982
|
+
semaphore = asyncio.Semaphore(concurrency) if concurrency else None
|
|
1983
|
+
|
|
1984
|
+
async def fetch_one(
|
|
1985
|
+
entity_id: PersonId | CompanyId | OpportunityId,
|
|
1986
|
+
) -> tuple[PersonId | CompanyId | OpportunityId, builtins.list[FieldValue] | None]:
|
|
1987
|
+
async def do_fetch() -> builtins.list[FieldValue]:
|
|
1988
|
+
if name == "person_ids":
|
|
1989
|
+
return await self.list(person_id=cast(PersonId, entity_id))
|
|
1990
|
+
elif name == "company_ids":
|
|
1991
|
+
return await self.list(company_id=cast(CompanyId, entity_id))
|
|
1992
|
+
else:
|
|
1993
|
+
return await self.list(opportunity_id=cast(OpportunityId, entity_id))
|
|
1994
|
+
|
|
1995
|
+
try:
|
|
1996
|
+
if semaphore:
|
|
1997
|
+
async with semaphore:
|
|
1998
|
+
values = await do_fetch()
|
|
1999
|
+
else:
|
|
2000
|
+
values = await do_fetch()
|
|
2001
|
+
return (entity_id, values)
|
|
2002
|
+
except AffinityError:
|
|
2003
|
+
if on_error == "raise":
|
|
2004
|
+
raise
|
|
2005
|
+
return (entity_id, None)
|
|
2006
|
+
except Exception as e:
|
|
2007
|
+
if on_error == "raise":
|
|
2008
|
+
status_code = getattr(e, "status_code", None)
|
|
2009
|
+
raise AffinityError(
|
|
2010
|
+
f"Failed to get field values for {name[:-1]} {entity_id}: {e}",
|
|
2011
|
+
status_code=status_code,
|
|
2012
|
+
) from e
|
|
2013
|
+
return (entity_id, None)
|
|
2014
|
+
|
|
2015
|
+
results = await asyncio.gather(*[fetch_one(eid) for eid in ids])
|
|
2016
|
+
return {eid: values for eid, values in results if values is not None}
|
|
2017
|
+
|
|
2018
|
+
|
|
2019
|
+
class AsyncFieldValueChangesService:
|
|
2020
|
+
"""Async service for querying field value change history (V1 API)."""
|
|
2021
|
+
|
|
2022
|
+
def __init__(self, client: AsyncHTTPClient):
|
|
2023
|
+
self._client = client
|
|
2024
|
+
|
|
2025
|
+
@staticmethod
|
|
2026
|
+
def _validate_selector(
|
|
2027
|
+
*,
|
|
2028
|
+
person_id: PersonId | None,
|
|
2029
|
+
company_id: CompanyId | None,
|
|
2030
|
+
opportunity_id: OpportunityId | None,
|
|
2031
|
+
list_entry_id: ListEntryId | None,
|
|
2032
|
+
) -> None:
|
|
2033
|
+
provided = [
|
|
2034
|
+
name
|
|
2035
|
+
for name, value in (
|
|
2036
|
+
("person_id", person_id),
|
|
2037
|
+
("company_id", company_id),
|
|
2038
|
+
("opportunity_id", opportunity_id),
|
|
2039
|
+
("list_entry_id", list_entry_id),
|
|
2040
|
+
)
|
|
2041
|
+
if value is not None
|
|
2042
|
+
]
|
|
2043
|
+
if len(provided) != 1:
|
|
2044
|
+
joined = ", ".join(provided) if provided else "(none)"
|
|
2045
|
+
raise ValueError(
|
|
2046
|
+
"FieldValueChangesService.list() requires exactly one of: "
|
|
2047
|
+
"person_id, company_id, opportunity_id, or list_entry_id; "
|
|
2048
|
+
f"got {len(provided)}: {joined}"
|
|
2049
|
+
)
|
|
2050
|
+
|
|
2051
|
+
async def list(
|
|
2052
|
+
self,
|
|
2053
|
+
field_id: AnyFieldId,
|
|
2054
|
+
*,
|
|
2055
|
+
person_id: PersonId | None = None,
|
|
2056
|
+
company_id: CompanyId | None = None,
|
|
2057
|
+
opportunity_id: OpportunityId | None = None,
|
|
2058
|
+
list_entry_id: ListEntryId | None = None,
|
|
2059
|
+
action_type: FieldValueChangeAction | None = None,
|
|
2060
|
+
) -> builtins.list[FieldValueChange]:
|
|
2061
|
+
"""
|
|
2062
|
+
Get field value changes for a specific field and entity.
|
|
2063
|
+
|
|
2064
|
+
This endpoint is not paginated. For large histories, use narrow filters.
|
|
2065
|
+
V1 requires numeric field IDs; only `field-<digits>` values are convertible.
|
|
2066
|
+
"""
|
|
2067
|
+
self._validate_selector(
|
|
2068
|
+
person_id=person_id,
|
|
2069
|
+
company_id=company_id,
|
|
2070
|
+
opportunity_id=opportunity_id,
|
|
2071
|
+
list_entry_id=list_entry_id,
|
|
2072
|
+
)
|
|
2073
|
+
|
|
2074
|
+
params: dict[str, Any] = {
|
|
2075
|
+
"field_id": field_id_to_v1_numeric(field_id),
|
|
2076
|
+
}
|
|
2077
|
+
if person_id is not None:
|
|
2078
|
+
params["person_id"] = int(person_id)
|
|
2079
|
+
if company_id is not None:
|
|
2080
|
+
params["organization_id"] = int(company_id)
|
|
2081
|
+
if opportunity_id is not None:
|
|
2082
|
+
params["opportunity_id"] = int(opportunity_id)
|
|
2083
|
+
if list_entry_id is not None:
|
|
2084
|
+
params["list_entry_id"] = int(list_entry_id)
|
|
2085
|
+
if action_type is not None:
|
|
2086
|
+
params["action_type"] = int(action_type)
|
|
2087
|
+
|
|
2088
|
+
data = await self._client.get("/field-value-changes", params=params, v1=True)
|
|
2089
|
+
items = data.get("data", [])
|
|
2090
|
+
if not isinstance(items, list):
|
|
2091
|
+
items = []
|
|
2092
|
+
return [FieldValueChange.model_validate(item) for item in items]
|
|
2093
|
+
|
|
2094
|
+
async def iter(
|
|
2095
|
+
self,
|
|
2096
|
+
field_id: AnyFieldId,
|
|
2097
|
+
*,
|
|
2098
|
+
person_id: PersonId | None = None,
|
|
2099
|
+
company_id: CompanyId | None = None,
|
|
2100
|
+
opportunity_id: OpportunityId | None = None,
|
|
2101
|
+
list_entry_id: ListEntryId | None = None,
|
|
2102
|
+
action_type: FieldValueChangeAction | None = None,
|
|
2103
|
+
) -> AsyncIterator[FieldValueChange]:
|
|
2104
|
+
"""Iterate field value changes (convenience wrapper for list())."""
|
|
2105
|
+
for item in await self.list(
|
|
2106
|
+
field_id,
|
|
2107
|
+
person_id=person_id,
|
|
2108
|
+
company_id=company_id,
|
|
2109
|
+
opportunity_id=opportunity_id,
|
|
2110
|
+
list_entry_id=list_entry_id,
|
|
2111
|
+
action_type=action_type,
|
|
2112
|
+
):
|
|
2113
|
+
yield item
|
|
2114
|
+
|
|
2115
|
+
|
|
2116
|
+
class AsyncRelationshipStrengthService:
|
|
2117
|
+
"""Async service for querying relationship strengths (V1 API)."""
|
|
2118
|
+
|
|
2119
|
+
def __init__(self, client: AsyncHTTPClient):
|
|
2120
|
+
self._client = client
|
|
2121
|
+
|
|
2122
|
+
async def get(
|
|
2123
|
+
self,
|
|
2124
|
+
external_id: PersonId,
|
|
2125
|
+
internal_id: UserId | None = None,
|
|
2126
|
+
) -> builtins.list[RelationshipStrength]:
|
|
2127
|
+
params: dict[str, Any] = {"external_id": int(external_id)}
|
|
2128
|
+
if internal_id:
|
|
2129
|
+
params["internal_id"] = int(internal_id)
|
|
2130
|
+
|
|
2131
|
+
data = await self._client.get("/relationships-strengths", params=params, v1=True)
|
|
2132
|
+
items = data.get("data", [])
|
|
2133
|
+
if not isinstance(items, list):
|
|
2134
|
+
items = []
|
|
2135
|
+
return [RelationshipStrength.model_validate(r) for r in items]
|
|
2136
|
+
|
|
2137
|
+
|
|
2138
|
+
class AsyncEntityFileService:
|
|
2139
|
+
"""Async service for managing files attached to entities (V1 API)."""
|
|
2140
|
+
|
|
2141
|
+
def __init__(self, client: AsyncHTTPClient):
|
|
2142
|
+
self._client = client
|
|
2143
|
+
|
|
2144
|
+
def _validate_exactly_one_target(
|
|
2145
|
+
self,
|
|
2146
|
+
*,
|
|
2147
|
+
person_id: PersonId | None,
|
|
2148
|
+
company_id: CompanyId | None,
|
|
2149
|
+
opportunity_id: OpportunityId | None,
|
|
2150
|
+
) -> None:
|
|
2151
|
+
targets = [person_id, company_id, opportunity_id]
|
|
2152
|
+
count = sum(1 for t in targets if t is not None)
|
|
2153
|
+
if count == 1:
|
|
2154
|
+
return
|
|
2155
|
+
if count == 0:
|
|
2156
|
+
raise ValueError("Exactly one of person_id, company_id, or opportunity_id is required")
|
|
2157
|
+
raise ValueError("Only one of person_id, company_id, or opportunity_id may be provided")
|
|
2158
|
+
|
|
2159
|
+
async def list(
|
|
2160
|
+
self,
|
|
2161
|
+
*,
|
|
2162
|
+
person_id: PersonId | None = None,
|
|
2163
|
+
company_id: CompanyId | None = None,
|
|
2164
|
+
opportunity_id: OpportunityId | None = None,
|
|
2165
|
+
page_size: int | None = None,
|
|
2166
|
+
page_token: str | None = None,
|
|
2167
|
+
) -> V1PaginatedResponse[EntityFile]:
|
|
2168
|
+
self._validate_exactly_one_target(
|
|
2169
|
+
person_id=person_id,
|
|
2170
|
+
company_id=company_id,
|
|
2171
|
+
opportunity_id=opportunity_id,
|
|
2172
|
+
)
|
|
2173
|
+
params: dict[str, Any] = {}
|
|
2174
|
+
if person_id:
|
|
2175
|
+
params["person_id"] = int(person_id)
|
|
2176
|
+
if company_id:
|
|
2177
|
+
params["organization_id"] = int(company_id)
|
|
2178
|
+
if opportunity_id:
|
|
2179
|
+
params["opportunity_id"] = int(opportunity_id)
|
|
2180
|
+
if page_size:
|
|
2181
|
+
params["page_size"] = page_size
|
|
2182
|
+
if page_token:
|
|
2183
|
+
params["page_token"] = page_token
|
|
2184
|
+
|
|
2185
|
+
data = await self._client.get("/entity-files", params=params or None, v1=True)
|
|
2186
|
+
items = (
|
|
2187
|
+
data.get("entity_files")
|
|
2188
|
+
or data.get("entityFiles")
|
|
2189
|
+
or data.get("files")
|
|
2190
|
+
or data.get("data", [])
|
|
2191
|
+
)
|
|
2192
|
+
if not isinstance(items, list):
|
|
2193
|
+
items = []
|
|
2194
|
+
return V1PaginatedResponse[EntityFile](
|
|
2195
|
+
data=[EntityFile.model_validate(f) for f in items],
|
|
2196
|
+
next_page_token=data.get("next_page_token") or data.get("nextPageToken"),
|
|
2197
|
+
)
|
|
2198
|
+
|
|
2199
|
+
async def get(self, file_id: FileId) -> EntityFile:
|
|
2200
|
+
data = await self._client.get(f"/entity-files/{file_id}", v1=True)
|
|
2201
|
+
return EntityFile.model_validate(data)
|
|
2202
|
+
|
|
2203
|
+
async def download(
|
|
2204
|
+
self,
|
|
2205
|
+
file_id: FileId,
|
|
2206
|
+
*,
|
|
2207
|
+
timeout: httpx.Timeout | float | None = None,
|
|
2208
|
+
deadline_seconds: float | None = None,
|
|
2209
|
+
) -> bytes:
|
|
2210
|
+
return await self._client.download_file(
|
|
2211
|
+
f"/entity-files/download/{file_id}",
|
|
2212
|
+
v1=True,
|
|
2213
|
+
timeout=timeout,
|
|
2214
|
+
deadline_seconds=deadline_seconds,
|
|
2215
|
+
)
|
|
2216
|
+
|
|
2217
|
+
def download_stream(
|
|
2218
|
+
self,
|
|
2219
|
+
file_id: FileId,
|
|
2220
|
+
*,
|
|
2221
|
+
chunk_size: int = 65_536,
|
|
2222
|
+
on_progress: ProgressCallback | None = None,
|
|
2223
|
+
timeout: httpx.Timeout | float | None = None,
|
|
2224
|
+
deadline_seconds: float | None = None,
|
|
2225
|
+
) -> AsyncIterator[bytes]:
|
|
2226
|
+
return self._client.stream_download(
|
|
2227
|
+
f"/entity-files/download/{file_id}",
|
|
2228
|
+
v1=True,
|
|
2229
|
+
chunk_size=chunk_size,
|
|
2230
|
+
on_progress=on_progress,
|
|
2231
|
+
timeout=timeout,
|
|
2232
|
+
deadline_seconds=deadline_seconds,
|
|
2233
|
+
)
|
|
2234
|
+
|
|
2235
|
+
async def download_stream_with_info(
|
|
2236
|
+
self,
|
|
2237
|
+
file_id: FileId,
|
|
2238
|
+
*,
|
|
2239
|
+
chunk_size: int = 65_536,
|
|
2240
|
+
on_progress: ProgressCallback | None = None,
|
|
2241
|
+
timeout: httpx.Timeout | float | None = None,
|
|
2242
|
+
deadline_seconds: float | None = None,
|
|
2243
|
+
) -> AsyncDownloadedFile:
|
|
2244
|
+
"""
|
|
2245
|
+
Stream-download a file and return response metadata (headers/filename/size).
|
|
2246
|
+
|
|
2247
|
+
Notes:
|
|
2248
|
+
- `filename` is derived from `Content-Disposition` when present.
|
|
2249
|
+
- If the server does not provide a filename, callers can fall back to
|
|
2250
|
+
`await files.get(file_id)` and use `.name`.
|
|
2251
|
+
"""
|
|
2252
|
+
return await self._client.stream_download_with_info(
|
|
2253
|
+
f"/entity-files/download/{file_id}",
|
|
2254
|
+
v1=True,
|
|
2255
|
+
chunk_size=chunk_size,
|
|
2256
|
+
on_progress=on_progress,
|
|
2257
|
+
timeout=timeout,
|
|
2258
|
+
deadline_seconds=deadline_seconds,
|
|
2259
|
+
)
|
|
2260
|
+
|
|
2261
|
+
async def download_to(
|
|
2262
|
+
self,
|
|
2263
|
+
file_id: FileId,
|
|
2264
|
+
path: str | Path,
|
|
2265
|
+
*,
|
|
2266
|
+
overwrite: bool = False,
|
|
2267
|
+
chunk_size: int = 65_536,
|
|
2268
|
+
on_progress: ProgressCallback | None = None,
|
|
2269
|
+
timeout: httpx.Timeout | float | None = None,
|
|
2270
|
+
deadline_seconds: float | None = None,
|
|
2271
|
+
) -> Path:
|
|
2272
|
+
target = Path(path)
|
|
2273
|
+
if target.exists() and not overwrite:
|
|
2274
|
+
raise FileExistsError(str(target))
|
|
2275
|
+
|
|
2276
|
+
with target.open("wb") as f:
|
|
2277
|
+
async for chunk in self.download_stream(
|
|
2278
|
+
file_id,
|
|
2279
|
+
chunk_size=chunk_size,
|
|
2280
|
+
on_progress=on_progress,
|
|
2281
|
+
timeout=timeout,
|
|
2282
|
+
deadline_seconds=deadline_seconds,
|
|
2283
|
+
):
|
|
2284
|
+
f.write(chunk)
|
|
2285
|
+
|
|
2286
|
+
return target
|
|
2287
|
+
|
|
2288
|
+
async def upload(
|
|
2289
|
+
self,
|
|
2290
|
+
files: dict[str, Any],
|
|
2291
|
+
*,
|
|
2292
|
+
person_id: PersonId | None = None,
|
|
2293
|
+
company_id: CompanyId | None = None,
|
|
2294
|
+
opportunity_id: OpportunityId | None = None,
|
|
2295
|
+
) -> bool:
|
|
2296
|
+
self._validate_exactly_one_target(
|
|
2297
|
+
person_id=person_id,
|
|
2298
|
+
company_id=company_id,
|
|
2299
|
+
opportunity_id=opportunity_id,
|
|
2300
|
+
)
|
|
2301
|
+
data: dict[str, Any] = {}
|
|
2302
|
+
if person_id:
|
|
2303
|
+
data["person_id"] = int(person_id)
|
|
2304
|
+
if company_id:
|
|
2305
|
+
data["organization_id"] = int(company_id)
|
|
2306
|
+
if opportunity_id:
|
|
2307
|
+
data["opportunity_id"] = int(opportunity_id)
|
|
2308
|
+
|
|
2309
|
+
result = await self._client.upload_file(
|
|
2310
|
+
"/entity-files",
|
|
2311
|
+
files=files,
|
|
2312
|
+
data=data,
|
|
2313
|
+
v1=True,
|
|
2314
|
+
)
|
|
2315
|
+
if "success" in result:
|
|
2316
|
+
return bool(result.get("success"))
|
|
2317
|
+
return True
|
|
2318
|
+
|
|
2319
|
+
async def upload_path(
|
|
2320
|
+
self,
|
|
2321
|
+
path: str | Path,
|
|
2322
|
+
*,
|
|
2323
|
+
person_id: PersonId | None = None,
|
|
2324
|
+
company_id: CompanyId | None = None,
|
|
2325
|
+
opportunity_id: OpportunityId | None = None,
|
|
2326
|
+
filename: str | None = None,
|
|
2327
|
+
content_type: str | None = None,
|
|
2328
|
+
on_progress: ProgressCallback | None = None,
|
|
2329
|
+
) -> bool:
|
|
2330
|
+
self._validate_exactly_one_target(
|
|
2331
|
+
person_id=person_id,
|
|
2332
|
+
company_id=company_id,
|
|
2333
|
+
opportunity_id=opportunity_id,
|
|
2334
|
+
)
|
|
2335
|
+
|
|
2336
|
+
p = Path(path)
|
|
2337
|
+
upload_filename = filename or p.name
|
|
2338
|
+
guessed, _ = mimetypes.guess_type(upload_filename)
|
|
2339
|
+
final_content_type = content_type or guessed or "application/octet-stream"
|
|
2340
|
+
total = p.stat().st_size
|
|
2341
|
+
|
|
2342
|
+
if on_progress:
|
|
2343
|
+
on_progress(0, total, phase="upload")
|
|
2344
|
+
|
|
2345
|
+
with p.open("rb") as f:
|
|
2346
|
+
ok = await self.upload(
|
|
2347
|
+
files={"file": (upload_filename, f, final_content_type)},
|
|
2348
|
+
person_id=person_id,
|
|
2349
|
+
company_id=company_id,
|
|
2350
|
+
opportunity_id=opportunity_id,
|
|
2351
|
+
)
|
|
2352
|
+
|
|
2353
|
+
if on_progress:
|
|
2354
|
+
on_progress(total, total, phase="upload")
|
|
2355
|
+
|
|
2356
|
+
return ok
|
|
2357
|
+
|
|
2358
|
+
async def upload_bytes(
|
|
2359
|
+
self,
|
|
2360
|
+
data: bytes,
|
|
2361
|
+
filename: str,
|
|
2362
|
+
*,
|
|
2363
|
+
person_id: PersonId | None = None,
|
|
2364
|
+
company_id: CompanyId | None = None,
|
|
2365
|
+
opportunity_id: OpportunityId | None = None,
|
|
2366
|
+
content_type: str | None = None,
|
|
2367
|
+
on_progress: ProgressCallback | None = None,
|
|
2368
|
+
) -> bool:
|
|
2369
|
+
self._validate_exactly_one_target(
|
|
2370
|
+
person_id=person_id,
|
|
2371
|
+
company_id=company_id,
|
|
2372
|
+
opportunity_id=opportunity_id,
|
|
2373
|
+
)
|
|
2374
|
+
|
|
2375
|
+
guessed, _ = mimetypes.guess_type(filename)
|
|
2376
|
+
final_content_type = content_type or guessed or "application/octet-stream"
|
|
2377
|
+
total = len(data)
|
|
2378
|
+
|
|
2379
|
+
if on_progress:
|
|
2380
|
+
on_progress(0, total, phase="upload")
|
|
2381
|
+
|
|
2382
|
+
ok = await self.upload(
|
|
2383
|
+
files={"file": (filename, data, final_content_type)},
|
|
2384
|
+
person_id=person_id,
|
|
2385
|
+
company_id=company_id,
|
|
2386
|
+
opportunity_id=opportunity_id,
|
|
2387
|
+
)
|
|
2388
|
+
|
|
2389
|
+
if on_progress:
|
|
2390
|
+
on_progress(total, total, phase="upload")
|
|
2391
|
+
|
|
2392
|
+
return ok
|
|
2393
|
+
|
|
2394
|
+
async def all(
|
|
2395
|
+
self,
|
|
2396
|
+
*,
|
|
2397
|
+
person_id: PersonId | None = None,
|
|
2398
|
+
company_id: CompanyId | None = None,
|
|
2399
|
+
opportunity_id: OpportunityId | None = None,
|
|
2400
|
+
) -> AsyncIterator[EntityFile]:
|
|
2401
|
+
self._validate_exactly_one_target(
|
|
2402
|
+
person_id=person_id,
|
|
2403
|
+
company_id=company_id,
|
|
2404
|
+
opportunity_id=opportunity_id,
|
|
2405
|
+
)
|
|
2406
|
+
|
|
2407
|
+
page_token: str | None = None
|
|
2408
|
+
while True:
|
|
2409
|
+
page = await self.list(
|
|
2410
|
+
person_id=person_id,
|
|
2411
|
+
company_id=company_id,
|
|
2412
|
+
opportunity_id=opportunity_id,
|
|
2413
|
+
page_token=page_token,
|
|
2414
|
+
)
|
|
2415
|
+
for item in page.data:
|
|
2416
|
+
yield item
|
|
2417
|
+
if not page.has_next:
|
|
2418
|
+
break
|
|
2419
|
+
page_token = page.next_page_token
|
|
2420
|
+
|
|
2421
|
+
def iter(
|
|
2422
|
+
self,
|
|
2423
|
+
*,
|
|
2424
|
+
person_id: PersonId | None = None,
|
|
2425
|
+
company_id: CompanyId | None = None,
|
|
2426
|
+
opportunity_id: OpportunityId | None = None,
|
|
2427
|
+
) -> AsyncIterator[EntityFile]:
|
|
2428
|
+
return self.all(
|
|
2429
|
+
person_id=person_id,
|
|
2430
|
+
company_id=company_id,
|
|
2431
|
+
opportunity_id=opportunity_id,
|
|
2432
|
+
)
|
|
2433
|
+
|
|
2434
|
+
|
|
2435
|
+
class AsyncAuthService:
|
|
2436
|
+
"""Async service for authentication info."""
|
|
2437
|
+
|
|
2438
|
+
def __init__(self, client: AsyncHTTPClient):
|
|
2439
|
+
self._client = client
|
|
2440
|
+
|
|
2441
|
+
async def whoami(self) -> WhoAmI:
|
|
2442
|
+
data = await self._client.get("/auth/whoami")
|
|
2443
|
+
return WhoAmI.model_validate(data)
|
|
2444
|
+
|
|
2445
|
+
# Note: rate limit handling is exposed via `client.rate_limits` (version-agnostic).
|