projectal.entity API documentation

# projectal.entity

The base Entity class that all entities inherit from.

View Source

       1"""
       2The base Entity class that all entities inherit from.
       3"""
       4import copy
       5import logging
       6import sys
       7
       8import projectal
       9from projectal import api
      10
      11
      12class Entity(dict):
      13    """
      14    The parent class for all our entities, offering requests
      15    and validation for the fundamental create/read/update/delete
      16    operations.
      17
      18    This class (and all our entities) inherit from the builtin
      19    `dict` class. This means all entity classes can be used
      20    like standard Python dictionary objects, but we can also
      21    offer additional utility functions that operate on the
      22    instance itself (see `linkers` for an example). Any method
      23    that expects a `dict` can also consume an `Entity` subclass.
      24
      25    The class methods in this class can operate on one or more
      26    entities in one request. If the methods are called with
      27    lists (for batch operation), the output returned will also
      28    be a list. Otherwise, a single `Entity` subclass is returned.
      29
      30    Note for batch operations: a `ProjectalException` is raised
      31    if *any* of the entities fail during the operation. The
      32    changes will *still be saved to the database for the entities
      33    that did not fail*.
      34    """
      35
      36    #: Child classes must override these with their entity names
      37    _path = 'entity'  # URL portion to api
      38    _name = 'entity'
      39
      40    # And to which entities they link to
      41    _links = []
      42    _links_reverse = []
      43
      44    def __init__(self, data):
      45        dict.__init__(self, data)
      46        self._is_new = True
      47        self._link_def_by_key = {}
      48        self._link_def_by_name = {}
      49        self._create_link_defs()
      50        self._with_links = set()
      51
      52        self.__fetch = self.get
      53        self.get = self.__get
      54        self.update = self.__update
      55        self.delete = self.__delete
      56        self.history = self.__history
      57        self.__old = copy.deepcopy(self)
      58        self.__type_links()
      59
      60    # ----- LINKING -----
      61
      62    def _create_link_defs(self):
      63        for cls in self._links:
      64            self._add_link_def(cls)
      65        for cls in self._links_reverse:
      66            self._add_link_def(cls, reverse=True)
      67
      68    def _add_link_def(self, cls, reverse=False):
      69        """
      70        Each entity is accompanied by a dict with details about how to
      71        get access to the data of the link within the object. Subclasses
      72        can pass in customizations to this dict when their APIs differ.
      73
      74        reverse denotes a reverse linker, where extra work is done to
      75        reverse the relationship of the link internally so that it works.
      76        The backend only offers one side of the relationship.
      77        """
      78        d = {
      79            'name': cls._link_name,
      80            'link_key': cls._link_key or cls._link_name + 'List',
      81            'data_name': cls._link_data_name,
      82            'type': cls._link_type,
      83            'entity': cls._link_entity or cls._link_name.capitalize(),
      84            'reverse': reverse
      85        }
      86        self._link_def_by_key[d['link_key']] = d
      87        self._link_def_by_name[d['name']] = d
      88
      89    def _add_link(self, to_entity_name, to_link):
      90        self._link(to_entity_name, to_link, 'add', batch_linking=False)
      91
      92    def _update_link(self,to_entity_name, to_link):
      93        self._link(to_entity_name, to_link, 'update', batch_linking=False)
      94
      95    def _delete_link(self, to_entity_name, to_link):
      96        self._link(to_entity_name, to_link, 'delete', batch_linking=False)
      97
      98    def _link(self, to_entity_name, to_link, operation, update_cache=True, batch_linking=True):
      99        """
     100        `to_entity_name`: Destination entity name (e.g. 'staff')
     101
     102        `to_link`: List of Entities of the same type (and optional data) to link to
     103
     104        `operation`: `add`, `update`, `delete`
     105
     106        'update_cache': also modify the entity's internal representation of the links
     107        to match the operation that was done. Set this to False when replacing the
     108        list with a new one (i.e., when calling save() instead of a linker method).
     109
     110        'batch_linking': Enabled by default, batches any link
     111        updates required into composite API requests. If disabled
     112        a request will be executed for each link update.
     113        Recommended to leave enabled to increase performance.
     114        """
     115
     116        link_def = self._link_def_by_name[to_entity_name]
     117        to_key = link_def['link_key']
     118
     119        if isinstance(to_link, dict) and link_def['type'] == list:
     120            # Convert input dict to list when link type is a list (we allow linking to single entity for convenience)
     121            to_link = [to_link]
     122
     123            # For cases where user passed in dict instead of Entity, we turn them into
     124            # Entity on their behalf.
     125            typed_list = []
     126            target_cls = getattr(sys.modules['projectal.entities'], link_def['entity'])
     127            for link in to_link:
     128                if not isinstance(link, target_cls):
     129                    typed_list.append(target_cls(link))
     130                else:
     131                    typed_list.append(link)
     132            to_link = typed_list
     133        else:
     134            # For everything else, we expect types to match.
     135            if not isinstance(to_link, link_def['type']):
     136                raise api.UsageException('Expected link type to be {}. Got {}.'.format(link_def['type'], type(to_link)))
     137
     138        if not to_link:
     139            return
     140
     141        url = ''
     142        payload = {}
     143        request_list = []
     144        # Is it a reverse linker? If so, invert the relationship
     145        if link_def['reverse']:
     146            for link in to_link:
     147                request_list.extend(link._link(self._name, self, operation, update_cache, batch_linking=batch_linking))
     148        else:
     149            # Only keep UUID and the data attribute, if it has one
     150            def strip_payload(link):
     151                single = {'uuId': link['uuId']}
     152                data_name = link_def.get('data_name')
     153                if data_name and data_name in link:
     154                    single[data_name] = copy.deepcopy(link[data_name])
     155                return single
     156
     157            # If batch linking is enabled and the entity to link is a list of entities,
     158            # a separate request must be constructed for each one because the final composite
     159            # request permits only one input per call
     160            url = '/api/{}/link/{}/{}'.format(self._path, to_entity_name, operation)
     161            to_link_payload = None
     162            if isinstance(to_link, list):
     163                to_link_payload = []
     164                for link in to_link:
     165                    if batch_linking:
     166                        request_list.append({
     167                            'method': "POST",
     168                            'invoke': url,
     169                            'body': {
     170                                'uuId': self['uuId'],
     171                                to_key: [strip_payload(link)],
     172                            }
     173                        })
     174                    else:
     175                        to_link_payload.append(strip_payload(link))
     176            if isinstance(to_link, dict):
     177                if batch_linking:
     178                    request_list.append({
     179                        'method': "POST",
     180                        'invoke': url,
     181                        'body': {
     182                            'uuId': self['uuId'],
     183                            to_key: strip_payload(to_link),
     184                        }
     185                    })
     186                else:
     187                    to_link_payload = strip_payload(to_link)
     188
     189            if not batch_linking:
     190                payload = {
     191                    'uuId': self['uuId'],
     192                    to_key: to_link_payload
     193                }
     194                api.post(url, payload=payload)
     195
     196        if not update_cache:
     197            return request_list
     198
     199        # Set the initial state if first add. We need the type to be set to correctly update the cache
     200        if operation == 'add' and self.get(to_key, None) is None:
     201            if link_def.get('type') == dict:
     202                self[to_key] = {}
     203            elif link_def.get('type') == list:
     204                self[to_key] = []
     205
     206        # Modify the entity object's cache of links to match the changes we pushed to the server.
     207        if isinstance(self.get(to_key, []), list):
     208            if operation == 'add':
     209                # Sometimes the backend doesn't return a list when it has none. Create it.
     210                if to_key not in self:
     211                    self[to_key] = []
     212
     213                for to_entity in to_link:
     214                    self[to_key].append(to_entity)
     215            else:
     216                for to_entity in to_link:
     217                    # Find it in original list
     218                    for i, old in enumerate(self.get(to_key, [])):
     219                        if old['uuId'] == to_entity['uuId']:
     220                            if operation == 'update':
     221                                self[to_key][i] = to_entity
     222                            elif operation == 'delete':
     223                                del self[to_key][i]
     224        if isinstance(self.get(to_key, None), dict):
     225            if operation in ['add', 'update']:
     226                self[to_key] = to_link
     227            elif operation == 'delete':
     228                self[to_key] = None
     229
     230        # Update the "old" record of the link on the entity to avoid
     231        # flagging it for changes (link lists are not meant to be user editable).
     232        if to_key in self:
     233            self.__old[to_key] = self[to_key]
     234
     235        return request_list
     236
     237    # -----
     238
     239    @classmethod
     240    def create(cls, entities, params=None, batch_linking=True):
     241        """
     242        Create one or more entities of the same type. The entity
     243        type is determined by the subclass calling this method.
     244
     245        `entities`: Can be a `dict` to create a single entity,
     246        or a list of `dict`s to create many entities in bulk.
     247
     248        `params`: Optional URL parameters that may apply to the
     249        entity's API (e.g: `?holder=1234`).
     250
     251        'batch_linking': Enabled by default, batches any link
     252        updates required into composite API requests. If disabled
     253        a request will be executed for each link update.
     254        Recommended to leave enabled to increase performance.
     255
     256        If input was a `dict`, returns an entity subclass. If input was
     257        a list of `dict`s, returns a list of entity subclasses.
     258
     259        ```
     260        # Example usage:
     261        projectal.Customer.create({'name': 'NewCustomer'})
     262        # returns Customer object
     263        ```
     264        """
     265
     266        if isinstance(entities, dict):
     267            # Dict input needs to be a list
     268            e_list = [entities]
     269        else:
     270            # We have a list of dicts already, the expected format
     271            e_list = entities
     272
     273        # Apply type
     274        typed_list = []
     275        for e in e_list:
     276            if not isinstance(e, Entity):
     277                # Start empty to correctly populate history
     278                new = cls({})
     279                new.update(e)
     280                typed_list.append(new)
     281            else:
     282                typed_list.append(e)
     283        e_list = typed_list
     284
     285        endpoint = '/api/{}/add'.format(cls._path)
     286        if params:
     287            endpoint += params
     288        if not e_list:
     289            return []
     290
     291        # Strip links from payload
     292        payload = []
     293        keys = e_list[0]._link_def_by_key.keys()
     294        for e in e_list:
     295            cleancopy = copy.deepcopy(e)
     296            # Remove any fields that match a link key
     297            for key in keys:
     298                cleancopy.pop(key, None)
     299            payload.append(cleancopy)
     300
     301        objects = []
     302        for i in range(0, len(payload), projectal.chunk_size_write):
     303            chunk = payload[i:i + projectal.chunk_size_write]
     304            orig_chunk = e_list[i:i + projectal.chunk_size_write]
     305            response = api.post(endpoint, chunk)
     306            # Put uuId from response into each input dict
     307            for e, o, orig in zip(chunk, response, orig_chunk):
     308                orig['uuId'] = o['uuId']
     309                orig.__old = copy.deepcopy(orig)
     310                # Delete links from the history in order to trigger a change on them after
     311                for key in orig._link_def_by_key:
     312                    orig.__old.pop(key, None)
     313                objects.append(orig)
     314
     315        # Detect and apply any link additions
     316        # if batch_linking is enabled, builds a list of link requests
     317        # needed for each entity, then executes them with composite
     318        # API requests
     319        link_request_batch = []
     320        for e in e_list:
     321            requests = e.__apply_link_changes(batch_linking=batch_linking)
     322            link_request_batch.extend(requests)
     323
     324        if len(link_request_batch) > 0 and batch_linking:
     325            for i in range(0, len(link_request_batch), 1000):
     326                chunk = link_request_batch[i:i + 1000]
     327                api.post('/api/composite', chunk)
     328
     329        if not isinstance(entities, list):
     330            return objects[0]
     331        return objects
     332
     333    @classmethod
     334    def _get_linkset(cls, links):
     335        """Get a set of link names we have been asked to fetch with. Raise an
     336        error if the requested link is not valid for this Entity type."""
     337        link_set = set()
     338        if links is not None:
     339            if isinstance(links, str) or not hasattr(links, '__iter__'):
     340                raise projectal.UsageException("Parameter 'links' must be a list or None.")
     341
     342            defs = cls({})._link_def_by_name
     343            for link in links:
     344                name = link.lower()
     345                if name not in defs:
     346                    raise projectal.UsageException(
     347                        "Link '{}' is invalid for {}".format(name, cls._name))
     348                link_set.add(name)
     349        return link_set
     350
     351    @classmethod
     352    def get(cls, entities, links=None, deleted_at=None):
     353        """
     354        Get one or more entities of the same type. The entity
     355        type is determined by the subclass calling this method.
     356
     357        `entities`: One of several formats containing the `uuId`s
     358        of the entities you want to get (see bottom for examples):
     359
     360        - `str` or list of `str`
     361        - `dict` or list of `dict` (with `uuId` key)
     362
     363        `links`: A case-insensitive list of entity names to fetch with
     364        this entity. For performance reasons, links are only returned
     365        on demand.
     366
     367        Links follow a common naming convention in the output with
     368        a *_List* suffix. E.g.:
     369        `links=['company', 'location']` will appear as `companyList` and
     370        `locationList` in the response.
     371        ```
     372        # Example usage:
     373        # str
     374        projectal.Project.get('1b21e445-f29a-4a9f-95ff-fe253a3e1b11')
     375
     376        # list of str
     377        ids = ['1b21e445-f29a...', '1b21e445-f29a...', '1b21e445-f29a...']
     378        projectal.Project.get(ids)
     379
     380        # dict
     381        project = project.Project.create({'name': 'MyProject'})
     382        # project = {'uuId': '1b21e445-f29a...', 'name': 'MyProject', ...}
     383        projectal.Project.get(project)
     384
     385        # list of dicts (e.g. from a query)
     386        # projects = [{'uuId': '1b21e445-f29a...'}, {'uuId': '1b21e445-f29a...'}, ...]
     387        project.Project.get(projects)
     388
     389        # str with links
     390        projectal.Project.get('1b21e445-f29a...', 'links=['company', 'location']')
     391        ```
     392
     393        `deleted_at`: Include this parameter to get a deleted entity.
     394        This value should be a UTC timestamp from a webhook delete event.
     395        """
     396        link_set = cls._get_linkset(links)
     397
     398        if isinstance(entities, str):
     399            # String input is a uuId
     400            payload = [{'uuId': entities}]
     401        elif isinstance(entities, dict):
     402            # Dict input needs to be a list
     403            payload = [entities]
     404        elif isinstance(entities, list):
     405            # List input can be a list of uuIds or list of dicts
     406            # If uuIds (strings), convert to list of dicts
     407            if len(entities) > 0 and isinstance(entities[0], str):
     408                payload = [{'uuId': uuId} for uuId in entities]
     409            else:
     410                # Already expected format
     411                payload = entities
     412        else:
     413            # We have a list of dicts already, the expected format
     414            payload = entities
     415
     416        if deleted_at:
     417            if not isinstance(deleted_at, int):
     418                raise projectal.UsageException("deleted_at must be a number")
     419
     420        url = '/api/{}/get'.format(cls._path)
     421        params = []
     422        params.append('links={}'.format(','.join(links))) if links else None
     423        params.append('epoch={}'.format(deleted_at - 1)) if deleted_at else None
     424        if len(params) > 0:
     425            url += '?' + '&'.join(params)
     426
     427        # We only need to send over the uuIds
     428        payload = [{'uuId': e['uuId']} for e in payload]
     429        if not payload:
     430            return []
     431        objects = []
     432        for i in range(0, len(payload), projectal.chunk_size_read):
     433            chunk = payload[i:i + projectal.chunk_size_read]
     434            dicts = api.post(url, chunk)
     435            for d in dicts:
     436                obj = cls(d)
     437                obj._with_links.update(link_set)
     438                obj._is_new = False
     439                # Create default fields for links we ask for. Workaround for backend
     440                # sometimes omitting links if no links exist.
     441                for link_name in link_set:
     442                    link_def = obj._link_def_by_name[link_name]
     443                    if link_def['link_key'] not in obj:
     444                        if link_def['type'] == dict:
     445                            obj.set_readonly(link_def['link_key'], None)
     446                        else:
     447                            obj.set_readonly(link_def['link_key'], link_def['type']())
     448                objects.append(obj)
     449
     450        if not isinstance(entities, list):
     451            return objects[0]
     452        return objects
     453
     454    def __get(self, *args, **kwargs):
     455        """Use the dict get for instances."""
     456        return super(Entity, self).get(*args, **kwargs)
     457
     458    @classmethod
     459    def update(cls, entities, batch_linking=True):
     460        """
     461        Save one or more entities of the same type. The entity
     462        type is determined by the subclass calling this method.
     463        Only the fields that have been modifier will be sent
     464        to the server as part of the request.
     465
     466        `entities`: Can be a `dict` to update a single entity,
     467        or a list of `dict`s to update many entities in bulk.
     468
     469        'batch_linking': Enabled by default, batches any link
     470        updates required into composite API requests. If disabled
     471        a request will be executed for each link update.
     472        Recommended to leave enabled to increase performance.
     473
     474        Returns `True` if all entities update successfully.
     475
     476        ```
     477        # Example usage:
     478        rebate = projectal.Rebate.create({'name': 'Rebate2022', 'rebate': 0.2})
     479        rebate['name'] = 'Rebate2024'
     480        projectal.Rebate.update(rebate)
     481        # Returns True. New rebate name has been saved.
     482        ```
     483        """
     484        if isinstance(entities, dict):
     485            e_list = [entities]
     486        else:
     487            e_list = entities
     488
     489        # Reduce the list to only modified entities and their modified fields.
     490        # Only do this to an Entity subclass - the consumer may have passed
     491        # in a dict of changes on their own.
     492        payload = []
     493
     494        for e in e_list:
     495            if isinstance(e, Entity):
     496                changes = e._changes_internal()
     497                if changes:
     498                    changes['uuId'] = e['uuId']
     499                    payload.append(changes)
     500            else:
     501                payload.append(e)
     502        if payload:
     503            for i in range(0, len(payload), projectal.chunk_size_write):
     504                chunk = payload[i:i + projectal.chunk_size_write]
     505                api.put('/api/{}/update'.format(cls._path), chunk)
     506
     507        # Detect and apply any link changes
     508        # if batch_linking is enabled, builds a list of link requests
     509        # from the changes of each entity, then executes
     510        # composite API requests with those changes
     511        link_request_batch = []
     512        for e in e_list:
     513            if isinstance(e, Entity):
     514                requests = e.__apply_link_changes(batch_linking=batch_linking)
     515                link_request_batch.extend(requests)
     516
     517        if len(link_request_batch) > 0 and batch_linking:
     518            for i in range(0, len(link_request_batch), 1000):
     519                chunk = link_request_batch[i:i + 1000]
     520                api.post('/api/composite', chunk)
     521
     522        return True
     523
     524    def __update(self, *args, **kwargs):
     525        """Use the dict update for instances."""
     526        return super(Entity, self).update(*args, **kwargs)
     527
     528    def save(self):
     529        """Calls `update()` on this instance of the entity, saving
     530        it to the database."""
     531        return self.__class__.update(self)
     532
     533    @classmethod
     534    def delete(cls, entities):
     535        """
     536        Delete one or more entities of the same type. The entity
     537        type is determined by the subclass calling this method.
     538
     539        `entities`: See `Entity.get()` for expected formats.
     540
     541        ```
     542        # Example usage:
     543        ids = ['1b21e445-f29a...', '1b21e445-f29a...', '1b21e445-f29a...']
     544        projectal.Customer.delete(ids)
     545        ```
     546        """
     547        if isinstance(entities, str):
     548            # String input is a uuId
     549            payload = [{'uuId': entities}]
     550        elif isinstance(entities, dict):
     551            # Dict input needs to be a list
     552            payload = [entities]
     553        elif isinstance(entities, list):
     554            # List input can be a list of uuIds or list of dicts
     555            # If uuIds (strings), convert to list of dicts
     556            if len(entities) > 0 and isinstance(entities[0], str):
     557                payload = [{'uuId': uuId} for uuId in entities]
     558            else:
     559                # Already expected format
     560                payload = entities
     561        else:
     562            # We have a list of dicts already, the expected format
     563            payload = entities
     564
     565        # We only need to send over the uuIds
     566        payload = [{'uuId': e['uuId']} for e in payload]
     567        if not payload:
     568            return True
     569        for i in range(0, len(payload), projectal.chunk_size_write):
     570            chunk = payload[i:i + projectal.chunk_size_write]
     571            api.delete('/api/{}/delete'.format(cls._path), chunk)
     572        return True
     573
     574    def __delete(self):
     575        """Let an instance delete itself."""
     576        return self.__class__.delete(self)
     577
     578    def clone(self, entity):
     579        """
     580        Clones an entity and returns its `uuId`.
     581
     582        Each entity has its own set of required values when cloning.
     583        Check the API documentation of that entity for details.
     584        """
     585        url = '/api/{}/clone?reference={}'.format(self._path, self['uuId'])
     586        response = api.post(url, entity)
     587        return response['jobClue']['uuId']
     588
     589    @classmethod
     590    def history(cls, UUID, start=0, limit=-1, order='desc', epoch=None, event=None):
     591        """
     592        Returns an ordered list of all changes made to the entity.
     593
     594        `UUID`: the UUID of the entity.
     595
     596        `start`: Start index for pagination (default: `0`).
     597
     598        `limit`: Number of results to include for pagination. Use
     599        `-1` to return the entire history (default: `-1`).
     600
     601        `order`: `asc` or `desc` (default: `desc` (index 0 is newest))
     602
     603        `epoch`: only return the history UP TO epoch date
     604
     605        `event`:
     606        """
     607        url = '/api/{}/history?holder={}&'.format(cls._path, UUID)
     608        params = []
     609        params.append('start={}'.format(start))
     610        params.append('limit={}'.format(limit))
     611        params.append('order={}'.format(order))
     612        params.append('epoch={}'.format(epoch)) if epoch else None
     613        params.append('event={}'.format(event)) if event else None
     614        url += '&'.join(params)
     615        return api.get(url)
     616
     617    def __history(self, **kwargs):
     618        """Get history of instance."""
     619        return self.__class__.history(self['uuId'], **kwargs)
     620
     621    @classmethod
     622    def list(cls, expand=False, links=None):
     623        """Return a list of all entity UUIDs of this type.
     624
     625        You may pass in `expand=True` to get full Entity objects
     626        instead, but be aware this may be very slow if you have
     627        thousands of objects.
     628
     629        If you are expanding the objects, you may further expand
     630        the results with `links`.
     631        """
     632
     633        payload = {
     634            "name": "List all entities of type {}".format(cls._name.upper()),
     635            "type": "msql", "start": 0, "limit": -1,
     636            "select": [
     637                ["{}.uuId".format(cls._name.upper())]
     638            ],
     639        }
     640        ids = api.query(payload)
     641        ids = [id[0] for id in ids]
     642        if ids:
     643            return cls.get(ids, links=links) if expand else ids
     644        return []
     645
     646    @classmethod
     647    def match(cls, field, term, links=None):
     648        """Find entities where `field`=`term` (exact match), optionally
     649        expanding the results with `links`.
     650
     651        Relies on `Entity.query()` with a pre-built set of rules.
     652        ```
     653        projects = projectal.Project.match('identifier', 'zmb-005')
     654        ```
     655        """
     656        filter = [["{}.{}".format(cls._name.upper(), field), "eq", term]]
     657        return cls.query(filter, links)
     658
     659    @classmethod
     660    def match_startswith(cls, field, term, links=None):
     661        """Find entities where `field` starts with the text `term`,
     662        optionally expanding the results with `links`.
     663
     664        Relies on `Entity.query()` with a pre-built set of rules.
     665        ```
     666        projects = projectal.Project.match_startswith('name', 'Zomb')
     667        ```
     668        """
     669        filter = [["{}.{}".format(cls._name.upper(), field), "prefix", term]]
     670        return cls.query(filter, links)
     671
     672    @classmethod
     673    def match_endswith(cls, field, term, links=None):
     674        """Find entities where `field` ends with the text `term`,
     675        optionally expanding the results with `links`.
     676
     677        Relies on `Entity.query()` with a pre-built set of rules.
     678        ```
     679        projects = projectal.Project.match_endswith('identifier', '-2023')
     680        ```
     681        """
     682        term = "(?i).*{}$".format(term)
     683        filter = [["{}.{}".format(cls._name.upper(), field), "regex", term]]
     684        return cls.query(filter, links)
     685
     686    @classmethod
     687    def match_one(cls, field, term, links=None):
     688        """Convenience function for match(). Returns the first match or None."""
     689        matches = cls.match(field, term, links)
     690        if matches:
     691            return matches[0]
     692
     693    @classmethod
     694    def match_startswith_one(cls, field, term, links=None):
     695        """Convenience function for match_startswith(). Returns the first match or None."""
     696        matches = cls.match_startswith(field, term, links)
     697        if matches:
     698            return matches[0]
     699
     700    @classmethod
     701    def match_endswith_one(cls, field, term, links=None):
     702        """Convenience function for match_endswith(). Returns the first match or None."""
     703        matches = cls.match_endswith(field, term, links)
     704        if matches:
     705            return matches[0]
     706
     707    @classmethod
     708    def search(cls, fields=None, term='', case_sensitive=True, links=None):
     709        """Find entities that contain the text `term` within `fields`.
     710        `fields` is a list of field names to target in the search.
     711
     712        `case_sensitive`: Optionally turn off case sensitivity in the search.
     713
     714        Relies on `Entity.query()` with a pre-built set of rules.
     715        ```
     716        projects = projectal.Project.search(['name', 'description'], 'zombie')
     717        ```
     718        """
     719        filter = []
     720        term = '(?{}).*{}.*'.format('' if case_sensitive else '?', term)
     721        for field in fields:
     722            filter.append(["{}.{}".format(cls._name.upper(), field), "regex", term])
     723        filter = ['_or_', filter]
     724        return cls.query(filter, links)
     725
     726    @classmethod
     727    def query(cls, filter, links=None):
     728        """Run a query on this entity with the supplied filter.
     729
     730        The query is already set up to target this entity type, and the
     731        results will be converted into full objects when found, optionally
     732        expanded with the `links` provided. You only need to supply a
     733        filter to reduce the result set.
     734
     735        See [the filter documentation](https://projectal.com/docs/v1.1.1#section/Filter-section)
     736        for a detailed overview of the kinds of filters you can construct.
     737        """
     738        payload = {
     739            "name": "Python library entity query ({})".format(cls._name.upper()),
     740            "type": "msql", "start": 0, "limit": -1,
     741            "select": [
     742                ["{}.uuId".format(cls._name.upper())]
     743            ],
     744            "filter": filter
     745        }
     746        ids = api.query(payload)
     747        ids = [id[0] for id in ids]
     748        if ids:
     749            return cls.get(ids, links=links)
     750        return []
     751
     752    def profile_get(self, key):
     753        """Get the profile (metadata) stored for this entity at `key`."""
     754        return projectal.profile.get(key, self.__class__._name.lower(), self['uuId'])
     755
     756    def profile_set(self, key, data):
     757        """Set the profile (metadata) stored for this entity at `key`. The contents
     758        of `data` will completely overwrite the existing data dictionary."""
     759        return projectal.profile.set(key, self.__class__._name.lower(), self['uuId'], data)
     760
     761    def __type_links(self):
     762        """Find links and turn their dicts into typed objects matching their Entity type."""
     763
     764        for key, _def in self._link_def_by_key.items():
     765            if key in self:
     766                cls = getattr(projectal, _def['entity'])
     767                if _def['type'] == list:
     768                    as_obj = []
     769                    for link in self[key]:
     770                        as_obj.append(cls(link))
     771                elif _def['type'] == dict:
     772                    as_obj = cls(self[key])
     773                else:
     774                    raise projectal.UsageException("Unexpected link type")
     775                self[key] = as_obj
     776
     777    def changes(self):
     778        """Return a dict containing the fields that have changed since fetching the object.
     779        Dict values contain both the old and new values. E.g.: {'old': 'original', 'new': 'current'}.
     780
     781        In the case of link lists, there are three values: added, removed, updated. Only links with
     782        a data attribute can end up in the updated list, and the old/new dictionary is placed within
     783        that data attribute. E.g. for a staff-resource link:
     784        'updated': [{
     785            'uuId': '24eb4c31-0f92-49d1-8b4d-507ab939003e',
     786            'resourceLink': {'quantity': {'old': 2, 'new': 5}}
     787        }]
     788        """
     789        changed = {}
     790        for key in self.keys():
     791            link_def = self._link_def_by_key.get(key)
     792            if link_def:
     793                changes = self._changes_for_link_list(link_def, key)
     794                # Only add it if something in it changed
     795                for action in changes.values():
     796                    if len(action):
     797                        changed[key] = changes
     798                        break
     799            elif key not in self.__old and self[key] is not None:
     800                changed[key] = {'old': None, 'new': self[key]}
     801            elif self.__old.get(key) != self[key]:
     802                changed[key] = {'old': self.__old.get(key), 'new': self[key]}
     803        return changed
     804
     805    def _changes_for_link_list(self, link_def, key):
     806        changes = self.__apply_list(link_def, report_only=True)
     807        data_key = link_def['data_name']
     808
     809        # For linked entities, we will only report their UUID, name (if it has one),
     810        # and the content of their data attribute (if it has one).
     811        def get_slim_list(entities):
     812            slim = []
     813            if isinstance(entities, dict):
     814                entities = [entities]
     815            for e in entities:
     816                fields = {'uuId': e['uuId']}
     817                name = e.get('name')
     818                if name:
     819                    fields['name'] = e['name']
     820                if data_key and e[data_key]:
     821                    fields[data_key] = e[data_key]
     822                slim.append(fields)
     823            return slim
     824
     825        out = {
     826            'added': get_slim_list(changes.get('add', [])),
     827            'updated': [],
     828            'removed': get_slim_list(changes.get('remove', [])),
     829        }
     830
     831        updated = changes.get('update', [])
     832        if updated:
     833            before_map = {}
     834            for entity in self.__old.get(key):
     835                before_map[entity['uuId']] = entity
     836
     837            for entity in updated:
     838                old_data = before_map[entity['uuId']][data_key]
     839                new_data = entity[data_key]
     840                diff = {}
     841                for key in new_data.keys():
     842                    if key not in old_data and new_data[key] is not None:
     843                        diff[key] = {'old': None, 'new': new_data[key]}
     844                    elif old_data.get(key) != new_data[key]:
     845                        diff[key] = {'old': old_data.get(key), 'new': new_data[key]}
     846                out['updated'].append({'uuId': entity['uuId'], data_key: diff})
     847        return out
     848
     849    def _changes_internal(self):
     850        """Return a dict containing only the fields that have changed and their current value,
     851        without any link data.
     852
     853        This method is used internally to strip payloads down to only the fields that have changed.
     854        """
     855        changed = {}
     856        for key in self.keys():
     857            # We don't deal with link or link data changes here. We only want standard fields.
     858            if key in self._link_def_by_key:
     859                continue
     860            if key not in self.__old and self[key] is not None:
     861                changed[key] = self[key]
     862            elif self.__old.get(key) != self[key]:
     863                changed[key] = self[key]
     864        return changed
     865
     866    def set_readonly(self, key, value):
     867        """Set a field on this Entity that will not be sent over to the
     868        server on update unless modified."""
     869        self[key] = value
     870        self.__old[key] = value
     871
     872    # --- Link management ---
     873
     874    @staticmethod
     875    def __link_data_differs(have_link, want_link, data_key):
     876
     877        if data_key:
     878            if 'uuId' in have_link[data_key]:
     879                del have_link[data_key]['uuId']
     880            if 'uuId' in want_link[data_key]:
     881                del want_link[data_key]['uuId']
     882            return have_link[data_key] != want_link[data_key]
     883
     884        # Links without data never differ
     885        return False
     886
     887    def __apply_link_changes(self, batch_linking=True):
     888        """Send each link list to the conflict resolver. If we detect
     889        that the entity was not fetched with that link, we do the fetch
     890        first and use the result as the basis for comparison."""
     891
     892        # Find which lists belong to links but were not fetched so we can fetch them
     893        need = []
     894        find_list = []
     895        if not self._is_new:
     896            for link in self._link_def_by_key.values():
     897                if link['link_key'] in self and link['name'] not in self._with_links:
     898                    need.append(link['name'])
     899                    find_list.append(link['link_key'])
     900
     901        if len(need):
     902            logging.warning("Entity links were modified but entity not fetched with links. "
     903                            "For better performance, include the links when getting the entity.")
     904            logging.warning("Fetching {} again with missing links: {}".format(self._name.upper(), ','.join(need)))
     905            new = self.__fetch(self, links=need)
     906            for _list in find_list:
     907                self.__old[_list] = copy.deepcopy(new.get(_list, []))
     908
     909        # if batch_linking is enabled, builds a list of link requests
     910        # for each link definition of the calling entity then returns the list
     911        request_list = []
     912        for link_def in self._link_def_by_key.values():
     913            link_def_requests = self.__apply_list(link_def, batch_linking=batch_linking)
     914            if batch_linking:
     915                request_list.extend(link_def_requests)
     916        return request_list
     917
     918    def __apply_list(self, link_def, report_only=False, batch_linking=True):
     919        """Automatically resolve differences and issue the correct sequence of
     920        link/unlink/relink for the link list to result in the supplied list
     921        of entities.
     922
     923        report_only will not make any changes to the data or issue network requests.
     924        Instead, it returns the three lists of changes (add, update, delete).
     925        """
     926        to_add = []
     927        to_remove = []
     928        to_update = []
     929        should_only_have = set()
     930        link_key = link_def['link_key']
     931
     932        if link_def['type'] == list:
     933            want_entities = self.get(link_key, [])
     934            have_entities = self.__old.get(link_key, [])
     935
     936            if not isinstance(want_entities, list):
     937                raise api.UsageException("Expecting '{}' to be {}. Found {} instead.".format(
     938                    link_key, link_def['type'].__name__, type(want_entities).__name__))
     939
     940            for want_entity in want_entities:
     941                if want_entity['uuId'] in should_only_have:
     942                    raise api.UsageException("Duplicate {} in {}".format(link_def['name'], link_key))
     943                should_only_have.add(want_entity['uuId'])
     944                have = False
     945                for have_entity in have_entities:
     946                    if have_entity['uuId'] == want_entity['uuId']:
     947                        have = True
     948                        data_name = link_def.get('data_name')
     949                        if data_name and self.__link_data_differs(have_entity, want_entity, data_name):
     950                            to_update.append(want_entity)
     951                if not have:
     952                    to_add.append(want_entity)
     953            for have_entity in have_entities:
     954                if have_entity['uuId'] not in should_only_have:
     955                    to_remove.append(have_entity)
     956        elif link_def['type'] == dict:
     957            # Note: dict type does not implement updates as we have no dict links
     958            # that support update (yet?).
     959            want_entity = self.get(link_key, None)
     960            have_entity = self.__old.get(link_key, None)
     961
     962            if want_entity is not None and not isinstance(want_entity, dict):
     963                raise api.UsageException("Expecting '{}' to be {}. Found {} instead.".format(
     964                    link_key, link_def['type'].__name__, type(have_entity).__name__))
     965
     966            if want_entity:
     967                if have_entity:
     968                    if want_entity['uuId'] != have_entity['uuId']:
     969                        to_remove = have_entity
     970                        to_add = want_entity
     971                else:
     972                    to_add = want_entity
     973            if not want_entity:
     974                if have_entity:
     975                    to_remove = have_entity
     976
     977            want_entities = want_entity
     978        else:
     979            # Would be an error in this library if we reach here
     980            raise projectal.UnsupportedException("This type does not support linking")
     981
     982        # if batch_linking is enabled, builds a list of requests
     983        # from each link method
     984        if not report_only:
     985            request_list = []
     986            if to_remove:
     987                delete_requests = self._link(
     988                        link_def['name'], to_remove, 'delete',
     989                        update_cache=False, batch_linking=batch_linking
     990                )
     991                request_list.extend(delete_requests)
     992            if to_update:
     993                update_requests = self._link(
     994                    link_def['name'], to_update, 'update',
     995                    update_cache=False, batch_linking=batch_linking
     996                )
     997                request_list.extend(update_requests)
     998            if to_add:
     999                add_requests = self._link(
    1000                    link_def['name'], to_add, 'add',
    1001                    update_cache=False, batch_linking=batch_linking
    1002                )
    1003                request_list.extend(add_requests)
    1004            self.__old[link_key] = copy.deepcopy(want_entities)
    1005            return request_list
    1006        else:
    1007            changes = {}
    1008            if to_remove:
    1009                changes['remove'] = to_remove
    1010            if to_update:
    1011                changes['update'] = to_update
    1012            if to_add:
    1013                changes['add'] = to_add
    1014            return changes
    1015
    1016    @classmethod
    1017    def get_link_definitions(cls):
    1018        return cls({})._link_def_by_name
    1019    # --- ---
    1020
    1021    def entity_name(self):
    1022        return self._name.capitalize()

