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.
Files changed (92) hide show
  1. affinity/__init__.py +139 -0
  2. affinity/cli/__init__.py +7 -0
  3. affinity/cli/click_compat.py +27 -0
  4. affinity/cli/commands/__init__.py +1 -0
  5. affinity/cli/commands/_entity_files_dump.py +219 -0
  6. affinity/cli/commands/_list_entry_fields.py +41 -0
  7. affinity/cli/commands/_v1_parsing.py +77 -0
  8. affinity/cli/commands/company_cmds.py +2139 -0
  9. affinity/cli/commands/completion_cmd.py +33 -0
  10. affinity/cli/commands/config_cmds.py +540 -0
  11. affinity/cli/commands/entry_cmds.py +33 -0
  12. affinity/cli/commands/field_cmds.py +413 -0
  13. affinity/cli/commands/interaction_cmds.py +875 -0
  14. affinity/cli/commands/list_cmds.py +3152 -0
  15. affinity/cli/commands/note_cmds.py +433 -0
  16. affinity/cli/commands/opportunity_cmds.py +1174 -0
  17. affinity/cli/commands/person_cmds.py +1980 -0
  18. affinity/cli/commands/query_cmd.py +444 -0
  19. affinity/cli/commands/relationship_strength_cmds.py +62 -0
  20. affinity/cli/commands/reminder_cmds.py +595 -0
  21. affinity/cli/commands/resolve_url_cmd.py +127 -0
  22. affinity/cli/commands/session_cmds.py +84 -0
  23. affinity/cli/commands/task_cmds.py +110 -0
  24. affinity/cli/commands/version_cmd.py +29 -0
  25. affinity/cli/commands/whoami_cmd.py +36 -0
  26. affinity/cli/config.py +108 -0
  27. affinity/cli/context.py +749 -0
  28. affinity/cli/csv_utils.py +195 -0
  29. affinity/cli/date_utils.py +42 -0
  30. affinity/cli/decorators.py +77 -0
  31. affinity/cli/errors.py +28 -0
  32. affinity/cli/field_utils.py +355 -0
  33. affinity/cli/formatters.py +551 -0
  34. affinity/cli/help_json.py +283 -0
  35. affinity/cli/logging.py +100 -0
  36. affinity/cli/main.py +261 -0
  37. affinity/cli/options.py +53 -0
  38. affinity/cli/paths.py +32 -0
  39. affinity/cli/progress.py +183 -0
  40. affinity/cli/query/__init__.py +163 -0
  41. affinity/cli/query/aggregates.py +357 -0
  42. affinity/cli/query/dates.py +194 -0
  43. affinity/cli/query/exceptions.py +147 -0
  44. affinity/cli/query/executor.py +1236 -0
  45. affinity/cli/query/filters.py +248 -0
  46. affinity/cli/query/models.py +333 -0
  47. affinity/cli/query/output.py +331 -0
  48. affinity/cli/query/parser.py +619 -0
  49. affinity/cli/query/planner.py +430 -0
  50. affinity/cli/query/progress.py +270 -0
  51. affinity/cli/query/schema.py +439 -0
  52. affinity/cli/render.py +1589 -0
  53. affinity/cli/resolve.py +222 -0
  54. affinity/cli/resolvers.py +249 -0
  55. affinity/cli/results.py +308 -0
  56. affinity/cli/runner.py +218 -0
  57. affinity/cli/serialization.py +65 -0
  58. affinity/cli/session_cache.py +276 -0
  59. affinity/cli/types.py +70 -0
  60. affinity/client.py +771 -0
  61. affinity/clients/__init__.py +19 -0
  62. affinity/clients/http.py +3664 -0
  63. affinity/clients/pipeline.py +165 -0
  64. affinity/compare.py +501 -0
  65. affinity/downloads.py +114 -0
  66. affinity/exceptions.py +615 -0
  67. affinity/filters.py +1128 -0
  68. affinity/hooks.py +198 -0
  69. affinity/inbound_webhooks.py +302 -0
  70. affinity/models/__init__.py +163 -0
  71. affinity/models/entities.py +798 -0
  72. affinity/models/pagination.py +513 -0
  73. affinity/models/rate_limit_snapshot.py +48 -0
  74. affinity/models/secondary.py +413 -0
  75. affinity/models/types.py +663 -0
  76. affinity/policies.py +40 -0
  77. affinity/progress.py +22 -0
  78. affinity/py.typed +0 -0
  79. affinity/services/__init__.py +42 -0
  80. affinity/services/companies.py +1286 -0
  81. affinity/services/lists.py +1892 -0
  82. affinity/services/opportunities.py +1330 -0
  83. affinity/services/persons.py +1348 -0
  84. affinity/services/rate_limits.py +173 -0
  85. affinity/services/tasks.py +193 -0
  86. affinity/services/v1_only.py +2445 -0
  87. affinity/types.py +83 -0
  88. affinity_sdk-0.9.5.dist-info/METADATA +622 -0
  89. affinity_sdk-0.9.5.dist-info/RECORD +92 -0
  90. affinity_sdk-0.9.5.dist-info/WHEEL +4 -0
  91. affinity_sdk-0.9.5.dist-info/entry_points.txt +2 -0
  92. 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()