contackd-sync 0.2.0__tar.gz → 0.3.0__tar.gz
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.
- {contackd_sync-0.2.0 → contackd_sync-0.3.0}/PKG-INFO +57 -11
- contackd_sync-0.3.0/README.md +141 -0
- {contackd_sync-0.2.0 → contackd_sync-0.3.0}/contackd_sync/__init__.py +4 -0
- {contackd_sync-0.2.0 → contackd_sync-0.3.0}/contackd_sync/client.py +107 -11
- {contackd_sync-0.2.0 → contackd_sync-0.3.0}/contackd_sync.egg-info/PKG-INFO +57 -11
- {contackd_sync-0.2.0 → contackd_sync-0.3.0}/contackd_sync.egg-info/SOURCES.txt +1 -0
- {contackd_sync-0.2.0 → contackd_sync-0.3.0}/pyproject.toml +1 -1
- contackd_sync-0.3.0/tests/test_business_sync.py +151 -0
- contackd_sync-0.2.0/README.md +0 -95
- {contackd_sync-0.2.0 → contackd_sync-0.3.0}/contackd_sync/_version.py +0 -0
- {contackd_sync-0.2.0 → contackd_sync-0.3.0}/contackd_sync.egg-info/dependency_links.txt +0 -0
- {contackd_sync-0.2.0 → contackd_sync-0.3.0}/contackd_sync.egg-info/requires.txt +0 -0
- {contackd_sync-0.2.0 → contackd_sync-0.3.0}/contackd_sync.egg-info/top_level.txt +0 -0
- {contackd_sync-0.2.0 → contackd_sync-0.3.0}/setup.cfg +0 -0
- {contackd_sync-0.2.0 → contackd_sync-0.3.0}/tests/test_client.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: contackd-sync
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Python SDK for Contackd Sync
|
|
5
5
|
Project-URL: Homepage, https://example.com/contackd-sync
|
|
6
6
|
Requires-Python: >=3.10
|
|
@@ -10,7 +10,7 @@ Requires-Dist: urllib3>=2.0.0
|
|
|
10
10
|
|
|
11
11
|
# contackd-sync (Python)
|
|
12
12
|
|
|
13
|
-
Python SDK for pushing and syncing customer data (`contacts`, `leads`, `prospects`).
|
|
13
|
+
Python SDK for pushing and syncing customer data (`contacts`, `leads`, `prospects`, `businesses`).
|
|
14
14
|
|
|
15
15
|
## Install
|
|
16
16
|
|
|
@@ -80,6 +80,52 @@ print(len(result.created), len(result.updated), result.errors)
|
|
|
80
80
|
page = client.contacts.list(page=1, per_page=50, email="jane@acme.com")
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
+
## Sync Businesses Before Their Contacts
|
|
84
|
+
|
|
85
|
+
Create or update businesses first so contacts can reference the returned CRM
|
|
86
|
+
business IDs. Business synchronization uses `external_business_id` as its
|
|
87
|
+
primary natural key and falls back to the normalized business name.
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from contackd_sync import Business, Contact, ContackdClient
|
|
91
|
+
|
|
92
|
+
client = ContackdClient(
|
|
93
|
+
api_key="sk_live_...",
|
|
94
|
+
base_url="https://api.yourapp.com/api/v1",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
business_result = client.businesses.bulk_upsert(
|
|
98
|
+
[
|
|
99
|
+
Business(
|
|
100
|
+
name="Default Facility",
|
|
101
|
+
external_business_id="bb7d2b66-034a-4333-8573-dce07b9684ed",
|
|
102
|
+
stage="customer",
|
|
103
|
+
status="active",
|
|
104
|
+
tags=["FACILITY"],
|
|
105
|
+
custom={"org_code": "FACILITYBILLS"},
|
|
106
|
+
)
|
|
107
|
+
]
|
|
108
|
+
)
|
|
109
|
+
business = [*business_result.created, *business_result.updated][0]
|
|
110
|
+
|
|
111
|
+
contact_result = client.contacts.bulk_upsert(
|
|
112
|
+
[
|
|
113
|
+
Contact(
|
|
114
|
+
email="resident@example.com",
|
|
115
|
+
first_name="Resident",
|
|
116
|
+
external_contact_id="resident-1",
|
|
117
|
+
business_id=business.id,
|
|
118
|
+
tags=["RESIDENT"],
|
|
119
|
+
custom={"role": "resident"},
|
|
120
|
+
)
|
|
121
|
+
]
|
|
122
|
+
)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Callers should merge duplicate source rows before sending them. For a person
|
|
126
|
+
belonging to multiple estates, keep the complete estate list in `custom` and
|
|
127
|
+
use `business_id` for the single primary CRM relationship.
|
|
128
|
+
|
|
83
129
|
## API Key Setup
|
|
84
130
|
|
|
85
131
|
Contact support@dalmlabs.com for the API key and base domain.
|
|
@@ -94,12 +140,12 @@ Given `base_url = https://api.yourapp.com/api/v1`:
|
|
|
94
140
|
- `GET /tags/{id}`
|
|
95
141
|
- `PUT /tags/{id}`
|
|
96
142
|
- `DELETE /tags/{id}`
|
|
97
|
-
- `POST /contacts|leads|prospects`
|
|
98
|
-
- `GET /contacts|leads|prospects/{id}`
|
|
99
|
-
- `PUT /contacts|leads|prospects/{id}`
|
|
100
|
-
- `DELETE /contacts|leads|prospects/{id}`
|
|
101
|
-
- `GET /contacts|leads|prospects`
|
|
102
|
-
- `POST /contacts|leads|prospects/upsert`
|
|
103
|
-
- `POST /contacts|leads|prospects/bulk`
|
|
104
|
-
- `POST /contacts|leads|prospects/bulk/upsert`
|
|
105
|
-
- `POST /contacts|leads|prospects/bulk/delete`
|
|
143
|
+
- `POST /contacts|leads|prospects|businesses`
|
|
144
|
+
- `GET /contacts|leads|prospects|businesses/{id}`
|
|
145
|
+
- `PUT /contacts|leads|prospects|businesses/{id}`
|
|
146
|
+
- `DELETE /contacts|leads|prospects|businesses/{id}`
|
|
147
|
+
- `GET /contacts|leads|prospects|businesses`
|
|
148
|
+
- `POST /contacts|leads|prospects|businesses/upsert`
|
|
149
|
+
- `POST /contacts|leads|prospects|businesses/bulk`
|
|
150
|
+
- `POST /contacts|leads|prospects|businesses/bulk/upsert`
|
|
151
|
+
- `POST /contacts|leads|prospects|businesses/bulk/delete`
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# contackd-sync (Python)
|
|
2
|
+
|
|
3
|
+
Python SDK for pushing and syncing customer data (`contacts`, `leads`, `prospects`, `businesses`).
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install contackd-sync
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## 2-Minute Start (Push Customer Details)
|
|
12
|
+
|
|
13
|
+
```python
|
|
14
|
+
from contackd_sync import ContackdClient, Contact
|
|
15
|
+
|
|
16
|
+
client = ContackdClient(
|
|
17
|
+
api_key="sk_live_...",
|
|
18
|
+
base_url="https://api.yourapp.com/api/v1", # no trailing slash required
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
# Create/update a customer contact by email
|
|
22
|
+
customer = client.contacts.upsert(
|
|
23
|
+
Contact(
|
|
24
|
+
email="jane@acme.com",
|
|
25
|
+
first_name="Jane",
|
|
26
|
+
last_name="Doe",
|
|
27
|
+
phone="+14155550199",
|
|
28
|
+
tags=["customer", "vip"],
|
|
29
|
+
custom={
|
|
30
|
+
"customer_id": "CUST-1001",
|
|
31
|
+
"plan": "pro",
|
|
32
|
+
"country": "US",
|
|
33
|
+
},
|
|
34
|
+
)
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
print(customer)
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Common Customer Push Patterns
|
|
41
|
+
|
|
42
|
+
### 1) Push one new customer
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from contackd_sync import Contact
|
|
46
|
+
|
|
47
|
+
created = client.contacts.create(
|
|
48
|
+
Contact(email="new@acme.com", first_name="New", last_name="Customer")
|
|
49
|
+
)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### 2) Push many customers (bulk upsert)
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from contackd_sync import Contact
|
|
56
|
+
|
|
57
|
+
result = client.contacts.bulk_upsert(
|
|
58
|
+
[
|
|
59
|
+
Contact(email="a@acme.com", first_name="A"),
|
|
60
|
+
Contact(email="b@acme.com", first_name="B", tags=["trial"]),
|
|
61
|
+
]
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
print(len(result.created), len(result.updated), result.errors)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### 3) Read/filter customers
|
|
68
|
+
|
|
69
|
+
```python
|
|
70
|
+
page = client.contacts.list(page=1, per_page=50, email="jane@acme.com")
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Sync Businesses Before Their Contacts
|
|
74
|
+
|
|
75
|
+
Create or update businesses first so contacts can reference the returned CRM
|
|
76
|
+
business IDs. Business synchronization uses `external_business_id` as its
|
|
77
|
+
primary natural key and falls back to the normalized business name.
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from contackd_sync import Business, Contact, ContackdClient
|
|
81
|
+
|
|
82
|
+
client = ContackdClient(
|
|
83
|
+
api_key="sk_live_...",
|
|
84
|
+
base_url="https://api.yourapp.com/api/v1",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
business_result = client.businesses.bulk_upsert(
|
|
88
|
+
[
|
|
89
|
+
Business(
|
|
90
|
+
name="Default Facility",
|
|
91
|
+
external_business_id="bb7d2b66-034a-4333-8573-dce07b9684ed",
|
|
92
|
+
stage="customer",
|
|
93
|
+
status="active",
|
|
94
|
+
tags=["FACILITY"],
|
|
95
|
+
custom={"org_code": "FACILITYBILLS"},
|
|
96
|
+
)
|
|
97
|
+
]
|
|
98
|
+
)
|
|
99
|
+
business = [*business_result.created, *business_result.updated][0]
|
|
100
|
+
|
|
101
|
+
contact_result = client.contacts.bulk_upsert(
|
|
102
|
+
[
|
|
103
|
+
Contact(
|
|
104
|
+
email="resident@example.com",
|
|
105
|
+
first_name="Resident",
|
|
106
|
+
external_contact_id="resident-1",
|
|
107
|
+
business_id=business.id,
|
|
108
|
+
tags=["RESIDENT"],
|
|
109
|
+
custom={"role": "resident"},
|
|
110
|
+
)
|
|
111
|
+
]
|
|
112
|
+
)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Callers should merge duplicate source rows before sending them. For a person
|
|
116
|
+
belonging to multiple estates, keep the complete estate list in `custom` and
|
|
117
|
+
use `business_id` for the single primary CRM relationship.
|
|
118
|
+
|
|
119
|
+
## API Key Setup
|
|
120
|
+
|
|
121
|
+
Contact support@dalmlabs.com for the API key and base domain.
|
|
122
|
+
|
|
123
|
+
## Backend Routes Expected by SDK
|
|
124
|
+
|
|
125
|
+
Given `base_url = https://api.yourapp.com/api/v1`:
|
|
126
|
+
|
|
127
|
+
- `GET /health`
|
|
128
|
+
- `POST /tags`
|
|
129
|
+
- `GET /tags`
|
|
130
|
+
- `GET /tags/{id}`
|
|
131
|
+
- `PUT /tags/{id}`
|
|
132
|
+
- `DELETE /tags/{id}`
|
|
133
|
+
- `POST /contacts|leads|prospects|businesses`
|
|
134
|
+
- `GET /contacts|leads|prospects|businesses/{id}`
|
|
135
|
+
- `PUT /contacts|leads|prospects|businesses/{id}`
|
|
136
|
+
- `DELETE /contacts|leads|prospects|businesses/{id}`
|
|
137
|
+
- `GET /contacts|leads|prospects|businesses`
|
|
138
|
+
- `POST /contacts|leads|prospects|businesses/upsert`
|
|
139
|
+
- `POST /contacts|leads|prospects|businesses/bulk`
|
|
140
|
+
- `POST /contacts|leads|prospects|businesses/bulk/upsert`
|
|
141
|
+
- `POST /contacts|leads|prospects|businesses/bulk/delete`
|
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
from ._version import __version__
|
|
4
4
|
from .client import (
|
|
5
5
|
AuthError,
|
|
6
|
+
Business,
|
|
7
|
+
BusinessesResource,
|
|
6
8
|
BulkResult,
|
|
7
9
|
ConflictStrategy,
|
|
8
10
|
Contact,
|
|
@@ -26,6 +28,8 @@ from .client import (
|
|
|
26
28
|
__all__ = [
|
|
27
29
|
"__version__",
|
|
28
30
|
"AuthError",
|
|
31
|
+
"Business",
|
|
32
|
+
"BusinessesResource",
|
|
29
33
|
"BulkResult",
|
|
30
34
|
"ConflictStrategy",
|
|
31
35
|
"Contact",
|
|
@@ -74,6 +74,8 @@ class Contact:
|
|
|
74
74
|
first_name: str = ""
|
|
75
75
|
last_name: str = ""
|
|
76
76
|
phone: Optional[str] = None
|
|
77
|
+
external_contact_id: Optional[str] = None
|
|
78
|
+
business_id: Optional[str] = None
|
|
77
79
|
tags: List[str] = field(default_factory=list)
|
|
78
80
|
custom: Dict[str, Any] = field(default_factory=dict)
|
|
79
81
|
id: Optional[str] = None
|
|
@@ -180,9 +182,49 @@ class Prospect:
|
|
|
180
182
|
return hashlib.sha256(payload.encode()).hexdigest()
|
|
181
183
|
|
|
182
184
|
|
|
183
|
-
|
|
185
|
+
@dataclass
|
|
186
|
+
class Business:
|
|
187
|
+
"""Business record model used by the Businesses API."""
|
|
188
|
+
|
|
189
|
+
name: str
|
|
190
|
+
external_business_id: Optional[str] = None
|
|
191
|
+
email: Optional[str] = None
|
|
192
|
+
phone: Optional[str] = None
|
|
193
|
+
website: Optional[str] = None
|
|
194
|
+
industry: Optional[str] = None
|
|
195
|
+
address: Optional[str] = None
|
|
196
|
+
notes: Optional[str] = None
|
|
197
|
+
primary_contact_id: Optional[str] = None
|
|
198
|
+
stage: str = "lead"
|
|
199
|
+
status: str = "active"
|
|
200
|
+
estimated_value: Optional[int] = None
|
|
201
|
+
tags: List[str] = field(default_factory=list)
|
|
202
|
+
custom: Dict[str, Any] = field(default_factory=dict)
|
|
203
|
+
id: Optional[str] = None
|
|
204
|
+
updated_at: Optional[str] = None
|
|
205
|
+
|
|
206
|
+
def to_dict(self) -> dict:
|
|
207
|
+
"""Serialize the record to a JSON-friendly dictionary."""
|
|
208
|
+
data = asdict(self)
|
|
209
|
+
return {key: value for key, value in data.items() if value is not None}
|
|
210
|
+
|
|
211
|
+
@classmethod
|
|
212
|
+
def from_dict(cls, data: dict) -> "Business":
|
|
213
|
+
"""Build a business from API response data."""
|
|
214
|
+
return cls(**{key: value for key, value in data.items() if key in cls.__dataclass_fields__})
|
|
215
|
+
|
|
216
|
+
def fingerprint(self) -> str:
|
|
217
|
+
"""Return a stable SHA-256 digest for change detection."""
|
|
218
|
+
payload = json.dumps(
|
|
219
|
+
{key: value for key, value in self.to_dict().items() if key not in ("id", "updated_at")},
|
|
220
|
+
sort_keys=True,
|
|
221
|
+
)
|
|
222
|
+
return hashlib.sha256(payload.encode()).hexdigest()
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
RecordType = Contact | Lead | Prospect | Business
|
|
184
226
|
ConflictStrategy = Literal["local", "remote", "raise"]
|
|
185
|
-
RecordT = TypeVar("RecordT", Contact, Lead, Prospect)
|
|
227
|
+
RecordT = TypeVar("RecordT", Contact, Lead, Prospect, Business)
|
|
186
228
|
|
|
187
229
|
|
|
188
230
|
class _RecordModel(Protocol):
|
|
@@ -199,8 +241,31 @@ class _RecordFactory(Protocol[RecordT]):
|
|
|
199
241
|
def from_dict(cls, data: dict) -> RecordT: ...
|
|
200
242
|
|
|
201
243
|
|
|
244
|
+
def _record_keys(record: _RecordModel) -> List[str]:
|
|
245
|
+
keys: List[str] = []
|
|
246
|
+
if hasattr(record, "external_business_id"):
|
|
247
|
+
external_business_id = getattr(record, "external_business_id", None)
|
|
248
|
+
if external_business_id:
|
|
249
|
+
keys.append(
|
|
250
|
+
f"external_business_id:{str(external_business_id).strip().casefold()}"
|
|
251
|
+
)
|
|
252
|
+
name = getattr(record, "name", None)
|
|
253
|
+
if name:
|
|
254
|
+
keys.append(f"name:{str(name).strip().casefold()}")
|
|
255
|
+
else:
|
|
256
|
+
if record.email:
|
|
257
|
+
keys.append(f"email:{record.email.strip().casefold()}")
|
|
258
|
+
external_thread_id = getattr(record, "external_thread_id", None)
|
|
259
|
+
if external_thread_id:
|
|
260
|
+
keys.append(f"external_thread_id:{str(external_thread_id).strip()}")
|
|
261
|
+
if record.id:
|
|
262
|
+
keys.append(f"id:{record.id}")
|
|
263
|
+
return keys
|
|
264
|
+
|
|
265
|
+
|
|
202
266
|
def _record_key(record: _RecordModel) -> Optional[str]:
|
|
203
|
-
|
|
267
|
+
keys = _record_keys(record)
|
|
268
|
+
return keys[0] if keys else None
|
|
204
269
|
|
|
205
270
|
|
|
206
271
|
class _HttpClient:
|
|
@@ -365,6 +430,13 @@ class ProspectsResource(_Resource[Prospect]):
|
|
|
365
430
|
_model = Prospect
|
|
366
431
|
|
|
367
432
|
|
|
433
|
+
class BusinessesResource(_Resource[Business]):
|
|
434
|
+
"""Typed resource wrapper for businesses."""
|
|
435
|
+
|
|
436
|
+
_path = "businesses"
|
|
437
|
+
_model = Business
|
|
438
|
+
|
|
439
|
+
|
|
368
440
|
@dataclass
|
|
369
441
|
class SyncResult:
|
|
370
442
|
"""Outcome summary for a bidirectional sync operation."""
|
|
@@ -419,19 +491,23 @@ class SyncEngine(Generic[RecordT]):
|
|
|
419
491
|
"""Run a full bidirectional sync against the remote API."""
|
|
420
492
|
result = SyncResult()
|
|
421
493
|
remote_records = self.pull(**filters)
|
|
422
|
-
remote_by_key: Dict[str, RecordT] = {
|
|
423
|
-
|
|
424
|
-
for
|
|
425
|
-
|
|
426
|
-
}
|
|
494
|
+
remote_by_key: Dict[str, RecordT] = {}
|
|
495
|
+
for record in remote_records:
|
|
496
|
+
for key in _record_keys(record):
|
|
497
|
+
remote_by_key.setdefault(key, record)
|
|
427
498
|
local_keys = {
|
|
428
|
-
key
|
|
499
|
+
key
|
|
500
|
+
for record in local_records
|
|
501
|
+
for key in _record_keys(record)
|
|
429
502
|
}
|
|
430
503
|
|
|
431
504
|
to_push: List[RecordT] = []
|
|
432
505
|
|
|
433
506
|
for local in local_records:
|
|
434
|
-
remote =
|
|
507
|
+
remote = next(
|
|
508
|
+
(remote_by_key[key] for key in _record_keys(local) if key in remote_by_key),
|
|
509
|
+
None,
|
|
510
|
+
)
|
|
435
511
|
|
|
436
512
|
if remote is None:
|
|
437
513
|
to_push.append(local)
|
|
@@ -453,7 +529,9 @@ class SyncEngine(Generic[RecordT]):
|
|
|
453
529
|
result.errors.extend(bulk.errors)
|
|
454
530
|
|
|
455
531
|
result.remote_only = [
|
|
456
|
-
record
|
|
532
|
+
record
|
|
533
|
+
for record in remote_records
|
|
534
|
+
if not local_keys.intersection(_record_keys(record))
|
|
457
535
|
]
|
|
458
536
|
return result
|
|
459
537
|
|
|
@@ -487,6 +565,7 @@ class ContackdClient:
|
|
|
487
565
|
self.contacts = ContactsResource(self._http)
|
|
488
566
|
self.leads = LeadsResource(self._http)
|
|
489
567
|
self.prospects = ProspectsResource(self._http)
|
|
568
|
+
self.businesses = BusinessesResource(self._http)
|
|
490
569
|
|
|
491
570
|
def sync_contacts(
|
|
492
571
|
self,
|
|
@@ -533,6 +612,21 @@ class ContackdClient:
|
|
|
533
612
|
)
|
|
534
613
|
return engine.sync(local_records, **filters)
|
|
535
614
|
|
|
615
|
+
def sync_businesses(
|
|
616
|
+
self,
|
|
617
|
+
local_records: List[Business],
|
|
618
|
+
conflict_strategy: Optional[ConflictStrategy] = None,
|
|
619
|
+
on_conflict: Optional[Callable[[SyncConflictError], Business]] = None,
|
|
620
|
+
**filters: Any,
|
|
621
|
+
) -> SyncResult:
|
|
622
|
+
"""Synchronize businesses between local state and the remote API."""
|
|
623
|
+
engine = SyncEngine(
|
|
624
|
+
self.businesses,
|
|
625
|
+
conflict_strategy=conflict_strategy or self.conflict_strategy,
|
|
626
|
+
on_conflict=on_conflict,
|
|
627
|
+
)
|
|
628
|
+
return engine.sync(local_records, **filters)
|
|
629
|
+
|
|
536
630
|
def ping(self) -> bool:
|
|
537
631
|
"""Return ``True`` when the health endpoint responds successfully."""
|
|
538
632
|
try:
|
|
@@ -544,6 +638,8 @@ class ContackdClient:
|
|
|
544
638
|
|
|
545
639
|
__all__ = [
|
|
546
640
|
"AuthError",
|
|
641
|
+
"Business",
|
|
642
|
+
"BusinessesResource",
|
|
547
643
|
"BulkResult",
|
|
548
644
|
"ConflictStrategy",
|
|
549
645
|
"Contact",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: contackd-sync
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Python SDK for Contackd Sync
|
|
5
5
|
Project-URL: Homepage, https://example.com/contackd-sync
|
|
6
6
|
Requires-Python: >=3.10
|
|
@@ -10,7 +10,7 @@ Requires-Dist: urllib3>=2.0.0
|
|
|
10
10
|
|
|
11
11
|
# contackd-sync (Python)
|
|
12
12
|
|
|
13
|
-
Python SDK for pushing and syncing customer data (`contacts`, `leads`, `prospects`).
|
|
13
|
+
Python SDK for pushing and syncing customer data (`contacts`, `leads`, `prospects`, `businesses`).
|
|
14
14
|
|
|
15
15
|
## Install
|
|
16
16
|
|
|
@@ -80,6 +80,52 @@ print(len(result.created), len(result.updated), result.errors)
|
|
|
80
80
|
page = client.contacts.list(page=1, per_page=50, email="jane@acme.com")
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
+
## Sync Businesses Before Their Contacts
|
|
84
|
+
|
|
85
|
+
Create or update businesses first so contacts can reference the returned CRM
|
|
86
|
+
business IDs. Business synchronization uses `external_business_id` as its
|
|
87
|
+
primary natural key and falls back to the normalized business name.
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
from contackd_sync import Business, Contact, ContackdClient
|
|
91
|
+
|
|
92
|
+
client = ContackdClient(
|
|
93
|
+
api_key="sk_live_...",
|
|
94
|
+
base_url="https://api.yourapp.com/api/v1",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
business_result = client.businesses.bulk_upsert(
|
|
98
|
+
[
|
|
99
|
+
Business(
|
|
100
|
+
name="Default Facility",
|
|
101
|
+
external_business_id="bb7d2b66-034a-4333-8573-dce07b9684ed",
|
|
102
|
+
stage="customer",
|
|
103
|
+
status="active",
|
|
104
|
+
tags=["FACILITY"],
|
|
105
|
+
custom={"org_code": "FACILITYBILLS"},
|
|
106
|
+
)
|
|
107
|
+
]
|
|
108
|
+
)
|
|
109
|
+
business = [*business_result.created, *business_result.updated][0]
|
|
110
|
+
|
|
111
|
+
contact_result = client.contacts.bulk_upsert(
|
|
112
|
+
[
|
|
113
|
+
Contact(
|
|
114
|
+
email="resident@example.com",
|
|
115
|
+
first_name="Resident",
|
|
116
|
+
external_contact_id="resident-1",
|
|
117
|
+
business_id=business.id,
|
|
118
|
+
tags=["RESIDENT"],
|
|
119
|
+
custom={"role": "resident"},
|
|
120
|
+
)
|
|
121
|
+
]
|
|
122
|
+
)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Callers should merge duplicate source rows before sending them. For a person
|
|
126
|
+
belonging to multiple estates, keep the complete estate list in `custom` and
|
|
127
|
+
use `business_id` for the single primary CRM relationship.
|
|
128
|
+
|
|
83
129
|
## API Key Setup
|
|
84
130
|
|
|
85
131
|
Contact support@dalmlabs.com for the API key and base domain.
|
|
@@ -94,12 +140,12 @@ Given `base_url = https://api.yourapp.com/api/v1`:
|
|
|
94
140
|
- `GET /tags/{id}`
|
|
95
141
|
- `PUT /tags/{id}`
|
|
96
142
|
- `DELETE /tags/{id}`
|
|
97
|
-
- `POST /contacts|leads|prospects`
|
|
98
|
-
- `GET /contacts|leads|prospects/{id}`
|
|
99
|
-
- `PUT /contacts|leads|prospects/{id}`
|
|
100
|
-
- `DELETE /contacts|leads|prospects/{id}`
|
|
101
|
-
- `GET /contacts|leads|prospects`
|
|
102
|
-
- `POST /contacts|leads|prospects/upsert`
|
|
103
|
-
- `POST /contacts|leads|prospects/bulk`
|
|
104
|
-
- `POST /contacts|leads|prospects/bulk/upsert`
|
|
105
|
-
- `POST /contacts|leads|prospects/bulk/delete`
|
|
143
|
+
- `POST /contacts|leads|prospects|businesses`
|
|
144
|
+
- `GET /contacts|leads|prospects|businesses/{id}`
|
|
145
|
+
- `PUT /contacts|leads|prospects|businesses/{id}`
|
|
146
|
+
- `DELETE /contacts|leads|prospects|businesses/{id}`
|
|
147
|
+
- `GET /contacts|leads|prospects|businesses`
|
|
148
|
+
- `POST /contacts|leads|prospects|businesses/upsert`
|
|
149
|
+
- `POST /contacts|leads|prospects|businesses/bulk`
|
|
150
|
+
- `POST /contacts|leads|prospects|businesses/bulk/upsert`
|
|
151
|
+
- `POST /contacts|leads|prospects|businesses/bulk/delete`
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import contackd_sync
|
|
6
|
+
from contackd_sync import Business, Contact, ContackdClient, SyncEngine
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RecordingHttpClient:
|
|
10
|
+
def __init__(self, responses: list[dict[str, Any]] | None = None) -> None:
|
|
11
|
+
self.responses = list(responses or [])
|
|
12
|
+
self.posts: list[tuple[str, dict[str, Any]]] = []
|
|
13
|
+
|
|
14
|
+
def post(self, path: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
15
|
+
self.posts.append((path, body))
|
|
16
|
+
return self.responses.pop(0) if self.responses else {}
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class InMemoryBusinessResource:
|
|
20
|
+
def __init__(self, records: list[Business]) -> None:
|
|
21
|
+
self.records = records
|
|
22
|
+
self.pushed: list[Business] = []
|
|
23
|
+
|
|
24
|
+
def list(self, page: int = 1, per_page: int = 100, **filters: Any) -> list[Business]:
|
|
25
|
+
del per_page, filters
|
|
26
|
+
return self.records if page == 1 else []
|
|
27
|
+
|
|
28
|
+
def bulk_upsert(self, records: list[Business], chunk_size: int = 100) -> contackd_sync.BulkResult:
|
|
29
|
+
del chunk_size
|
|
30
|
+
self.pushed.extend(records)
|
|
31
|
+
return contackd_sync.BulkResult(updated=list(records))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_contact_serializes_source_and_business_ids() -> None:
|
|
35
|
+
contact = Contact(
|
|
36
|
+
email="resident@example.com",
|
|
37
|
+
external_contact_id="resident-1",
|
|
38
|
+
business_id="business-1",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
assert contact.to_dict()["external_contact_id"] == "resident-1"
|
|
42
|
+
assert contact.to_dict()["business_id"] == "business-1"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_business_serializes_facility_fields() -> None:
|
|
46
|
+
business = contackd_sync.Business(
|
|
47
|
+
name="Default Facility",
|
|
48
|
+
external_business_id="estate-1",
|
|
49
|
+
stage="customer",
|
|
50
|
+
status="active",
|
|
51
|
+
tags=["FACILITY"],
|
|
52
|
+
custom={"org_code": "FACILITYBILLS"},
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
assert business.to_dict() == {
|
|
56
|
+
"name": "Default Facility",
|
|
57
|
+
"external_business_id": "estate-1",
|
|
58
|
+
"stage": "customer",
|
|
59
|
+
"status": "active",
|
|
60
|
+
"tags": ["FACILITY"],
|
|
61
|
+
"custom": {"org_code": "FACILITYBILLS"},
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_business_resource_bulk_upserts_in_chunks() -> None:
|
|
66
|
+
http = RecordingHttpClient(
|
|
67
|
+
[
|
|
68
|
+
{"created": [{"id": "business-1", "name": "Estate 1", "external_business_id": "estate-1"}]},
|
|
69
|
+
{"updated": [{"id": "business-2", "name": "Estate 2", "external_business_id": "estate-2"}]},
|
|
70
|
+
]
|
|
71
|
+
)
|
|
72
|
+
resource_type = getattr(contackd_sync, "BusinessesResource")
|
|
73
|
+
resource = resource_type(http)
|
|
74
|
+
records = [
|
|
75
|
+
Business(name="Estate 1", external_business_id="estate-1"),
|
|
76
|
+
Business(name="Estate 2", external_business_id="estate-2"),
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
result = resource.bulk_upsert(records, chunk_size=1)
|
|
80
|
+
|
|
81
|
+
assert [path for path, _ in http.posts] == ["businesses/bulk/upsert", "businesses/bulk/upsert"]
|
|
82
|
+
assert result.total == 2
|
|
83
|
+
assert [record.id for record in result.created] == ["business-1"]
|
|
84
|
+
assert [record.id for record in result.updated] == ["business-2"]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_business_sync_matches_by_external_business_id() -> None:
|
|
88
|
+
remote = Business(
|
|
89
|
+
id="business-1",
|
|
90
|
+
name="Old Facility Name",
|
|
91
|
+
external_business_id="estate-1",
|
|
92
|
+
)
|
|
93
|
+
other_remote = Business(
|
|
94
|
+
id="business-2",
|
|
95
|
+
name="Other Facility",
|
|
96
|
+
external_business_id="estate-2",
|
|
97
|
+
)
|
|
98
|
+
local = Business(
|
|
99
|
+
name="Renamed Facility",
|
|
100
|
+
external_business_id="estate-1",
|
|
101
|
+
)
|
|
102
|
+
resource = InMemoryBusinessResource([remote, other_remote])
|
|
103
|
+
engine = SyncEngine(resource, conflict_strategy="local") # type: ignore[arg-type]
|
|
104
|
+
|
|
105
|
+
result = engine.sync([local])
|
|
106
|
+
|
|
107
|
+
assert result.pushed == []
|
|
108
|
+
assert len(result.resolved) == 1
|
|
109
|
+
assert result.resolved[0][1] is remote
|
|
110
|
+
assert resource.pushed == [local]
|
|
111
|
+
assert result.remote_only == [other_remote]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_business_sync_falls_back_to_name_when_remote_has_no_external_id() -> None:
|
|
115
|
+
remote = Business(id="business-1", name="Default Facility")
|
|
116
|
+
local = Business(name=" default facility ", external_business_id="estate-1")
|
|
117
|
+
resource = InMemoryBusinessResource([remote])
|
|
118
|
+
engine = SyncEngine(resource, conflict_strategy="local") # type: ignore[arg-type]
|
|
119
|
+
|
|
120
|
+
result = engine.sync([local])
|
|
121
|
+
|
|
122
|
+
assert result.pushed == []
|
|
123
|
+
assert len(result.resolved) == 1
|
|
124
|
+
assert result.resolved[0][1] is remote
|
|
125
|
+
assert result.remote_only == []
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_client_exposes_business_resource() -> None:
|
|
129
|
+
client = ContackdClient(api_key="test-key", base_url="https://api.example.com/api/v1")
|
|
130
|
+
|
|
131
|
+
resource_type = getattr(contackd_sync, "BusinessesResource")
|
|
132
|
+
assert isinstance(client.businesses, resource_type)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_client_syncs_businesses_with_requested_conflict_strategy() -> None:
|
|
136
|
+
remote = Business(id="business-1", name="Old Name", external_business_id="estate-1")
|
|
137
|
+
local = Business(name="New Name", external_business_id="estate-1")
|
|
138
|
+
resource = InMemoryBusinessResource([remote])
|
|
139
|
+
client = ContackdClient(api_key="test-key", base_url="https://api.example.com/api/v1")
|
|
140
|
+
client.businesses = resource # type: ignore[assignment]
|
|
141
|
+
|
|
142
|
+
result = client.sync_businesses([local], conflict_strategy="local")
|
|
143
|
+
|
|
144
|
+
assert resource.pushed == [local]
|
|
145
|
+
assert len(result.resolved) == 1
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def test_client_reports_the_installed_sdk_version() -> None:
|
|
149
|
+
client = ContackdClient(api_key="test-key", base_url="https://api.example.com/api/v1")
|
|
150
|
+
|
|
151
|
+
assert client._http.session.headers["X-SDK-Version"] == contackd_sync.__version__
|
contackd_sync-0.2.0/README.md
DELETED
|
@@ -1,95 +0,0 @@
|
|
|
1
|
-
# contackd-sync (Python)
|
|
2
|
-
|
|
3
|
-
Python SDK for pushing and syncing customer data (`contacts`, `leads`, `prospects`).
|
|
4
|
-
|
|
5
|
-
## Install
|
|
6
|
-
|
|
7
|
-
```bash
|
|
8
|
-
pip install contackd-sync
|
|
9
|
-
```
|
|
10
|
-
|
|
11
|
-
## 2-Minute Start (Push Customer Details)
|
|
12
|
-
|
|
13
|
-
```python
|
|
14
|
-
from contackd_sync import ContackdClient, Contact
|
|
15
|
-
|
|
16
|
-
client = ContackdClient(
|
|
17
|
-
api_key="sk_live_...",
|
|
18
|
-
base_url="https://api.yourapp.com/api/v1", # no trailing slash required
|
|
19
|
-
)
|
|
20
|
-
|
|
21
|
-
# Create/update a customer contact by email
|
|
22
|
-
customer = client.contacts.upsert(
|
|
23
|
-
Contact(
|
|
24
|
-
email="jane@acme.com",
|
|
25
|
-
first_name="Jane",
|
|
26
|
-
last_name="Doe",
|
|
27
|
-
phone="+14155550199",
|
|
28
|
-
tags=["customer", "vip"],
|
|
29
|
-
custom={
|
|
30
|
-
"customer_id": "CUST-1001",
|
|
31
|
-
"plan": "pro",
|
|
32
|
-
"country": "US",
|
|
33
|
-
},
|
|
34
|
-
)
|
|
35
|
-
)
|
|
36
|
-
|
|
37
|
-
print(customer)
|
|
38
|
-
```
|
|
39
|
-
|
|
40
|
-
## Common Customer Push Patterns
|
|
41
|
-
|
|
42
|
-
### 1) Push one new customer
|
|
43
|
-
|
|
44
|
-
```python
|
|
45
|
-
from contackd_sync import Contact
|
|
46
|
-
|
|
47
|
-
created = client.contacts.create(
|
|
48
|
-
Contact(email="new@acme.com", first_name="New", last_name="Customer")
|
|
49
|
-
)
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
### 2) Push many customers (bulk upsert)
|
|
53
|
-
|
|
54
|
-
```python
|
|
55
|
-
from contackd_sync import Contact
|
|
56
|
-
|
|
57
|
-
result = client.contacts.bulk_upsert(
|
|
58
|
-
[
|
|
59
|
-
Contact(email="a@acme.com", first_name="A"),
|
|
60
|
-
Contact(email="b@acme.com", first_name="B", tags=["trial"]),
|
|
61
|
-
]
|
|
62
|
-
)
|
|
63
|
-
|
|
64
|
-
print(len(result.created), len(result.updated), result.errors)
|
|
65
|
-
```
|
|
66
|
-
|
|
67
|
-
### 3) Read/filter customers
|
|
68
|
-
|
|
69
|
-
```python
|
|
70
|
-
page = client.contacts.list(page=1, per_page=50, email="jane@acme.com")
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
## API Key Setup
|
|
74
|
-
|
|
75
|
-
Contact support@dalmlabs.com for the API key and base domain.
|
|
76
|
-
|
|
77
|
-
## Backend Routes Expected by SDK
|
|
78
|
-
|
|
79
|
-
Given `base_url = https://api.yourapp.com/api/v1`:
|
|
80
|
-
|
|
81
|
-
- `GET /health`
|
|
82
|
-
- `POST /tags`
|
|
83
|
-
- `GET /tags`
|
|
84
|
-
- `GET /tags/{id}`
|
|
85
|
-
- `PUT /tags/{id}`
|
|
86
|
-
- `DELETE /tags/{id}`
|
|
87
|
-
- `POST /contacts|leads|prospects`
|
|
88
|
-
- `GET /contacts|leads|prospects/{id}`
|
|
89
|
-
- `PUT /contacts|leads|prospects/{id}`
|
|
90
|
-
- `DELETE /contacts|leads|prospects/{id}`
|
|
91
|
-
- `GET /contacts|leads|prospects`
|
|
92
|
-
- `POST /contacts|leads|prospects/upsert`
|
|
93
|
-
- `POST /contacts|leads|prospects/bulk`
|
|
94
|
-
- `POST /contacts|leads|prospects/bulk/upsert`
|
|
95
|
-
- `POST /contacts|leads|prospects/bulk/delete`
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|