class Entity(builtins.dict): View Source

      13class Entity(dict):
      14    """
      15    The parent class for all our entities, offering requests
      16    and validation for the fundamental create/read/update/delete
      17    operations.
      18
      19    This class (and all our entities) inherit from the builtin
      20    `dict` class. This means all entity classes can be used
      21    like standard Python dictionary objects, but we can also
      22    offer additional utility functions that operate on the
      23    instance itself (see `linkers` for an example). Any method
      24    that expects a `dict` can also consume an `Entity` subclass.
      25
      26    The class methods in this class can operate on one or more
      27    entities in one request. If the methods are called with
      28    lists (for batch operation), the output returned will also
      29    be a list. Otherwise, a single `Entity` subclass is returned.
      30
      31    Note for batch operations: a `ProjectalException` is raised
      32    if *any* of the entities fail during the operation. The
      33    changes will *still be saved to the database for the entities
      34    that did not fail*.
      35    """
      36
      37    #: Child classes must override these with their entity names
      38    _path = 'entity'  # URL portion to api
      39    _name = 'entity'
      40
      41    # And to which entities they link to
      42    _links = []
      43    _links_reverse = []
      44
      45    def __init__(self, data):
      46        dict.__init__(self, data)
      47        self._is_new = True
      48        self._link_def_by_key = {}
      49        self._link_def_by_name = {}
      50        self._create_link_defs()
      51        self._with_links = set()
      52
      53        self.__fetch = self.get
      54        self.get = self.__get
      55        self.update = self.__update
      56        self.delete = self.__delete
      57        self.history = self.__history
      58        self.__old = copy.deepcopy(self)
      59        self.__type_links()
      60
      61    # ----- LINKING -----
      62
      63    def _create_link_defs(self):
      64        for cls in self._links:
      65            self._add_link_def(cls)
      66        for cls in self._links_reverse:
      67            self._add_link_def(cls, reverse=True)
      68
      69    def _add_link_def(self, cls, reverse=False):
      70        """
      71        Each entity is accompanied by a dict with details about how to
      72        get access to the data of the link within the object. Subclasses
      73        can pass in customizations to this dict when their APIs differ.
      74
      75        reverse denotes a reverse linker, where extra work is done to
      76        reverse the relationship of the link internally so that it works.
      77        The backend only offers one side of the relationship.
      78        """
      79        d = {
      80            'name': cls._link_name,
      81            'link_key': cls._link_key or cls._link_name + 'List',
      82            'data_name': cls._link_data_name,
      83            'type': cls._link_type,
      84            'entity': cls._link_entity or cls._link_name.capitalize(),
      85            'reverse': reverse
      86        }
      87        self._link_def_by_key[d['link_key']] = d
      88        self._link_def_by_name[d['name']] = d
      89
      90    def _add_link(self, to_entity_name, to_link):
      91        self._link(to_entity_name, to_link, 'add', batch_linking=False)
      92
      93    def _update_link(self,to_entity_name, to_link):
      94        self._link(to_entity_name, to_link, 'update', batch_linking=False)
      95
      96    def _delete_link(self, to_entity_name, to_link):
      97        self._link(to_entity_name, to_link, 'delete', batch_linking=False)
      98
      99    def _link(self, to_entity_name, to_link, operation, update_cache=True, batch_linking=True):
     100        """
     101        `to_entity_name`: Destination entity name (e.g. 'staff')
     102
     103        `to_link`: List of Entities of the same type (and optional data) to link to
     104
     105        `operation`: `add`, `update`, `delete`
     106
     107        'update_cache': also modify the entity's internal representation of the links
     108        to match the operation that was done. Set this to False when replacing the
     109        list with a new one (i.e., when calling save() instead of a linker method).
     110
     111        'batch_linking': Enabled by default, batches any link
     112        updates required into composite API requests. If disabled
     113        a request will be executed for each link update.
     114        Recommended to leave enabled to increase performance.
     115        """
     116
     117        link_def = self._link_def_by_name[to_entity_name]
     118        to_key = link_def['link_key']
     119
     120        if isinstance(to_link, dict) and link_def['type'] == list:
     121            # Convert input dict to list when link type is a list (we allow linking to single entity for convenience)
     122            to_link = [to_link]
     123
     124            # For cases where user passed in dict instead of Entity, we turn them into
     125            # Entity on their behalf.
     126            typed_list = []
     127            target_cls = getattr(sys.modules['projectal.entities'], link_def['entity'])
     128            for link in to_link:
     129                if not isinstance(link, target_cls):
     130                    typed_list.append(target_cls(link))
     131                else:
     132                    typed_list.append(link)
     133            to_link = typed_list
     134        else:
     135            # For everything else, we expect types to match.
     136            if not isinstance(to_link, link_def['type']):
     137                raise api.UsageException('Expected link type to be {}. Got {}.'.format(link_def['type'], type(to_link)))
     138
     139        if not to_link:
     140            return
     141
     142        url = ''
     143        payload = {}
     144        request_list = []
     145        # Is it a reverse linker? If so, invert the relationship
     146        if link_def['reverse']:
     147            for link in to_link:
     148                request_list.extend(link._link(self._name, self, operation, update_cache, batch_linking=batch_linking))
     149        else:
     150            # Only keep UUID and the data attribute, if it has one
     151            def strip_payload(link):
     152                single = {'uuId': link['uuId']}
     153                data_name = link_def.get('data_name')
     154                if data_name and data_name in link:
     155                    single[data_name] = copy.deepcopy(link[data_name])
     156                return single
     157
     158            # If batch linking is enabled and the entity to link is a list of entities,
     159            # a separate request must be constructed for each one because the final composite
     160            # request permits only one input per call
     161            url = '/api/{}/link/{}/{}'.format(self._path, to_entity_name, operation)
     162            to_link_payload = None
     163            if isinstance(to_link, list):
     164                to_link_payload = []
     165                for link in to_link:
     166                    if batch_linking:
     167                        request_list.append({
     168                            'method': "POST",
     169                            'invoke': url,
     170                            'body': {
     171                                'uuId': self['uuId'],
     172                                to_key: [strip_payload(link)],
     173                            }
     174                        })
     175                    else:
     176                        to_link_payload.append(strip_payload(link))
     177            if isinstance(to_link, dict):
     178                if batch_linking:
     179                    request_list.append({
     180                        'method': "POST",
     181                        'invoke': url,
     182                        'body': {
     183                            'uuId': self['uuId'],
     184                            to_key: strip_payload(to_link),
     185                        }
     186                    })
     187                else:
     188                    to_link_payload = strip_payload(to_link)
     189
     190            if not batch_linking:
     191                payload = {
     192                    'uuId': self['uuId'],
     193                    to_key: to_link_payload
     194                }
     195                api.post(url, payload=payload)
     196
     197        if not update_cache:
     198            return request_list
     199
     200        # Set the initial state if first add. We need the type to be set to correctly update the cache
     201        if operation == 'add' and self.get(to_key, None) is None:
     202            if link_def.get('type') == dict:
     203                self[to_key] = {}
     204            elif link_def.get('type') == list:
     205                self[to_key] = []
     206
     207        # Modify the entity object's cache of links to match the changes we pushed to the server.
     208        if isinstance(self.get(to_key, []), list):
     209            if operation == 'add':
     210                # Sometimes the backend doesn't return a list when it has none. Create it.
     211                if to_key not in self:
     212                    self[to_key] = []
     213
     214                for to_entity in to_link:
     215                    self[to_key].append(to_entity)
     216            else:
     217                for to_entity in to_link:
     218                    # Find it in original list
     219                    for i, old in enumerate(self.get(to_key, [])):
     220                        if old['uuId'] == to_entity['uuId']:
     221                            if operation == 'update':
     222                                self[to_key][i] = to_entity
     223                            elif operation == 'delete':
     224                                del self[to_key][i]
     225        if isinstance(self.get(to_key, None), dict):
     226            if operation in ['add', 'update']:
     227                self[to_key] = to_link
     228            elif operation == 'delete':
     229                self[to_key] = None
     230
     231        # Update the "old" record of the link on the entity to avoid
     232        # flagging it for changes (link lists are not meant to be user editable).
     233        if to_key in self:
     234            self.__old[to_key] = self[to_key]
     235
     236        return request_list
     237
     238    # -----
     239
     240    @classmethod
     241    def create(cls, entities, params=None, batch_linking=True):
     242        """
     243        Create one or more entities of the same type. The entity
     244        type is determined by the subclass calling this method.
     245
     246        `entities`: Can be a `dict` to create a single entity,
     247        or a list of `dict`s to create many entities in bulk.
     248
     249        `params`: Optional URL parameters that may apply to the
     250        entity's API (e.g: `?holder=1234`).
     251
     252        'batch_linking': Enabled by default, batches any link
     253        updates required into composite API requests. If disabled
     254        a request will be executed for each link update.
     255        Recommended to leave enabled to increase performance.
     256
     257        If input was a `dict`, returns an entity subclass. If input was
     258        a list of `dict`s, returns a list of entity subclasses.
     259
     260        ```
     261        # Example usage:
     262        projectal.Customer.create({'name': 'NewCustomer'})
     263        # returns Customer object
     264        ```
     265        """
     266
     267        if isinstance(entities, dict):
     268            # Dict input needs to be a list
     269            e_list = [entities]
     270        else:
     271            # We have a list of dicts already, the expected format
     272            e_list = entities
     273
     274        # Apply type
     275        typed_list = []
     276        for e in e_list:
     277            if not isinstance(e, Entity):
     278                # Start empty to correctly populate history
     279                new = cls({})
     280                new.update(e)
     281                typed_list.append(new)
     282            else:
     283                typed_list.append(e)
     284        e_list = typed_list
     285
     286        endpoint = '/api/{}/add'.format(cls._path)
     287        if params:
     288            endpoint += params
     289        if not e_list:
     290            return []
     291
     292        # Strip links from payload
     293        payload = []
     294        keys = e_list[0]._link_def_by_key.keys()
     295        for e in e_list:
     296            cleancopy = copy.deepcopy(e)
     297            # Remove any fields that match a link key
     298            for key in keys:
     299                cleancopy.pop(key, None)
     300            payload.append(cleancopy)
     301
     302        objects = []
     303        for i in range(0, len(payload), projectal.chunk_size_write):
     304            chunk = payload[i:i + projectal.chunk_size_write]
     305            orig_chunk = e_list[i:i + projectal.chunk_size_write]
     306            response = api.post(endpoint, chunk)
     307            # Put uuId from response into each input dict
     308            for e, o, orig in zip(chunk, response, orig_chunk):
     309                orig['uuId'] = o['uuId']
     310                orig.__old = copy.deepcopy(orig)
     311                # Delete links from the history in order to trigger a change on them after
     312                for key in orig._link_def_by_key:
     313                    orig.__old.pop(key, None)
     314                objects.append(orig)
     315
     316        # Detect and apply any link additions
     317        # if batch_linking is enabled, builds a list of link requests
     318        # needed for each entity, then executes them with composite
     319        # API requests
     320        link_request_batch = []
     321        for e in e_list:
     322            requests = e.__apply_link_changes(batch_linking=batch_linking)
     323            link_request_batch.extend(requests)
     324
     325        if len(link_request_batch) > 0 and batch_linking:
     326            for i in range(0, len(link_request_batch), 1000):
     327                chunk = link_request_batch[i:i + 1000]
     328                api.post('/api/composite', chunk)
     329
     330        if not isinstance(entities, list):
     331            return objects[0]
     332        return objects
     333
     334    @classmethod
     335    def _get_linkset(cls, links):
     336        """Get a set of link names we have been asked to fetch with. Raise an
     337        error if the requested link is not valid for this Entity type."""
     338        link_set = set()
     339        if links is not None:
     340            if isinstance(links, str) or not hasattr(links, '__iter__'):
     341                raise projectal.UsageException("Parameter 'links' must be a list or None.")
     342
     343            defs = cls({})._link_def_by_name
     344            for link in links:
     345                name = link.lower()
     346                if name not in defs:
     347                    raise projectal.UsageException(
     348                        "Link '{}' is invalid for {}".format(name, cls._name))
     349                link_set.add(name)
     350        return link_set
     351
     352    @classmethod
     353    def get(cls, entities, links=None, deleted_at=None):
     354        """
     355        Get one or more entities of the same type. The entity
     356        type is determined by the subclass calling this method.
     357
     358        `entities`: One of several formats containing the `uuId`s
     359        of the entities you want to get (see bottom for examples):
     360
     361        - `str` or list of `str`
     362        - `dict` or list of `dict` (with `uuId` key)
     363
     364        `links`: A case-insensitive list of entity names to fetch with
     365        this entity. For performance reasons, links are only returned
     366        on demand.
     367
     368        Links follow a common naming convention in the output with
     369        a *_List* suffix. E.g.:
     370        `links=['company', 'location']` will appear as `companyList` and
     371        `locationList` in the response.
     372        ```
     373        # Example usage:
     374        # str
     375        projectal.Project.get('1b21e445-f29a-4a9f-95ff-fe253a3e1b11')
     376
     377        # list of str
     378        ids = ['1b21e445-f29a...', '1b21e445-f29a...', '1b21e445-f29a...']
     379        projectal.Project.get(ids)
     380
     381        # dict
     382        project = project.Project.create({'name': 'MyProject'})
     383        # project = {'uuId': '1b21e445-f29a...', 'name': 'MyProject', ...}
     384        projectal.Project.get(project)
     385
     386        # list of dicts (e.g. from a query)
     387        # projects = [{'uuId': '1b21e445-f29a...'}, {'uuId': '1b21e445-f29a...'}, ...]
     388        project.Project.get(projects)
     389
     390        # str with links
     391        projectal.Project.get('1b21e445-f29a...', 'links=['company', 'location']')
     392        ```
     393
     394        `deleted_at`: Include this parameter to get a deleted entity.
     395        This value should be a UTC timestamp from a webhook delete event.
     396        """
     397        link_set = cls._get_linkset(links)
     398
     399        if isinstance(entities, str):
     400            # String input is a uuId
     401            payload = [{'uuId': entities}]
     402        elif isinstance(entities, dict):
     403            # Dict input needs to be a list
     404            payload = [entities]
     405        elif isinstance(entities, list):
     406            # List input can be a list of uuIds or list of dicts
     407            # If uuIds (strings), convert to list of dicts
     408            if len(entities) > 0 and isinstance(entities[0], str):
     409                payload = [{'uuId': uuId} for uuId in entities]
     410            else:
     411                # Already expected format
     412                payload = entities
     413        else:
     414            # We have a list of dicts already, the expected format
     415            payload = entities
     416
     417        if deleted_at:
     418            if not isinstance(deleted_at, int):
     419                raise projectal.UsageException("deleted_at must be a number")
     420
     421        url = '/api/{}/get'.format(cls._path)
     422        params = []
     423        params.append('links={}'.format(','.join(links))) if links else None
     424        params.append('epoch={}'.format(deleted_at - 1)) if deleted_at else None
     425        if len(params) > 0:
     426            url += '?' + '&'.join(params)
     427
     428        # We only need to send over the uuIds
     429        payload = [{'uuId': e['uuId']} for e in payload]
     430        if not payload:
     431            return []
     432        objects = []
     433        for i in range(0, len(payload), projectal.chunk_size_read):
     434            chunk = payload[i:i + projectal.chunk_size_read]
     435            dicts = api.post(url, chunk)
     436            for d in dicts:
     437                obj = cls(d)
     438                obj._with_links.update(link_set)
     439                obj._is_new = False
     440                # Create default fields for links we ask for. Workaround for backend
     441                # sometimes omitting links if no links exist.
     442                for link_name in link_set:
     443                    link_def = obj._link_def_by_name[link_name]
     444                    if link_def['link_key'] not in obj:
     445                        if link_def['type'] == dict:
     446                            obj.set_readonly(link_def['link_key'], None)
     447                        else:
     448                            obj.set_readonly(link_def['link_key'], link_def['type']())
     449                objects.append(obj)
     450
     451        if not isinstance(entities, list):
     452            return objects[0]
     453        return objects
     454
     455    def __get(self, *args, **kwargs):
     456        """Use the dict get for instances."""
     457        return super(Entity, self).get(*args, **kwargs)
     458
     459    @classmethod
     460    def update(cls, entities, batch_linking=True):
     461        """
     462        Save one or more entities of the same type. The entity
     463        type is determined by the subclass calling this method.
     464        Only the fields that have been modifier will be sent
     465        to the server as part of the request.
     466
     467        `entities`: Can be a `dict` to update a single entity,
     468        or a list of `dict`s to update many entities in bulk.
     469
     470        'batch_linking': Enabled by default, batches any link
     471        updates required into composite API requests. If disabled
     472        a request will be executed for each link update.
     473        Recommended to leave enabled to increase performance.
     474
     475        Returns `True` if all entities update successfully.
     476
     477        ```
     478        # Example usage:
     479        rebate = projectal.Rebate.create({'name': 'Rebate2022', 'rebate': 0.2})
     480        rebate['name'] = 'Rebate2024'
     481        projectal.Rebate.update(rebate)
     482        # Returns True. New rebate name has been saved.
     483        ```
     484        """
     485        if isinstance(entities, dict):
     486            e_list = [entities]
     487        else:
     488            e_list = entities
     489
     490        # Reduce the list to only modified entities and their modified fields.
     491        # Only do this to an Entity subclass - the consumer may have passed
     492        # in a dict of changes on their own.
     493        payload = []
     494
     495        for e in e_list:
     496            if isinstance(e, Entity):
     497                changes = e._changes_internal()
     498                if changes:
     499                    changes['uuId'] = e['uuId']
     500                    payload.append(changes)
     501            else:
     502                payload.append(e)
     503        if payload:
     504            for i in range(0, len(payload), projectal.chunk_size_write):
     505                chunk = payload[i:i + projectal.chunk_size_write]
     506                api.put('/api/{}/update'.format(cls._path), chunk)
     507
     508        # Detect and apply any link changes
     509        # if batch_linking is enabled, builds a list of link requests
     510        # from the changes of each entity, then executes
     511        # composite API requests with those changes
     512        link_request_batch = []
     513        for e in e_list:
     514            if isinstance(e, Entity):
     515                requests = e.__apply_link_changes(batch_linking=batch_linking)
     516                link_request_batch.extend(requests)
     517
     518        if len(link_request_batch) > 0 and batch_linking:
     519            for i in range(0, len(link_request_batch), 1000):
     520                chunk = link_request_batch[i:i + 1000]
     521                api.post('/api/composite', chunk)
     522
     523        return True
     524
     525    def __update(self, *args, **kwargs):
     526        """Use the dict update for instances."""
     527        return super(Entity, self).update(*args, **kwargs)
     528
     529    def save(self):
     530        """Calls `update()` on this instance of the entity, saving
     531        it to the database."""
     532        return self.__class__.update(self)
     533
     534    @classmethod
     535    def delete(cls, entities):
     536        """
     537        Delete one or more entities of the same type. The entity
     538        type is determined by the subclass calling this method.
     539
     540        `entities`: See `Entity.get()` for expected formats.
     541
     542        ```
     543        # Example usage:
     544        ids = ['1b21e445-f29a...', '1b21e445-f29a...', '1b21e445-f29a...']
     545        projectal.Customer.delete(ids)
     546        ```
     547        """
     548        if isinstance(entities, str):
     549            # String input is a uuId
     550            payload = [{'uuId': entities}]
     551        elif isinstance(entities, dict):
     552            # Dict input needs to be a list
     553            payload = [entities]
     554        elif isinstance(entities, list):
     555            # List input can be a list of uuIds or list of dicts
     556            # If uuIds (strings), convert to list of dicts
     557            if len(entities) > 0 and isinstance(entities[0], str):
     558                payload = [{'uuId': uuId} for uuId in entities]
     559            else:
     560                # Already expected format
     561                payload = entities
     562        else:
     563            # We have a list of dicts already, the expected format
     564            payload = entities
     565
     566        # We only need to send over the uuIds
     567        payload = [{'uuId': e['uuId']} for e in payload]
     568        if not payload:
     569            return True
     570        for i in range(0, len(payload), projectal.chunk_size_write):
     571            chunk = payload[i:i + projectal.chunk_size_write]
     572            api.delete('/api/{}/delete'.format(cls._path), chunk)
     573        return True
     574
     575    def __delete(self):
     576        """Let an instance delete itself."""
     577        return self.__class__.delete(self)
     578
     579    def clone(self, entity):
     580        """
     581        Clones an entity and returns its `uuId`.
     582
     583        Each entity has its own set of required values when cloning.
     584        Check the API documentation of that entity for details.
     585        """
     586        url = '/api/{}/clone?reference={}'.format(self._path, self['uuId'])
     587        response = api.post(url, entity)
     588        return response['jobClue']['uuId']
     589
     590    @classmethod
     591    def history(cls, UUID, start=0, limit=-1, order='desc', epoch=None, event=None):
     592        """
     593        Returns an ordered list of all changes made to the entity.
     594
     595        `UUID`: the UUID of the entity.
     596
     597        `start`: Start index for pagination (default: `0`).
     598
     599        `limit`: Number of results to include for pagination. Use
     600        `-1` to return the entire history (default: `-1`).
     601
     602        `order`: `asc` or `desc` (default: `desc` (index 0 is newest))
     603
     604        `epoch`: only return the history UP TO epoch date
     605
     606        `event`:
     607        """
     608        url = '/api/{}/history?holder={}&'.format(cls._path, UUID)
     609        params = []
     610        params.append('start={}'.format(start))
     611        params.append('limit={}'.format(limit))
     612        params.append('order={}'.format(order))
     613        params.append('epoch={}'.format(epoch)) if epoch else None
     614        params.append('event={}'.format(event)) if event else None
     615        url += '&'.join(params)
     616        return api.get(url)
     617
     618    def __history(self, **kwargs):
     619        """Get history of instance."""
     620        return self.__class__.history(self['uuId'], **kwargs)
     621
     622    @classmethod
     623    def list(cls, expand=False, links=None):
     624        """Return a list of all entity UUIDs of this type.
     625
     626        You may pass in `expand=True` to get full Entity objects
     627        instead, but be aware this may be very slow if you have
     628        thousands of objects.
     629
     630        If you are expanding the objects, you may further expand
     631        the results with `links`.
     632        """
     633
     634        payload = {
     635            "name": "List all entities of type {}".format(cls._name.upper()),
     636            "type": "msql", "start": 0, "limit": -1,
     637            "select": [
     638                ["{}.uuId".format(cls._name.upper())]
     639            ],
     640        }
     641        ids = api.query(payload)
     642        ids = [id[0] for id in ids]
     643        if ids:
     644            return cls.get(ids, links=links) if expand else ids
     645        return []
     646
     647    @classmethod
     648    def match(cls, field, term, links=None):
     649        """Find entities where `field`=`term` (exact match), optionally
     650        expanding the results with `links`.
     651
     652        Relies on `Entity.query()` with a pre-built set of rules.
     653        ```
     654        projects = projectal.Project.match('identifier', 'zmb-005')
     655        ```
     656        """
     657        filter = [["{}.{}".format(cls._name.upper(), field), "eq", term]]
     658        return cls.query(filter, links)
     659
     660    @classmethod
     661    def match_startswith(cls, field, term, links=None):
     662        """Find entities where `field` starts with the text `term`,
     663        optionally expanding the results with `links`.
     664
     665        Relies on `Entity.query()` with a pre-built set of rules.
     666        ```
     667        projects = projectal.Project.match_startswith('name', 'Zomb')
     668        ```
     669        """
     670        filter = [["{}.{}".format(cls._name.upper(), field), "prefix", term]]
     671        return cls.query(filter, links)
     672
     673    @classmethod
     674    def match_endswith(cls, field, term, links=None):
     675        """Find entities where `field` ends with the text `term`,
     676        optionally expanding the results with `links`.
     677
     678        Relies on `Entity.query()` with a pre-built set of rules.
     679        ```
     680        projects = projectal.Project.match_endswith('identifier', '-2023')
     681        ```
     682        """
     683        term = "(?i).*{}$".format(term)
     684        filter = [["{}.{}".format(cls._name.upper(), field), "regex", term]]
     685        return cls.query(filter, links)
     686
     687    @classmethod
     688    def match_one(cls, field, term, links=None):
     689        """Convenience function for match(). Returns the first match or None."""
     690        matches = cls.match(field, term, links)
     691        if matches:
     692            return matches[0]
     693
     694    @classmethod
     695    def match_startswith_one(cls, field, term, links=None):
     696        """Convenience function for match_startswith(). Returns the first match or None."""
     697        matches = cls.match_startswith(field, term, links)
     698        if matches:
     699            return matches[0]
     700
     701    @classmethod
     702    def match_endswith_one(cls, field, term, links=None):
     703        """Convenience function for match_endswith(). Returns the first match or None."""
     704        matches = cls.match_endswith(field, term, links)
     705        if matches:
     706            return matches[0]
     707
     708    @classmethod
     709    def search(cls, fields=None, term='', case_sensitive=True, links=None):
     710        """Find entities that contain the text `term` within `fields`.
     711        `fields` is a list of field names to target in the search.
     712
     713        `case_sensitive`: Optionally turn off case sensitivity in the search.
     714
     715        Relies on `Entity.query()` with a pre-built set of rules.
     716        ```
     717        projects = projectal.Project.search(['name', 'description'], 'zombie')
     718        ```
     719        """
     720        filter = []
     721        term = '(?{}).*{}.*'.format('' if case_sensitive else '?', term)
     722        for field in fields:
     723            filter.append(["{}.{}".format(cls._name.upper(), field), "regex", term])
     724        filter = ['_or_', filter]
     725        return cls.query(filter, links)
     726
     727    @classmethod
     728    def query(cls, filter, links=None):
     729        """Run a query on this entity with the supplied filter.
     730
     731        The query is already set up to target this entity type, and the
     732        results will be converted into full objects when found, optionally
     733        expanded with the `links` provided. You only need to supply a
     734        filter to reduce the result set.
     735
     736        See [the filter documentation](https://projectal.com/docs/v1.1.1#section/Filter-section)
     737        for a detailed overview of the kinds of filters you can construct.
     738        """
     739        payload = {
     740            "name": "Python library entity query ({})".format(cls._name.upper()),
     741            "type": "msql", "start": 0, "limit": -1,
     742            "select": [
     743                ["{}.uuId".format(cls._name.upper())]
     744            ],
     745            "filter": filter
     746        }
     747        ids = api.query(payload)
     748        ids = [id[0] for id in ids]
     749        if ids:
     750            return cls.get(ids, links=links)
     751        return []
     752
     753    def profile_get(self, key):
     754        """Get the profile (metadata) stored for this entity at `key`."""
     755        return projectal.profile.get(key, self.__class__._name.lower(), self['uuId'])
     756
     757    def profile_set(self, key, data):
     758        """Set the profile (metadata) stored for this entity at `key`. The contents
     759        of `data` will completely overwrite the existing data dictionary."""
     760        return projectal.profile.set(key, self.__class__._name.lower(), self['uuId'], data)
     761
     762    def __type_links(self):
     763        """Find links and turn their dicts into typed objects matching their Entity type."""
     764
     765        for key, _def in self._link_def_by_key.items():
     766            if key in self:
     767                cls = getattr(projectal, _def['entity'])
     768                if _def['type'] == list:
     769                    as_obj = []
     770                    for link in self[key]:
     771                        as_obj.append(cls(link))
     772                elif _def['type'] == dict:
     773                    as_obj = cls(self[key])
     774                else:
     775                    raise projectal.UsageException("Unexpected link type")
     776                self[key] = as_obj
     777
     778    def changes(self):
     779        """Return a dict containing the fields that have changed since fetching the object.
     780        Dict values contain both the old and new values. E.g.: {'old': 'original', 'new': 'current'}.
     781
     782        In the case of link lists, there are three values: added, removed, updated. Only links with
     783        a data attribute can end up in the updated list, and the old/new dictionary is placed within
     784        that data attribute. E.g. for a staff-resource link:
     785        'updated': [{
     786            'uuId': '24eb4c31-0f92-49d1-8b4d-507ab939003e',
     787            'resourceLink': {'quantity': {'old': 2, 'new': 5}}
     788        }]
     789        """
     790        changed = {}
     791        for key in self.keys():
     792            link_def = self._link_def_by_key.get(key)
     793            if link_def:
     794                changes = self._changes_for_link_list(link_def, key)
     795                # Only add it if something in it changed
     796                for action in changes.values():
     797                    if len(action):
     798                        changed[key] = changes
     799                        break
     800            elif key not in self.__old and self[key] is not None:
     801                changed[key] = {'old': None, 'new': self[key]}
     802            elif self.__old.get(key) != self[key]:
     803                changed[key] = {'old': self.__old.get(key), 'new': self[key]}
     804        return changed
     805
     806    def _changes_for_link_list(self, link_def, key):
     807        changes = self.__apply_list(link_def, report_only=True)
     808        data_key = link_def['data_name']
     809
     810        # For linked entities, we will only report their UUID, name (if it has one),
     811        # and the content of their data attribute (if it has one).
     812        def get_slim_list(entities):
     813            slim = []
     814            if isinstance(entities, dict):
     815                entities = [entities]
     816            for e in entities:
     817                fields = {'uuId': e['uuId']}
     818                name = e.get('name')
     819                if name:
     820                    fields['name'] = e['name']
     821                if data_key and e[data_key]:
     822                    fields[data_key] = e[data_key]
     823                slim.append(fields)
     824            return slim
     825
     826        out = {
     827            'added': get_slim_list(changes.get('add', [])),
     828            'updated': [],
     829            'removed': get_slim_list(changes.get('remove', [])),
     830        }
     831
     832        updated = changes.get('update', [])
     833        if updated:
     834            before_map = {}
     835            for entity in self.__old.get(key):
     836                before_map[entity['uuId']] = entity
     837
     838            for entity in updated:
     839                old_data = before_map[entity['uuId']][data_key]
     840                new_data = entity[data_key]
     841                diff = {}
     842                for key in new_data.keys():
     843                    if key not in old_data and new_data[key] is not None:
     844                        diff[key] = {'old': None, 'new': new_data[key]}
     845                    elif old_data.get(key) != new_data[key]:
     846                        diff[key] = {'old': old_data.get(key), 'new': new_data[key]}
     847                out['updated'].append({'uuId': entity['uuId'], data_key: diff})
     848        return out
     849
     850    def _changes_internal(self):
     851        """Return a dict containing only the fields that have changed and their current value,
     852        without any link data.
     853
     854        This method is used internally to strip payloads down to only the fields that have changed.
     855        """
     856        changed = {}
     857        for key in self.keys():
     858            # We don't deal with link or link data changes here. We only want standard fields.
     859            if key in self._link_def_by_key:
     860                continue
     861            if key not in self.__old and self[key] is not None:
     862                changed[key] = self[key]
     863            elif self.__old.get(key) != self[key]:
     864                changed[key] = self[key]
     865        return changed
     866
     867    def set_readonly(self, key, value):
     868        """Set a field on this Entity that will not be sent over to the
     869        server on update unless modified."""
     870        self[key] = value
     871        self.__old[key] = value
     872
     873    # --- Link management ---
     874
     875    @staticmethod
     876    def __link_data_differs(have_link, want_link, data_key):
     877
     878        if data_key:
     879            if 'uuId' in have_link[data_key]:
     880                del have_link[data_key]['uuId']
     881            if 'uuId' in want_link[data_key]:
     882                del want_link[data_key]['uuId']
     883            return have_link[data_key] != want_link[data_key]
     884
     885        # Links without data never differ
     886        return False
     887
     888    def __apply_link_changes(self, batch_linking=True):
     889        """Send each link list to the conflict resolver. If we detect
     890        that the entity was not fetched with that link, we do the fetch
     891        first and use the result as the basis for comparison."""
     892
     893        # Find which lists belong to links but were not fetched so we can fetch them
     894        need = []
     895        find_list = []
     896        if not self._is_new:
     897            for link in self._link_def_by_key.values():
     898                if link['link_key'] in self and link['name'] not in self._with_links:
     899                    need.append(link['name'])
     900                    find_list.append(link['link_key'])
     901
     902        if len(need):
     903            logging.warning("Entity links were modified but entity not fetched with links. "
     904                            "For better performance, include the links when getting the entity.")
     905            logging.warning("Fetching {} again with missing links: {}".format(self._name.upper(), ','.join(need)))
     906            new = self.__fetch(self, links=need)
     907            for _list in find_list:
     908                self.__old[_list] = copy.deepcopy(new.get(_list, []))
     909
     910        # if batch_linking is enabled, builds a list of link requests
     911        # for each link definition of the calling entity then returns the list
     912        request_list = []
     913        for link_def in self._link_def_by_key.values():
     914            link_def_requests = self.__apply_list(link_def, batch_linking=batch_linking)
     915            if batch_linking:
     916                request_list.extend(link_def_requests)
     917        return request_list
     918
     919    def __apply_list(self, link_def, report_only=False, batch_linking=True):
     920        """Automatically resolve differences and issue the correct sequence of
     921        link/unlink/relink for the link list to result in the supplied list
     922        of entities.
     923
     924        report_only will not make any changes to the data or issue network requests.
     925        Instead, it returns the three lists of changes (add, update, delete).
     926        """
     927        to_add = []
     928        to_remove = []
     929        to_update = []
     930        should_only_have = set()
     931        link_key = link_def['link_key']
     932
     933        if link_def['type'] == list:
     934            want_entities = self.get(link_key, [])
     935            have_entities = self.__old.get(link_key, [])
     936
     937            if not isinstance(want_entities, list):
     938                raise api.UsageException("Expecting '{}' to be {}. Found {} instead.".format(
     939                    link_key, link_def['type'].__name__, type(want_entities).__name__))
     940
     941            for want_entity in want_entities:
     942                if want_entity['uuId'] in should_only_have:
     943                    raise api.UsageException("Duplicate {} in {}".format(link_def['name'], link_key))
     944                should_only_have.add(want_entity['uuId'])
     945                have = False
     946                for have_entity in have_entities:
     947                    if have_entity['uuId'] == want_entity['uuId']:
     948                        have = True
     949                        data_name = link_def.get('data_name')
     950                        if data_name and self.__link_data_differs(have_entity, want_entity, data_name):
     951                            to_update.append(want_entity)
     952                if not have:
     953                    to_add.append(want_entity)
     954            for have_entity in have_entities:
     955                if have_entity['uuId'] not in should_only_have:
     956                    to_remove.append(have_entity)
     957        elif link_def['type'] == dict:
     958            # Note: dict type does not implement updates as we have no dict links
     959            # that support update (yet?).
     960            want_entity = self.get(link_key, None)
     961            have_entity = self.__old.get(link_key, None)
     962
     963            if want_entity is not None and not isinstance(want_entity, dict):
     964                raise api.UsageException("Expecting '{}' to be {}. Found {} instead.".format(
     965                    link_key, link_def['type'].__name__, type(have_entity).__name__))
     966
     967            if want_entity:
     968                if have_entity:
     969                    if want_entity['uuId'] != have_entity['uuId']:
     970                        to_remove = have_entity
     971                        to_add = want_entity
     972                else:
     973                    to_add = want_entity
     974            if not want_entity:
     975                if have_entity:
     976                    to_remove = have_entity
     977
     978            want_entities = want_entity
     979        else:
     980            # Would be an error in this library if we reach here
     981            raise projectal.UnsupportedException("This type does not support linking")
     982
     983        # if batch_linking is enabled, builds a list of requests
     984        # from each link method
     985        if not report_only:
     986            request_list = []
     987            if to_remove:
     988                delete_requests = self._link(
     989                        link_def['name'], to_remove, 'delete',
     990                        update_cache=False, batch_linking=batch_linking
     991                )
     992                request_list.extend(delete_requests)
     993            if to_update:
     994                update_requests = self._link(
     995                    link_def['name'], to_update, 'update',
     996                    update_cache=False, batch_linking=batch_linking
     997                )
     998                request_list.extend(update_requests)
     999            if to_add:
    1000                add_requests = self._link(
    1001                    link_def['name'], to_add, 'add',
    1002                    update_cache=False, batch_linking=batch_linking
    1003                )
    1004                request_list.extend(add_requests)
    1005            self.__old[link_key] = copy.deepcopy(want_entities)
    1006            return request_list
    1007        else:
    1008            changes = {}
    1009            if to_remove:
    1010                changes['remove'] = to_remove
    1011            if to_update:
    1012                changes['update'] = to_update
    1013            if to_add:
    1014                changes['add'] = to_add
    1015            return changes
    1016
    1017    @classmethod
    1018    def get_link_definitions(cls):
    1019        return cls({})._link_def_by_name
    1020    # --- ---
    1021
    1022    def entity_name(self):
    1023        return self._name.capitalize()

