projectal.entity

The base Entity class that all entities inherit from.

   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), 100):
 326                chunk = link_request_batch[i:i + 100]
 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        # allows for filtering of link keys
 490        typed_list = []
 491        for e in e_list:
 492            if not isinstance(e, Entity):
 493                new = cls({})
 494                new.update(e)
 495                typed_list.append(new)
 496            else:
 497                typed_list.append(e)
 498        e_list = typed_list
 499
 500        # Reduce the list to only modified entities and their modified fields.
 501        # Only do this to an Entity subclass - the consumer may have passed
 502        # in a dict of changes on their own.
 503        payload = []
 504
 505        for e in e_list:
 506            if isinstance(e, Entity):
 507                changes = e._changes_internal()
 508                if changes:
 509                    changes['uuId'] = e['uuId']
 510                    payload.append(changes)
 511            else:
 512                payload.append(e)
 513        if payload:
 514            for i in range(0, len(payload), projectal.chunk_size_write):
 515                chunk = payload[i:i + projectal.chunk_size_write]
 516                api.put('/api/{}/update'.format(cls._path), chunk)
 517
 518        # Detect and apply any link changes
 519        # if batch_linking is enabled, builds a list of link requests
 520        # from the changes of each entity, then executes
 521        # composite API requests with those changes
 522        link_request_batch = []
 523        for e in e_list:
 524            if isinstance(e, Entity):
 525                requests = e.__apply_link_changes(batch_linking=batch_linking)
 526                link_request_batch.extend(requests)
 527
 528        if len(link_request_batch) > 0 and batch_linking:
 529            for i in range(0, len(link_request_batch), 100):
 530                chunk = link_request_batch[i:i + 100]
 531                api.post('/api/composite', chunk)
 532
 533        return True
 534
 535    def __update(self, *args, **kwargs):
 536        """Use the dict update for instances."""
 537        return super(Entity, self).update(*args, **kwargs)
 538
 539    def save(self):
 540        """Calls `update()` on this instance of the entity, saving
 541        it to the database."""
 542        return self.__class__.update(self)
 543
 544    @classmethod
 545    def delete(cls, entities):
 546        """
 547        Delete one or more entities of the same type. The entity
 548        type is determined by the subclass calling this method.
 549
 550        `entities`: See `Entity.get()` for expected formats.
 551
 552        ```
 553        # Example usage:
 554        ids = ['1b21e445-f29a...', '1b21e445-f29a...', '1b21e445-f29a...']
 555        projectal.Customer.delete(ids)
 556        ```
 557        """
 558        if isinstance(entities, str):
 559            # String input is a uuId
 560            payload = [{'uuId': entities}]
 561        elif isinstance(entities, dict):
 562            # Dict input needs to be a list
 563            payload = [entities]
 564        elif isinstance(entities, list):
 565            # List input can be a list of uuIds or list of dicts
 566            # If uuIds (strings), convert to list of dicts
 567            if len(entities) > 0 and isinstance(entities[0], str):
 568                payload = [{'uuId': uuId} for uuId in entities]
 569            else:
 570                # Already expected format
 571                payload = entities
 572        else:
 573            # We have a list of dicts already, the expected format
 574            payload = entities
 575
 576        # We only need to send over the uuIds
 577        payload = [{'uuId': e['uuId']} for e in payload]
 578        if not payload:
 579            return True
 580        for i in range(0, len(payload), projectal.chunk_size_write):
 581            chunk = payload[i:i + projectal.chunk_size_write]
 582            api.delete('/api/{}/delete'.format(cls._path), chunk)
 583        return True
 584
 585    def __delete(self):
 586        """Let an instance delete itself."""
 587        return self.__class__.delete(self)
 588
 589    def clone(self, entity):
 590        """
 591        Clones an entity and returns its `uuId`.
 592
 593        Each entity has its own set of required values when cloning.
 594        Check the API documentation of that entity for details.
 595        """
 596        url = '/api/{}/clone?reference={}'.format(self._path, self['uuId'])
 597        response = api.post(url, entity)
 598        return response['jobClue']['uuId']
 599
 600    @classmethod
 601    def history(cls, UUID, start=0, limit=-1, order='desc', epoch=None, event=None):
 602        """
 603        Returns an ordered list of all changes made to the entity.
 604
 605        `UUID`: the UUID of the entity.
 606
 607        `start`: Start index for pagination (default: `0`).
 608
 609        `limit`: Number of results to include for pagination. Use
 610        `-1` to return the entire history (default: `-1`).
 611
 612        `order`: `asc` or `desc` (default: `desc` (index 0 is newest))
 613
 614        `epoch`: only return the history UP TO epoch date
 615
 616        `event`:
 617        """
 618        url = '/api/{}/history?holder={}&'.format(cls._path, UUID)
 619        params = []
 620        params.append('start={}'.format(start))
 621        params.append('limit={}'.format(limit))
 622        params.append('order={}'.format(order))
 623        params.append('epoch={}'.format(epoch)) if epoch else None
 624        params.append('event={}'.format(event)) if event else None
 625        url += '&'.join(params)
 626        return api.get(url)
 627
 628    def __history(self, **kwargs):
 629        """Get history of instance."""
 630        return self.__class__.history(self['uuId'], **kwargs)
 631
 632    @classmethod
 633    def list(cls, expand=False, links=None):
 634        """Return a list of all entity UUIDs of this type.
 635
 636        You may pass in `expand=True` to get full Entity objects
 637        instead, but be aware this may be very slow if you have
 638        thousands of objects.
 639
 640        If you are expanding the objects, you may further expand
 641        the results with `links`.
 642        """
 643
 644        payload = {
 645            "name": "List all entities of type {}".format(cls._name.upper()),
 646            "type": "msql", "start": 0, "limit": -1,
 647            "select": [
 648                ["{}.uuId".format(cls._name.upper())]
 649            ],
 650        }
 651        ids = api.query(payload)
 652        ids = [id[0] for id in ids]
 653        if ids:
 654            return cls.get(ids, links=links) if expand else ids
 655        return []
 656
 657    @classmethod
 658    def match(cls, field, term, links=None):
 659        """Find entities where `field`=`term` (exact match), optionally
 660        expanding the results with `links`.
 661
 662        Relies on `Entity.query()` with a pre-built set of rules.
 663        ```
 664        projects = projectal.Project.match('identifier', 'zmb-005')
 665        ```
 666        """
 667        filter = [["{}.{}".format(cls._name.upper(), field), "eq", term]]
 668        return cls.query(filter, links)
 669
 670    @classmethod
 671    def match_startswith(cls, field, term, links=None):
 672        """Find entities where `field` starts with the text `term`,
 673        optionally expanding the results with `links`.
 674
 675        Relies on `Entity.query()` with a pre-built set of rules.
 676        ```
 677        projects = projectal.Project.match_startswith('name', 'Zomb')
 678        ```
 679        """
 680        filter = [["{}.{}".format(cls._name.upper(), field), "prefix", term]]
 681        return cls.query(filter, links)
 682
 683    @classmethod
 684    def match_endswith(cls, field, term, links=None):
 685        """Find entities where `field` ends with the text `term`,
 686        optionally expanding the results with `links`.
 687
 688        Relies on `Entity.query()` with a pre-built set of rules.
 689        ```
 690        projects = projectal.Project.match_endswith('identifier', '-2023')
 691        ```
 692        """
 693        term = "(?i).*{}$".format(term)
 694        filter = [["{}.{}".format(cls._name.upper(), field), "regex", term]]
 695        return cls.query(filter, links)
 696
 697    @classmethod
 698    def match_one(cls, field, term, links=None):
 699        """Convenience function for match(). Returns the first match or None."""
 700        matches = cls.match(field, term, links)
 701        if matches:
 702            return matches[0]
 703
 704    @classmethod
 705    def match_startswith_one(cls, field, term, links=None):
 706        """Convenience function for match_startswith(). Returns the first match or None."""
 707        matches = cls.match_startswith(field, term, links)
 708        if matches:
 709            return matches[0]
 710
 711    @classmethod
 712    def match_endswith_one(cls, field, term, links=None):
 713        """Convenience function for match_endswith(). Returns the first match or None."""
 714        matches = cls.match_endswith(field, term, links)
 715        if matches:
 716            return matches[0]
 717
 718    @classmethod
 719    def search(cls, fields=None, term='', case_sensitive=True, links=None):
 720        """Find entities that contain the text `term` within `fields`.
 721        `fields` is a list of field names to target in the search.
 722
 723        `case_sensitive`: Optionally turn off case sensitivity in the search.
 724
 725        Relies on `Entity.query()` with a pre-built set of rules.
 726        ```
 727        projects = projectal.Project.search(['name', 'description'], 'zombie')
 728        ```
 729        """
 730        filter = []
 731        term = '(?{}).*{}.*'.format('' if case_sensitive else '?', term)
 732        for field in fields:
 733            filter.append(["{}.{}".format(cls._name.upper(), field), "regex", term])
 734        filter = ['_or_', filter]
 735        return cls.query(filter, links)
 736
 737    @classmethod
 738    def query(cls, filter, links=None):
 739        """Run a query on this entity with the supplied filter.
 740
 741        The query is already set up to target this entity type, and the
 742        results will be converted into full objects when found, optionally
 743        expanded with the `links` provided. You only need to supply a
 744        filter to reduce the result set.
 745
 746        See [the filter documentation](https://projectal.com/docs/v1.1.1#section/Filter-section)
 747        for a detailed overview of the kinds of filters you can construct.
 748        """
 749        payload = {
 750            "name": "Python library entity query ({})".format(cls._name.upper()),
 751            "type": "msql", "start": 0, "limit": -1,
 752            "select": [
 753                ["{}.uuId".format(cls._name.upper())]
 754            ],
 755            "filter": filter
 756        }
 757        ids = api.query(payload)
 758        ids = [id[0] for id in ids]
 759        if ids:
 760            return cls.get(ids, links=links)
 761        return []
 762
 763    def profile_get(self, key):
 764        """Get the profile (metadata) stored for this entity at `key`."""
 765        return projectal.profile.get(key, self.__class__._name.lower(), self['uuId'])
 766
 767    def profile_set(self, key, data):
 768        """Set the profile (metadata) stored for this entity at `key`. The contents
 769        of `data` will completely overwrite the existing data dictionary."""
 770        return projectal.profile.set(key, self.__class__._name.lower(), self['uuId'], data)
 771
 772    def __type_links(self):
 773        """Find links and turn their dicts into typed objects matching their Entity type."""
 774
 775        for key, _def in self._link_def_by_key.items():
 776            if key in self:
 777                cls = getattr(projectal, _def['entity'])
 778                if _def['type'] == list:
 779                    as_obj = []
 780                    for link in self[key]:
 781                        as_obj.append(cls(link))
 782                elif _def['type'] == dict:
 783                    as_obj = cls(self[key])
 784                else:
 785                    raise projectal.UsageException("Unexpected link type")
 786                self[key] = as_obj
 787
 788    def changes(self):
 789        """Return a dict containing the fields that have changed since fetching the object.
 790        Dict values contain both the old and new values. E.g.: {'old': 'original', 'new': 'current'}.
 791
 792        In the case of link lists, there are three values: added, removed, updated. Only links with
 793        a data attribute can end up in the updated list, and the old/new dictionary is placed within
 794        that data attribute. E.g. for a staff-resource link:
 795        'updated': [{
 796            'uuId': '24eb4c31-0f92-49d1-8b4d-507ab939003e',
 797            'resourceLink': {'quantity': {'old': 2, 'new': 5}}
 798        }]
 799        """
 800        changed = {}
 801        for key in self.keys():
 802            link_def = self._link_def_by_key.get(key)
 803            if link_def:
 804                changes = self._changes_for_link_list(link_def, key)
 805                # Only add it if something in it changed
 806                for action in changes.values():
 807                    if len(action):
 808                        changed[key] = changes
 809                        break
 810            elif key not in self.__old and self[key] is not None:
 811                changed[key] = {'old': None, 'new': self[key]}
 812            elif self.__old.get(key) != self[key]:
 813                changed[key] = {'old': self.__old.get(key), 'new': self[key]}
 814        return changed
 815
 816    def _changes_for_link_list(self, link_def, key):
 817        changes = self.__apply_list(link_def, report_only=True)
 818        data_key = link_def['data_name']
 819
 820        # For linked entities, we will only report their UUID, name (if it has one),
 821        # and the content of their data attribute (if it has one).
 822        def get_slim_list(entities):
 823            slim = []
 824            if isinstance(entities, dict):
 825                entities = [entities]
 826            for e in entities:
 827                fields = {'uuId': e['uuId']}
 828                name = e.get('name')
 829                if name:
 830                    fields['name'] = e['name']
 831                if data_key and e[data_key]:
 832                    fields[data_key] = e[data_key]
 833                slim.append(fields)
 834            return slim
 835
 836        out = {
 837            'added': get_slim_list(changes.get('add', [])),
 838            'updated': [],
 839            'removed': get_slim_list(changes.get('remove', [])),
 840        }
 841
 842        updated = changes.get('update', [])
 843        if updated:
 844            before_map = {}
 845            for entity in self.__old.get(key):
 846                before_map[entity['uuId']] = entity
 847
 848            for entity in updated:
 849                old_data = before_map[entity['uuId']][data_key]
 850                new_data = entity[data_key]
 851                diff = {}
 852                for key in new_data.keys():
 853                    if key not in old_data and new_data[key] is not None:
 854                        diff[key] = {'old': None, 'new': new_data[key]}
 855                    elif old_data.get(key) != new_data[key]:
 856                        diff[key] = {'old': old_data.get(key), 'new': new_data[key]}
 857                out['updated'].append({'uuId': entity['uuId'], data_key: diff})
 858        return out
 859
 860    def _changes_internal(self):
 861        """Return a dict containing only the fields that have changed and their current value,
 862        without any link data.
 863
 864        This method is used internally to strip payloads down to only the fields that have changed.
 865        """
 866        changed = {}
 867        for key in self.keys():
 868            # We don't deal with link or link data changes here. We only want standard fields.
 869            if key in self._link_def_by_key:
 870                continue
 871            if key not in self.__old and self[key] is not None:
 872                changed[key] = self[key]
 873            elif self.__old.get(key) != self[key]:
 874                changed[key] = self[key]
 875        return changed
 876
 877    def set_readonly(self, key, value):
 878        """Set a field on this Entity that will not be sent over to the
 879        server on update unless modified."""
 880        self[key] = value
 881        self.__old[key] = value
 882
 883    # --- Link management ---
 884
 885    @staticmethod
 886    def __link_data_differs(have_link, want_link, data_key):
 887
 888        if data_key:
 889            if 'uuId' in have_link[data_key]:
 890                del have_link[data_key]['uuId']
 891            if 'uuId' in want_link[data_key]:
 892                del want_link[data_key]['uuId']
 893            return have_link[data_key] != want_link[data_key]
 894
 895        # Links without data never differ
 896        return False
 897
 898    def __apply_link_changes(self, batch_linking=True):
 899        """Send each link list to the conflict resolver. If we detect
 900        that the entity was not fetched with that link, we do the fetch
 901        first and use the result as the basis for comparison."""
 902
 903        # Find which lists belong to links but were not fetched so we can fetch them
 904        need = []
 905        find_list = []
 906        if not self._is_new:
 907            for link in self._link_def_by_key.values():
 908                if link['link_key'] in self and link['name'] not in self._with_links:
 909                    need.append(link['name'])
 910                    find_list.append(link['link_key'])
 911
 912        if len(need):
 913            logging.warning("Entity links were modified but entity not fetched with links. "
 914                            "For better performance, include the links when getting the entity.")
 915            logging.warning("Fetching {} again with missing links: {}".format(self._name.upper(), ','.join(need)))
 916            new = self.__fetch(self, links=need)
 917            for _list in find_list:
 918                self.__old[_list] = copy.deepcopy(new.get(_list, []))
 919
 920        # if batch_linking is enabled, builds a list of link requests
 921        # for each link definition of the calling entity then returns the list
 922        request_list = []
 923        for link_def in self._link_def_by_key.values():
 924            link_def_requests = self.__apply_list(link_def, batch_linking=batch_linking)
 925            if batch_linking:
 926                request_list.extend(link_def_requests)
 927        return request_list
 928
 929    def __apply_list(self, link_def, report_only=False, batch_linking=True):
 930        """Automatically resolve differences and issue the correct sequence of
 931        link/unlink/relink for the link list to result in the supplied list
 932        of entities.
 933
 934        report_only will not make any changes to the data or issue network requests.
 935        Instead, it returns the three lists of changes (add, update, delete).
 936        """
 937        to_add = []
 938        to_remove = []
 939        to_update = []
 940        should_only_have = set()
 941        link_key = link_def['link_key']
 942
 943        if link_def['type'] == list:
 944            want_entities = self.get(link_key, [])
 945            have_entities = self.__old.get(link_key, [])
 946
 947            if not isinstance(want_entities, list):
 948                raise api.UsageException("Expecting '{}' to be {}. Found {} instead.".format(
 949                    link_key, link_def['type'].__name__, type(want_entities).__name__))
 950
 951            for want_entity in want_entities:
 952                if want_entity['uuId'] in should_only_have:
 953                    raise api.UsageException("Duplicate {} in {}".format(link_def['name'], link_key))
 954                should_only_have.add(want_entity['uuId'])
 955                have = False
 956                for have_entity in have_entities:
 957                    if have_entity['uuId'] == want_entity['uuId']:
 958                        have = True
 959                        data_name = link_def.get('data_name')
 960                        if data_name and self.__link_data_differs(have_entity, want_entity, data_name):
 961                            to_update.append(want_entity)
 962                if not have:
 963                    to_add.append(want_entity)
 964            for have_entity in have_entities:
 965                if have_entity['uuId'] not in should_only_have:
 966                    to_remove.append(have_entity)
 967        elif link_def['type'] == dict:
 968            # Note: dict type does not implement updates as we have no dict links
 969            # that support update (yet?).
 970            want_entity = self.get(link_key, None)
 971            have_entity = self.__old.get(link_key, None)
 972
 973            if want_entity is not None and not isinstance(want_entity, dict):
 974                raise api.UsageException("Expecting '{}' to be {}. Found {} instead.".format(
 975                    link_key, link_def['type'].__name__, type(have_entity).__name__))
 976
 977            if want_entity:
 978                if have_entity:
 979                    if want_entity['uuId'] != have_entity['uuId']:
 980                        to_remove = have_entity
 981                        to_add = want_entity
 982                else:
 983                    to_add = want_entity
 984            if not want_entity:
 985                if have_entity:
 986                    to_remove = have_entity
 987
 988            want_entities = want_entity
 989        else:
 990            # Would be an error in this library if we reach here
 991            raise projectal.UnsupportedException("This type does not support linking")
 992
 993        # if batch_linking is enabled, builds a list of requests
 994        # from each link method
 995        if not report_only:
 996            request_list = []
 997            if to_remove:
 998                delete_requests = self._link(
 999                        link_def['name'], to_remove, 'delete',
1000                        update_cache=False, batch_linking=batch_linking
1001                )
1002                request_list.extend(delete_requests)
1003            if to_update:
1004                update_requests = self._link(
1005                    link_def['name'], to_update, 'update',
1006                    update_cache=False, batch_linking=batch_linking
1007                )
1008                request_list.extend(update_requests)
1009            if to_add:
1010                add_requests = self._link(
1011                    link_def['name'], to_add, 'add',
1012                    update_cache=False, batch_linking=batch_linking
1013                )
1014                request_list.extend(add_requests)
1015            self.__old[link_key] = copy.deepcopy(want_entities)
1016            return request_list
1017        else:
1018            changes = {}
1019            if to_remove:
1020                changes['remove'] = to_remove
1021            if to_update:
1022                changes['update'] = to_update
1023            if to_add:
1024                changes['add'] = to_add
1025            return changes
1026
1027    @classmethod
1028    def get_link_definitions(cls):
1029        return cls({})._link_def_by_name
1030    # --- ---
1031
1032    def entity_name(self):
1033        return self._name.capitalize()
class Entity(builtins.dict):
  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), 100):
 327                chunk = link_request_batch[i:i + 100]
 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        # allows for filtering of link keys
 491        typed_list = []
 492        for e in e_list:
 493            if not isinstance(e, Entity):
 494                new = cls({})
 495                new.update(e)
 496                typed_list.append(new)
 497            else:
 498                typed_list.append(e)
 499        e_list = typed_list
 500
 501        # Reduce the list to only modified entities and their modified fields.
 502        # Only do this to an Entity subclass - the consumer may have passed
 503        # in a dict of changes on their own.
 504        payload = []
 505
 506        for e in e_list:
 507            if isinstance(e, Entity):
 508                changes = e._changes_internal()
 509                if changes:
 510                    changes['uuId'] = e['uuId']
 511                    payload.append(changes)
 512            else:
 513                payload.append(e)
 514        if payload:
 515            for i in range(0, len(payload), projectal.chunk_size_write):
 516                chunk = payload[i:i + projectal.chunk_size_write]
 517                api.put('/api/{}/update'.format(cls._path), chunk)
 518
 519        # Detect and apply any link changes
 520        # if batch_linking is enabled, builds a list of link requests
 521        # from the changes of each entity, then executes
 522        # composite API requests with those changes
 523        link_request_batch = []
 524        for e in e_list:
 525            if isinstance(e, Entity):
 526                requests = e.__apply_link_changes(batch_linking=batch_linking)
 527                link_request_batch.extend(requests)
 528
 529        if len(link_request_batch) > 0 and batch_linking:
 530            for i in range(0, len(link_request_batch), 100):
 531                chunk = link_request_batch[i:i + 100]
 532                api.post('/api/composite', chunk)
 533
 534        return True
 535
 536    def __update(self, *args, **kwargs):
 537        """Use the dict update for instances."""
 538        return super(Entity, self).update(*args, **kwargs)
 539
 540    def save(self):
 541        """Calls `update()` on this instance of the entity, saving
 542        it to the database."""
 543        return self.__class__.update(self)
 544
 545    @classmethod
 546    def delete(cls, entities):
 547        """
 548        Delete one or more entities of the same type. The entity
 549        type is determined by the subclass calling this method.
 550
 551        `entities`: See `Entity.get()` for expected formats.
 552
 553        ```
 554        # Example usage:
 555        ids = ['1b21e445-f29a...', '1b21e445-f29a...', '1b21e445-f29a...']
 556        projectal.Customer.delete(ids)
 557        ```
 558        """
 559        if isinstance(entities, str):
 560            # String input is a uuId
 561            payload = [{'uuId': entities}]
 562        elif isinstance(entities, dict):
 563            # Dict input needs to be a list
 564            payload = [entities]
 565        elif isinstance(entities, list):
 566            # List input can be a list of uuIds or list of dicts
 567            # If uuIds (strings), convert to list of dicts
 568            if len(entities) > 0 and isinstance(entities[0], str):
 569                payload = [{'uuId': uuId} for uuId in entities]
 570            else:
 571                # Already expected format
 572                payload = entities
 573        else:
 574            # We have a list of dicts already, the expected format
 575            payload = entities
 576
 577        # We only need to send over the uuIds
 578        payload = [{'uuId': e['uuId']} for e in payload]
 579        if not payload:
 580            return True
 581        for i in range(0, len(payload), projectal.chunk_size_write):
 582            chunk = payload[i:i + projectal.chunk_size_write]
 583            api.delete('/api/{}/delete'.format(cls._path), chunk)
 584        return True
 585
 586    def __delete(self):
 587        """Let an instance delete itself."""
 588        return self.__class__.delete(self)
 589
 590    def clone(self, entity):
 591        """
 592        Clones an entity and returns its `uuId`.
 593
 594        Each entity has its own set of required values when cloning.
 595        Check the API documentation of that entity for details.
 596        """
 597        url = '/api/{}/clone?reference={}'.format(self._path, self['uuId'])
 598        response = api.post(url, entity)
 599        return response['jobClue']['uuId']
 600
 601    @classmethod
 602    def history(cls, UUID, start=0, limit=-1, order='desc', epoch=None, event=None):
 603        """
 604        Returns an ordered list of all changes made to the entity.
 605
 606        `UUID`: the UUID of the entity.
 607
 608        `start`: Start index for pagination (default: `0`).
 609
 610        `limit`: Number of results to include for pagination. Use
 611        `-1` to return the entire history (default: `-1`).
 612
 613        `order`: `asc` or `desc` (default: `desc` (index 0 is newest))
 614
 615        `epoch`: only return the history UP TO epoch date
 616
 617        `event`:
 618        """
 619        url = '/api/{}/history?holder={}&'.format(cls._path, UUID)
 620        params = []
 621        params.append('start={}'.format(start))
 622        params.append('limit={}'.format(limit))
 623        params.append('order={}'.format(order))
 624        params.append('epoch={}'.format(epoch)) if epoch else None
 625        params.append('event={}'.format(event)) if event else None
 626        url += '&'.join(params)
 627        return api.get(url)
 628
 629    def __history(self, **kwargs):
 630        """Get history of instance."""
 631        return self.__class__.history(self['uuId'], **kwargs)
 632
 633    @classmethod
 634    def list(cls, expand=False, links=None):
 635        """Return a list of all entity UUIDs of this type.
 636
 637        You may pass in `expand=True` to get full Entity objects
 638        instead, but be aware this may be very slow if you have
 639        thousands of objects.
 640
 641        If you are expanding the objects, you may further expand
 642        the results with `links`.
 643        """
 644
 645        payload = {
 646            "name": "List all entities of type {}".format(cls._name.upper()),
 647            "type": "msql", "start": 0, "limit": -1,
 648            "select": [
 649                ["{}.uuId".format(cls._name.upper())]
 650            ],
 651        }
 652        ids = api.query(payload)
 653        ids = [id[0] for id in ids]
 654        if ids:
 655            return cls.get(ids, links=links) if expand else ids
 656        return []
 657
 658    @classmethod
 659    def match(cls, field, term, links=None):
 660        """Find entities where `field`=`term` (exact match), optionally
 661        expanding the results with `links`.
 662
 663        Relies on `Entity.query()` with a pre-built set of rules.
 664        ```
 665        projects = projectal.Project.match('identifier', 'zmb-005')
 666        ```
 667        """
 668        filter = [["{}.{}".format(cls._name.upper(), field), "eq", term]]
 669        return cls.query(filter, links)
 670
 671    @classmethod
 672    def match_startswith(cls, field, term, links=None):
 673        """Find entities where `field` starts with the text `term`,
 674        optionally expanding the results with `links`.
 675
 676        Relies on `Entity.query()` with a pre-built set of rules.
 677        ```
 678        projects = projectal.Project.match_startswith('name', 'Zomb')
 679        ```
 680        """
 681        filter = [["{}.{}".format(cls._name.upper(), field), "prefix", term]]
 682        return cls.query(filter, links)
 683
 684    @classmethod
 685    def match_endswith(cls, field, term, links=None):
 686        """Find entities where `field` ends with the text `term`,
 687        optionally expanding the results with `links`.
 688
 689        Relies on `Entity.query()` with a pre-built set of rules.
 690        ```
 691        projects = projectal.Project.match_endswith('identifier', '-2023')
 692        ```
 693        """
 694        term = "(?i).*{}$".format(term)
 695        filter = [["{}.{}".format(cls._name.upper(), field), "regex", term]]
 696        return cls.query(filter, links)
 697
 698    @classmethod
 699    def match_one(cls, field, term, links=None):
 700        """Convenience function for match(). Returns the first match or None."""
 701        matches = cls.match(field, term, links)
 702        if matches:
 703            return matches[0]
 704
 705    @classmethod
 706    def match_startswith_one(cls, field, term, links=None):
 707        """Convenience function for match_startswith(). Returns the first match or None."""
 708        matches = cls.match_startswith(field, term, links)
 709        if matches:
 710            return matches[0]
 711
 712    @classmethod
 713    def match_endswith_one(cls, field, term, links=None):
 714        """Convenience function for match_endswith(). Returns the first match or None."""
 715        matches = cls.match_endswith(field, term, links)
 716        if matches:
 717            return matches[0]
 718
 719    @classmethod
 720    def search(cls, fields=None, term='', case_sensitive=True, links=None):
 721        """Find entities that contain the text `term` within `fields`.
 722        `fields` is a list of field names to target in the search.
 723
 724        `case_sensitive`: Optionally turn off case sensitivity in the search.
 725
 726        Relies on `Entity.query()` with a pre-built set of rules.
 727        ```
 728        projects = projectal.Project.search(['name', 'description'], 'zombie')
 729        ```
 730        """
 731        filter = []
 732        term = '(?{}).*{}.*'.format('' if case_sensitive else '?', term)
 733        for field in fields:
 734            filter.append(["{}.{}".format(cls._name.upper(), field), "regex", term])
 735        filter = ['_or_', filter]
 736        return cls.query(filter, links)
 737
 738    @classmethod
 739    def query(cls, filter, links=None):
 740        """Run a query on this entity with the supplied filter.
 741
 742        The query is already set up to target this entity type, and the
 743        results will be converted into full objects when found, optionally
 744        expanded with the `links` provided. You only need to supply a
 745        filter to reduce the result set.
 746
 747        See [the filter documentation](https://projectal.com/docs/v1.1.1#section/Filter-section)
 748        for a detailed overview of the kinds of filters you can construct.
 749        """
 750        payload = {
 751            "name": "Python library entity query ({})".format(cls._name.upper()),
 752            "type": "msql", "start": 0, "limit": -1,
 753            "select": [
 754                ["{}.uuId".format(cls._name.upper())]
 755            ],
 756            "filter": filter
 757        }
 758        ids = api.query(payload)
 759        ids = [id[0] for id in ids]
 760        if ids:
 761            return cls.get(ids, links=links)
 762        return []
 763
 764    def profile_get(self, key):
 765        """Get the profile (metadata) stored for this entity at `key`."""
 766        return projectal.profile.get(key, self.__class__._name.lower(), self['uuId'])
 767
 768    def profile_set(self, key, data):
 769        """Set the profile (metadata) stored for this entity at `key`. The contents
 770        of `data` will completely overwrite the existing data dictionary."""
 771        return projectal.profile.set(key, self.__class__._name.lower(), self['uuId'], data)
 772
 773    def __type_links(self):
 774        """Find links and turn their dicts into typed objects matching their Entity type."""
 775
 776        for key, _def in self._link_def_by_key.items():
 777            if key in self:
 778                cls = getattr(projectal, _def['entity'])
 779                if _def['type'] == list:
 780                    as_obj = []
 781                    for link in self[key]:
 782                        as_obj.append(cls(link))
 783                elif _def['type'] == dict:
 784                    as_obj = cls(self[key])
 785                else:
 786                    raise projectal.UsageException("Unexpected link type")
 787                self[key] = as_obj
 788
 789    def changes(self):
 790        """Return a dict containing the fields that have changed since fetching the object.
 791        Dict values contain both the old and new values. E.g.: {'old': 'original', 'new': 'current'}.
 792
 793        In the case of link lists, there are three values: added, removed, updated. Only links with
 794        a data attribute can end up in the updated list, and the old/new dictionary is placed within
 795        that data attribute. E.g. for a staff-resource link:
 796        'updated': [{
 797            'uuId': '24eb4c31-0f92-49d1-8b4d-507ab939003e',
 798            'resourceLink': {'quantity': {'old': 2, 'new': 5}}
 799        }]
 800        """
 801        changed = {}
 802        for key in self.keys():
 803            link_def = self._link_def_by_key.get(key)
 804            if link_def:
 805                changes = self._changes_for_link_list(link_def, key)
 806                # Only add it if something in it changed
 807                for action in changes.values():
 808                    if len(action):
 809                        changed[key] = changes
 810                        break
 811            elif key not in self.__old and self[key] is not None:
 812                changed[key] = {'old': None, 'new': self[key]}
 813            elif self.__old.get(key) != self[key]:
 814                changed[key] = {'old': self.__old.get(key), 'new': self[key]}
 815        return changed
 816
 817    def _changes_for_link_list(self, link_def, key):
 818        changes = self.__apply_list(link_def, report_only=True)
 819        data_key = link_def['data_name']
 820
 821        # For linked entities, we will only report their UUID, name (if it has one),
 822        # and the content of their data attribute (if it has one).
 823        def get_slim_list(entities):
 824            slim = []
 825            if isinstance(entities, dict):
 826                entities = [entities]
 827            for e in entities:
 828                fields = {'uuId': e['uuId']}
 829                name = e.get('name')
 830                if name:
 831                    fields['name'] = e['name']
 832                if data_key and e[data_key]:
 833                    fields[data_key] = e[data_key]
 834                slim.append(fields)
 835            return slim
 836
 837        out = {
 838            'added': get_slim_list(changes.get('add', [])),
 839            'updated': [],
 840            'removed': get_slim_list(changes.get('remove', [])),
 841        }
 842
 843        updated = changes.get('update', [])
 844        if updated:
 845            before_map = {}
 846            for entity in self.__old.get(key):
 847                before_map[entity['uuId']] = entity
 848
 849            for entity in updated:
 850                old_data = before_map[entity['uuId']][data_key]
 851                new_data = entity[data_key]
 852                diff = {}
 853                for key in new_data.keys():
 854                    if key not in old_data and new_data[key] is not None:
 855                        diff[key] = {'old': None, 'new': new_data[key]}
 856                    elif old_data.get(key) != new_data[key]:
 857                        diff[key] = {'old': old_data.get(key), 'new': new_data[key]}
 858                out['updated'].append({'uuId': entity['uuId'], data_key: diff})
 859        return out
 860
 861    def _changes_internal(self):
 862        """Return a dict containing only the fields that have changed and their current value,
 863        without any link data.
 864
 865        This method is used internally to strip payloads down to only the fields that have changed.
 866        """
 867        changed = {}
 868        for key in self.keys():
 869            # We don't deal with link or link data changes here. We only want standard fields.
 870            if key in self._link_def_by_key:
 871                continue
 872            if key not in self.__old and self[key] is not None:
 873                changed[key] = self[key]
 874            elif self.__old.get(key) != self[key]:
 875                changed[key] = self[key]
 876        return changed
 877
 878    def set_readonly(self, key, value):
 879        """Set a field on this Entity that will not be sent over to the
 880        server on update unless modified."""
 881        self[key] = value
 882        self.__old[key] = value
 883
 884    # --- Link management ---
 885
 886    @staticmethod
 887    def __link_data_differs(have_link, want_link, data_key):
 888
 889        if data_key:
 890            if 'uuId' in have_link[data_key]:
 891                del have_link[data_key]['uuId']
 892            if 'uuId' in want_link[data_key]:
 893                del want_link[data_key]['uuId']
 894            return have_link[data_key] != want_link[data_key]
 895
 896        # Links without data never differ
 897        return False
 898
 899    def __apply_link_changes(self, batch_linking=True):
 900        """Send each link list to the conflict resolver. If we detect
 901        that the entity was not fetched with that link, we do the fetch
 902        first and use the result as the basis for comparison."""
 903
 904        # Find which lists belong to links but were not fetched so we can fetch them
 905        need = []
 906        find_list = []
 907        if not self._is_new:
 908            for link in self._link_def_by_key.values():
 909                if link['link_key'] in self and link['name'] not in self._with_links:
 910                    need.append(link['name'])
 911                    find_list.append(link['link_key'])
 912
 913        if len(need):
 914            logging.warning("Entity links were modified but entity not fetched with links. "
 915                            "For better performance, include the links when getting the entity.")
 916            logging.warning("Fetching {} again with missing links: {}".format(self._name.upper(), ','.join(need)))
 917            new = self.__fetch(self, links=need)
 918            for _list in find_list:
 919                self.__old[_list] = copy.deepcopy(new.get(_list, []))
 920
 921        # if batch_linking is enabled, builds a list of link requests
 922        # for each link definition of the calling entity then returns the list
 923        request_list = []
 924        for link_def in self._link_def_by_key.values():
 925            link_def_requests = self.__apply_list(link_def, batch_linking=batch_linking)
 926            if batch_linking:
 927                request_list.extend(link_def_requests)
 928        return request_list
 929
 930    def __apply_list(self, link_def, report_only=False, batch_linking=True):
 931        """Automatically resolve differences and issue the correct sequence of
 932        link/unlink/relink for the link list to result in the supplied list
 933        of entities.
 934
 935        report_only will not make any changes to the data or issue network requests.
 936        Instead, it returns the three lists of changes (add, update, delete).
 937        """
 938        to_add = []
 939        to_remove = []
 940        to_update = []
 941        should_only_have = set()
 942        link_key = link_def['link_key']
 943
 944        if link_def['type'] == list:
 945            want_entities = self.get(link_key, [])
 946            have_entities = self.__old.get(link_key, [])
 947
 948            if not isinstance(want_entities, list):
 949                raise api.UsageException("Expecting '{}' to be {}. Found {} instead.".format(
 950                    link_key, link_def['type'].__name__, type(want_entities).__name__))
 951
 952            for want_entity in want_entities:
 953                if want_entity['uuId'] in should_only_have:
 954                    raise api.UsageException("Duplicate {} in {}".format(link_def['name'], link_key))
 955                should_only_have.add(want_entity['uuId'])
 956                have = False
 957                for have_entity in have_entities:
 958                    if have_entity['uuId'] == want_entity['uuId']:
 959                        have = True
 960                        data_name = link_def.get('data_name')
 961                        if data_name and self.__link_data_differs(have_entity, want_entity, data_name):
 962                            to_update.append(want_entity)
 963                if not have:
 964                    to_add.append(want_entity)
 965            for have_entity in have_entities:
 966                if have_entity['uuId'] not in should_only_have:
 967                    to_remove.append(have_entity)
 968        elif link_def['type'] == dict:
 969            # Note: dict type does not implement updates as we have no dict links
 970            # that support update (yet?).
 971            want_entity = self.get(link_key, None)
 972            have_entity = self.__old.get(link_key, None)
 973
 974            if want_entity is not None and not isinstance(want_entity, dict):
 975                raise api.UsageException("Expecting '{}' to be {}. Found {} instead.".format(
 976                    link_key, link_def['type'].__name__, type(have_entity).__name__))
 977
 978            if want_entity:
 979                if have_entity:
 980                    if want_entity['uuId'] != have_entity['uuId']:
 981                        to_remove = have_entity
 982                        to_add = want_entity
 983                else:
 984                    to_add = want_entity
 985            if not want_entity:
 986                if have_entity:
 987                    to_remove = have_entity
 988
 989            want_entities = want_entity
 990        else:
 991            # Would be an error in this library if we reach here
 992            raise projectal.UnsupportedException("This type does not support linking")
 993
 994        # if batch_linking is enabled, builds a list of requests
 995        # from each link method
 996        if not report_only:
 997            request_list = []
 998            if to_remove:
 999                delete_requests = self._link(
1000                        link_def['name'], to_remove, 'delete',
1001                        update_cache=False, batch_linking=batch_linking
1002                )
1003                request_list.extend(delete_requests)
1004            if to_update:
1005                update_requests = self._link(
1006                    link_def['name'], to_update, 'update',
1007                    update_cache=False, batch_linking=batch_linking
1008                )
1009                request_list.extend(update_requests)
1010            if to_add:
1011                add_requests = self._link(
1012                    link_def['name'], to_add, 'add',
1013                    update_cache=False, batch_linking=batch_linking
1014                )
1015                request_list.extend(add_requests)
1016            self.__old[link_key] = copy.deepcopy(want_entities)
1017            return request_list
1018        else:
1019            changes = {}
1020            if to_remove:
1021                changes['remove'] = to_remove
1022            if to_update:
1023                changes['update'] = to_update
1024            if to_add:
1025                changes['add'] = to_add
1026            return changes
1027
1028    @classmethod
1029    def get_link_definitions(cls):
1030        return cls({})._link_def_by_name
1031    # --- ---
1032
1033    def entity_name(self):
1034        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):
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 uuIds 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):
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        # allows for filtering of link keys
491        typed_list = []
492        for e in e_list:
493            if not isinstance(e, Entity):
494                new = cls({})
495                new.update(e)
496                typed_list.append(new)
497            else:
498                typed_list.append(e)
499        e_list = typed_list
500
501        # Reduce the list to only modified entities and their modified fields.
502        # Only do this to an Entity subclass - the consumer may have passed
503        # in a dict of changes on their own.
504        payload = []
505
506        for e in e_list:
507            if isinstance(e, Entity):
508                changes = e._changes_internal()
509                if changes:
510                    changes['uuId'] = e['uuId']
511                    payload.append(changes)
512            else:
513                payload.append(e)
514        if payload:
515            for i in range(0, len(payload), projectal.chunk_size_write):
516                chunk = payload[i:i + projectal.chunk_size_write]
517                api.put('/api/{}/update'.format(cls._path), chunk)
518
519        # Detect and apply any link changes
520        # if batch_linking is enabled, builds a list of link requests
521        # from the changes of each entity, then executes
522        # composite API requests with those changes
523        link_request_batch = []
524        for e in e_list:
525            if isinstance(e, Entity):
526                requests = e.__apply_link_changes(batch_linking=batch_linking)
527                link_request_batch.extend(requests)
528
529        if len(link_request_batch) > 0 and batch_linking:
530            for i in range(0, len(link_request_batch), 100):
531                chunk = link_request_batch[i:i + 100]
532                api.post('/api/composite', chunk)
533
534        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 dicts 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):
545    @classmethod
546    def delete(cls, entities):
547        """
548        Delete one or more entities of the same type. The entity
549        type is determined by the subclass calling this method.
550
551        `entities`: See `Entity.get()` for expected formats.
552
553        ```
554        # Example usage:
555        ids = ['1b21e445-f29a...', '1b21e445-f29a...', '1b21e445-f29a...']
556        projectal.Customer.delete(ids)
557        ```
558        """
559        if isinstance(entities, str):
560            # String input is a uuId
561            payload = [{'uuId': entities}]
562        elif isinstance(entities, dict):
563            # Dict input needs to be a list
564            payload = [entities]
565        elif isinstance(entities, list):
566            # List input can be a list of uuIds or list of dicts
567            # If uuIds (strings), convert to list of dicts
568            if len(entities) > 0 and isinstance(entities[0], str):
569                payload = [{'uuId': uuId} for uuId in entities]
570            else:
571                # Already expected format
572                payload = entities
573        else:
574            # We have a list of dicts already, the expected format
575            payload = entities
576
577        # We only need to send over the uuIds
578        payload = [{'uuId': e['uuId']} for e in payload]
579        if not payload:
580            return True
581        for i in range(0, len(payload), projectal.chunk_size_write):
582            chunk = payload[i:i + projectal.chunk_size_write]
583            api.delete('/api/{}/delete'.format(cls._path), chunk)
584        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):
601    @classmethod
602    def history(cls, UUID, start=0, limit=-1, order='desc', epoch=None, event=None):
603        """
604        Returns an ordered list of all changes made to the entity.
605
606        `UUID`: the UUID of the entity.
607
608        `start`: Start index for pagination (default: `0`).
609
610        `limit`: Number of results to include for pagination. Use
611        `-1` to return the entire history (default: `-1`).
612
613        `order`: `asc` or `desc` (default: `desc` (index 0 is newest))
614
615        `epoch`: only return the history UP TO epoch date
616
617        `event`:
618        """
619        url = '/api/{}/history?holder={}&'.format(cls._path, UUID)
620        params = []
621        params.append('start={}'.format(start))
622        params.append('limit={}'.format(limit))
623        params.append('order={}'.format(order))
624        params.append('epoch={}'.format(epoch)) if epoch else None
625        params.append('event={}'.format(event)) if event else None
626        url += '&'.join(params)
627        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):
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), 100):
327                chunk = link_request_batch[i:i + 100]
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 dicts 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 dicts, returns a list of entity subclasses.

