projectal.entity API documentation

# projectal.entity

The base Entity class that all entities inherit from.

View Source

       1"""
       2The base Entity class that all entities inherit from.
       3"""
       4import copy
       5import logging
       6import sys
       7
       8import projectal
       9from projectal import api
      10
      11
      12class Entity(dict):
      13    """
      14    The parent class for all our entities, offering requests
      15    and validation for the fundamental create/read/update/delete
      16    operations.
      17
      18    This class (and all our entities) inherit from the builtin
      19    `dict` class. This means all entity classes can be used
      20    like standard Python dictionary objects, but we can also
      21    offer additional utility functions that operate on the
      22    instance itself (see `linkers` for an example). Any method
      23    that expects a `dict` can also consume an `Entity` subclass.
      24
      25    The class methods in this class can operate on one or more
      26    entities in one request. If the methods are called with
      27    lists (for batch operation), the output returned will also
      28    be a list. Otherwise, a single `Entity` subclass is returned.
      29
      30    Note for batch operations: a `ProjectalException` is raised
      31    if *any* of the entities fail during the operation. The
      32    changes will *still be saved to the database for the entities
      33    that did not fail*.
      34    """
      35
      36    #: Child classes must override these with their entity names
      37    _path = "entity"  # URL portion to api
      38    _name = "entity"
      39
      40    # And to which entities they link to
      41    _links = []
      42    _links_reverse = []
      43
      44    def __init__(self, data):
      45        dict.__init__(self, data)
      46        self._is_new = True
      47        self._link_def_by_key = {}
      48        self._link_def_by_name = {}
      49        self._create_link_defs()
      50        self._with_links = set()
      51
      52        self.__fetch = self.get
      53        self.get = self.__get
      54        self.update = self.__update
      55        self.delete = self.__delete
      56        self.history = self.__history
      57        self.__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): View Source

      13class Entity(dict):
      14    """
      15    The parent class for all our entities, offering requests
      16    and validation for the fundamental create/read/update/delete
      17    operations.
      18
      19    This class (and all our entities) inherit from the builtin
      20    `dict` class. This means all entity classes can be used
      21    like standard Python dictionary objects, but we can also
      22    offer additional utility functions that operate on the
      23    instance itself (see `linkers` for an example). Any method
      24    that expects a `dict` can also consume an `Entity` subclass.
      25
      26    The class methods in this class can operate on one or more
      27    entities in one request. If the methods are called with
      28    lists (for batch operation), the output returned will also
      29    be a list. Otherwise, a single `Entity` subclass is returned.
      30
      31    Note for batch operations: a `ProjectalException` is raised
      32    if *any* of the entities fail during the operation. The
      33    changes will *still be saved to the database for the entities
      34    that did not fail*.
      35    """
      36
      37    #: Child classes must override these with their entity names
      38    _path = "entity"  # URL portion to api
      39    _name = "entity"
      40
      41    # And to which entities they link to
      42    _links = []
      43    _links_reverse = []
      44
      45    def __init__(self, data):
      46        dict.__init__(self, data)
      47        self._is_new = True
      48        self._link_def_by_key = {}
      49        self._link_def_by_name = {}
      50        self._create_link_defs()
      51        self._with_links = set()
      52
      53        self.__fetch = self.get
      54        self.get = self.__get
      55        self.update = self.__update
      56        self.delete = self.__delete
      57        self.history = self.__history
      58        self.__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): View Source

    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 `uuId`s of the entities you want to get (see bottom for examples):

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

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

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

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

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

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

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

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

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

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

    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 `dict`s to update many entities in bulk.

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

Returns `True` if all entities update successfully.

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

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

    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): View Source

    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): View Source

    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 `dict`s to create many entities in bulk.

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

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

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

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

def save(self): View Source

    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): View Source

    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): View Source

    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): View Source

    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): View Source

    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): View Source

    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): View Source

    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): View Source

    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): View Source

    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): View Source

    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): View Source

    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): View Source

    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): View Source

    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): View Source

    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): View Source

    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.

@classmethod
def get_link_definitions(cls): View Source

    1074    @classmethod
    1075    def get_link_definitions(cls):
    1076        return cls({})._link_def_by_name

def entity_name(self): View Source

    1080    def entity_name(self):
    1081        return self._name.capitalize()

##### Inherited Members

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