The parent class for all our entities, offering requests and validation for the fundamental create/read/update/delete operations.

This class (and all our entities) inherit from the builtin `dict` class. This means all entity classes can be used like standard Python dictionary objects, but we can also offer additional utility functions that operate on the instance itself (see `linkers` for an example). Any method that expects a `dict` can also consume an `Entity` subclass.

The class methods in this class can operate on one or more entities in one request. If the methods are called with lists (for batch operation), the output returned will also be a list. Otherwise, a single `Entity` subclass is returned.

Note for batch operations: a `ProjectalException` is raised if any of the entities fail during the operation. The changes will still be saved to the database for the entities that did not fail.

@classmethod
def get(cls, entities, links=None, deleted_at=None): View Source

    352    @classmethod
    353    def get(cls, entities, links=None, deleted_at=None):
    354        """
    355        Get one or more entities of the same type. The entity
    356        type is determined by the subclass calling this method.
    357
    358        `entities`: One of several formats containing the `uuId`s
    359        of the entities you want to get (see bottom for examples):
    360
    361        - `str` or list of `str`
    362        - `dict` or list of `dict` (with `uuId` key)
    363
    364        `links`: A case-insensitive list of entity names to fetch with
    365        this entity. For performance reasons, links are only returned
    366        on demand.
    367
    368        Links follow a common naming convention in the output with
    369        a *_List* suffix. E.g.:
    370        `links=['company', 'location']` will appear as `companyList` and
    371        `locationList` in the response.
    372        ```
    373        # Example usage:
    374        # str
    375        projectal.Project.get('1b21e445-f29a-4a9f-95ff-fe253a3e1b11')
    376
    377        # list of str
    378        ids = ['1b21e445-f29a...', '1b21e445-f29a...', '1b21e445-f29a...']
    379        projectal.Project.get(ids)
    380
    381        # dict
    382        project = project.Project.create({'name': 'MyProject'})
    383        # project = {'uuId': '1b21e445-f29a...', 'name': 'MyProject', ...}
    384        projectal.Project.get(project)
    385
    386        # list of dicts (e.g. from a query)
    387        # projects = [{'uuId': '1b21e445-f29a...'}, {'uuId': '1b21e445-f29a...'}, ...]
    388        project.Project.get(projects)
    389
    390        # str with links
    391        projectal.Project.get('1b21e445-f29a...', 'links=['company', 'location']')
    392        ```
    393
    394        `deleted_at`: Include this parameter to get a deleted entity.
    395        This value should be a UTC timestamp from a webhook delete event.
    396        """
    397        link_set = cls._get_linkset(links)
    398
    399        if isinstance(entities, str):
    400            # String input is a uuId
    401            payload = [{'uuId': entities}]
    402        elif isinstance(entities, dict):
    403            # Dict input needs to be a list
    404            payload = [entities]
    405        elif isinstance(entities, list):
    406            # List input can be a list of uuIds or list of dicts
    407            # If uuIds (strings), convert to list of dicts
    408            if len(entities) > 0 and isinstance(entities[0], str):
    409                payload = [{'uuId': uuId} for uuId in entities]
    410            else:
    411                # Already expected format
    412                payload = entities
    413        else:
    414            # We have a list of dicts already, the expected format
    415            payload = entities
    416
    417        if deleted_at:
    418            if not isinstance(deleted_at, int):
    419                raise projectal.UsageException("deleted_at must be a number")
    420
    421        url = '/api/{}/get'.format(cls._path)
    422        params = []
    423        params.append('links={}'.format(','.join(links))) if links else None
    424        params.append('epoch={}'.format(deleted_at - 1)) if deleted_at else None
    425        if len(params) > 0:
    426            url += '?' + '&'.join(params)
    427
    428        # We only need to send over the uuIds
    429        payload = [{'uuId': e['uuId']} for e in payload]
    430        if not payload:
    431            return []
    432        objects = []
    433        for i in range(0, len(payload), projectal.chunk_size_read):
    434            chunk = payload[i:i + projectal.chunk_size_read]
    435            dicts = api.post(url, chunk)
    436            for d in dicts:
    437                obj = cls(d)
    438                obj._with_links.update(link_set)
    439                obj._is_new = False
    440                # Create default fields for links we ask for. Workaround for backend
    441                # sometimes omitting links if no links exist.
    442                for link_name in link_set:
    443                    link_def = obj._link_def_by_name[link_name]
    444                    if link_def['link_key'] not in obj:
    445                        if link_def['type'] == dict:
    446                            obj.set_readonly(link_def['link_key'], None)
    447                        else:
    448                            obj.set_readonly(link_def['link_key'], link_def['type']())
    449                objects.append(obj)
    450
    451        if not isinstance(entities, list):
    452            return objects[0]
    453        return objects

