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,798 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Core entity models for the Affinity API.
|
|
3
|
+
|
|
4
|
+
These models represent the main entities in Affinity: Persons, Companies,
|
|
5
|
+
Opportunities, Lists, and List Entries. Uses V2 terminology throughout.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from collections.abc import Mapping, Sequence
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
|
15
|
+
|
|
16
|
+
from .types import (
|
|
17
|
+
AnyFieldId,
|
|
18
|
+
CompanyId,
|
|
19
|
+
DropdownOptionId,
|
|
20
|
+
EntityType,
|
|
21
|
+
FieldId,
|
|
22
|
+
FieldValueChangeAction,
|
|
23
|
+
FieldValueChangeId,
|
|
24
|
+
FieldValueId,
|
|
25
|
+
FieldValueType,
|
|
26
|
+
ISODatetime,
|
|
27
|
+
ListEntryId,
|
|
28
|
+
ListId,
|
|
29
|
+
ListRole,
|
|
30
|
+
ListType,
|
|
31
|
+
OpportunityId,
|
|
32
|
+
PersonId,
|
|
33
|
+
PersonType,
|
|
34
|
+
SavedViewId,
|
|
35
|
+
UserId,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
# Use the library logger; affinity/__init__.py installs a NullHandler by default.
|
|
39
|
+
_logger = logging.getLogger("affinity_sdk")
|
|
40
|
+
|
|
41
|
+
# =============================================================================
|
|
42
|
+
# Base configuration for all models
|
|
43
|
+
# =============================================================================
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AffinityModel(BaseModel):
|
|
47
|
+
"""Base model with common configuration."""
|
|
48
|
+
|
|
49
|
+
model_config = ConfigDict(
|
|
50
|
+
extra="ignore", # Ignore unknown fields from API
|
|
51
|
+
populate_by_name=True, # Allow both alias and field name
|
|
52
|
+
use_enum_values=True, # Serialize enums as values
|
|
53
|
+
validate_assignment=True, # Validate on attribute assignment
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class FieldValues(AffinityModel):
|
|
58
|
+
"""
|
|
59
|
+
Field values container that preserves the "requested vs not requested"
|
|
60
|
+
semantics.
|
|
61
|
+
|
|
62
|
+
- `requested=False` means the caller did not request field data and/or the
|
|
63
|
+
API omitted field data.
|
|
64
|
+
- `requested=True` means field data was requested and returned (possibly
|
|
65
|
+
empty/null-normalized).
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
requested: bool = False
|
|
69
|
+
data: dict[str, Any] = Field(default_factory=dict)
|
|
70
|
+
|
|
71
|
+
@model_validator(mode="before")
|
|
72
|
+
@classmethod
|
|
73
|
+
def _coerce_from_api(cls, value: Any) -> Any:
|
|
74
|
+
if isinstance(value, cls):
|
|
75
|
+
return value
|
|
76
|
+
if value is None:
|
|
77
|
+
return {"requested": True, "data": {}}
|
|
78
|
+
if isinstance(value, list):
|
|
79
|
+
# API returns fields as array: [{"id": "field-123", "value": {...}}, ...]
|
|
80
|
+
# Convert to dict keyed by field ID for easier access
|
|
81
|
+
data = {item["id"]: item for item in value if isinstance(item, dict) and "id" in item}
|
|
82
|
+
return {"requested": True, "data": data}
|
|
83
|
+
if isinstance(value, dict):
|
|
84
|
+
return {"requested": True, "data": value}
|
|
85
|
+
return {"requested": True, "data": {}}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _normalize_null_lists(value: Any, keys: Sequence[str]) -> Any:
|
|
89
|
+
if not isinstance(value, Mapping):
|
|
90
|
+
return value
|
|
91
|
+
|
|
92
|
+
data: dict[str, Any] = dict(value)
|
|
93
|
+
for key in keys:
|
|
94
|
+
if key in data and data[key] is None:
|
|
95
|
+
data[key] = []
|
|
96
|
+
return data
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _preserve_fields_raw(value: Any) -> Any:
|
|
100
|
+
if not isinstance(value, Mapping):
|
|
101
|
+
return value
|
|
102
|
+
|
|
103
|
+
data: dict[str, Any] = dict(value)
|
|
104
|
+
fields = data.get("fields")
|
|
105
|
+
if isinstance(fields, list):
|
|
106
|
+
data["fields_raw"] = fields
|
|
107
|
+
return data
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _normalize_person_type(value: Any) -> Any:
|
|
111
|
+
if value is None:
|
|
112
|
+
return value
|
|
113
|
+
if isinstance(value, PersonType):
|
|
114
|
+
return value
|
|
115
|
+
if isinstance(value, str):
|
|
116
|
+
text = value.strip()
|
|
117
|
+
if text.isdigit():
|
|
118
|
+
try:
|
|
119
|
+
value = int(text)
|
|
120
|
+
except ValueError:
|
|
121
|
+
return value
|
|
122
|
+
else:
|
|
123
|
+
return value
|
|
124
|
+
if isinstance(value, int):
|
|
125
|
+
mapping = {
|
|
126
|
+
0: PersonType.EXTERNAL,
|
|
127
|
+
1: PersonType.INTERNAL,
|
|
128
|
+
2: PersonType.COLLABORATOR,
|
|
129
|
+
}
|
|
130
|
+
return mapping.get(value, value)
|
|
131
|
+
return value
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# =============================================================================
|
|
135
|
+
# Location Value
|
|
136
|
+
# =============================================================================
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class Location(AffinityModel):
|
|
140
|
+
"""Geographic location value."""
|
|
141
|
+
|
|
142
|
+
street_address: str | None = None
|
|
143
|
+
city: str | None = None
|
|
144
|
+
state: str | None = None
|
|
145
|
+
country: str | None = None
|
|
146
|
+
continent: str | None = None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
# =============================================================================
|
|
150
|
+
# Dropdown Option
|
|
151
|
+
# =============================================================================
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class DropdownOption(AffinityModel):
|
|
155
|
+
"""A selectable option in a dropdown field."""
|
|
156
|
+
|
|
157
|
+
id: DropdownOptionId
|
|
158
|
+
text: str
|
|
159
|
+
rank: int | None = None
|
|
160
|
+
color: int | None = None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# =============================================================================
|
|
164
|
+
# Person Models
|
|
165
|
+
# =============================================================================
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class PersonSummary(AffinityModel):
|
|
169
|
+
"""Minimal person data returned in nested contexts."""
|
|
170
|
+
|
|
171
|
+
id: PersonId
|
|
172
|
+
first_name: str | None = Field(None, alias="firstName")
|
|
173
|
+
last_name: str | None = Field(None, alias="lastName")
|
|
174
|
+
primary_email: str | None = Field(None, alias="primaryEmailAddress")
|
|
175
|
+
type: PersonType
|
|
176
|
+
|
|
177
|
+
@field_validator("type", mode="before")
|
|
178
|
+
@classmethod
|
|
179
|
+
def _coerce_person_type(cls, value: Any) -> Any:
|
|
180
|
+
return _normalize_person_type(value)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class Person(AffinityModel):
|
|
184
|
+
"""
|
|
185
|
+
Full person representation.
|
|
186
|
+
|
|
187
|
+
Note: Companies are called Organizations in V1 API.
|
|
188
|
+
"""
|
|
189
|
+
|
|
190
|
+
id: PersonId
|
|
191
|
+
first_name: str | None = Field(None, alias="firstName")
|
|
192
|
+
last_name: str | None = Field(None, alias="lastName")
|
|
193
|
+
primary_email: str | None = Field(None, alias="primaryEmailAddress")
|
|
194
|
+
# V2 uses emailAddresses, V1 uses emails - accept both via alias
|
|
195
|
+
emails: list[str] = Field(default_factory=list, alias="emailAddresses")
|
|
196
|
+
type: PersonType = PersonType.EXTERNAL
|
|
197
|
+
|
|
198
|
+
@field_validator("type", mode="before")
|
|
199
|
+
@classmethod
|
|
200
|
+
def _coerce_person_type(cls, value: Any) -> Any:
|
|
201
|
+
return _normalize_person_type(value)
|
|
202
|
+
|
|
203
|
+
# Associations (V1 uses organizationIds)
|
|
204
|
+
company_ids: list[CompanyId] = Field(default_factory=list, alias="organizationIds")
|
|
205
|
+
opportunity_ids: list[OpportunityId] = Field(default_factory=list, alias="opportunityIds")
|
|
206
|
+
|
|
207
|
+
# V1: only returned when `with_current_organizations=true`
|
|
208
|
+
current_company_ids: list[CompanyId] = Field(
|
|
209
|
+
default_factory=list, alias="currentOrganizationIds"
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
# Field values (requested-vs-not-requested preserved)
|
|
213
|
+
fields: FieldValues = Field(default_factory=FieldValues, alias="fields")
|
|
214
|
+
fields_raw: list[dict[str, Any]] | None = Field(default=None, exclude=True)
|
|
215
|
+
|
|
216
|
+
@model_validator(mode="before")
|
|
217
|
+
@classmethod
|
|
218
|
+
def _normalize_null_lists_before(cls, value: Any) -> Any:
|
|
219
|
+
value = _normalize_null_lists(
|
|
220
|
+
value,
|
|
221
|
+
(
|
|
222
|
+
"emails",
|
|
223
|
+
"emailAddresses",
|
|
224
|
+
"companyIds",
|
|
225
|
+
"company_ids",
|
|
226
|
+
"organizationIds",
|
|
227
|
+
"organization_ids",
|
|
228
|
+
"currentCompanyIds",
|
|
229
|
+
"current_company_ids",
|
|
230
|
+
"currentOrganizationIds",
|
|
231
|
+
"current_organization_ids",
|
|
232
|
+
"opportunityIds",
|
|
233
|
+
"opportunity_ids",
|
|
234
|
+
),
|
|
235
|
+
)
|
|
236
|
+
return _preserve_fields_raw(value)
|
|
237
|
+
|
|
238
|
+
@model_validator(mode="after")
|
|
239
|
+
def _mark_fields_not_requested_when_omitted(self) -> Person:
|
|
240
|
+
if "fields" not in self.__pydantic_fields_set__:
|
|
241
|
+
self.fields.requested = False
|
|
242
|
+
return self
|
|
243
|
+
|
|
244
|
+
# Interaction dates (V1 format, returned when with_interaction_dates=True)
|
|
245
|
+
interaction_dates: InteractionDates | None = Field(None, alias="interactionDates")
|
|
246
|
+
|
|
247
|
+
# V1: only returned when with_interaction_dates=true; preserve shape for forward compatibility.
|
|
248
|
+
interactions: dict[str, Any] | None = None
|
|
249
|
+
|
|
250
|
+
# List entries (returned for single person fetch)
|
|
251
|
+
list_entries: list[ListEntry] | None = Field(None, alias="listEntries")
|
|
252
|
+
|
|
253
|
+
@property
|
|
254
|
+
def full_name(self) -> str:
|
|
255
|
+
"""Get the person's full name."""
|
|
256
|
+
parts = [self.first_name, self.last_name]
|
|
257
|
+
return " ".join(p for p in parts if p) or ""
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class PersonCreate(AffinityModel):
|
|
261
|
+
"""Data for creating a new person (V1 API)."""
|
|
262
|
+
|
|
263
|
+
first_name: str
|
|
264
|
+
last_name: str
|
|
265
|
+
emails: list[str] = Field(default_factory=list)
|
|
266
|
+
company_ids: list[CompanyId] = Field(default_factory=list, alias="organization_ids")
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class PersonUpdate(AffinityModel):
|
|
270
|
+
"""Data for updating a person (V1 API)."""
|
|
271
|
+
|
|
272
|
+
first_name: str | None = None
|
|
273
|
+
last_name: str | None = None
|
|
274
|
+
emails: list[str] | None = None
|
|
275
|
+
company_ids: list[CompanyId] | None = Field(None, alias="organization_ids")
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
# =============================================================================
|
|
279
|
+
# Company (Organization) Models
|
|
280
|
+
# =============================================================================
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
class CompanySummary(AffinityModel):
|
|
284
|
+
"""Minimal company data returned in nested contexts."""
|
|
285
|
+
|
|
286
|
+
id: CompanyId
|
|
287
|
+
name: str
|
|
288
|
+
domain: str | None = None
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
class Company(AffinityModel):
|
|
292
|
+
"""
|
|
293
|
+
Full company representation.
|
|
294
|
+
|
|
295
|
+
Note: Called Organization in V1 API.
|
|
296
|
+
"""
|
|
297
|
+
|
|
298
|
+
id: CompanyId
|
|
299
|
+
name: str
|
|
300
|
+
domain: str | None = None
|
|
301
|
+
domains: list[str] = Field(default_factory=list)
|
|
302
|
+
is_global: bool = Field(False, alias="global")
|
|
303
|
+
|
|
304
|
+
# Associations
|
|
305
|
+
person_ids: list[PersonId] = Field(default_factory=list, alias="personIds")
|
|
306
|
+
opportunity_ids: list[OpportunityId] = Field(default_factory=list, alias="opportunityIds")
|
|
307
|
+
|
|
308
|
+
# Field values (requested-vs-not-requested preserved)
|
|
309
|
+
fields: FieldValues = Field(default_factory=FieldValues, alias="fields")
|
|
310
|
+
fields_raw: list[dict[str, Any]] | None = Field(default=None, exclude=True)
|
|
311
|
+
|
|
312
|
+
@model_validator(mode="before")
|
|
313
|
+
@classmethod
|
|
314
|
+
def _normalize_null_lists_before(cls, value: Any) -> Any:
|
|
315
|
+
value = _normalize_null_lists(
|
|
316
|
+
value,
|
|
317
|
+
(
|
|
318
|
+
"domains",
|
|
319
|
+
"personIds",
|
|
320
|
+
"person_ids",
|
|
321
|
+
"opportunityIds",
|
|
322
|
+
"opportunity_ids",
|
|
323
|
+
),
|
|
324
|
+
)
|
|
325
|
+
return _preserve_fields_raw(value)
|
|
326
|
+
|
|
327
|
+
@model_validator(mode="after")
|
|
328
|
+
def _mark_fields_not_requested_when_omitted(self) -> Company:
|
|
329
|
+
if "fields" not in self.__pydantic_fields_set__:
|
|
330
|
+
self.fields.requested = False
|
|
331
|
+
return self
|
|
332
|
+
|
|
333
|
+
# List entries (returned for single company fetch)
|
|
334
|
+
list_entries: list[ListEntry] | None = Field(None, alias="listEntries")
|
|
335
|
+
|
|
336
|
+
# Interaction dates
|
|
337
|
+
interaction_dates: InteractionDates | None = Field(None, alias="interactionDates")
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
class CompanyCreate(AffinityModel):
|
|
341
|
+
"""Data for creating a new company (V1 API)."""
|
|
342
|
+
|
|
343
|
+
name: str
|
|
344
|
+
domain: str | None = None
|
|
345
|
+
person_ids: list[PersonId] = Field(default_factory=list)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
class CompanyUpdate(AffinityModel):
|
|
349
|
+
"""Data for updating a company (V1 API)."""
|
|
350
|
+
|
|
351
|
+
name: str | None = None
|
|
352
|
+
domain: str | None = None
|
|
353
|
+
person_ids: list[PersonId] | None = None
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
# =============================================================================
|
|
357
|
+
# Opportunity Models
|
|
358
|
+
# =============================================================================
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
class Opportunity(AffinityModel):
|
|
362
|
+
"""
|
|
363
|
+
Deal/opportunity in a pipeline.
|
|
364
|
+
|
|
365
|
+
Note:
|
|
366
|
+
The V2 API returns empty ``person_ids`` and ``company_ids`` arrays even
|
|
367
|
+
when associations exist. Use ``client.opportunities.get_associated_person_ids()``
|
|
368
|
+
or ``client.opportunities.get_details()`` to retrieve association data.
|
|
369
|
+
|
|
370
|
+
See the opportunity-associations guide for details.
|
|
371
|
+
"""
|
|
372
|
+
|
|
373
|
+
id: OpportunityId
|
|
374
|
+
name: str
|
|
375
|
+
list_id: ListId | None = Field(None, alias="listId")
|
|
376
|
+
|
|
377
|
+
# Associations (Note: V2 API returns empty arrays; use get_details() or
|
|
378
|
+
# get_associated_person_ids() for populated data)
|
|
379
|
+
person_ids: list[PersonId] = Field(default_factory=list, alias="personIds")
|
|
380
|
+
company_ids: list[CompanyId] = Field(default_factory=list, alias="organizationIds")
|
|
381
|
+
|
|
382
|
+
# Field values (requested-vs-not-requested preserved)
|
|
383
|
+
fields: FieldValues = Field(default_factory=FieldValues, alias="fields")
|
|
384
|
+
fields_raw: list[dict[str, Any]] | None = Field(default=None, exclude=True)
|
|
385
|
+
|
|
386
|
+
@model_validator(mode="before")
|
|
387
|
+
@classmethod
|
|
388
|
+
def _preserve_fields_raw_before(cls, value: Any) -> Any:
|
|
389
|
+
return _preserve_fields_raw(value)
|
|
390
|
+
|
|
391
|
+
@model_validator(mode="after")
|
|
392
|
+
def _mark_fields_not_requested_when_omitted(self) -> Opportunity:
|
|
393
|
+
if "fields" not in self.__pydantic_fields_set__:
|
|
394
|
+
self.fields.requested = False
|
|
395
|
+
return self
|
|
396
|
+
|
|
397
|
+
# List entries
|
|
398
|
+
list_entries: list[ListEntry] | None = Field(None, alias="listEntries")
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
class OpportunityCreate(AffinityModel):
|
|
402
|
+
"""Data for creating a new opportunity (V1 API)."""
|
|
403
|
+
|
|
404
|
+
name: str
|
|
405
|
+
list_id: ListId
|
|
406
|
+
person_ids: list[PersonId] = Field(default_factory=list)
|
|
407
|
+
company_ids: list[CompanyId] = Field(default_factory=list, alias="organization_ids")
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
class OpportunityUpdate(AffinityModel):
|
|
411
|
+
"""Data for updating an opportunity (V1 API)."""
|
|
412
|
+
|
|
413
|
+
name: str | None = None
|
|
414
|
+
person_ids: list[PersonId] | None = None
|
|
415
|
+
company_ids: list[CompanyId] | None = Field(None, alias="organization_ids")
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
# =============================================================================
|
|
419
|
+
# Opportunity Summary
|
|
420
|
+
# =============================================================================
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
class OpportunitySummary(AffinityModel):
|
|
424
|
+
"""Minimal opportunity data returned in nested contexts."""
|
|
425
|
+
|
|
426
|
+
id: OpportunityId
|
|
427
|
+
name: str
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
# =============================================================================
|
|
431
|
+
# List Models
|
|
432
|
+
# =============================================================================
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
class ListPermission(AffinityModel):
|
|
436
|
+
"""Additional permission on a list."""
|
|
437
|
+
|
|
438
|
+
internal_person_id: UserId = Field(alias="internalPersonId")
|
|
439
|
+
role_id: ListRole = Field(alias="roleId")
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
class AffinityList(AffinityModel):
|
|
443
|
+
"""
|
|
444
|
+
A list (spreadsheet) in Affinity.
|
|
445
|
+
|
|
446
|
+
Named AffinityList to avoid collision with Python's list type.
|
|
447
|
+
"""
|
|
448
|
+
|
|
449
|
+
id: ListId
|
|
450
|
+
name: str
|
|
451
|
+
type: ListType
|
|
452
|
+
is_public: bool = Field(alias="public")
|
|
453
|
+
owner_id: UserId = Field(alias="ownerId")
|
|
454
|
+
creator_id: UserId | None = Field(None, alias="creatorId")
|
|
455
|
+
list_size: int = Field(0, alias="listSize")
|
|
456
|
+
|
|
457
|
+
# Fields on this list (returned for single list fetch)
|
|
458
|
+
fields: list[FieldMetadata] | None = None
|
|
459
|
+
|
|
460
|
+
# Permissions
|
|
461
|
+
additional_permissions: list[ListPermission] = Field(
|
|
462
|
+
default_factory=list, alias="additionalPermissions"
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
@model_validator(mode="before")
|
|
466
|
+
@classmethod
|
|
467
|
+
def _coerce_v2_is_public(cls, value: Any) -> Any:
|
|
468
|
+
# V2 list endpoints use `isPublic`; v1 uses `public`.
|
|
469
|
+
if isinstance(value, Mapping) and "public" not in value and "isPublic" in value:
|
|
470
|
+
data = dict(value)
|
|
471
|
+
data["public"] = data.get("isPublic")
|
|
472
|
+
return data
|
|
473
|
+
return value
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
class ListSummary(AffinityModel):
|
|
477
|
+
"""Minimal list reference used by relationship endpoints."""
|
|
478
|
+
|
|
479
|
+
id: ListId
|
|
480
|
+
name: str | None = None
|
|
481
|
+
type: ListType | None = None
|
|
482
|
+
is_public: bool | None = Field(None, alias="public")
|
|
483
|
+
owner_id: UserId | None = Field(None, alias="ownerId")
|
|
484
|
+
list_size: int | None = Field(None, alias="listSize")
|
|
485
|
+
|
|
486
|
+
@model_validator(mode="before")
|
|
487
|
+
@classmethod
|
|
488
|
+
def _coerce_v2_is_public(cls, value: Any) -> Any:
|
|
489
|
+
if isinstance(value, Mapping) and "public" not in value and "isPublic" in value:
|
|
490
|
+
data = dict(value)
|
|
491
|
+
data["public"] = data.get("isPublic")
|
|
492
|
+
return data
|
|
493
|
+
return value
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
class ListCreate(AffinityModel):
|
|
497
|
+
"""Data for creating a new list (V1 API)."""
|
|
498
|
+
|
|
499
|
+
name: str
|
|
500
|
+
type: ListType
|
|
501
|
+
is_public: bool
|
|
502
|
+
owner_id: UserId | None = None
|
|
503
|
+
additional_permissions: list[ListPermission] = Field(default_factory=list)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
# =============================================================================
|
|
507
|
+
# List Entry Models
|
|
508
|
+
# =============================================================================
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
class ListEntry(AffinityModel):
|
|
512
|
+
"""
|
|
513
|
+
A row in a list, linking an entity to a list.
|
|
514
|
+
|
|
515
|
+
Contains the entity data and list-specific field values.
|
|
516
|
+
"""
|
|
517
|
+
|
|
518
|
+
id: ListEntryId
|
|
519
|
+
list_id: ListId = Field(alias="listId")
|
|
520
|
+
creator_id: UserId | None = Field(None, alias="creatorId")
|
|
521
|
+
entity_id: int | None = Field(None, alias="entityId")
|
|
522
|
+
entity_type: EntityType | None = Field(None, alias="entityType")
|
|
523
|
+
created_at: ISODatetime = Field(alias="createdAt")
|
|
524
|
+
|
|
525
|
+
# The entity this entry represents (can be Person, Company, or Opportunity)
|
|
526
|
+
entity: PersonSummary | CompanySummary | OpportunitySummary | dict[str, Any] | None = None
|
|
527
|
+
|
|
528
|
+
# Field values on this list entry (requested-vs-not-requested preserved)
|
|
529
|
+
fields: FieldValues = Field(default_factory=FieldValues, alias="fields")
|
|
530
|
+
fields_raw: list[dict[str, Any]] | None = Field(default=None, exclude=True)
|
|
531
|
+
|
|
532
|
+
@model_validator(mode="before")
|
|
533
|
+
@classmethod
|
|
534
|
+
def _coerce_entity_by_entity_type(cls, value: Any) -> Any:
|
|
535
|
+
"""
|
|
536
|
+
The v1 list-entry payload includes `entity_type` alongside a minimal `entity` dict.
|
|
537
|
+
|
|
538
|
+
Some entity summaries overlap in shape (e.g. opportunity and company both have
|
|
539
|
+
`{id, name}`), so we must use `entity_type` as the discriminator to avoid mis-parsing.
|
|
540
|
+
"""
|
|
541
|
+
if not isinstance(value, Mapping):
|
|
542
|
+
return value
|
|
543
|
+
|
|
544
|
+
data: dict[str, Any] = dict(value)
|
|
545
|
+
fields = data.get("fields")
|
|
546
|
+
if isinstance(fields, list):
|
|
547
|
+
data["fields_raw"] = fields
|
|
548
|
+
|
|
549
|
+
entity = data.get("entity")
|
|
550
|
+
if not isinstance(entity, Mapping):
|
|
551
|
+
return data
|
|
552
|
+
|
|
553
|
+
raw_entity_type = data.get("entityType")
|
|
554
|
+
if raw_entity_type is None:
|
|
555
|
+
raw_entity_type = data.get("entity_type")
|
|
556
|
+
if raw_entity_type is None:
|
|
557
|
+
return data
|
|
558
|
+
|
|
559
|
+
try:
|
|
560
|
+
entity_type = EntityType(raw_entity_type)
|
|
561
|
+
except Exception:
|
|
562
|
+
return data
|
|
563
|
+
|
|
564
|
+
if entity_type == EntityType.PERSON:
|
|
565
|
+
try:
|
|
566
|
+
data["entity"] = PersonSummary.model_validate(entity)
|
|
567
|
+
except Exception:
|
|
568
|
+
return data
|
|
569
|
+
elif entity_type == EntityType.ORGANIZATION:
|
|
570
|
+
try:
|
|
571
|
+
data["entity"] = CompanySummary.model_validate(entity)
|
|
572
|
+
except Exception:
|
|
573
|
+
return data
|
|
574
|
+
elif entity_type == EntityType.OPPORTUNITY:
|
|
575
|
+
try:
|
|
576
|
+
data["entity"] = OpportunitySummary.model_validate(entity)
|
|
577
|
+
except Exception:
|
|
578
|
+
return data
|
|
579
|
+
|
|
580
|
+
return data
|
|
581
|
+
|
|
582
|
+
@model_validator(mode="after")
|
|
583
|
+
def _mark_fields_not_requested_when_omitted(self) -> ListEntry:
|
|
584
|
+
if "fields" not in self.__pydantic_fields_set__:
|
|
585
|
+
self.fields.requested = False
|
|
586
|
+
return self
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
class ListEntryWithEntity(AffinityModel):
|
|
590
|
+
"""List entry with full entity data included (V2 response format)."""
|
|
591
|
+
|
|
592
|
+
id: ListEntryId
|
|
593
|
+
list_id: ListId = Field(alias="listId")
|
|
594
|
+
creator: PersonSummary | None = None
|
|
595
|
+
created_at: ISODatetime = Field(alias="createdAt")
|
|
596
|
+
|
|
597
|
+
# Entity type and data
|
|
598
|
+
type: str # "person", "company", or "opportunity"
|
|
599
|
+
entity: Person | Company | Opportunity | None = None
|
|
600
|
+
|
|
601
|
+
# Field values (requested-vs-not-requested preserved)
|
|
602
|
+
fields: FieldValues = Field(default_factory=FieldValues, alias="fields")
|
|
603
|
+
fields_raw: list[dict[str, Any]] | None = Field(default=None, exclude=True)
|
|
604
|
+
|
|
605
|
+
@model_validator(mode="before")
|
|
606
|
+
@classmethod
|
|
607
|
+
def _preserve_fields_raw_before(cls, value: Any) -> Any:
|
|
608
|
+
return _preserve_fields_raw(value)
|
|
609
|
+
|
|
610
|
+
@model_validator(mode="after")
|
|
611
|
+
def _mark_fields_not_requested_when_omitted(self) -> ListEntryWithEntity:
|
|
612
|
+
if "fields" not in self.__pydantic_fields_set__:
|
|
613
|
+
self.fields.requested = False
|
|
614
|
+
return self
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
class ListEntryCreate(AffinityModel):
|
|
618
|
+
"""Data for adding an entity to a list (V1 API)."""
|
|
619
|
+
|
|
620
|
+
entity_id: int
|
|
621
|
+
creator_id: UserId | None = None
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
# =============================================================================
|
|
625
|
+
# Saved View Models
|
|
626
|
+
# =============================================================================
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
class SavedView(AffinityModel):
|
|
630
|
+
"""A saved view configuration for a list."""
|
|
631
|
+
|
|
632
|
+
id: SavedViewId
|
|
633
|
+
name: str
|
|
634
|
+
type: str | None = None # V2 field: view type
|
|
635
|
+
list_id: ListId | None = Field(None, alias="listId")
|
|
636
|
+
# The Affinity API does not consistently include this field.
|
|
637
|
+
is_default: bool | None = Field(None, alias="isDefault")
|
|
638
|
+
created_at: ISODatetime | None = Field(None, alias="createdAt")
|
|
639
|
+
|
|
640
|
+
# Field IDs included in this view
|
|
641
|
+
field_ids: list[str] = Field(default_factory=list, alias="fieldIds")
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
# =============================================================================
|
|
645
|
+
# Field Metadata Models
|
|
646
|
+
# =============================================================================
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
class FieldMetadata(AffinityModel):
|
|
650
|
+
"""
|
|
651
|
+
Metadata about a field (column) in Affinity.
|
|
652
|
+
|
|
653
|
+
Includes both V1 numeric IDs and V2 string IDs for enriched fields.
|
|
654
|
+
"""
|
|
655
|
+
|
|
656
|
+
model_config = ConfigDict(use_enum_values=False)
|
|
657
|
+
|
|
658
|
+
id: AnyFieldId # Can be int (field-123) or string (affinity-data-description)
|
|
659
|
+
name: str
|
|
660
|
+
value_type: FieldValueType = Field(alias="valueType")
|
|
661
|
+
allows_multiple: bool = Field(False, alias="allowsMultiple")
|
|
662
|
+
value_type_raw: str | int | None = Field(None, exclude=True)
|
|
663
|
+
|
|
664
|
+
# V2 field type classification
|
|
665
|
+
type: str | None = None # "enriched", "list-specific", "global", etc.
|
|
666
|
+
|
|
667
|
+
# V1 specific fields
|
|
668
|
+
list_id: ListId | None = Field(None, alias="listId")
|
|
669
|
+
track_changes: bool = Field(False, alias="trackChanges")
|
|
670
|
+
enrichment_source: str | None = Field(None, alias="enrichmentSource")
|
|
671
|
+
is_required: bool = Field(False, alias="isRequired")
|
|
672
|
+
|
|
673
|
+
# Dropdown options for dropdown fields
|
|
674
|
+
dropdown_options: list[DropdownOption] = Field(default_factory=list, alias="dropdownOptions")
|
|
675
|
+
|
|
676
|
+
@model_validator(mode="before")
|
|
677
|
+
@classmethod
|
|
678
|
+
def _preserve_value_type_raw(cls, value: Any) -> Any:
|
|
679
|
+
if not isinstance(value, Mapping):
|
|
680
|
+
return value
|
|
681
|
+
|
|
682
|
+
data: dict[str, Any] = dict(value)
|
|
683
|
+
raw = data.get("valueType")
|
|
684
|
+
if raw is None and "value_type" in data:
|
|
685
|
+
raw = data.get("value_type")
|
|
686
|
+
data["value_type_raw"] = raw
|
|
687
|
+
return data
|
|
688
|
+
|
|
689
|
+
@model_validator(mode="after")
|
|
690
|
+
def _coerce_allows_multiple_from_value_type(self) -> FieldMetadata:
|
|
691
|
+
# If the server returns a `*-multi` value type, treat it as authoritative for multiplicity.
|
|
692
|
+
try:
|
|
693
|
+
text = str(self.value_type)
|
|
694
|
+
except Exception:
|
|
695
|
+
text = ""
|
|
696
|
+
if text.endswith("-multi") and not self.allows_multiple:
|
|
697
|
+
_logger.debug(
|
|
698
|
+
"FieldMetadata allowsMultiple mismatch: valueType=%s allowsMultiple=%s "
|
|
699
|
+
"(auto-correcting)",
|
|
700
|
+
text,
|
|
701
|
+
self.allows_multiple,
|
|
702
|
+
)
|
|
703
|
+
self.allows_multiple = True
|
|
704
|
+
return self
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
class FieldCreate(AffinityModel):
|
|
708
|
+
"""Data for creating a new field (V1 API)."""
|
|
709
|
+
|
|
710
|
+
model_config = ConfigDict(use_enum_values=False)
|
|
711
|
+
|
|
712
|
+
name: str
|
|
713
|
+
entity_type: EntityType
|
|
714
|
+
value_type: FieldValueType
|
|
715
|
+
list_id: ListId | None = None
|
|
716
|
+
allows_multiple: bool = False
|
|
717
|
+
is_list_specific: bool = False
|
|
718
|
+
is_required: bool = False
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
# =============================================================================
|
|
722
|
+
# Field Value Models
|
|
723
|
+
# =============================================================================
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
class FieldValue(AffinityModel):
|
|
727
|
+
"""
|
|
728
|
+
A single field value (cell data).
|
|
729
|
+
|
|
730
|
+
The value can be various types depending on the field's value_type.
|
|
731
|
+
"""
|
|
732
|
+
|
|
733
|
+
id: FieldValueId
|
|
734
|
+
field_id: AnyFieldId = Field(alias="fieldId")
|
|
735
|
+
entity_id: int = Field(alias="entityId")
|
|
736
|
+
list_entry_id: ListEntryId | None = Field(None, alias="listEntryId")
|
|
737
|
+
|
|
738
|
+
# The actual value - type depends on field type
|
|
739
|
+
value: Any
|
|
740
|
+
|
|
741
|
+
# Timestamps
|
|
742
|
+
created_at: ISODatetime | None = Field(None, alias="createdAt")
|
|
743
|
+
updated_at: ISODatetime | None = Field(None, alias="updatedAt")
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
class FieldValueCreate(AffinityModel):
|
|
747
|
+
"""Data for creating a field value (V1 API)."""
|
|
748
|
+
|
|
749
|
+
field_id: FieldId
|
|
750
|
+
entity_id: int
|
|
751
|
+
value: Any
|
|
752
|
+
list_entry_id: ListEntryId | None = None
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
class FieldValueUpdate(AffinityModel):
|
|
756
|
+
"""Data for updating a field value (V1 or V2 API)."""
|
|
757
|
+
|
|
758
|
+
value: Any
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
# =============================================================================
|
|
762
|
+
# Field Value Change (History) Models
|
|
763
|
+
# =============================================================================
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
class FieldValueChange(AffinityModel):
|
|
767
|
+
"""Historical change to a field value."""
|
|
768
|
+
|
|
769
|
+
id: FieldValueChangeId
|
|
770
|
+
field_id: FieldId = Field(alias="fieldId")
|
|
771
|
+
entity_id: int = Field(alias="entityId")
|
|
772
|
+
list_entry_id: ListEntryId | None = Field(None, alias="listEntryId")
|
|
773
|
+
action_type: FieldValueChangeAction = Field(alias="actionType")
|
|
774
|
+
value: Any
|
|
775
|
+
changed_at: ISODatetime = Field(alias="changedAt")
|
|
776
|
+
changer: PersonSummary | None = None
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
# =============================================================================
|
|
780
|
+
# Interaction Models
|
|
781
|
+
# =============================================================================
|
|
782
|
+
|
|
783
|
+
|
|
784
|
+
class InteractionDates(AffinityModel):
|
|
785
|
+
"""Dates of interactions with an entity."""
|
|
786
|
+
|
|
787
|
+
first_email_date: ISODatetime | None = Field(None, alias="firstEmailDate")
|
|
788
|
+
last_email_date: ISODatetime | None = Field(None, alias="lastEmailDate")
|
|
789
|
+
first_event_date: ISODatetime | None = Field(None, alias="firstEventDate")
|
|
790
|
+
last_event_date: ISODatetime | None = Field(None, alias="lastEventDate")
|
|
791
|
+
next_event_date: ISODatetime | None = Field(None, alias="nextEventDate")
|
|
792
|
+
last_chat_message_date: ISODatetime | None = Field(None, alias="lastChatMessageDate")
|
|
793
|
+
last_interaction_date: ISODatetime | None = Field(None, alias="lastInteractionDate")
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
# Forward reference resolution
|
|
797
|
+
ListEntry.model_rebuild()
|
|
798
|
+
Company.model_rebuild()
|