# Example usage:
projectal.Customer.create({'name': 'NewCustomer'})
# returns Customer object
def save(self):
540    def save(self):
541        """Calls `update()` on this instance of the entity, saving
542        it to the database."""
543        return self.__class__.update(self)

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

def clone(self, entity):
590    def clone(self, entity):
591        """
592        Clones an entity and returns its `uuId`.
593
594        Each entity has its own set of required values when cloning.
595        Check the API documentation of that entity for details.
596        """
597        url = '/api/{}/clone?reference={}'.format(self._path, self['uuId'])
598        response = api.post(url, entity)
599        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):
633    @classmethod
634    def list(cls, expand=False, links=None):
635        """Return a list of all entity UUIDs of this type.
636
637        You may pass in `expand=True` to get full Entity objects
638        instead, but be aware this may be very slow if you have
639        thousands of objects.
640
641        If you are expanding the objects, you may further expand
642        the results with `links`.
643        """
644
645        payload = {
646            "name": "List all entities of type {}".format(cls._name.upper()),
647            "type": "msql", "start": 0, "limit": -1,
648            "select": [
649                ["{}.uuId".format(cls._name.upper())]
650            ],
651        }
652        ids = api.query(payload)
653        ids = [id[0] for id in ids]
654        if ids:
655            return cls.get(ids, links=links) if expand else ids
656        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):
658    @classmethod
659    def match(cls, field, term, links=None):
660        """Find entities where `field`=`term` (exact match), optionally
661        expanding the results with `links`.
662
663        Relies on `Entity.query()` with a pre-built set of rules.
664        ```
665        projects = projectal.Project.match('identifier', 'zmb-005')
666        ```
667        """
668        filter = [["{}.{}".format(cls._name.upper(), field), "eq", term]]
669        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):
671    @classmethod
672    def match_startswith(cls, field, term, links=None):
673        """Find entities where `field` starts with the text `term`,
674        optionally expanding the results with `links`.
675
676        Relies on `Entity.query()` with a pre-built set of rules.
677        ```
678        projects = projectal.Project.match_startswith('name', 'Zomb')
679        ```
680        """
681        filter = [["{}.{}".format(cls._name.upper(), field), "prefix", term]]
682        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):
684    @classmethod
685    def match_endswith(cls, field, term, links=None):
686        """Find entities where `field` ends with the text `term`,
687        optionally expanding the results with `links`.
688
689        Relies on `Entity.query()` with a pre-built set of rules.
690        ```
691        projects = projectal.Project.match_endswith('identifier', '-2023')
692        ```
693        """
694        term = "(?i).*{}$".format(term)
695        filter = [["{}.{}".format(cls._name.upper(), field), "regex", term]]
696        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):
698    @classmethod
699    def match_one(cls, field, term, links=None):
700        """Convenience function for match(). Returns the first match or None."""
701        matches = cls.match(field, term, links)
702        if matches:
703            return matches[0]

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