Get one or more entities of the same type. The entity type is determined by the subclass calling this method.

`entities`: One of several formats containing the `uuId`s of the entities you want to get (see bottom for examples):

- `str` or list of `str`
- `dict` or list of `dict` (with `uuId` key)

`links`: A case-insensitive list of entity names to fetch with this entity. For performance reasons, links are only returned on demand.

Links follow a common naming convention in the output with a _List suffix. E.g.: `links=['company', 'location']` will appear as `companyList` and `locationList` in the response.

    # Example usage:
    # str
    projectal.Project.get('1b21e445-f29a-4a9f-95ff-fe253a3e1b11')

    # list of str
    ids = ['1b21e445-f29a...', '1b21e445-f29a...', '1b21e445-f29a...']
    projectal.Project.get(ids)

    # dict
    project = project.Project.create({'name': 'MyProject'})
    # project = {'uuId': '1b21e445-f29a...', 'name': 'MyProject', ...}
    projectal.Project.get(project)

    # list of dicts (e.g. from a query)
    # projects = [{'uuId': '1b21e445-f29a...'}, {'uuId': '1b21e445-f29a...'}, ...]
    project.Project.get(projects)

    # str with links
    projectal.Project.get('1b21e445-f29a...', 'links=['company', 'location']')

`deleted_at`: Include this parameter to get a deleted entity. This value should be a UTC timestamp from a webhook delete event.

