projectal.entity

The base Entity class that all entities inherit from.

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

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

def clone(self, entity):
658    def clone(self, entity):
659        """
660        Clones an entity and returns its `uuId`.
661
662        Each entity has its own set of required values when cloning.
663        Check the API documentation of that entity for details.
664        """
665        url = "/api/{}/clone?reference={}".format(self._path, self["uuId"])
666        response = api.post(url, entity)
667        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):
701    @classmethod
702    def list(cls, expand=False, links=None):
703        """Return a list of all entity UUIDs of this type.
704
705        You may pass in `expand=True` to get full Entity objects
706        instead, but be aware this may be very slow if you have
707        thousands of objects.
708
709        If you are expanding the objects, you may further expand
710        the results with `links`.
711        """
712
713        payload = {
714            "name": "List all entities of type {}".format(cls._name.upper()),
715            "type": "msql",
716            "start": 0,
717            "limit": -1,
718            "select": [["{}.uuId".format(cls._name.upper())]],
719        }
720        ids = api.query(payload)
721        ids = [id[0] for id in ids]
722        if ids:
723            return cls.get(ids, links=links) if expand else ids
724        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):
726    @classmethod
727    def match(cls, field, term, links=None):
728        """Find entities where `field`=`term` (exact match), optionally
729        expanding the results with `links`.
730
731        Relies on `Entity.query()` with a pre-built set of rules.
732        ```
733        projects = projectal.Project.match('identifier', 'zmb-005')
734        ```
735        """
736        filter = [["{}.{}".format(cls._name.upper(), field), "eq", term]]
737        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):
739    @classmethod
740    def match_startswith(cls, field, term, links=None):
741        """Find entities where `field` starts with the text `term`,
742        optionally expanding the results with `links`.
743
744        Relies on `Entity.query()` with a pre-built set of rules.
745        ```
746        projects = projectal.Project.match_startswith('name', 'Zomb')
747        ```
748        """
749        filter = [["{}.{}".format(cls._name.upper(), field), "prefix", term]]
750        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):
752    @classmethod
753    def match_endswith(cls, field, term, links=None):
754        """Find entities where `field` ends with the text `term`,
755        optionally expanding the results with `links`.
756
757        Relies on `Entity.query()` with a pre-built set of rules.
758        ```
759        projects = projectal.Project.match_endswith('identifier', '-2023')
760        ```
761        """
762        term = "(?i).*{}$".format(term)
763        filter = [["{}.{}".format(cls._name.upper(), field), "regex", term]]
764        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):
766    @classmethod
767    def match_one(cls, field, term, links=None):
768        """Convenience function for match(). Returns the first match or None."""
769        matches = cls.match(field, term, links)
770        if matches:
771            return matches[0]

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

@classmethod
def match_startswith_one(cls, field, term, links=None):
773    @classmethod
774    def match_startswith_one(cls, field, term, links=None):
775        """Convenience function for match_startswith(). Returns the first match or None."""
776        matches = cls.match_startswith(field, term, links)
777        if matches:
778            return matches[0]

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

@classmethod
def match_endswith_one(cls, field, term, links=None):
780    @classmethod
781    def match_endswith_one(cls, field, term, links=None):
782        """Convenience function for match_endswith(). Returns the first match or None."""
783        matches = cls.match_endswith(field, term, links)
784        if matches:
785            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):
787    @classmethod
788    def search(cls, fields=None, term="", case_sensitive=True, links=None):
789        """Find entities that contain the text `term` within `fields`.
790        `fields` is a list of field names to target in the search.
791
792        `case_sensitive`: Optionally turn off case sensitivity in the search.
793
794        Relies on `Entity.query()` with a pre-built set of rules.
795        ```
796        projects = projectal.Project.search(['name', 'description'], 'zombie')
797        ```
798        """
799        filter = []
800        term = "(?{}).*{}.*".format("" if case_sensitive else "?", term)
801        for field in fields:
802            filter.append(["{}.{}".format(cls._name.upper(), field), "regex", term])
803        filter = ["_or_", filter]
804        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, timeout=30):
806    @classmethod
807    def query(cls, filter, links=None, timeout=30):
808        """Run a query on this entity with the supplied filter.
809
810        The query is already set up to target this entity type, and the
811        results will be converted into full objects when found, optionally
812        expanded with the `links` provided. You only need to supply a
813        filter to reduce the result set.
814
815        See [the filter documentation](https://projectal.com/docs/v1.1.1#section/Filter-section)
816        for a detailed overview of the kinds of filters you can construct.
817        """
818        payload = {
819            "name": "Python library entity query ({})".format(cls._name.upper()),
820            "type": "msql",
821            "start": 0,
822            "limit": -1,
823            "select": [["{}.uuId".format(cls._name.upper())]],
824            "filter": filter,
825            "timeout": timeout,
826        }
827        ids = api.query(payload)
828        ids = [id[0] for id in ids]
829        if ids:
830            return cls.get(ids, links=links)
831        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):
833    def profile_get(self, key):
834        """Get the profile (metadata) stored for this entity at `key`."""
835        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):
837    def profile_set(self, key, data):
838        """Set the profile (metadata) stored for this entity at `key`. The contents
839        of `data` will completely overwrite the existing data dictionary."""
840        return projectal.profile.set(
841            key, self.__class__._name.lower(), self["uuId"], data
842        )

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

def changes(self):
860    def changes(self):
861        """Return a dict containing the fields that have changed since fetching the object.
862        Dict values contain both the old and new values. E.g.: {'old': 'original', 'new': 'current'}.
863
864        In the case of link lists, there are three values: added, removed, updated. Only links with
865        a data attribute can end up in the updated list, and the old/new dictionary is placed within
866        that data attribute. E.g. for a staff-resource link:
867        'updated': [{
868            'uuId': '24eb4c31-0f92-49d1-8b4d-507ab939003e',
869            'resourceLink': {'quantity': {'old': 2, 'new': 5}}
870        }]
871        """
872        changed = {}
873        for key in self.keys():
874            link_def = self._link_def_by_key.get(key)
875            if link_def:
876                changes = self._changes_for_link_list(link_def, key)
877                # Only add it if something in it changed
878                for action in changes.values():
879                    if len(action):
880                        changed[key] = changes
881                        break
882            elif key not in self.__old and self[key] is not None:
883                changed[key] = {"old": None, "new": self[key]}
884            elif self.__old.get(key) != self[key]:
885                changed[key] = {"old": self.__old.get(key), "new": self[key]}
886        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):
949    def set_readonly(self, key, value):
950        """Set a field on this Entity that will not be sent over to the
951        server on update unless modified."""
952        self[key] = value
953        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):
1131    def entity_name(self):
1132        return self._name.capitalize()