@classmethod
def match_startswith_one(cls, field, term, links=None):
705    @classmethod
706    def match_startswith_one(cls, field, term, links=None):
707        """Convenience function for match_startswith(). Returns the first match or None."""
708        matches = cls.match_startswith(field, term, links)
709        if matches:
710            return matches[0]

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

@classmethod
def match_endswith_one(cls, field, term, links=None):
712    @classmethod
713    def match_endswith_one(cls, field, term, links=None):
714        """Convenience function for match_endswith(). Returns the first match or None."""
715        matches = cls.match_endswith(field, term, links)
716        if matches:
717            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):
719    @classmethod
720    def search(cls, fields=None, term='', case_sensitive=True, links=None):
721        """Find entities that contain the text `term` within `fields`.
722        `fields` is a list of field names to target in the search.
723
724        `case_sensitive`: Optionally turn off case sensitivity in the search.
725
726        Relies on `Entity.query()` with a pre-built set of rules.
727        ```
728        projects = projectal.Project.search(['name', 'description'], 'zombie')
729        ```
730        """
731        filter = []
732        term = '(?{}).*{}.*'.format('' if case_sensitive else '?', term)
733        for field in fields:
734            filter.append(["{}.{}".format(cls._name.upper(), field), "regex", term])
735        filter = ['_or_', filter]
736        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):
738    @classmethod
739    def query(cls, filter, links=None):
740        """Run a query on this entity with the supplied filter.
741
742        The query is already set up to target this entity type, and the
743        results will be converted into full objects when found, optionally
744        expanded with the `links` provided. You only need to supply a
745        filter to reduce the result set.
746
747        See [the filter documentation](https://projectal.com/docs/v1.1.1#section/Filter-section)
748        for a detailed overview of the kinds of filters you can construct.
749        """
750        payload = {
751            "name": "Python library entity query ({})".format(cls._name.upper()),
752            "type": "msql", "start": 0, "limit": -1,
753            "select": [
754                ["{}.uuId".format(cls._name.upper())]
755            ],
756            "filter": filter
757        }
758        ids = api.query(payload)
759        ids = [id[0] for id in ids]
760        if ids:
761            return cls.get(ids, links=links)
762        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):
764    def profile_get(self, key):
765        """Get the profile (metadata) stored for this entity at `key`."""
766        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):
768    def profile_set(self, key, data):
769        """Set the profile (metadata) stored for this entity at `key`. The contents
770        of `data` will completely overwrite the existing data dictionary."""
771        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):
789    def changes(self):
790        """Return a dict containing the fields that have changed since fetching the object.
791        Dict values contain both the old and new values. E.g.: {'old': 'original', 'new': 'current'}.
792
793        In the case of link lists, there are three values: added, removed, updated. Only links with
794        a data attribute can end up in the updated list, and the old/new dictionary is placed within
795        that data attribute. E.g. for a staff-resource link:
796        'updated': [{
797            'uuId': '24eb4c31-0f92-49d1-8b4d-507ab939003e',
798            'resourceLink': {'quantity': {'old': 2, 'new': 5}}
799        }]
800        """
801        changed = {}
802        for key in self.keys():
803            link_def = self._link_def_by_key.get(key)
804            if link_def:
805                changes = self._changes_for_link_list(link_def, key)
806                # Only add it if something in it changed
807                for action in changes.values():
808                    if len(action):
809                        changed[key] = changes
810                        break
811            elif key not in self.__old and self[key] is not None:
812                changed[key] = {'old': None, 'new': self[key]}
813            elif self.__old.get(key) != self[key]:
814                changed[key] = {'old': self.__old.get(key), 'new': self[key]}
815        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):
878    def set_readonly(self, key, value):
879        """Set a field on this Entity that will not be sent over to the
880        server on update unless modified."""
881        self[key] = value
882        self.__old[key] = value

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

def entity_name(self):
1033    def entity_name(self):
1034        return self._name.capitalize()
Inherited Members
builtins.dict
setdefault
pop
popitem
keys
items
values
fromkeys
clear
copy