@classmethod
def update(cls, entities, batch_linking=True): View Source

    459    @classmethod
    460    def update(cls, entities, batch_linking=True):
    461        """
    462        Save one or more entities of the same type. The entity
    463        type is determined by the subclass calling this method.
    464        Only the fields that have been modifier will be sent
    465        to the server as part of the request.
    466
    467        `entities`: Can be a `dict` to update a single entity,
    468        or a list of `dict`s to update many entities in bulk.
    469
    470        'batch_linking': Enabled by default, batches any link
    471        updates required into composite API requests. If disabled
    472        a request will be executed for each link update.
    473        Recommended to leave enabled to increase performance.
    474
    475        Returns `True` if all entities update successfully.
    476
    477        ```
    478        # Example usage:
    479        rebate = projectal.Rebate.create({'name': 'Rebate2022', 'rebate': 0.2})
    480        rebate['name'] = 'Rebate2024'
    481        projectal.Rebate.update(rebate)
    482        # Returns True. New rebate name has been saved.
    483        ```
    484        """
    485        if isinstance(entities, dict):
    486            e_list = [entities]
    487        else:
    488            e_list = entities
    489
    490        # Reduce the list to only modified entities and their modified fields.
    491        # Only do this to an Entity subclass - the consumer may have passed
    492        # in a dict of changes on their own.
    493        payload = []
    494
    495        for e in e_list:
    496            if isinstance(e, Entity):
    497                changes = e._changes_internal()
    498                if changes:
    499                    changes['uuId'] = e['uuId']
    500                    payload.append(changes)
    501            else:
    502                payload.append(e)
    503        if payload:
    504            for i in range(0, len(payload), projectal.chunk_size_write):
    505                chunk = payload[i:i + projectal.chunk_size_write]
    506                api.put('/api/{}/update'.format(cls._path), chunk)
    507
    508        # Detect and apply any link changes
    509        # if batch_linking is enabled, builds a list of link requests
    510        # from the changes of each entity, then executes
    511        # composite API requests with those changes
    512        link_request_batch = []
    513        for e in e_list:
    514            if isinstance(e, Entity):
    515                requests = e.__apply_link_changes(batch_linking=batch_linking)
    516                link_request_batch.extend(requests)
    517
    518        if len(link_request_batch) > 0 and batch_linking:
    519            for i in range(0, len(link_request_batch), 1000):
    520                chunk = link_request_batch[i:i + 1000]
    521                api.post('/api/composite', chunk)
    522
    523        return True

