Data model
Most other Sugar clients return data queried from the server – for example a list of records – more or less exactly as they receive it from the server. That means that all fields from the server will be made available, without further processing. While this has some benefits (like being able to quickly evaluate the available data over a large number of modules), Zucker takes a bit of a different approach.
In order to interact with records from the CRM system, you must first define a data model. This is basically a one-to-one copy of the content structure that the server defines into special Python classes, much like other ORM libraries do. Once this definition has been created, these classes are used to interact with the server. Using this approach has a number of benefits:
Records are now rich Python objects which can have their own methods for further data processing, instead of plain dictionaries.
Filtering statements can be expressed in a more Pythonic way.
The model can be validated against the current server schema, which ensures that we are always working against a known state.
Support for static typing using the
typing
module.
Defining modules
To define a module, you need to create a subclass of Zucker’s
BaseModule
base class. This is done by using
either SyncModule
or AsyncModule
as the superclass, depending on the client implementation. This type of module
is also referred to as a bound module because it is fixed to the client it is
initialized with. Inside your class, define fields with the same name as they
appear in the API:
from zucker import model
crm = SomeClient(...)
# Here, the 'Contact' module is bound to the 'crm' client:
class Contact(model.SyncModule, client=crm, api_name="Contacts"):
first_name = model.StringField()
lead_source = model.StringField()
phone_mobile = model.StringField()
phone_work = model.StringField()
email_opt_out = model.BooleanField()
If you in an asynchronous environment, use model.AsyncModule
instead. Note
that it is not possible (and not supported) to mix synchronous models with
asynchronous clients or vice-versa.
Extending the model
Make sure to use the correct field types, depending on the server’s database schema. You don’t need to recreate the entire model here, either – only defining the fields you will actually use is encouraged for two reasons:
Changes to other parts of the data model won’t impact your implementation if you are ignoring the fields (by not defining them).
Zucker will evaluate the list of fields you have defined and only fetch the relevant data, decreasing the total amount of bandwidth needed.
You can name the class whatever you want, but make sure the name in the generic
(square brackets) matches the name of the class. In case you are not using
Python typing
, you can also leave out the generic entirely.
If the class has a different name as the corresponding Sugar module, you need to provide the latter as an api_name parameter. This will mostly be the case for plural naming and Sugar and singular class names in Python models.
Since this module is a normal Python class, you can also define your own methods, which will be available to use:
class Contact(...):
...
@property
def actual_phone(self) -> Optional[str]:
return self.phone_mobile or self.phone_work or None
Now, all contact objects have a computed property actual_phone
, which will
refer to the first defined phone number.
Reusing models with multiple clients
Having multiple clients can be beneficial if you are connecting to different CRM
servers at the same time. It may also occur that multiple projects need to
access the same Sugar instance. In order to minimize duplicate code, model
base classes can be created and used as additional superclasses when defining
a client-bound module. To aid with this pattern, Zucker provides an
UnboundModule
class. As the name already implies, these
modules are referred to as unbound because they don’t belong to a specific
client yet.
from zucker import model, RequestsClient, AioClient
class BaseContact(model.UnboundModule):
first_name = model.StringField()
lead_source = model.StringField()
phone_mobile = model.StringField()
phone_work = model.StringField()
@property
def actual_phone(self) -> Optional[str]:
return self.phone_mobile or self.phone_work or None
alpha_crm = RequestsClient("https://alpha.example.com", "zucker", "password")
beta_crm = AioClient("https://alpha.example.com", "zucker", "wordpass")
class AlphaContact(model.SyncModule, BaseContact, client=alpha_crm, api_name="Contacts"):
pass
class BetaContact(model.AsyncModule, BaseContact, client=beta_crm, api_name="Contacts"):
# This field will only be present on BetaContact instances:
email_opt_out = model.BooleanField()
In the example above, contacts from the alpha CRM will be synchronous and those from the other client will be asynchronous. This can be helpful when you share the base models in multiple codebases.
API Reference
All bound modules define the following API. You don’t need to implement the abstract methods, as their implementation is already provided when you use the synchronous or asynchronous modules as a superclass.
- class zucker.model.module.BoundModule(**data: JsonType)
Bound modules are module classes are already scoped to a client and therefore also to the sync or async paradigm.
Bound modules contain all the logic that enables server-side communication. That means that bound records (records are the instances of module classes) can be saved, refreshed and deleted.
- classmethod get_client() ClientType
Client instance bounded to this module.
This client will be used for all server-side communication. Further, any caches are scoped to this client.
- property client: ClientType
Shorthand for
get_client()
.
- __eq__(other: Any) bool
Test if this record reference is equal to another.
Records are treated equal if their module and id match. When comparing two records that could potentially be in different modules, always compare the Record objects directly and not just their id properties.
- abstract save() Union[None, Awaitable[None]]
Save all updated fields back to the server.
- abstract delete() Union[None, Awaitable[None]]
Delete this record.
Calling this method will delete the record on the server and remove the ID information from this module record instance. Subsequent calls to
save()
will create a new record.
- abstract refresh(*, _record_data: Optional[JsonMapping] = None) Union[None, Awaitable[None]]
Refresh cached data from the server.
This will clear any updated data that has been manually saved and not set yet.
- abstract classmethod find(*filters: Union[JsonMapping, GenericFilter]) View[BoundSelf, Union[BoundSelf, Awaitable[BoundSelf]], Union[Optional[BoundSelf], Awaitable[Optional[BoundSelf]]]]
Create a view on the module.
Any parameters passed here will be used as filters.
- abstract classmethod get_by_id(key: str) Union[BoundSelf, None, Awaitable[Optional[BoundSelf]]]
Retrieve a record object by the ID.
Using the code generation pipeline
Instead of to manually defining modules, you can also make use of Zucker’s code generation system that will use Sugar’s metadata API to find out which fields are supported in the ORM.
Note
Please remember that the code generated by these features should be treated as a guideline and may not be suitable for all cases (many fields aren’t supported yet). Also note that this system is still under development.
Inspecting
Before actually generating Python code, use the inspect
command to narrow
down search results and find those you actually need. It works like this:
python -m zucker.codegen -b "https://crm.example.com" -u "admin" -P inspect
This will output a list of all modules Zucker can find, with their corresponding
fields. See python -m zucker.codegen -h
for more options.