projectal.entity

The base Entity class that all entities inherit from.

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

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

def clone(self, entity):
608    def clone(self, entity):
609        """
610        Clones an entity and returns its `uuId`.
611
612        Each entity has its own set of required values when cloning.
613        Check the API documentation of that entity for details.
614        """
615        url = "/api/{}/clone?reference={}".format(self._path, self["uuId"])
616        response = api.post(url, entity)
617        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):
651    @classmethod
652    def list(cls, expand=False, links=None):
653        """Return a list of all entity UUIDs of this type.
654
655        You may pass in `expand=True` to get full Entity objects
656        instead, but be aware this may be very slow if you have
657        thousands of objects.
658
659        If you are expanding the objects, you may further expand
660        the results with `links`.
661        """
662
663        payload = {
664            "name": "List all entities of type {}".format(cls._name.upper()),
665            "type": "msql",
666            "start": 0,
667            "limit": -1,
668            "select": [["{}.uuId".format(cls._name.upper())]],
669        }
670        ids = api.query(payload)
671        ids = [id[0] for id in ids]
672        if ids:
673            return cls.get(ids, links=links) if expand else ids
674        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):
676    @classmethod
677    def match(cls, field, term, links=None):
678        """Find entities where `field`=`term` (exact match), optionally
679        expanding the results with `links`.
680
681        Relies on `Entity.query()` with a pre-built set of rules.
682        ```
683        projects = projectal.Project.match('identifier', 'zmb-005')
684        ```
685        """
686        filter = [["{}.{}".format(cls._name.upper(), field), "eq", term]]
687        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):
689    @classmethod
690    def match_startswith(cls, field, term, links=None):
691        """Find entities where `field` starts with the text `term`,
692        optionally expanding the results with `links`.
693
694        Relies on `Entity.query()` with a pre-built set of rules.
695        ```
696        projects = projectal.Project.match_startswith('name', 'Zomb')
697        ```
698        """
699        filter = [["{}.{}".format(cls._name.upper(), field), "prefix", term]]
700        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):
702    @classmethod
703    def match_endswith(cls, field, term, links=None):
704        """Find entities where `field` ends with the text `term`,
705        optionally expanding the results with `links`.
706
707        Relies on `Entity.query()` with a pre-built set of rules.
708        ```
709        projects = projectal.Project.match_endswith('identifier', '-2023')
710        ```
711        """
712        term = "(?i).*{}$".format(term)
713        filter = [["{}.{}".format(cls._name.upper(), field), "regex", term]]
714        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):
716    @classmethod
717    def match_one(cls, field, term, links=None):
718        """Convenience function for match(). Returns the first match or None."""
719        matches = cls.match(field, term, links)
720        if matches:
721            return matches[0]

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

@classmethod
def match_startswith_one(cls, field, term, links=None):
723    @classmethod
724    def match_startswith_one(cls, field, term, links=None):
725        """Convenience function for match_startswith(). Returns the first match or None."""
726        matches = cls.match_startswith(field, term, links)
727        if matches:
728            return matches[0]

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

@classmethod
def match_endswith_one(cls, field, term, links=None):
730    @classmethod
731    def match_endswith_one(cls, field, term, links=None):
732        """Convenience function for match_endswith(). Returns the first match or None."""
733        matches = cls.match_endswith(field, term, links)
734        if matches:
735            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):
737    @classmethod
738    def search(cls, fields=None, term="", case_sensitive=True, links=None):
739        """Find entities that contain the text `term` within `fields`.
740        `fields` is a list of field names to target in the search.
741
742        `case_sensitive`: Optionally turn off case sensitivity in the search.
743
744        Relies on `Entity.query()` with a pre-built set of rules.
745        ```
746        projects = projectal.Project.search(['name', 'description'], 'zombie')
747        ```
748        """
749        filter = []
750        term = "(?{}).*{}.*".format("" if case_sensitive else "?", term)
751        for field in fields:
752            filter.append(["{}.{}".format(cls._name.upper(), field), "regex", term])
753        filter = ["_or_", filter]
754        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):
756    @classmethod
757    def query(cls, filter, links=None):
758        """Run a query on this entity with the supplied filter.
759
760        The query is already set up to target this entity type, and the
761        results will be converted into full objects when found, optionally
762        expanded with the `links` provided. You only need to supply a
763        filter to reduce the result set.
764
765        See [the filter documentation](https://projectal.com/docs/v1.1.1#section/Filter-section)
766        for a detailed overview of the kinds of filters you can construct.
767        """
768        payload = {
769            "name": "Python library entity query ({})".format(cls._name.upper()),
770            "type": "msql",
771            "start": 0,
772            "limit": -1,
773            "select": [["{}.uuId".format(cls._name.upper())]],
774            "filter": filter,
775        }
776        ids = api.query(payload)
777        ids = [id[0] for id in ids]
778        if ids:
779            return cls.get(ids, links=links)
780        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):
782    def profile_get(self, key):
783        """Get the profile (metadata) stored for this entity at `key`."""
784        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):
786    def profile_set(self, key, data):
787        """Set the profile (metadata) stored for this entity at `key`. The contents
788        of `data` will completely overwrite the existing data dictionary."""
789        return projectal.profile.set(
790            key, self.__class__._name.lower(), self["uuId"], data
791        )

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

def changes(self):
809    def changes(self):
810        """Return a dict containing the fields that have changed since fetching the object.
811        Dict values contain both the old and new values. E.g.: {'old': 'original', 'new': 'current'}.
812
813        In the case of link lists, there are three values: added, removed, updated. Only links with
814        a data attribute can end up in the updated list, and the old/new dictionary is placed within
815        that data attribute. E.g. for a staff-resource link:
816        'updated': [{
817            'uuId': '24eb4c31-0f92-49d1-8b4d-507ab939003e',
818            'resourceLink': {'quantity': {'old': 2, 'new': 5}}
819        }]
820        """
821        changed = {}
822        for key in self.keys():
823            link_def = self._link_def_by_key.get(key)
824            if link_def:
825                changes = self._changes_for_link_list(link_def, key)
826                # Only add it if something in it changed
827                for action in changes.values():
828                    if len(action):
829                        changed[key] = changes
830                        break
831            elif key not in self.__old and self[key] is not None:
832                changed[key] = {"old": None, "new": self[key]}
833            elif self.__old.get(key) != self[key]:
834                changed[key] = {"old": self.__old.get(key), "new": self[key]}
835        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):
898    def set_readonly(self, key, value):
899        """Set a field on this Entity that will not be sent over to the
900        server on update unless modified."""
901        self[key] = value
902        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):
1080    def entity_name(self):
1081        return self._name.capitalize()
Inherited Members
builtins.dict
setdefault
pop
popitem
keys
items
values
fromkeys
clear
copy