Save one or more entities of the same type. The entity type is determined by the subclass calling this method. Only the fields that have been modifier will be sent to the server as part of the request.

`entities`: Can be a `dict` to update a single entity, or a list of `dict`s to update many entities in bulk.

'batch_linking': Enabled by default, batches any link updates required into composite API requests. If disabled a request will be executed for each link update. Recommended to leave enabled to increase performance.

Returns `True` if all entities update successfully.

    # Example usage:
    rebate = projectal.Rebate.create({'name': 'Rebate2022', 'rebate': 0.2})
    rebate['name'] = 'Rebate2024'
    projectal.Rebate.update(rebate)
    # Returns True. New rebate name has been saved.

@classmethod
def delete(cls, entities): View Source

    534    @classmethod
    535    def delete(cls, entities):
    536        """
    537        Delete one or more entities of the same type. The entity
    538        type is determined by the subclass calling this method.
    539
    540        `entities`: See `Entity.get()` for expected formats.
    541
    542        ```
    543        # Example usage:
    544        ids = ['1b21e445-f29a...', '1b21e445-f29a...', '1b21e445-f29a...']
    545        projectal.Customer.delete(ids)
    546        ```
    547        """
    548        if isinstance(entities, str):
    549            # String input is a uuId
    550            payload = [{'uuId': entities}]
    551        elif isinstance(entities, dict):
    552            # Dict input needs to be a list
    553            payload = [entities]
    554        elif isinstance(entities, list):
    555            # List input can be a list of uuIds or list of dicts
    556            # If uuIds (strings), convert to list of dicts
    557            if len(entities) > 0 and isinstance(entities[0], str):
    558                payload = [{'uuId': uuId} for uuId in entities]
    559            else:
    560                # Already expected format
    561                payload = entities
    562        else:
    563            # We have a list of dicts already, the expected format
    564            payload = entities
    565
    566        # We only need to send over the uuIds
    567        payload = [{'uuId': e['uuId']} for e in payload]
    568        if not payload:
    569            return True
    570        for i in range(0, len(payload), projectal.chunk_size_write):
    571            chunk = payload[i:i + projectal.chunk_size_write]
    572            api.delete('/api/{}/delete'.format(cls._path), chunk)
    573        return True

