contackd-sync 0.3.0__tar.gz → 0.4.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.3.0/contackd_sync.egg-info → contackd_sync-0.4.0}/PKG-INFO +69 -4
- contackd_sync-0.3.0/PKG-INFO → contackd_sync-0.4.0/README.md +59 -11
- {contackd_sync-0.3.0 → contackd_sync-0.4.0}/contackd_sync/__init__.py +22 -0
- {contackd_sync-0.3.0 → contackd_sync-0.4.0}/contackd_sync/client.py +351 -3
- contackd_sync-0.3.0/README.md → contackd_sync-0.4.0/contackd_sync.egg-info/PKG-INFO +76 -1
- {contackd_sync-0.3.0 → contackd_sync-0.4.0}/contackd_sync.egg-info/SOURCES.txt +3 -1
- contackd_sync-0.4.0/pyproject.toml +30 -0
- contackd_sync-0.4.0/tests/test_client.py +179 -0
- contackd_sync-0.4.0/tests/test_deals.py +158 -0
- contackd_sync-0.4.0/tests/test_python39_compatibility.py +7 -0
- contackd_sync-0.3.0/pyproject.toml +0 -21
- contackd_sync-0.3.0/tests/test_client.py +0 -67
- {contackd_sync-0.3.0 → contackd_sync-0.4.0}/contackd_sync/_version.py +0 -0
- {contackd_sync-0.3.0 → contackd_sync-0.4.0}/contackd_sync.egg-info/dependency_links.txt +0 -0
- {contackd_sync-0.3.0 → contackd_sync-0.4.0}/contackd_sync.egg-info/requires.txt +0 -0
- {contackd_sync-0.3.0 → contackd_sync-0.4.0}/contackd_sync.egg-info/top_level.txt +0 -0
- {contackd_sync-0.3.0 → contackd_sync-0.4.0}/setup.cfg +0 -0
- {contackd_sync-0.3.0 → contackd_sync-0.4.0}/tests/test_business_sync.py +0 -0
|
@@ -1,16 +1,25 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: contackd-sync
|
|
3
|
-
Version: 0.
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Sync CRM records and capture immutable person and business events with the Contackd API
|
|
5
5
|
Project-URL: Homepage, https://example.com/contackd-sync
|
|
6
|
-
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
13
|
+
Requires-Python: >=3.9
|
|
7
14
|
Description-Content-Type: text/markdown
|
|
8
15
|
Requires-Dist: requests>=2.31.0
|
|
9
16
|
Requires-Dist: urllib3>=2.0.0
|
|
10
17
|
|
|
11
18
|
# contackd-sync (Python)
|
|
12
19
|
|
|
13
|
-
Python SDK for
|
|
20
|
+
Python SDK for syncing CRM records (`contacts`, `leads`, `prospects`, and `businesses`), managing deals, and capturing immutable person and business events.
|
|
21
|
+
|
|
22
|
+
Requires Python 3.9 or later.
|
|
14
23
|
|
|
15
24
|
## Install
|
|
16
25
|
|
|
@@ -47,6 +56,53 @@ customer = client.contacts.upsert(
|
|
|
47
56
|
print(customer)
|
|
48
57
|
```
|
|
49
58
|
|
|
59
|
+
## Capture Custom Events
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from contackd_sync import PersonEvent
|
|
63
|
+
|
|
64
|
+
event = client.person_events.track(
|
|
65
|
+
PersonEvent(
|
|
66
|
+
unified_id=customer.id,
|
|
67
|
+
event_name="financing",
|
|
68
|
+
status="applied",
|
|
69
|
+
correlation_id="application-10042",
|
|
70
|
+
amount="10000.00",
|
|
71
|
+
currency="NGN",
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
current_states = client.person_events.latest(customer.id)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Use `BusinessEvent` with `client.business_events` for B2B actions. Both event resources expose `track`, `track_batch`, `list`, and `latest`; a retry-safe `event_id` is generated when omitted.
|
|
79
|
+
|
|
80
|
+
## Manage Deals
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from contackd_sync import Deal, DealStage, DealTransaction
|
|
84
|
+
|
|
85
|
+
deal = client.deals.create(
|
|
86
|
+
Deal(
|
|
87
|
+
contact_id=customer.id,
|
|
88
|
+
title="Annual facility management plan",
|
|
89
|
+
amount="150000.00",
|
|
90
|
+
currency="NGN",
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
client.deals.update(deal.id, assigned_to_user_id="sales-agent-id")
|
|
95
|
+
won = client.deals.update_stage(
|
|
96
|
+
deal.id,
|
|
97
|
+
DealStage.WON,
|
|
98
|
+
transaction=DealTransaction(external_transaction_id="payment-10042"),
|
|
99
|
+
)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`client.deals` also provides `get` and `list`. List filters include
|
|
103
|
+
`contact_id`, `lead_id`, `conversation_id`, `stage`, `q`, and
|
|
104
|
+
`assigned_to_user_id`.
|
|
105
|
+
|
|
50
106
|
## Common Customer Push Patterns
|
|
51
107
|
|
|
52
108
|
### 1) Push one new customer
|
|
@@ -149,3 +205,12 @@ Given `base_url = https://api.yourapp.com/api/v1`:
|
|
|
149
205
|
- `POST /contacts|leads|prospects|businesses/bulk`
|
|
150
206
|
- `POST /contacts|leads|prospects|businesses/bulk/upsert`
|
|
151
207
|
- `POST /contacts|leads|prospects|businesses/bulk/delete`
|
|
208
|
+
- `POST /events/person|business`
|
|
209
|
+
- `POST /events/person|business/batch`
|
|
210
|
+
- `GET /events/person|business/{id}`
|
|
211
|
+
- `GET /events/person|business/{id}/latest`
|
|
212
|
+
- `POST /deals`
|
|
213
|
+
- `GET /deals`
|
|
214
|
+
- `GET /deals/{id}`
|
|
215
|
+
- `PATCH /deals/{id}`
|
|
216
|
+
- `PATCH /deals/{id}/stage`
|
|
@@ -1,16 +1,8 @@
|
|
|
1
|
-
Metadata-Version: 2.4
|
|
2
|
-
Name: contackd-sync
|
|
3
|
-
Version: 0.3.0
|
|
4
|
-
Summary: Python SDK for Contackd Sync
|
|
5
|
-
Project-URL: Homepage, https://example.com/contackd-sync
|
|
6
|
-
Requires-Python: >=3.10
|
|
7
|
-
Description-Content-Type: text/markdown
|
|
8
|
-
Requires-Dist: requests>=2.31.0
|
|
9
|
-
Requires-Dist: urllib3>=2.0.0
|
|
10
|
-
|
|
11
1
|
# contackd-sync (Python)
|
|
12
2
|
|
|
13
|
-
Python SDK for
|
|
3
|
+
Python SDK for syncing CRM records (`contacts`, `leads`, `prospects`, and `businesses`), managing deals, and capturing immutable person and business events.
|
|
4
|
+
|
|
5
|
+
Requires Python 3.9 or later.
|
|
14
6
|
|
|
15
7
|
## Install
|
|
16
8
|
|
|
@@ -47,6 +39,53 @@ customer = client.contacts.upsert(
|
|
|
47
39
|
print(customer)
|
|
48
40
|
```
|
|
49
41
|
|
|
42
|
+
## Capture Custom Events
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from contackd_sync import PersonEvent
|
|
46
|
+
|
|
47
|
+
event = client.person_events.track(
|
|
48
|
+
PersonEvent(
|
|
49
|
+
unified_id=customer.id,
|
|
50
|
+
event_name="financing",
|
|
51
|
+
status="applied",
|
|
52
|
+
correlation_id="application-10042",
|
|
53
|
+
amount="10000.00",
|
|
54
|
+
currency="NGN",
|
|
55
|
+
)
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
current_states = client.person_events.latest(customer.id)
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Use `BusinessEvent` with `client.business_events` for B2B actions. Both event resources expose `track`, `track_batch`, `list`, and `latest`; a retry-safe `event_id` is generated when omitted.
|
|
62
|
+
|
|
63
|
+
## Manage Deals
|
|
64
|
+
|
|
65
|
+
```python
|
|
66
|
+
from contackd_sync import Deal, DealStage, DealTransaction
|
|
67
|
+
|
|
68
|
+
deal = client.deals.create(
|
|
69
|
+
Deal(
|
|
70
|
+
contact_id=customer.id,
|
|
71
|
+
title="Annual facility management plan",
|
|
72
|
+
amount="150000.00",
|
|
73
|
+
currency="NGN",
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
client.deals.update(deal.id, assigned_to_user_id="sales-agent-id")
|
|
78
|
+
won = client.deals.update_stage(
|
|
79
|
+
deal.id,
|
|
80
|
+
DealStage.WON,
|
|
81
|
+
transaction=DealTransaction(external_transaction_id="payment-10042"),
|
|
82
|
+
)
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`client.deals` also provides `get` and `list`. List filters include
|
|
86
|
+
`contact_id`, `lead_id`, `conversation_id`, `stage`, `q`, and
|
|
87
|
+
`assigned_to_user_id`.
|
|
88
|
+
|
|
50
89
|
## Common Customer Push Patterns
|
|
51
90
|
|
|
52
91
|
### 1) Push one new customer
|
|
@@ -149,3 +188,12 @@ Given `base_url = https://api.yourapp.com/api/v1`:
|
|
|
149
188
|
- `POST /contacts|leads|prospects|businesses/bulk`
|
|
150
189
|
- `POST /contacts|leads|prospects|businesses/bulk/upsert`
|
|
151
190
|
- `POST /contacts|leads|prospects|businesses/bulk/delete`
|
|
191
|
+
- `POST /events/person|business`
|
|
192
|
+
- `POST /events/person|business/batch`
|
|
193
|
+
- `GET /events/person|business/{id}`
|
|
194
|
+
- `GET /events/person|business/{id}/latest`
|
|
195
|
+
- `POST /deals`
|
|
196
|
+
- `GET /deals`
|
|
197
|
+
- `GET /deals/{id}`
|
|
198
|
+
- `PATCH /deals/{id}`
|
|
199
|
+
- `PATCH /deals/{id}/stage`
|
|
@@ -6,16 +6,24 @@ from .client import (
|
|
|
6
6
|
Business,
|
|
7
7
|
BusinessesResource,
|
|
8
8
|
BulkResult,
|
|
9
|
+
BusinessEvent,
|
|
10
|
+
BusinessEventsResource,
|
|
9
11
|
ConflictStrategy,
|
|
10
12
|
Contact,
|
|
11
13
|
ContactsResource,
|
|
12
14
|
ContackdClient,
|
|
13
15
|
ContackdError,
|
|
16
|
+
Deal,
|
|
17
|
+
DealsResource,
|
|
18
|
+
DealStage,
|
|
19
|
+
DealTransaction,
|
|
14
20
|
Lead,
|
|
15
21
|
LeadStatus,
|
|
16
22
|
LeadsResource,
|
|
17
23
|
NotFoundError,
|
|
18
24
|
PipelineStage,
|
|
25
|
+
PersonEvent,
|
|
26
|
+
PersonEventsResource,
|
|
19
27
|
Prospect,
|
|
20
28
|
ProspectsResource,
|
|
21
29
|
RateLimitError,
|
|
@@ -23,6 +31,9 @@ from .client import (
|
|
|
23
31
|
SyncConflictError,
|
|
24
32
|
SyncEngine,
|
|
25
33
|
SyncResult,
|
|
34
|
+
EventBatchError,
|
|
35
|
+
EventBatchResult,
|
|
36
|
+
EventList,
|
|
26
37
|
)
|
|
27
38
|
|
|
28
39
|
__all__ = [
|
|
@@ -31,16 +42,24 @@ __all__ = [
|
|
|
31
42
|
"Business",
|
|
32
43
|
"BusinessesResource",
|
|
33
44
|
"BulkResult",
|
|
45
|
+
"BusinessEvent",
|
|
46
|
+
"BusinessEventsResource",
|
|
34
47
|
"ConflictStrategy",
|
|
35
48
|
"Contact",
|
|
36
49
|
"ContactsResource",
|
|
37
50
|
"ContackdClient",
|
|
38
51
|
"ContackdError",
|
|
52
|
+
"Deal",
|
|
53
|
+
"DealsResource",
|
|
54
|
+
"DealStage",
|
|
55
|
+
"DealTransaction",
|
|
39
56
|
"Lead",
|
|
40
57
|
"LeadStatus",
|
|
41
58
|
"LeadsResource",
|
|
42
59
|
"NotFoundError",
|
|
43
60
|
"PipelineStage",
|
|
61
|
+
"PersonEvent",
|
|
62
|
+
"PersonEventsResource",
|
|
44
63
|
"Prospect",
|
|
45
64
|
"ProspectsResource",
|
|
46
65
|
"RateLimitError",
|
|
@@ -48,4 +67,7 @@ __all__ = [
|
|
|
48
67
|
"SyncConflictError",
|
|
49
68
|
"SyncEngine",
|
|
50
69
|
"SyncResult",
|
|
70
|
+
"EventBatchError",
|
|
71
|
+
"EventBatchResult",
|
|
72
|
+
"EventList",
|
|
51
73
|
]
|
|
@@ -4,8 +4,9 @@ import hashlib
|
|
|
4
4
|
import json
|
|
5
5
|
from dataclasses import asdict, dataclass, field
|
|
6
6
|
from enum import Enum
|
|
7
|
-
from typing import Any, Callable, Dict, Generic, List, Optional, Protocol, TypeVar,
|
|
8
|
-
from urllib.parse import urljoin
|
|
7
|
+
from typing import Any, Callable, Dict, Generic, List, Literal, Optional, Protocol, TypeVar, Union
|
|
8
|
+
from urllib.parse import quote, urljoin
|
|
9
|
+
from uuid import uuid4
|
|
9
10
|
|
|
10
11
|
import requests
|
|
11
12
|
from requests.adapters import HTTPAdapter
|
|
@@ -66,6 +67,18 @@ class PipelineStage(str, Enum):
|
|
|
66
67
|
PURCHASE = "purchase"
|
|
67
68
|
|
|
68
69
|
|
|
70
|
+
class DealStage(str, Enum):
|
|
71
|
+
"""Supported first-class deal stages."""
|
|
72
|
+
|
|
73
|
+
NEW = "new"
|
|
74
|
+
QUALIFIED = "qualified"
|
|
75
|
+
PROPOSAL = "proposal"
|
|
76
|
+
NEGOTIATION = "negotiation"
|
|
77
|
+
FOLLOW_UP = "follow_up"
|
|
78
|
+
WON = "won"
|
|
79
|
+
LOST = "lost"
|
|
80
|
+
|
|
81
|
+
|
|
69
82
|
@dataclass
|
|
70
83
|
class Contact:
|
|
71
84
|
"""Contact record model used by the Contacts API."""
|
|
@@ -222,7 +235,170 @@ class Business:
|
|
|
222
235
|
return hashlib.sha256(payload.encode()).hexdigest()
|
|
223
236
|
|
|
224
237
|
|
|
225
|
-
|
|
238
|
+
@dataclass
|
|
239
|
+
class DealTransaction:
|
|
240
|
+
"""Purchase transaction attached to a won deal."""
|
|
241
|
+
|
|
242
|
+
external_transaction_id: Optional[str] = None
|
|
243
|
+
order_id: Optional[str] = None
|
|
244
|
+
status: str = "completed"
|
|
245
|
+
amount: Optional[str] = None
|
|
246
|
+
currency: Optional[str] = None
|
|
247
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
248
|
+
id: Optional[str] = None
|
|
249
|
+
tenant_id: Optional[str] = None
|
|
250
|
+
deal_id: Optional[str] = None
|
|
251
|
+
created_at: Optional[str] = None
|
|
252
|
+
updated_at: Optional[str] = None
|
|
253
|
+
|
|
254
|
+
def to_dict(self) -> dict:
|
|
255
|
+
"""Serialize fields accepted by a deal stage update."""
|
|
256
|
+
data = {
|
|
257
|
+
"external_transaction_id": self.external_transaction_id,
|
|
258
|
+
"order_id": self.order_id,
|
|
259
|
+
"status": self.status,
|
|
260
|
+
"amount": self.amount,
|
|
261
|
+
"currency": self.currency,
|
|
262
|
+
"metadata": self.metadata or None,
|
|
263
|
+
}
|
|
264
|
+
return {key: value for key, value in data.items() if value is not None}
|
|
265
|
+
|
|
266
|
+
@classmethod
|
|
267
|
+
def from_dict(cls, data: dict) -> "DealTransaction":
|
|
268
|
+
"""Build a transaction from API response data."""
|
|
269
|
+
return cls(**{key: value for key, value in data.items() if key in cls.__dataclass_fields__})
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
@dataclass
|
|
273
|
+
class Deal:
|
|
274
|
+
"""First-class sales opportunity linked to one contact or lead."""
|
|
275
|
+
|
|
276
|
+
title: str
|
|
277
|
+
contact_id: Optional[str] = None
|
|
278
|
+
lead_id: Optional[str] = None
|
|
279
|
+
conversation_id: Optional[str] = None
|
|
280
|
+
assigned_to_user_id: Optional[str] = None
|
|
281
|
+
description: Optional[str] = None
|
|
282
|
+
product_or_service: Optional[str] = None
|
|
283
|
+
amount: Optional[str] = None
|
|
284
|
+
currency: Optional[str] = None
|
|
285
|
+
expected_close_at: Optional[str] = None
|
|
286
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
287
|
+
id: Optional[str] = None
|
|
288
|
+
tenant_id: Optional[str] = None
|
|
289
|
+
stage: DealStage = DealStage.NEW
|
|
290
|
+
won_at: Optional[str] = None
|
|
291
|
+
lost_at: Optional[str] = None
|
|
292
|
+
loss_reason: Optional[str] = None
|
|
293
|
+
transaction: Optional[DealTransaction] = None
|
|
294
|
+
created_at: Optional[str] = None
|
|
295
|
+
updated_at: Optional[str] = None
|
|
296
|
+
|
|
297
|
+
def to_dict(self) -> dict:
|
|
298
|
+
"""Serialize fields accepted when creating a deal."""
|
|
299
|
+
data = {
|
|
300
|
+
"contact_id": self.contact_id,
|
|
301
|
+
"lead_id": self.lead_id,
|
|
302
|
+
"conversation_id": self.conversation_id,
|
|
303
|
+
"assigned_to_user_id": self.assigned_to_user_id,
|
|
304
|
+
"title": self.title,
|
|
305
|
+
"description": self.description,
|
|
306
|
+
"product_or_service": self.product_or_service,
|
|
307
|
+
"amount": self.amount,
|
|
308
|
+
"currency": self.currency,
|
|
309
|
+
"expected_close_at": self.expected_close_at,
|
|
310
|
+
"metadata": self.metadata or None,
|
|
311
|
+
}
|
|
312
|
+
return {key: value for key, value in data.items() if value is not None}
|
|
313
|
+
|
|
314
|
+
@classmethod
|
|
315
|
+
def from_dict(cls, data: dict) -> "Deal":
|
|
316
|
+
"""Build a deal from API response data."""
|
|
317
|
+
payload = dict(data)
|
|
318
|
+
if "stage" in payload:
|
|
319
|
+
payload["stage"] = DealStage(payload["stage"])
|
|
320
|
+
if isinstance(payload.get("transaction"), dict):
|
|
321
|
+
payload["transaction"] = DealTransaction.from_dict(payload["transaction"])
|
|
322
|
+
return cls(**{key: value for key, value in payload.items() if key in cls.__dataclass_fields__})
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
@dataclass
|
|
326
|
+
class PersonEvent:
|
|
327
|
+
"""Immutable custom event attributed to a person CRM ID."""
|
|
328
|
+
|
|
329
|
+
unified_id: str
|
|
330
|
+
event_name: str
|
|
331
|
+
status: str
|
|
332
|
+
event_id: Optional[str] = None
|
|
333
|
+
correlation_id: Optional[str] = None
|
|
334
|
+
amount: Optional[str] = None
|
|
335
|
+
currency: Optional[str] = None
|
|
336
|
+
properties: Dict[str, Any] = field(default_factory=dict)
|
|
337
|
+
occurred_at: Optional[str] = None
|
|
338
|
+
id: Optional[str] = None
|
|
339
|
+
actor: Dict[str, Any] = field(default_factory=dict)
|
|
340
|
+
source: Optional[str] = None
|
|
341
|
+
sdk_version: Optional[str] = None
|
|
342
|
+
received_at: Optional[str] = None
|
|
343
|
+
|
|
344
|
+
def to_dict(self) -> dict:
|
|
345
|
+
data = {
|
|
346
|
+
"event_id": self.event_id,
|
|
347
|
+
"unified_id": self.unified_id,
|
|
348
|
+
"event_name": self.event_name,
|
|
349
|
+
"status": self.status,
|
|
350
|
+
"correlation_id": self.correlation_id,
|
|
351
|
+
"amount": self.amount,
|
|
352
|
+
"currency": self.currency,
|
|
353
|
+
"properties": self.properties,
|
|
354
|
+
"occurred_at": self.occurred_at,
|
|
355
|
+
}
|
|
356
|
+
return {key: value for key, value in data.items() if value is not None}
|
|
357
|
+
|
|
358
|
+
@classmethod
|
|
359
|
+
def from_dict(cls, data: dict) -> "PersonEvent":
|
|
360
|
+
return cls(**{key: value for key, value in data.items() if key in cls.__dataclass_fields__})
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
@dataclass
|
|
364
|
+
class BusinessEvent:
|
|
365
|
+
"""Immutable custom event attributed to a business CRM ID."""
|
|
366
|
+
|
|
367
|
+
business_id: str
|
|
368
|
+
event_name: str
|
|
369
|
+
status: str
|
|
370
|
+
event_id: Optional[str] = None
|
|
371
|
+
correlation_id: Optional[str] = None
|
|
372
|
+
amount: Optional[str] = None
|
|
373
|
+
currency: Optional[str] = None
|
|
374
|
+
properties: Dict[str, Any] = field(default_factory=dict)
|
|
375
|
+
occurred_at: Optional[str] = None
|
|
376
|
+
id: Optional[str] = None
|
|
377
|
+
actor: Dict[str, Any] = field(default_factory=dict)
|
|
378
|
+
source: Optional[str] = None
|
|
379
|
+
sdk_version: Optional[str] = None
|
|
380
|
+
received_at: Optional[str] = None
|
|
381
|
+
|
|
382
|
+
def to_dict(self) -> dict:
|
|
383
|
+
data = {
|
|
384
|
+
"event_id": self.event_id,
|
|
385
|
+
"business_id": self.business_id,
|
|
386
|
+
"event_name": self.event_name,
|
|
387
|
+
"status": self.status,
|
|
388
|
+
"correlation_id": self.correlation_id,
|
|
389
|
+
"amount": self.amount,
|
|
390
|
+
"currency": self.currency,
|
|
391
|
+
"properties": self.properties,
|
|
392
|
+
"occurred_at": self.occurred_at,
|
|
393
|
+
}
|
|
394
|
+
return {key: value for key, value in data.items() if value is not None}
|
|
395
|
+
|
|
396
|
+
@classmethod
|
|
397
|
+
def from_dict(cls, data: dict) -> "BusinessEvent":
|
|
398
|
+
return cls(**{key: value for key, value in data.items() if key in cls.__dataclass_fields__})
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
RecordType = Union[Contact, Lead, Prospect, Business]
|
|
226
402
|
ConflictStrategy = Literal["local", "remote", "raise"]
|
|
227
403
|
RecordT = TypeVar("RecordT", Contact, Lead, Prospect, Business)
|
|
228
404
|
|
|
@@ -321,11 +497,120 @@ class _HttpClient:
|
|
|
321
497
|
self._raise(response)
|
|
322
498
|
return response.json()
|
|
323
499
|
|
|
500
|
+
def patch(self, path: str, body: dict) -> dict:
|
|
501
|
+
response = self.session.patch(self._url(path), json=body, timeout=self.timeout)
|
|
502
|
+
self._raise(response)
|
|
503
|
+
return response.json()
|
|
504
|
+
|
|
324
505
|
def delete(self, path: str) -> None:
|
|
325
506
|
response = self.session.delete(self._url(path), timeout=self.timeout)
|
|
326
507
|
self._raise(response)
|
|
327
508
|
|
|
328
509
|
|
|
510
|
+
EventT = TypeVar("EventT", PersonEvent, BusinessEvent)
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
@dataclass
|
|
514
|
+
class EventBatchError:
|
|
515
|
+
index: int
|
|
516
|
+
status_code: int
|
|
517
|
+
detail: Any
|
|
518
|
+
event_id: Optional[str] = None
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
@dataclass
|
|
522
|
+
class EventBatchResult(Generic[EventT]):
|
|
523
|
+
created: List[EventT] = field(default_factory=list)
|
|
524
|
+
duplicates: List[EventT] = field(default_factory=list)
|
|
525
|
+
errors: List[EventBatchError] = field(default_factory=list)
|
|
526
|
+
total: int = 0
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
@dataclass
|
|
530
|
+
class EventList(Generic[EventT]):
|
|
531
|
+
results: List[EventT] = field(default_factory=list)
|
|
532
|
+
page: int = 1
|
|
533
|
+
per_page: int = 50
|
|
534
|
+
total: int = 0
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
class _EventsResource(Generic[EventT]):
|
|
538
|
+
def __init__(self, http: _HttpClient, kind: str, model: type[EventT]):
|
|
539
|
+
self._http = http
|
|
540
|
+
self._kind = kind
|
|
541
|
+
self._model = model
|
|
542
|
+
|
|
543
|
+
@staticmethod
|
|
544
|
+
def _event_body(event: EventT) -> dict:
|
|
545
|
+
body = event.to_dict()
|
|
546
|
+
if not body.get("event_id"):
|
|
547
|
+
body["event_id"] = f"evt_{uuid4()}"
|
|
548
|
+
return body
|
|
549
|
+
|
|
550
|
+
def track(self, event: EventT) -> EventT:
|
|
551
|
+
data = self._http.post(f"events/{self._kind}", self._event_body(event))
|
|
552
|
+
return self._model.from_dict(data)
|
|
553
|
+
|
|
554
|
+
def track_batch(self, events: List[EventT]) -> EventBatchResult[EventT]:
|
|
555
|
+
data = self._http.post(
|
|
556
|
+
f"events/{self._kind}/batch",
|
|
557
|
+
{"events": [self._event_body(event) for event in events]},
|
|
558
|
+
)
|
|
559
|
+
return EventBatchResult(
|
|
560
|
+
created=[self._model.from_dict(item) for item in data.get("created", [])],
|
|
561
|
+
duplicates=[self._model.from_dict(item) for item in data.get("duplicates", [])],
|
|
562
|
+
errors=[
|
|
563
|
+
EventBatchError(
|
|
564
|
+
index=item["index"],
|
|
565
|
+
event_id=item.get("event_id"),
|
|
566
|
+
status_code=item["status_code"],
|
|
567
|
+
detail=item.get("detail"),
|
|
568
|
+
)
|
|
569
|
+
for item in data.get("errors", [])
|
|
570
|
+
],
|
|
571
|
+
total=data.get("total", 0),
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
def list(self, subject_id: str, page: int = 1, per_page: int = 50, **filters: Any) -> EventList[EventT]:
|
|
575
|
+
return self._read(subject_id, page=page, per_page=per_page, latest=False, filters=filters)
|
|
576
|
+
|
|
577
|
+
def latest(self, subject_id: str, page: int = 1, per_page: int = 50, **filters: Any) -> EventList[EventT]:
|
|
578
|
+
return self._read(subject_id, page=page, per_page=per_page, latest=True, filters=filters)
|
|
579
|
+
|
|
580
|
+
def _read(
|
|
581
|
+
self,
|
|
582
|
+
subject_id: str,
|
|
583
|
+
*,
|
|
584
|
+
page: int,
|
|
585
|
+
per_page: int,
|
|
586
|
+
latest: bool,
|
|
587
|
+
filters: Dict[str, Any],
|
|
588
|
+
) -> EventList[EventT]:
|
|
589
|
+
suffix = "/latest" if latest else ""
|
|
590
|
+
data = self._http.get(
|
|
591
|
+
f"events/{self._kind}/{quote(subject_id, safe='')}{suffix}",
|
|
592
|
+
page=page,
|
|
593
|
+
per_page=per_page,
|
|
594
|
+
**filters,
|
|
595
|
+
)
|
|
596
|
+
return EventList(
|
|
597
|
+
results=[self._model.from_dict(item) for item in data.get("results", [])],
|
|
598
|
+
page=data.get("page", page),
|
|
599
|
+
per_page=data.get("per_page", per_page),
|
|
600
|
+
total=data.get("total", 0),
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
class PersonEventsResource(_EventsResource[PersonEvent]):
|
|
605
|
+
def __init__(self, http: _HttpClient):
|
|
606
|
+
super().__init__(http, "person", PersonEvent)
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
class BusinessEventsResource(_EventsResource[BusinessEvent]):
|
|
610
|
+
def __init__(self, http: _HttpClient):
|
|
611
|
+
super().__init__(http, "business", BusinessEvent)
|
|
612
|
+
|
|
613
|
+
|
|
329
614
|
class _Resource(Generic[RecordT]):
|
|
330
615
|
"""Base CRUD and bulk helpers for a typed API resource."""
|
|
331
616
|
|
|
@@ -437,6 +722,55 @@ class BusinessesResource(_Resource[Business]):
|
|
|
437
722
|
_model = Business
|
|
438
723
|
|
|
439
724
|
|
|
725
|
+
class DealsResource:
|
|
726
|
+
"""Typed resource wrapper for first-class deals."""
|
|
727
|
+
|
|
728
|
+
def __init__(self, http: _HttpClient):
|
|
729
|
+
self._http = http
|
|
730
|
+
|
|
731
|
+
def create(self, deal: Deal) -> Deal:
|
|
732
|
+
"""Create a deal linked to one contact or lead."""
|
|
733
|
+
return Deal.from_dict(self._http.post("deals", deal.to_dict()))
|
|
734
|
+
|
|
735
|
+
def get(self, deal_id: str) -> Deal:
|
|
736
|
+
"""Fetch a deal by ID."""
|
|
737
|
+
data = self._http.get(f"deals/{quote(deal_id, safe='')}")
|
|
738
|
+
return Deal.from_dict(data)
|
|
739
|
+
|
|
740
|
+
def list(self, limit: int = 50, offset: int = 0, **filters: Any) -> List[Deal]:
|
|
741
|
+
"""List deals using the API's filters and offset pagination."""
|
|
742
|
+
data = self._http.get("deals", limit=limit, offset=offset, **filters)
|
|
743
|
+
items = data.get("results", data) if isinstance(data, dict) else data
|
|
744
|
+
return [Deal.from_dict(item) for item in items]
|
|
745
|
+
|
|
746
|
+
def update(self, deal_id: str, **changes: Any) -> Deal:
|
|
747
|
+
"""Patch assignment or editable deal details."""
|
|
748
|
+
data = self._http.patch(f"deals/{quote(deal_id, safe='')}", changes)
|
|
749
|
+
return Deal.from_dict(data)
|
|
750
|
+
|
|
751
|
+
def update_stage(
|
|
752
|
+
self,
|
|
753
|
+
deal_id: str,
|
|
754
|
+
stage: Union[DealStage, str],
|
|
755
|
+
*,
|
|
756
|
+
loss_reason: Optional[str] = None,
|
|
757
|
+
transaction: Optional[DealTransaction] = None,
|
|
758
|
+
) -> Deal:
|
|
759
|
+
"""Move a deal to another stage and optionally attach a transaction."""
|
|
760
|
+
body: Dict[str, Any] = {
|
|
761
|
+
"stage": stage.value if isinstance(stage, DealStage) else stage,
|
|
762
|
+
}
|
|
763
|
+
if loss_reason is not None:
|
|
764
|
+
body["loss_reason"] = loss_reason
|
|
765
|
+
if transaction is not None:
|
|
766
|
+
body["transaction"] = transaction.to_dict()
|
|
767
|
+
data = self._http.patch(
|
|
768
|
+
f"deals/{quote(deal_id, safe='')}/stage",
|
|
769
|
+
body,
|
|
770
|
+
)
|
|
771
|
+
return Deal.from_dict(data)
|
|
772
|
+
|
|
773
|
+
|
|
440
774
|
@dataclass
|
|
441
775
|
class SyncResult:
|
|
442
776
|
"""Outcome summary for a bidirectional sync operation."""
|
|
@@ -566,6 +900,9 @@ class ContackdClient:
|
|
|
566
900
|
self.leads = LeadsResource(self._http)
|
|
567
901
|
self.prospects = ProspectsResource(self._http)
|
|
568
902
|
self.businesses = BusinessesResource(self._http)
|
|
903
|
+
self.deals = DealsResource(self._http)
|
|
904
|
+
self.person_events = PersonEventsResource(self._http)
|
|
905
|
+
self.business_events = BusinessEventsResource(self._http)
|
|
569
906
|
|
|
570
907
|
def sync_contacts(
|
|
571
908
|
self,
|
|
@@ -641,16 +978,24 @@ __all__ = [
|
|
|
641
978
|
"Business",
|
|
642
979
|
"BusinessesResource",
|
|
643
980
|
"BulkResult",
|
|
981
|
+
"BusinessEvent",
|
|
982
|
+
"BusinessEventsResource",
|
|
644
983
|
"ConflictStrategy",
|
|
645
984
|
"Contact",
|
|
646
985
|
"ContactsResource",
|
|
647
986
|
"ContackdClient",
|
|
648
987
|
"ContackdError",
|
|
988
|
+
"Deal",
|
|
989
|
+
"DealsResource",
|
|
990
|
+
"DealStage",
|
|
991
|
+
"DealTransaction",
|
|
649
992
|
"Lead",
|
|
650
993
|
"LeadStatus",
|
|
651
994
|
"LeadsResource",
|
|
652
995
|
"NotFoundError",
|
|
653
996
|
"PipelineStage",
|
|
997
|
+
"PersonEvent",
|
|
998
|
+
"PersonEventsResource",
|
|
654
999
|
"Prospect",
|
|
655
1000
|
"ProspectsResource",
|
|
656
1001
|
"RateLimitError",
|
|
@@ -658,4 +1003,7 @@ __all__ = [
|
|
|
658
1003
|
"SyncConflictError",
|
|
659
1004
|
"SyncEngine",
|
|
660
1005
|
"SyncResult",
|
|
1006
|
+
"EventBatchError",
|
|
1007
|
+
"EventBatchResult",
|
|
1008
|
+
"EventList",
|
|
661
1009
|
]
|
|
@@ -1,6 +1,25 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: contackd-sync
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Sync CRM records and capture immutable person and business events with the Contackd API
|
|
5
|
+
Project-URL: Homepage, https://example.com/contackd-sync
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
13
|
+
Requires-Python: >=3.9
|
|
14
|
+
Description-Content-Type: text/markdown
|
|
15
|
+
Requires-Dist: requests>=2.31.0
|
|
16
|
+
Requires-Dist: urllib3>=2.0.0
|
|
17
|
+
|
|
1
18
|
# contackd-sync (Python)
|
|
2
19
|
|
|
3
|
-
Python SDK for
|
|
20
|
+
Python SDK for syncing CRM records (`contacts`, `leads`, `prospects`, and `businesses`), managing deals, and capturing immutable person and business events.
|
|
21
|
+
|
|
22
|
+
Requires Python 3.9 or later.
|
|
4
23
|
|
|
5
24
|
## Install
|
|
6
25
|
|
|
@@ -37,6 +56,53 @@ customer = client.contacts.upsert(
|
|
|
37
56
|
print(customer)
|
|
38
57
|
```
|
|
39
58
|
|
|
59
|
+
## Capture Custom Events
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from contackd_sync import PersonEvent
|
|
63
|
+
|
|
64
|
+
event = client.person_events.track(
|
|
65
|
+
PersonEvent(
|
|
66
|
+
unified_id=customer.id,
|
|
67
|
+
event_name="financing",
|
|
68
|
+
status="applied",
|
|
69
|
+
correlation_id="application-10042",
|
|
70
|
+
amount="10000.00",
|
|
71
|
+
currency="NGN",
|
|
72
|
+
)
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
current_states = client.person_events.latest(customer.id)
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Use `BusinessEvent` with `client.business_events` for B2B actions. Both event resources expose `track`, `track_batch`, `list`, and `latest`; a retry-safe `event_id` is generated when omitted.
|
|
79
|
+
|
|
80
|
+
## Manage Deals
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from contackd_sync import Deal, DealStage, DealTransaction
|
|
84
|
+
|
|
85
|
+
deal = client.deals.create(
|
|
86
|
+
Deal(
|
|
87
|
+
contact_id=customer.id,
|
|
88
|
+
title="Annual facility management plan",
|
|
89
|
+
amount="150000.00",
|
|
90
|
+
currency="NGN",
|
|
91
|
+
)
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
client.deals.update(deal.id, assigned_to_user_id="sales-agent-id")
|
|
95
|
+
won = client.deals.update_stage(
|
|
96
|
+
deal.id,
|
|
97
|
+
DealStage.WON,
|
|
98
|
+
transaction=DealTransaction(external_transaction_id="payment-10042"),
|
|
99
|
+
)
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`client.deals` also provides `get` and `list`. List filters include
|
|
103
|
+
`contact_id`, `lead_id`, `conversation_id`, `stage`, `q`, and
|
|
104
|
+
`assigned_to_user_id`.
|
|
105
|
+
|
|
40
106
|
## Common Customer Push Patterns
|
|
41
107
|
|
|
42
108
|
### 1) Push one new customer
|
|
@@ -139,3 +205,12 @@ Given `base_url = https://api.yourapp.com/api/v1`:
|
|
|
139
205
|
- `POST /contacts|leads|prospects|businesses/bulk`
|
|
140
206
|
- `POST /contacts|leads|prospects|businesses/bulk/upsert`
|
|
141
207
|
- `POST /contacts|leads|prospects|businesses/bulk/delete`
|
|
208
|
+
- `POST /events/person|business`
|
|
209
|
+
- `POST /events/person|business/batch`
|
|
210
|
+
- `GET /events/person|business/{id}`
|
|
211
|
+
- `GET /events/person|business/{id}/latest`
|
|
212
|
+
- `POST /deals`
|
|
213
|
+
- `GET /deals`
|
|
214
|
+
- `GET /deals/{id}`
|
|
215
|
+
- `PATCH /deals/{id}`
|
|
216
|
+
- `PATCH /deals/{id}/stage`
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "contackd-sync"
|
|
7
|
+
version = "0.4.0"
|
|
8
|
+
description = "Sync CRM records and capture immutable person and business events with the Contackd API"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"requests>=2.31.0",
|
|
13
|
+
"urllib3>=2.0.0",
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.9",
|
|
18
|
+
"Programming Language :: Python :: 3.10",
|
|
19
|
+
"Programming Language :: Python :: 3.11",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Programming Language :: Python :: 3.13",
|
|
22
|
+
"Programming Language :: Python :: 3.14",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://example.com/contackd-sync"
|
|
27
|
+
|
|
28
|
+
[tool.setuptools.packages.find]
|
|
29
|
+
where = ["."]
|
|
30
|
+
include = ["contackd_sync*"]
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from importlib.metadata import version
|
|
4
|
+
import json
|
|
5
|
+
from contextlib import contextmanager
|
|
6
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
7
|
+
from threading import Thread
|
|
8
|
+
from urllib.parse import parse_qs, urlparse
|
|
9
|
+
|
|
10
|
+
import contackd_sync
|
|
11
|
+
from contackd_sync import ContackdClient, Lead, LeadStatus, SyncEngine
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def test_cold_lead_without_email_parses_conversation_fields() -> None:
|
|
15
|
+
lead = Lead.from_dict(
|
|
16
|
+
{
|
|
17
|
+
"id": "lead-1",
|
|
18
|
+
"status": "cold",
|
|
19
|
+
"title": "WhatsApp inquiry",
|
|
20
|
+
"display_name": "Ada",
|
|
21
|
+
"conversation_id": "conversation-1",
|
|
22
|
+
"external_thread_id": "+2348012345678",
|
|
23
|
+
"phone": "+2348012345678",
|
|
24
|
+
}
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
assert lead.email is None
|
|
28
|
+
assert lead.status is LeadStatus.COLD
|
|
29
|
+
assert lead.title == "WhatsApp inquiry"
|
|
30
|
+
assert lead.display_name == "Ada"
|
|
31
|
+
assert lead.conversation_id == "conversation-1"
|
|
32
|
+
assert lead.external_thread_id == "+2348012345678"
|
|
33
|
+
assert lead.phone == "+2348012345678"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def test_sdk_version_header_matches_installed_package_version() -> None:
|
|
37
|
+
client = ContackdClient(api_key="test-key", base_url="https://api.example.com")
|
|
38
|
+
|
|
39
|
+
assert client._http.session.headers["X-SDK-Version"] == version("contackd-sync")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class _InMemoryLeadResource:
|
|
43
|
+
def __init__(self, records: list[Lead]):
|
|
44
|
+
self.records = records
|
|
45
|
+
|
|
46
|
+
def list(self, page: int = 1, per_page: int = 100, **filters: object) -> list[Lead]:
|
|
47
|
+
del per_page, filters
|
|
48
|
+
return self.records if page == 1 else []
|
|
49
|
+
|
|
50
|
+
def bulk_upsert(self, records: list[Lead], chunk_size: int = 100) -> None:
|
|
51
|
+
raise AssertionError(f"unexpected push of {len(records)} records in chunks of {chunk_size}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_sync_matches_email_less_leads_by_external_thread_id() -> None:
|
|
55
|
+
remote_a = Lead(
|
|
56
|
+
status=LeadStatus.COLD,
|
|
57
|
+
display_name="Remote Ada",
|
|
58
|
+
external_thread_id="thread-a",
|
|
59
|
+
)
|
|
60
|
+
remote_b = Lead(
|
|
61
|
+
status=LeadStatus.COLD,
|
|
62
|
+
display_name="Remote Grace",
|
|
63
|
+
external_thread_id="thread-b",
|
|
64
|
+
)
|
|
65
|
+
local_a = Lead(
|
|
66
|
+
status=LeadStatus.COLD,
|
|
67
|
+
display_name="Local Ada",
|
|
68
|
+
external_thread_id="thread-a",
|
|
69
|
+
)
|
|
70
|
+
engine = SyncEngine(_InMemoryLeadResource([remote_a, remote_b]), conflict_strategy="remote")
|
|
71
|
+
|
|
72
|
+
result = engine.sync([local_a])
|
|
73
|
+
|
|
74
|
+
assert result.pushed == []
|
|
75
|
+
assert result.remote_only == [remote_b]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@contextmanager
|
|
79
|
+
def _event_api_server():
|
|
80
|
+
requests: list[dict] = []
|
|
81
|
+
|
|
82
|
+
class Handler(BaseHTTPRequestHandler):
|
|
83
|
+
def log_message(self, format: str, *args: object) -> None:
|
|
84
|
+
del format, args
|
|
85
|
+
|
|
86
|
+
def do_POST(self) -> None:
|
|
87
|
+
length = int(self.headers.get("Content-Length", "0"))
|
|
88
|
+
body = json.loads(self.rfile.read(length) or b"{}")
|
|
89
|
+
requests.append({"method": "POST", "path": self.path, "body": body})
|
|
90
|
+
if self.path.endswith("/batch"):
|
|
91
|
+
response = {"created": [], "duplicates": [], "errors": [], "total": len(body["events"])}
|
|
92
|
+
else:
|
|
93
|
+
response = {
|
|
94
|
+
"id": "internal-1",
|
|
95
|
+
**body,
|
|
96
|
+
"actor": {"type": "api_key", "id": "user-1", "email": "apikey:mobile"},
|
|
97
|
+
"source": "sdk",
|
|
98
|
+
"sdk_version": "0.3.0",
|
|
99
|
+
"received_at": "2026-08-28T10:31:00Z",
|
|
100
|
+
}
|
|
101
|
+
encoded = json.dumps(response).encode()
|
|
102
|
+
self.send_response(201)
|
|
103
|
+
self.send_header("Content-Type", "application/json")
|
|
104
|
+
self.send_header("Content-Length", str(len(encoded)))
|
|
105
|
+
self.end_headers()
|
|
106
|
+
self.wfile.write(encoded)
|
|
107
|
+
|
|
108
|
+
def do_GET(self) -> None:
|
|
109
|
+
parsed = urlparse(self.path)
|
|
110
|
+
requests.append({"method": "GET", "path": parsed.path, "query": parse_qs(parsed.query)})
|
|
111
|
+
encoded = json.dumps({"results": [], "page": 2, "per_page": 25, "total": 0}).encode()
|
|
112
|
+
self.send_response(200)
|
|
113
|
+
self.send_header("Content-Type", "application/json")
|
|
114
|
+
self.send_header("Content-Length", str(len(encoded)))
|
|
115
|
+
self.end_headers()
|
|
116
|
+
self.wfile.write(encoded)
|
|
117
|
+
|
|
118
|
+
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
|
119
|
+
thread = Thread(target=server.serve_forever, daemon=True)
|
|
120
|
+
thread.start()
|
|
121
|
+
try:
|
|
122
|
+
yield f"http://127.0.0.1:{server.server_port}", requests
|
|
123
|
+
finally:
|
|
124
|
+
server.shutdown()
|
|
125
|
+
thread.join()
|
|
126
|
+
server.server_close()
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_python_person_events_generate_ids_and_serialize_exact_contract() -> None:
|
|
130
|
+
assert hasattr(contackd_sync, "PersonEvent")
|
|
131
|
+
with _event_api_server() as (base_url, requests):
|
|
132
|
+
client = ContackdClient(api_key="test-key", base_url=base_url)
|
|
133
|
+
event = client.person_events.track(
|
|
134
|
+
contackd_sync.PersonEvent(
|
|
135
|
+
unified_id="contact-1",
|
|
136
|
+
event_name="financing",
|
|
137
|
+
status="applied",
|
|
138
|
+
correlation_id="application-1",
|
|
139
|
+
amount="10000.00",
|
|
140
|
+
currency="NGN",
|
|
141
|
+
properties={"product": "vehicle"},
|
|
142
|
+
occurred_at="2026-08-28T10:30:00Z",
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
assert requests[0]["body"]["event_id"].startswith("evt_")
|
|
147
|
+
assert requests[0]["body"]["unified_id"] == "contact-1"
|
|
148
|
+
assert event.event_id == requests[0]["body"]["event_id"]
|
|
149
|
+
assert event.received_at == "2026-08-28T10:31:00Z"
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_python_business_events_batch_and_latest_filters() -> None:
|
|
153
|
+
assert hasattr(contackd_sync, "BusinessEvent")
|
|
154
|
+
with _event_api_server() as (base_url, requests):
|
|
155
|
+
client = ContackdClient(api_key="test-key", base_url=base_url)
|
|
156
|
+
batch = client.business_events.track_batch(
|
|
157
|
+
[
|
|
158
|
+
contackd_sync.BusinessEvent(
|
|
159
|
+
event_id="evt-order-1",
|
|
160
|
+
business_id="business-1",
|
|
161
|
+
event_name="purchase",
|
|
162
|
+
status="completed",
|
|
163
|
+
)
|
|
164
|
+
]
|
|
165
|
+
)
|
|
166
|
+
latest = client.business_events.latest(
|
|
167
|
+
"business-1",
|
|
168
|
+
page=2,
|
|
169
|
+
per_page=25,
|
|
170
|
+
event_name="purchase",
|
|
171
|
+
correlation_id="order-1",
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
assert batch.total == 1
|
|
175
|
+
assert latest.page == 2
|
|
176
|
+
assert requests[0]["body"]["events"][0]["event_id"] == "evt-order-1"
|
|
177
|
+
assert requests[1]["path"] == "/events/business/business-1/latest"
|
|
178
|
+
assert requests[1]["query"]["event_name"] == ["purchase"]
|
|
179
|
+
assert requests[1]["query"]["correlation_id"] == ["order-1"]
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import contackd_sync
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RecordingHttpClient:
|
|
7
|
+
def __init__(self, responses: list[object]) -> None:
|
|
8
|
+
self.responses = list(responses)
|
|
9
|
+
self.requests: list[tuple[str, str, object]] = []
|
|
10
|
+
|
|
11
|
+
def get(self, path: str, **params: object) -> object:
|
|
12
|
+
self.requests.append(("GET", path, params))
|
|
13
|
+
return self.responses.pop(0)
|
|
14
|
+
|
|
15
|
+
def post(self, path: str, body: dict[str, object]) -> object:
|
|
16
|
+
self.requests.append(("POST", path, body))
|
|
17
|
+
return self.responses.pop(0)
|
|
18
|
+
|
|
19
|
+
def patch(self, path: str, body: dict[str, object]) -> object:
|
|
20
|
+
self.requests.append(("PATCH", path, body))
|
|
21
|
+
return self.responses.pop(0)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_deal_models_serialize_requests_and_parse_responses() -> None:
|
|
25
|
+
assert hasattr(contackd_sync, "Deal")
|
|
26
|
+
assert hasattr(contackd_sync, "DealStage")
|
|
27
|
+
assert hasattr(contackd_sync, "DealTransaction")
|
|
28
|
+
|
|
29
|
+
deal = contackd_sync.Deal(
|
|
30
|
+
contact_id="contact-1",
|
|
31
|
+
title="Annual plan",
|
|
32
|
+
amount="150000.00",
|
|
33
|
+
currency="NGN",
|
|
34
|
+
metadata={"source": "website"},
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
assert deal.to_dict() == {
|
|
38
|
+
"contact_id": "contact-1",
|
|
39
|
+
"title": "Annual plan",
|
|
40
|
+
"amount": "150000.00",
|
|
41
|
+
"currency": "NGN",
|
|
42
|
+
"metadata": {"source": "website"},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
parsed = contackd_sync.Deal.from_dict(
|
|
46
|
+
{
|
|
47
|
+
**deal.to_dict(),
|
|
48
|
+
"id": "deal-1",
|
|
49
|
+
"tenant_id": "tenant-1",
|
|
50
|
+
"stage": "won",
|
|
51
|
+
"transaction": {
|
|
52
|
+
"id": "transaction-1",
|
|
53
|
+
"deal_id": "deal-1",
|
|
54
|
+
"tenant_id": "tenant-1",
|
|
55
|
+
"status": "completed",
|
|
56
|
+
"amount": "150000.00",
|
|
57
|
+
"currency": "NGN",
|
|
58
|
+
"metadata": {},
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
assert parsed.stage is contackd_sync.DealStage.WON
|
|
64
|
+
assert isinstance(parsed.transaction, contackd_sync.DealTransaction)
|
|
65
|
+
assert parsed.transaction.id == "transaction-1"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_deals_resource_creates_gets_and_lists_typed_deals() -> None:
|
|
69
|
+
assert hasattr(contackd_sync, "DealsResource")
|
|
70
|
+
response = {
|
|
71
|
+
"id": "deal-1",
|
|
72
|
+
"tenant_id": "tenant-1",
|
|
73
|
+
"contact_id": "contact-1",
|
|
74
|
+
"title": "Annual plan",
|
|
75
|
+
"stage": "new",
|
|
76
|
+
"metadata": {},
|
|
77
|
+
}
|
|
78
|
+
http = RecordingHttpClient([response, response, [response]])
|
|
79
|
+
resource = contackd_sync.DealsResource(http)
|
|
80
|
+
|
|
81
|
+
created = resource.create(
|
|
82
|
+
contackd_sync.Deal(contact_id="contact-1", title="Annual plan")
|
|
83
|
+
)
|
|
84
|
+
fetched = resource.get("deal-1")
|
|
85
|
+
listed = resource.list(limit=25, offset=10, stage="new")
|
|
86
|
+
|
|
87
|
+
assert created.id == "deal-1"
|
|
88
|
+
assert fetched.id == "deal-1"
|
|
89
|
+
assert [deal.id for deal in listed] == ["deal-1"]
|
|
90
|
+
assert http.requests == [
|
|
91
|
+
(
|
|
92
|
+
"POST",
|
|
93
|
+
"deals",
|
|
94
|
+
{"contact_id": "contact-1", "title": "Annual plan"},
|
|
95
|
+
),
|
|
96
|
+
("GET", "deals/deal-1", {}),
|
|
97
|
+
("GET", "deals", {"limit": 25, "offset": 10, "stage": "new"}),
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def test_deals_resource_updates_details_and_stage_with_patch() -> None:
|
|
102
|
+
response = {
|
|
103
|
+
"id": "deal-1",
|
|
104
|
+
"tenant_id": "tenant-1",
|
|
105
|
+
"contact_id": "contact-1",
|
|
106
|
+
"title": "Renewed plan",
|
|
107
|
+
"stage": "won",
|
|
108
|
+
"metadata": {},
|
|
109
|
+
}
|
|
110
|
+
http = RecordingHttpClient([response, response])
|
|
111
|
+
resource = contackd_sync.DealsResource(http)
|
|
112
|
+
|
|
113
|
+
updated = resource.update(
|
|
114
|
+
"deal-1",
|
|
115
|
+
title="Renewed plan",
|
|
116
|
+
assigned_to_user_id=None,
|
|
117
|
+
)
|
|
118
|
+
won = resource.update_stage(
|
|
119
|
+
"deal-1",
|
|
120
|
+
contackd_sync.DealStage.WON,
|
|
121
|
+
transaction=contackd_sync.DealTransaction(
|
|
122
|
+
external_transaction_id="payment-1",
|
|
123
|
+
amount="150000.00",
|
|
124
|
+
currency="NGN",
|
|
125
|
+
),
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
assert updated.title == "Renewed plan"
|
|
129
|
+
assert won.stage is contackd_sync.DealStage.WON
|
|
130
|
+
assert http.requests == [
|
|
131
|
+
(
|
|
132
|
+
"PATCH",
|
|
133
|
+
"deals/deal-1",
|
|
134
|
+
{"title": "Renewed plan", "assigned_to_user_id": None},
|
|
135
|
+
),
|
|
136
|
+
(
|
|
137
|
+
"PATCH",
|
|
138
|
+
"deals/deal-1/stage",
|
|
139
|
+
{
|
|
140
|
+
"stage": "won",
|
|
141
|
+
"transaction": {
|
|
142
|
+
"external_transaction_id": "payment-1",
|
|
143
|
+
"status": "completed",
|
|
144
|
+
"amount": "150000.00",
|
|
145
|
+
"currency": "NGN",
|
|
146
|
+
},
|
|
147
|
+
},
|
|
148
|
+
),
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_client_exposes_deals_resource() -> None:
|
|
153
|
+
client = contackd_sync.ContackdClient(
|
|
154
|
+
api_key="test-key",
|
|
155
|
+
base_url="https://api.example.com/api/v1",
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
assert isinstance(client.deals, contackd_sync.DealsResource)
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
[build-system]
|
|
2
|
-
requires = ["setuptools>=68", "wheel"]
|
|
3
|
-
build-backend = "setuptools.build_meta"
|
|
4
|
-
|
|
5
|
-
[project]
|
|
6
|
-
name = "contackd-sync"
|
|
7
|
-
version = "0.3.0"
|
|
8
|
-
description = "Python SDK for Contackd Sync"
|
|
9
|
-
readme = "README.md"
|
|
10
|
-
requires-python = ">=3.10"
|
|
11
|
-
dependencies = [
|
|
12
|
-
"requests>=2.31.0",
|
|
13
|
-
"urllib3>=2.0.0",
|
|
14
|
-
]
|
|
15
|
-
|
|
16
|
-
[project.urls]
|
|
17
|
-
Homepage = "https://example.com/contackd-sync"
|
|
18
|
-
|
|
19
|
-
[tool.setuptools.packages.find]
|
|
20
|
-
where = ["."]
|
|
21
|
-
include = ["contackd_sync*"]
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
from importlib.metadata import version
|
|
2
|
-
|
|
3
|
-
from contackd_sync import ContackdClient, Lead, LeadStatus, SyncEngine
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
def test_cold_lead_without_email_parses_conversation_fields() -> None:
|
|
7
|
-
lead = Lead.from_dict(
|
|
8
|
-
{
|
|
9
|
-
"id": "lead-1",
|
|
10
|
-
"status": "cold",
|
|
11
|
-
"title": "WhatsApp inquiry",
|
|
12
|
-
"display_name": "Ada",
|
|
13
|
-
"conversation_id": "conversation-1",
|
|
14
|
-
"external_thread_id": "+2348012345678",
|
|
15
|
-
"phone": "+2348012345678",
|
|
16
|
-
}
|
|
17
|
-
)
|
|
18
|
-
|
|
19
|
-
assert lead.email is None
|
|
20
|
-
assert lead.status is LeadStatus.COLD
|
|
21
|
-
assert lead.title == "WhatsApp inquiry"
|
|
22
|
-
assert lead.display_name == "Ada"
|
|
23
|
-
assert lead.conversation_id == "conversation-1"
|
|
24
|
-
assert lead.external_thread_id == "+2348012345678"
|
|
25
|
-
assert lead.phone == "+2348012345678"
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def test_sdk_version_header_matches_installed_package_version() -> None:
|
|
29
|
-
client = ContackdClient(api_key="test-key", base_url="https://api.example.com")
|
|
30
|
-
|
|
31
|
-
assert client._http.session.headers["X-SDK-Version"] == version("contackd-sync")
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
class _InMemoryLeadResource:
|
|
35
|
-
def __init__(self, records: list[Lead]):
|
|
36
|
-
self.records = records
|
|
37
|
-
|
|
38
|
-
def list(self, page: int = 1, per_page: int = 100, **filters: object) -> list[Lead]:
|
|
39
|
-
del per_page, filters
|
|
40
|
-
return self.records if page == 1 else []
|
|
41
|
-
|
|
42
|
-
def bulk_upsert(self, records: list[Lead], chunk_size: int = 100) -> None:
|
|
43
|
-
raise AssertionError(f"unexpected push of {len(records)} records in chunks of {chunk_size}")
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def test_sync_matches_email_less_leads_by_external_thread_id() -> None:
|
|
47
|
-
remote_a = Lead(
|
|
48
|
-
status=LeadStatus.COLD,
|
|
49
|
-
display_name="Remote Ada",
|
|
50
|
-
external_thread_id="thread-a",
|
|
51
|
-
)
|
|
52
|
-
remote_b = Lead(
|
|
53
|
-
status=LeadStatus.COLD,
|
|
54
|
-
display_name="Remote Grace",
|
|
55
|
-
external_thread_id="thread-b",
|
|
56
|
-
)
|
|
57
|
-
local_a = Lead(
|
|
58
|
-
status=LeadStatus.COLD,
|
|
59
|
-
display_name="Local Ada",
|
|
60
|
-
external_thread_id="thread-a",
|
|
61
|
-
)
|
|
62
|
-
engine = SyncEngine(_InMemoryLeadResource([remote_a, remote_b]), conflict_strategy="remote")
|
|
63
|
-
|
|
64
|
-
result = engine.sync([local_a])
|
|
65
|
-
|
|
66
|
-
assert result.pushed == []
|
|
67
|
-
assert result.remote_only == [remote_b]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|