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:

  1. Changes to other parts of the data model won’t impact your implementation if you are ignoring the fields (by not defining them).

  2. 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.