Delete one or more entities of the same type. The entity type is determined by the subclass calling this method.

`entities`: See `Entity.get()` for expected formats.

    # Example usage:
    ids = ['1b21e445-f29a...', '1b21e445-f29a...', '1b21e445-f29a...']
    projectal.Customer.delete(ids)

@classmethod
def history(cls, UUID, start=0, limit=-1, order='desc', epoch=None, event=None): View Source

    590    @classmethod
    591    def history(cls, UUID, start=0, limit=-1, order='desc', epoch=None, event=None):
    592        """
    593        Returns an ordered list of all changes made to the entity.
    594
    595        `UUID`: the UUID of the entity.
    596
    597        `start`: Start index for pagination (default: `0`).
    598
    599        `limit`: Number of results to include for pagination. Use
    600        `-1` to return the entire history (default: `-1`).
    601
    602        `order`: `asc` or `desc` (default: `desc` (index 0 is newest))
    603
    604        `epoch`: only return the history UP TO epoch date
    605
    606        `event`:
    607        """
    608        url = '/api/{}/history?holder={}&'.format(cls._path, UUID)
    609        params = []
    610        params.append('start={}'.format(start))
    611        params.append('limit={}'.format(limit))
    612        params.append('order={}'.format(order))
    613        params.append('epoch={}'.format(epoch)) if epoch else None
    614        params.append('event={}'.format(event)) if event else None
    615        url += '&'.join(params)
    616        return api.get(url)

Returns an ordered list of all changes made to the entity.

`UUID`: the UUID of the entity.

`start`: Start index for pagination (default: `0`).

`limit`: Number of results to include for pagination. Use `-1` to return the entire history (default: `-1`).

`order`: `asc` or `desc` (default: `desc` (index 0 is newest))

`epoch`: only return the history UP TO epoch date

`event`:

@classmethod
def create(cls, entities, params=None, batch_linking=True): View Source

    240    @classmethod
    241    def create(cls, entities, params=None, batch_linking=True):
    242        """
    243        Create one or more entities of the same type. The entity
    244        type is determined by the subclass calling this method.
    245
    246        `entities`: Can be a `dict` to create a single entity,
    247        or a list of `dict`s to create many entities in bulk.
    248
    249        `params`: Optional URL parameters that may apply to the
    250        entity's API (e.g: `?holder=1234`).
    251
    252        'batch_linking': Enabled by default, batches any link
    253        updates required into composite API requests. If disabled
    254        a request will be executed for each link update.
    255        Recommended to leave enabled to increase performance.
    256
    257        If input was a `dict`, returns an entity subclass. If input was
    258        a list of `dict`s, returns a list of entity subclasses.
    259
    260        ```
    261        # Example usage:
    262        projectal.Customer.create({'name': 'NewCustomer'})
    263        # returns Customer object
    264        ```
    265        """
    266
    267        if isinstance(entities, dict):
    268            # Dict input needs to be a list
    269            e_list = [entities]
    270        else:
    271            # We have a list of dicts already, the expected format
    272            e_list = entities
    273
    274        # Apply type
    275        typed_list = []
    276        for e in e_list:
    277            if not isinstance(e, Entity):
    278                # Start empty to correctly populate history
    279                new = cls({})
    280                new.update(e)
    281                typed_list.append(new)
    282            else:
    283                typed_list.append(e)
    284        e_list = typed_list
    285
    286        endpoint = '/api/{}/add'.format(cls._path)
    287        if params:
    288            endpoint += params
    289        if not e_list:
    290            return []
    291
    292        # Strip links from payload
    293        payload = []
    294        keys = e_list[0]._link_def_by_key.keys()
    295        for e in e_list:
    296            cleancopy = copy.deepcopy(e)
    297            # Remove any fields that match a link key
    298            for key in keys:
    299                cleancopy.pop(key, None)
    300            payload.append(cleancopy)
    301
    302        objects = []
    303        for i in range(0, len(payload), projectal.chunk_size_write):
    304            chunk = payload[i:i + projectal.chunk_size_write]
    305            orig_chunk = e_list[i:i + projectal.chunk_size_write]
    306            response = api.post(endpoint, chunk)
    307            # Put uuId from response into each input dict
    308            for e, o, orig in zip(chunk, response, orig_chunk):
    309                orig['uuId'] = o['uuId']
    310                orig.__old = copy.deepcopy(orig)
    311                # Delete links from the history in order to trigger a change on them after
    312                for key in orig._link_def_by_key:
    313                    orig.__old.pop(key, None)
    314                objects.append(orig)
    315
    316        # Detect and apply any link additions
    317        # if batch_linking is enabled, builds a list of link requests
    318        # needed for each entity, then executes them with composite
    319        # API requests
    320        link_request_batch = []
    321        for e in e_list:
    322            requests = e.__apply_link_changes(batch_linking=batch_linking)
    323            link_request_batch.extend(requests)
    324
    325        if len(link_request_batch) > 0 and batch_linking:
    326            for i in range(0, len(link_request_batch), 1000):
    327                chunk = link_request_batch[i:i + 1000]
    328                api.post('/api/composite', chunk)
    329
    330        if not isinstance(entities, list):
    331            return objects[0]
    332        return objects

