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

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

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

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

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

@classmethod
def get(cls, entities, links=None, deleted_at=None):
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        # Reduce the list to only modified entities and their modified fields.
491        # Only do this to an Entity subclass - the consumer may have passed
492        # in a dict of changes on their own.
493        payload = []
494
495        for e in e_list:
496            if isinstance(e, Entity):
497                changes = e._changes_internal()
498                if changes:
499                    changes['uuId'] = e['uuId']
500                    payload.append(changes)
501            else:
502                payload.append(e)
503        if payload:
504            for i in range(0, len(payload), projectal.chunk_size_write):
505                chunk = payload[i:i + projectal.chunk_size_write]
506                api.put('/api/{}/update'.format(cls._path), chunk)
507
508        # Detect and apply any link changes
509        # if batch_linking is enabled, builds a list of link requests
510        # from the changes of each entity, then executes
511        # composite API requests with those changes
512        link_request_batch = []
513        for e in e_list:
514            if isinstance(e, Entity):
515                requests = e.__apply_link_changes(batch_linking=batch_linking)
516                link_request_batch.extend(requests)
517
518        if len(link_request_batch) > 0 and batch_linking:
519            for i in range(0, len(link_request_batch), 100):
520                chunk = link_request_batch[i:i + 100]
521                api.post('/api/composite', chunk)
522
523        return True

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

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

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

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

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

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

UUID: the UUID of the entity.

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

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

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

epoch: only return the history UP TO epoch date

event:

@classmethod
def create(cls, entities, params=None, batch_linking=True):
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):
529    def save(self):
530        """Calls `update()` on this instance of the entity, saving
531        it to the database."""
532        return self.__class__.update(self)

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

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

Clones an entity and returns its uuId.

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

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

Return a list of all entity UUIDs of this type.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

case_sensitive: Optionally turn off case sensitivity in the search.

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

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

Run a query on this entity with the supplied filter.

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

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

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

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

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

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

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

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

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

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

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

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