Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 65 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,8 +178,12 @@ chartmogul.Customer.disconnectSubscriptions(config, uuid='cus_5915ee5a-babd-406b
})
chartmogul.Customer.contacts(config, uuid='cus_5915ee5a-babd-406b-b8ce-d207133fb4cb', cursor='aabbcc', per_page=20)
chartmogul.Customer.createContact(config, uuid='cus_5915ee5a-babd-406b-b8ce-d207133fb4cb', data={})
# DEPRECATED: use chartmogul.Customer.entityNotes() instead
chartmogul.Customer.notes(config, uuid='cus_5915ee5a-babd-406b-b8ce-d207133fb4cb', cursor='aabbcc', per_page=20)
# DEPRECATED: use chartmogul.Customer.createEntityNote() instead
chartmogul.Customer.createNote(config, uuid='cus_5915ee5a-babd-406b-b8ce-d207133fb4cb', data={})
chartmogul.Customer.entityNotes(config, uuid='cus_5915ee5a-babd-406b-b8ce-d207133fb4cb', cursor='aabbcc', per_page=20)
chartmogul.Customer.createEntityNote(config, uuid='cus_5915ee5a-babd-406b-b8ce-d207133fb4cb', data={})
chartmogul.Customer.opportunities(config, uuid='cus_5915ee5a-babd-406b-b8ce-d207133fb4cb', cursor='aabbcc', per_page=20)
chartmogul.Customer.createOpportunity(config, uuid='cus_5915ee5a-babd-406b-b8ce-d207133fb4cb', data={})
chartmogul.Customer.tasks(config, uuid='cus_5915ee5a-babd-406b-b8ce-d207133fb4cb', cursor='aabbcc', per_page=20)
Expand All @@ -190,17 +194,55 @@ chartmogul.Customer.createTask(config, uuid='cus_5915ee5a-babd-406b-b8ce-d207133

```python
chartmogul.Contact.create(config, data={})
chartmogul.Contact.all(config, cursor='aabbcc', per_page=20)
chartmogul.Contact.all(config, cursor='aabbcc', per_page=20, customer_uuid='cus_5915ee5a-babd-406b-b8ce-d207133fb4cb', data_source_uuid='ds_fef05d54-47b4-431b-aed2-eb6b9e545430', email='test@example.com', customer_external_id='cus_0001', external_id='con_0001')
chartmogul.Contact.retrieve(config, uuid='con_5915ee5a-babd-406b-b8ce-d207133fb4cb')
chartmogul.Contact.merge(config, into_uuid='con_5915ee5a-babd-406b-b8ce-d207133fb4cb', from_uuid='con_2123290f-09c8-4628-a205-db5596bd58f7')
chartmogul.Contact.modify(config, uuid='con_5915ee5a-babd-406b-b8ce-d207133fb4cb', data={
"email": "test@example.com"
"email": "test@example.com",
"last_seen": "2025-04-01T12:00:00Z"
})
chartmogul.Contact.destroy(config, uuid='con_5915ee5a-babd-406b-b8ce-d207133fb4cb')
chartmogul.Contact.tasks(config, uuid='con_5915ee5a-babd-406b-b8ce-d207133fb4cb', cursor='aabbcc', per_page=20)
chartmogul.Contact.createTask(config, uuid='con_5915ee5a-babd-406b-b8ce-d207133fb4cb', data={})
chartmogul.Contact.entityNotes(config, uuid='con_5915ee5a-babd-406b-b8ce-d207133fb4cb', cursor='aabbcc', per_page=20)
chartmogul.Contact.createEntityNote(config, uuid='con_5915ee5a-babd-406b-b8ce-d207133fb4cb', data={})
```

#### [Customer Notes](https://dev.chartmogul.com/reference/notes-and-call-logs/)
`Contact.createTask` and `Contact.createEntityNote` add an `associated_object_identifier` pointing at the contact unless the payload already contains `customer_uuid` or `associated_object_identifier`.

#### [Notes](https://dev.chartmogul.com/reference/notes-and-call-logs/)

Notes and call logs attached to a customer or a contact. Replaces Customer Notes.

```python
chartmogul.EntityNote.create(config, data={
"customer_uuid": "cus_5915ee5a-babd-406b-b8ce-d207133fb4cb",
"type": "note",
"text": "This is a note",
"author_email": "john@example.com"
})
chartmogul.EntityNote.create(config, data={
"associated_object_identifier": {
"associated_object": "contact",
"method": "uuid",
"value": "con_5915ee5a-babd-406b-b8ce-d207133fb4cb"
},
"type": "call",
"call_duration": 60,
"text": "This is a call log"
})
chartmogul.EntityNote.all(config, cursor='aabbcc', per_page=20, customer_uuid='cus_5915ee5a-babd-406b-b8ce-d207133fb4cb')
chartmogul.EntityNote.all(config, contact_uuid='con_5915ee5a-babd-406b-b8ce-d207133fb4cb', type='call', author_email='john@example.com')
chartmogul.EntityNote.retrieve(config, uuid='note_5915ee5a-babd-406b-b8ce-d207133fb4cb')
chartmogul.EntityNote.patch(config, uuid='note_5915ee5a-babd-406b-b8ce-d207133fb4cb', data={"text": "Updated text"})
chartmogul.EntityNote.destroy(config, uuid='note_5915ee5a-babd-406b-b8ce-d207133fb4cb')
```

`EntityNote.patch` returns `None` when the API reports nothing was updated (HTTP 304).

#### [Customer Notes (deprecated)](https://dev.chartmogul.com/reference/notes-and-call-logs/)
```python
# DEPRECATED: use chartmogul.EntityNote instead
chartmogul.CustomerNote.create(config, data={})
chartmogul.CustomerNote.all(config, cursor='aabbcc', per_page=20, customer_uuid='cus_5915ee5a-babd-406b-b8ce-d207133fb4cb')
chartmogul.CustomerNote.retrieve(config, uuid='note_5915ee5a-babd-406b-b8ce-d207133fb4cb')
Expand All @@ -221,13 +263,31 @@ chartmogul.Opportunity.destroy(config, uuid='5915ee5a-babd-406b-b8ce-d207133fb4c
#### [Tasks](https://dev.chartmogul.com/reference/tasks/)

```python
chartmogul.Task.create(config, data={})
chartmogul.Task.create(config, data={
"customer_uuid": "cus_5915ee5a-babd-406b-b8ce-d207133fb4cb",
"task_details": "Follow up on renewal",
"assignee": "john@example.com",
"due_date": "2025-04-30T00:00:00Z"
})
chartmogul.Task.create(config, data={
"associated_object_identifier": {
"associated_object": "contact",
"method": "uuid",
"value": "con_5915ee5a-babd-406b-b8ce-d207133fb4cb"
},
"task_details": "Follow up on renewal",
"assignee": "john@example.com",
"due_date": "2025-04-30T00:00:00Z"
})
chartmogul.Task.all(config, cursor='aabbcc', per_page=20, customer_uuid='cus_5915ee5a-babd-406b-b8ce-d207133fb4cb')
chartmogul.Task.all(config, contact_uuid='con_5915ee5a-babd-406b-b8ce-d207133fb4cb', assignee='john@example.com', due_date_on_or_after='2025-04-01T00:00:00Z', due_date_on_or_before='2025-04-30T00:00:00Z', completed=False)
chartmogul.Task.retrieve(config, uuid='5915ee5a-babd-406b-b8ce-d207133fb4cb')
chartmogul.Task.patch(config, uuid='5915ee5a-babd-406b-b8ce-d207133fb4cb')
chartmogul.Task.patch(config, uuid='5915ee5a-babd-406b-b8ce-d207133fb4cb', data={"completed_at": "2025-04-20T00:00:00Z"})
chartmogul.Task.destroy(config, uuid='5915ee5a-babd-406b-b8ce-d207133fb4cb')
```

Date filters take ISO 8601 strings. `Task.patch` returns `None` when the API reports nothing was updated (HTTP 304).

#### [Customer Attributes](https://dev.chartmogul.com/reference/customers/attributes/)

Note that the returned attributes of type date are not parsed and stay in string.
Expand Down
1 change: 1 addition & 0 deletions chartmogul/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from .api.custom_attrs import CustomAttributes
from .api.customer import Customer
from .api.customer_note import CustomerNote
from .api.entity_note import EntityNote
from .api.contact import Contact
from .api.data_source import DataSource
from .api.invoice import Invoice
Expand Down
17 changes: 16 additions & 1 deletion chartmogul/api/contact.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
from marshmallow import Schema, fields, post_load, EXCLUDE
from ..resource import Resource
from ..resource import ASSOCIATED_OBJECT_CONTACT, Resource
from collections import namedtuple
from .entity_note import EntityNote
from .task import Task


class Contact(Resource):
Expand Down Expand Up @@ -29,6 +31,7 @@ class _Schema(Schema):
# load_default=None ensures this attribute is always present on the Contact
# object even when the API omits the field (e.g. older responses)
external_id = fields.String(allow_none=True, load_default=None)
last_seen = fields.DateTime(allow_none=True)
custom = fields.Dict(allow_none=True)

@post_load
Expand All @@ -39,3 +42,15 @@ def make(self, data, **kwargs):


Contact.merge = Contact._method("merge", "post", "/contacts/{into_uuid}/merge/{from_uuid}")
Contact.tasks = Task._method("all", "get", "/tasks?contact_uuid={uuid}", useCallerClass=True)
Contact.createTask = Task._method(
"create", "post", "/tasks", useCallerClass=True,
useAssociatedObjectFor=ASSOCIATED_OBJECT_CONTACT
)
Contact.entityNotes = EntityNote._method(
"all", "get", "/notes?contact_uuid={uuid}", useCallerClass=True
)
Contact.createEntityNote = EntityNote._method(
"create", "post", "/notes", useCallerClass=True,
useAssociatedObjectFor=ASSOCIATED_OBJECT_CONTACT
)
15 changes: 12 additions & 3 deletions chartmogul/api/customer.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,12 @@
Validation is done on server.
"""
from marshmallow import Schema, fields, post_load, EXCLUDE
from ..resource import DataObject, Resource
from ..resource import DataObject, Resource, _deprecated_method
from collections import namedtuple
from .attributes import Attributes
from .contact import Contact
from .customer_note import CustomerNote
from .entity_note import EntityNote
from .opportunity import Opportunity
from .task import Task
from .customers.subscription import CustomerSubscription
Expand Down Expand Up @@ -94,8 +95,16 @@ def make(self, data, **kwargs):
Customer.createContact = Contact._method(
"create", "post", "/customers/{uuid}/contacts", useCallerClass=True
)
Customer.notes = CustomerNote._method("all", "get", "/customer_notes?customer_uuid={uuid}", useCallerClass=True)
Customer.createNote = CustomerNote._method("create", "post", "/customer_notes", useCallerClass=True, useUUIDFor="customer_uuid")
Customer.notes = _deprecated_method(
CustomerNote._method("all", "get", "/customer_notes?customer_uuid={uuid}", useCallerClass=True),
"Customer.notes() is deprecated. Use Customer.entityNotes() instead.",
)
Customer.createNote = _deprecated_method(
CustomerNote._method("create", "post", "/customer_notes", useCallerClass=True, useUUIDFor="customer_uuid"),
"Customer.createNote() is deprecated. Use Customer.createEntityNote() instead.",
)
Customer.entityNotes = EntityNote._method("all", "get", "/notes?customer_uuid={uuid}", useCallerClass=True)
Customer.createEntityNote = EntityNote._method("create", "post", "/notes", useCallerClass=True, useUUIDFor="customer_uuid")
Customer.opportunities = Opportunity._method("all", "get", "/opportunities?customer_uuid={uuid}", useCallerClass=True)
Customer.createOpportunity = Opportunity._method("create", "post", "/opportunities", useCallerClass=True, useUUIDFor="customer_uuid")
Customer.tasks = Task._method("all", "get", "/tasks?customer_uuid={uuid}", useCallerClass=True)
Expand Down
22 changes: 21 additions & 1 deletion chartmogul/api/customer_note.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
from marshmallow import Schema, fields, post_load, EXCLUDE
from ..resource import Resource
from ..resource import Resource, _deprecated_method
from collections import namedtuple


class CustomerNote(Resource):
"""
https://dev.chartmogul.com/v1.0/reference#customer-notes

DEPRECATED: use EntityNote (/v1/notes) instead.
"""

_path = "/customer_notes{/uuid}"
Expand All @@ -27,3 +29,21 @@ def make(self, data, **kwargs):
return CustomerNote(**data)

_schema = _Schema(unknown=EXCLUDE)


for _name, _verb in [
("all", "get"),
("create", "post"),
("retrieve", "get"),
("modify", "patch"),
("patch", "patch"),
("destroy", "delete"),
]:
setattr(
CustomerNote,
_name,
_deprecated_method(
CustomerNote._method(_name, _verb),
"CustomerNote.%s() is deprecated. Use EntityNote.%s() instead." % (_name, _name),
),
)
35 changes: 35 additions & 0 deletions chartmogul/api/entity_note.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
from marshmallow import Schema, fields, post_load, EXCLUDE
from ..resource import Resource
from collections import namedtuple


class EntityNote(Resource):
"""
https://dev.chartmogul.com/reference/notes-and-call-logs/

Notes and call logs attached to a customer or a contact (/v1/notes).
Supersedes CustomerNote (/v1/customer_notes).
"""

_path = "/notes{/uuid}"
_root_key = "entries"
_many = namedtuple("EntityNotes", [_root_key, "has_more", "cursor"], defaults=[None, None])

class _Schema(Schema):
uuid = fields.String()
customer_uuid = fields.String(allow_none=True)
# load_default=None keeps these attributes present even when the API omits them
associated_object = fields.String(allow_none=True, load_default=None)
associated_object_uuid = fields.String(allow_none=True, load_default=None)
type = fields.String()
text = fields.String()
author = fields.String()
call_duration = fields.Int()
created_at = fields.DateTime()
updated_at = fields.DateTime()

@post_load
def make(self, data, **kwargs):
return EntityNote(**data)

_schema = _Schema(unknown=EXCLUDE)
6 changes: 5 additions & 1 deletion chartmogul/api/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,15 @@ class Task(Resource):
_path = "/tasks{/uuid}"
_root_key = "entries"
_many = namedtuple("Tasks", [_root_key, "has_more", "cursor"])
_bool_query_params = ["completed"]

class _Schema(Schema):

task_uuid = fields.String()
customer_uuid = fields.String()
customer_uuid = fields.String(allow_none=True)
# load_default=None keeps these attributes present even when the API omits them
associated_object = fields.String(allow_none=True, load_default=None)
associated_object_uuid = fields.String(allow_none=True, load_default=None)
assignee = fields.String()
task_details = fields.String()
due_date = fields.DateTime()
Expand Down
40 changes: 38 additions & 2 deletions chartmogul/resource.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import warnings
from datetime import date, datetime
from json import dumps

Expand Down Expand Up @@ -36,6 +37,10 @@

LIST_PARAMS = ["has_more", "summary", "customer_uuid", "data_source_uuid", "cursor"]
ESCAPED_QUERY_KEYS = {"start_date": "start-date", "end_date": "end-date"}
EMPTY_BODY_STATUS_CODES = (202, 204, 304)

ASSOCIATED_OBJECT_CONTACT = "contact"
ASSOCIATED_OBJECT_IDENTIFIER_METHOD_UUID = "uuid"


class DataObject(object):
Expand Down Expand Up @@ -96,7 +101,7 @@ def _load(cls, response):
(immutable, therefore all set on creation by generator)
"""
response.raise_for_status()
if response.status_code == 204 or response.status_code == 202:
if response.status_code in EMPTY_BODY_STATUS_CODES:
return None
try:
jsonObj = response.json()
Expand Down Expand Up @@ -200,7 +205,8 @@ def _validate_arguments(cls, method, kwargs):
)

@classmethod
def _method(callerClass, method, http_verb, path=None, useCallerClass=False, useUUIDFor=None):
def _method(callerClass, method, http_verb, path=None, useCallerClass=False, useUUIDFor=None,
useAssociatedObjectFor=None):
@classmethod
def fc(calleeClass, config, **kwargs):
if config is None or not isinstance(config, Config):
Expand Down Expand Up @@ -244,6 +250,9 @@ def fc(calleeClass, config, **kwargs):
if useUUIDFor is not None and 'data' in kwargs.keys():
kwargs["data"][useUUIDFor] = kwargs["uuid"]

if useAssociatedObjectFor is not None and 'data' in kwargs.keys():
_inject_associated_object(kwargs["data"], useAssociatedObjectFor, kwargs["uuid"])

# UUID is always path parameter only.
if "uuid" in kwargs:
del kwargs["uuid"]
Expand All @@ -268,6 +277,33 @@ def _add_method(cls, method, http_verb, path=None):
_add_method(Resource, method, http_verb)


def _deprecated_method(original, message):
"""
Wraps a method built by Resource._method so it emits a DeprecationWarning
before delegating to the original.
"""
@classmethod
def fc(cls, config, **kwargs):
warnings.warn(message, DeprecationWarning, stacklevel=2)
return original.__func__(cls, config, **kwargs)

return fc


def _inject_associated_object(data, associated_object, uuid):
"""
Points a create payload at its parent object unless the caller already
identified it via customer_uuid or associated_object_identifier.
"""
if "customer_uuid" in data or "associated_object_identifier" in data:
return
data["associated_object_identifier"] = {
"associated_object": associated_object,
"method": ASSOCIATED_OBJECT_IDENTIFIER_METHOD_UUID,
"value": uuid,
}


def _build_ext_id_params(kwargs):
"""Build query params dict for external-id-based operations.

Expand Down
12 changes: 12 additions & 0 deletions test/api/test_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,18 @@ def test_page_parameter_deprecated(self, mock_requests):
else:
self.fail("DeprecatedArgumentError not raised")

@requests_mock.mock()
def test_not_modified_returns_none(self, mock_requests):
mock_requests.register_uri(
"PATCH",
"https://api.chartmogul.com/v1/data_sources/my_uuid",
status_code=304,
)

config = Config("token")
result = DataSource.modify(config, uuid="my_uuid", data={}).get()
self.assertEqual(result, None)

@requests_mock.mock()
def test_api_incorrect(self, mock_requests):
mock_requests.register_uri(
Expand Down
Loading