Create one or more entities of the same type. The entity type is determined by the subclass calling this method.

`entities`: Can be a `dict` to create a single entity, or a list of `dict`s to create many entities in bulk.

`params`: Optional URL parameters that may apply to the entity's API (e.g: `?holder=1234`).

'batch_linking': Enabled by default, batches any link updates required into composite API requests. If disabled a request will be executed for each link update. Recommended to leave enabled to increase performance.

If input was a `dict`, returns an entity subclass. If input was a list of `dict`s, returns a list of entity subclasses.

    # Example usage:
    projectal.Customer.create({'name': 'NewCustomer'})
    # returns Customer object

def save(self): View Source

    529    def save(self):
    530        """Calls `update()` on this instance of the entity, saving
    531        it to the database."""
    532        return self.__class__.update(self)

Calls `update()` on this instance of the entity, saving it to the database.

def clone(self, entity): View Source

    579    def clone(self, entity):
    580        """
    581        Clones an entity and returns its `uuId`.
    582
    583        Each entity has its own set of required values when cloning.
    584        Check the API documentation of that entity for details.
    585        """
    586        url = '/api/{}/clone?reference={}'.format(self._path, self['uuId'])
    587        response = api.post(url, entity)
    588        return response['jobClue']['uuId']

Clones an entity and returns its `uuId`.

Each entity has its own set of required values when cloning. Check the API documentation of that entity for details.

@classmethod
def list(cls, expand=False, links=None): View Source

    622    @classmethod
    623    def list(cls, expand=False, links=None):
    624        """Return a list of all entity UUIDs of this type.
    625
    626        You may pass in `expand=True` to get full Entity objects
    627        instead, but be aware this may be very slow if you have
    628        thousands of objects.
    629
    630        If you are expanding the objects, you may further expand
    631        the results with `links`.
    632        """
    633
    634        payload = {
    635            "name": "List all entities of type {}".format(cls._name.upper()),
    636            "type": "msql", "start": 0, "limit": -1,
    637            "select": [
    638                ["{}.uuId".format(cls._name.upper())]
    639            ],
    640        }
    641        ids = api.query(payload)
    642        ids = [id[0] for id in ids]
    643        if ids:
    644            return cls.get(ids, links=links) if expand else ids
    645        return []

Return a list of all entity UUIDs of this type.

You may pass in `expand=True` to get full Entity objects instead, but be aware this may be very slow if you have thousands of objects.

If you are expanding the objects, you may further expand the results with `links`.

@classmethod
def match(cls, field, term, links=None): View Source

    647    @classmethod
    648    def match(cls, field, term, links=None):
    649        """Find entities where `field`=`term` (exact match), optionally
    650        expanding the results with `links`.
    651
    652        Relies on `Entity.query()` with a pre-built set of rules.
    653        ```
    654        projects = projectal.Project.match('identifier', 'zmb-005')
    655        ```
    656        """
    657        filter = [["{}.{}".format(cls._name.upper(), field), "eq", term]]
    658        return cls.query(filter, links)

Find entities where `field`=`term` (exact match), optionally expanding the results with `links`.

Relies on `Entity.query()` with a pre-built set of rules.

    projects = projectal.Project.match('identifier', 'zmb-005')

@classmethod
def match_startswith(cls, field, term, links=None): View Source

    660    @classmethod
    661    def match_startswith(cls, field, term, links=None):
    662        """Find entities where `field` starts with the text `term`,
    663        optionally expanding the results with `links`.
    664
    665        Relies on `Entity.query()` with a pre-built set of rules.
    666        ```
    667        projects = projectal.Project.match_startswith('name', 'Zomb')
    668        ```
    669        """
    670        filter = [["{}.{}".format(cls._name.upper(), field), "prefix", term]]
    671        return cls.query(filter, links)

Find entities where `field` starts with the text `term`, optionally expanding the results with `links`.

Relies on `Entity.query()` with a pre-built set of rules.

    projects = projectal.Project.match_startswith('name', 'Zomb')

@classmethod
def match_endswith(cls, field, term, links=None): View Source

    673    @classmethod
    674    def match_endswith(cls, field, term, links=None):
    675        """Find entities where `field` ends with the text `term`,
    676        optionally expanding the results with `links`.
    677
    678        Relies on `Entity.query()` with a pre-built set of rules.
    679        ```
    680        projects = projectal.Project.match_endswith('identifier', '-2023')
    681        ```
    682        """
    683        term = "(?i).*{}$".format(term)
    684        filter = [["{}.{}".format(cls._name.upper(), field), "regex", term]]
    685        return cls.query(filter, links)

Find entities where `field` ends with the text `term`, optionally expanding the results with `links`.

Relies on `Entity.query()` with a pre-built set of rules.

    projects = projectal.Project.match_endswith('identifier', '-2023')

@classmethod
def match_one(cls, field, term, links=None): View Source

    687    @classmethod
    688    def match_one(cls, field, term, links=None):
    689        """Convenience function for match(). Returns the first match or None."""
    690        matches = cls.match(field, term, links)
    691        if matches:
    692            return matches[0]

Convenience function for match(). Returns the first match or None.

@classmethod
def match_startswith_one(cls, field, term, links=None): View Source

    694    @classmethod
    695    def match_startswith_one(cls, field, term, links=None):
    696        """Convenience function for match_startswith(). Returns the first match or None."""
    697        matches = cls.match_startswith(field, term, links)
    698        if matches:
    699            return matches[0]

Convenience function for match_startswith(). Returns the first match or None.

@classmethod
def match_endswith_one(cls, field, term, links=None): View Source

    701    @classmethod
    702    def match_endswith_one(cls, field, term, links=None):
    703        """Convenience function for match_endswith(). Returns the first match or None."""
    704        matches = cls.match_endswith(field, term, links)
    705        if matches:
    706            return matches[0]

Convenience function for match_endswith(). Returns the first match or None.

@classmethod
def search(cls, fields=None, term='', case_sensitive=True, links=None): View Source

    708    @classmethod
    709    def search(cls, fields=None, term='', case_sensitive=True, links=None):
    710        """Find entities that contain the text `term` within `fields`.
    711        `fields` is a list of field names to target in the search.
    712
    713        `case_sensitive`: Optionally turn off case sensitivity in the search.
    714
    715        Relies on `Entity.query()` with a pre-built set of rules.
    716        ```
    717        projects = projectal.Project.search(['name', 'description'], 'zombie')
    718        ```
    719        """
    720        filter = []
    721        term = '(?{}).*{}.*'.format('' if case_sensitive else '?', term)
    722        for field in fields:
    723            filter.append(["{}.{}".format(cls._name.upper(), field), "regex", term])
    724        filter = ['_or_', filter]
    725        return cls.query(filter, links)

Find entities that contain the text `term` within `fields`. `fields` is a list of field names to target in the search.

`case_sensitive`: Optionally turn off case sensitivity in the search.

Relies on `Entity.query()` with a pre-built set of rules.

    projects = projectal.Project.search(['name', 'description'], 'zombie')

@classmethod
def query(cls, filter, links=None): View Source

    727    @classmethod
    728    def query(cls, filter, links=None):
    729        """Run a query on this entity with the supplied filter.
    730
    731        The query is already set up to target this entity type, and the
    732        results will be converted into full objects when found, optionally
    733        expanded with the `links` provided. You only need to supply a
    734        filter to reduce the result set.
    735
    736        See [the filter documentation](https://projectal.com/docs/v1.1.1#section/Filter-section)
    737        for a detailed overview of the kinds of filters you can construct.
    738        """
    739        payload = {
    740            "name": "Python library entity query ({})".format(cls._name.upper()),
    741            "type": "msql", "start": 0, "limit": -1,
    742            "select": [
    743                ["{}.uuId".format(cls._name.upper())]
    744            ],
    745            "filter": filter
    746        }
    747        ids = api.query(payload)
    748        ids = [id[0] for id in ids]
    749        if ids:
    750            return cls.get(ids, links=links)
    751        return []

Run a query on this entity with the supplied filter.

The query is already set up to target this entity type, and the results will be converted into full objects when found, optionally expanded with the `links` provided. You only need to supply a filter to reduce the result set.

See the filter documentation for a detailed overview of the kinds of filters you can construct.

def profile_get(self, key): View Source

    753    def profile_get(self, key):
    754        """Get the profile (metadata) stored for this entity at `key`."""
    755        return projectal.profile.get(key, self.__class__._name.lower(), self['uuId'])

Get the profile (metadata) stored for this entity at `key`.

def profile_set(self, key, data): View Source

    757    def profile_set(self, key, data):
    758        """Set the profile (metadata) stored for this entity at `key`. The contents
    759        of `data` will completely overwrite the existing data dictionary."""
    760        return projectal.profile.set(key, self.__class__._name.lower(), self['uuId'], data)

Set the profile (metadata) stored for this entity at `key`. The contents of `data` will completely overwrite the existing data dictionary.

def changes(self): View Source

    778    def changes(self):
    779        """Return a dict containing the fields that have changed since fetching the object.
    780        Dict values contain both the old and new values. E.g.: {'old': 'original', 'new': 'current'}.
    781
    782        In the case of link lists, there are three values: added, removed, updated. Only links with
    783        a data attribute can end up in the updated list, and the old/new dictionary is placed within
    784        that data attribute. E.g. for a staff-resource link:
    785        'updated': [{
    786            'uuId': '24eb4c31-0f92-49d1-8b4d-507ab939003e',
    787            'resourceLink': {'quantity': {'old': 2, 'new': 5}}
    788        }]
    789        """
    790        changed = {}
    791        for key in self.keys():
    792            link_def = self._link_def_by_key.get(key)
    793            if link_def:
    794                changes = self._changes_for_link_list(link_def, key)
    795                # Only add it if something in it changed
    796                for action in changes.values():
    797                    if len(action):
    798                        changed[key] = changes
    799                        break
    800            elif key not in self.__old and self[key] is not None:
    801                changed[key] = {'old': None, 'new': self[key]}
    802            elif self.__old.get(key) != self[key]:
    803                changed[key] = {'old': self.__old.get(key), 'new': self[key]}
    804        return changed

Return a dict containing the fields that have changed since fetching the object. Dict values contain both the old and new values. E.g.: {'old': 'original', 'new': 'current'}.

In the case of link lists, there are three values: added, removed, updated. Only links with a data attribute can end up in the updated list, and the old/new dictionary is placed within that data attribute. E.g. for a staff-resource link: 'updated': [{ 'uuId': '24eb4c31-0f92-49d1-8b4d-507ab939003e', 'resourceLink': {'quantity': {'old': 2, 'new': 5}} }]

def set_readonly(self, key, value): View Source

    867    def set_readonly(self, key, value):
    868        """Set a field on this Entity that will not be sent over to the
    869        server on update unless modified."""
    870        self[key] = value
    871        self.__old[key] = value

Set a field on this Entity that will not be sent over to the server on update unless modified.

@classmethod
def get_link_definitions(cls): View Source

    1017    @classmethod
    1018    def get_link_definitions(cls):
    1019        return cls({})._link_def_by_name

def entity_name(self): View Source

    1022    def entity_name(self):
    1023        return self._name.capitalize()

##### Inherited Members

builtins.dict
  setdefault
  pop
  popitem
  keys
  items
  values
  fromkeys
  clear
  copy