projectal.entity

The base Entity class that all entities inherit from.

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

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

entities: One of several formats containing the uuIds of the entities you want to get (see bottom for examples):

  • str or list of str
  • dict or list of dict (with uuId key)

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

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

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

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

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

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

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

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

@classmethod
def update(cls, entities):
410    @classmethod
411    def update(cls, entities):
412        """
413        Save one or more entities of the same type. The entity
414        type is determined by the subclass calling this method.
415        Only the fields that have been modifier will be sent
416        to the server as part of the request.
417
418        `entities`: Can be a `dict` to update a single entity,
419        or a list of `dict`s to update many entities in bulk.
420
421        Returns `True` if all entities update successfully.
422
423        ```
424        # Example usage:
425        rebate = projectal.Rebate.create({'name': 'Rebate2022', 'rebate': 0.2})
426        rebate['name'] = 'Rebate2024'
427        projectal.Rebate.update(rebate)
428        # Returns True. New rebate name has been saved.
429        ```
430        """
431        if isinstance(entities, dict):
432            e_list = [entities]
433        else:
434            e_list = entities
435
436        # Reduce the list to only modified entities and their modified fields.
437        # Only do this to an Entity subclass - the consumer may have passed
438        # in a dict of changes on their own.
439        payload = []
440
441        for e in e_list:
442            if isinstance(e, Entity):
443                changes = e._changes_internal()
444                if changes:
445                    changes['uuId'] = e['uuId']
446                    payload.append(changes)
447            else:
448                payload.append(e)
449        if payload:
450            for i in range(0, len(payload), projectal.chunk_size_write):
451                chunk = payload[i:i + projectal.chunk_size_write]
452                api.put('/api/{}/update'.format(cls._path), chunk)
453
454        # Detect and apply any link changes
455        for e in e_list:
456            if isinstance(e, Entity):
457                e.__apply_link_changes()
458        return True

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

entities: Can be a dict to update a single entity, or a list of dicts to update many entities in bulk.

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):
469    @classmethod
470    def delete(cls, entities):
471        """
472        Delete one or more entities of the same type. The entity
473        type is determined by the subclass calling this method.
474
475        `entities`: See `Entity.get()` for expected formats.
476
477        ```
478        # Example usage:
479        ids = ['1b21e445-f29a...', '1b21e445-f29a...', '1b21e445-f29a...']
480        projectal.Customer.delete(ids)
481        ```
482        """
483        if isinstance(entities, str):
484            # String input is a uuId
485            payload = [{'uuId': entities}]
486        elif isinstance(entities, dict):
487            # Dict input needs to be a list
488            payload = [entities]
489        elif isinstance(entities, list):
490            # List input can be a list of uuIds or list of dicts
491            # If uuIds (strings), convert to list of dicts
492            if len(entities) > 0 and isinstance(entities[0], str):
493                payload = [{'uuId': uuId} for uuId in entities]
494            else:
495                # Already expected format
496                payload = entities
497        else:
498            # We have a list of dicts already, the expected format
499            payload = entities
500
501        # We only need to send over the uuIds
502        payload = [{'uuId': e['uuId']} for e in payload]
503        if not payload:
504            return True
505        for i in range(0, len(payload), projectal.chunk_size_write):
506            chunk = payload[i:i + projectal.chunk_size_write]
507            api.delete('/api/{}/delete'.format(cls._path), chunk)
508        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):
525    @classmethod
526    def history(cls, UUID, start=0, limit=-1, order='desc', epoch=None, event=None):
527        """
528        Returns an ordered list of all changes made to the entity.
529
530        `UUID`: the UUID of the entity.
531
532        `start`: Start index for pagination (default: `0`).
533
534        `limit`: Number of results to include for pagination. Use
535        `-1` to return the entire history (default: `-1`).
536
537        `order`: `asc` or `desc` (default: `desc` (index 0 is newest))
538
539        `epoch`: only return the history UP TO epoch date
540
541        `event`:
542        """
543        url = '/api/{}/history?holder={}&'.format(cls._path, UUID)
544        params = []
545        params.append('start={}'.format(start))
546        params.append('limit={}'.format(limit))
547        params.append('order={}'.format(order))
548        params.append('epoch={}'.format(epoch)) if epoch else None
549        params.append('event={}'.format(event)) if event else None
550        url += '&'.join(params)
551        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):
206    @classmethod
207    def create(cls, entities, params=None):
208        """
209        Create one or more entities of the same type. The entity
210        type is determined by the subclass calling this method.
211
212        `entities`: Can be a `dict` to create a single entity,
213        or a list of `dict`s to create many entities in bulk.
214
215        `params`: Optional URL parameters that may apply to the
216        entity's API (e.g: `?holder=1234`).
217
218        If input was a `dict`, returns an entity subclass. If input was
219        a list of `dict`s, returns a list of entity subclasses.
220
221        ```
222        # Example usage:
223        projectal.Customer.create({'name': 'NewCustomer'})
224        # returns Customer object
225        ```
226        """
227
228        if isinstance(entities, dict):
229            # Dict input needs to be a list
230            e_list = [entities]
231        else:
232            # We have a list of dicts already, the expected format
233            e_list = entities
234
235        # Apply type
236        typed_list = []
237        for e in e_list:
238            if not isinstance(e, Entity):
239                # Start empty to correctly populate history
240                new = cls({})
241                new.update(e)
242                typed_list.append(new)
243            else:
244                typed_list.append(e)
245        e_list = typed_list
246
247        endpoint = '/api/{}/add'.format(cls._path)
248        if params:
249            endpoint += params
250        if not e_list:
251            return []
252
253        # Strip links from payload
254        payload = []
255        keys = e_list[0]._link_def_by_key.keys()
256        for e in e_list:
257            cleancopy = copy.deepcopy(e)
258            # Remove any fields that match a link key
259            for key in keys:
260                cleancopy.pop(key, None)
261            payload.append(cleancopy)
262
263        objects = []
264        for i in range(0, len(payload), projectal.chunk_size_write):
265            chunk = payload[i:i + projectal.chunk_size_write]
266            orig_chunk = e_list[i:i + projectal.chunk_size_write]
267            response = api.post(endpoint, chunk)
268            # Put uuId from response into each input dict
269            for e, o, orig in zip(chunk, response, orig_chunk):
270                orig['uuId'] = o['uuId']
271                orig.__old = copy.deepcopy(orig)
272                # Delete links from the history in order to trigger a change on them after
273                for key in orig._link_def_by_key:
274                    orig.__old.pop(key, None)
275                objects.append(orig)
276
277        # Detect and apply any link additions
278        for e in e_list:
279            e.__apply_link_changes()
280
281        if not isinstance(entities, list):
282            return objects[0]
283        return objects

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

entities: Can be a dict to create a single entity, or a list of dicts to create many entities in bulk.

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

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

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

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

def clone(self, entity):
514    def clone(self, entity):
515        """
516        Clones an entity and returns its `uuId`.
517
518        Each entity has its own set of required values when cloning.
519        Check the API documentation of that entity for details.
520        """
521        url = '/api/{}/clone?reference={}'.format(self._path, self['uuId'])
522        response = api.post(url, entity)
523        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):
557    @classmethod
558    def list(cls, expand=False, links=None):
559        """Return a list of all entity UUIDs of this type.
560
561        You may pass in `expand=True` to get full Entity objects
562        instead, but be aware this may be very slow if you have
563        thousands of objects.
564
565        If you are expanding the objects, you may further expand
566        the results with `links`.
567        """
568
569        payload = {
570            "name": "List all entities of type {}".format(cls._name.upper()),
571            "type": "msql", "start": 0, "limit": -1,
572            "select": [
573                ["{}.uuId".format(cls._name.upper())]
574            ],
575        }
576        ids = api.query(payload)
577        ids = [id[0] for id in ids]
578        if ids:
579            return cls.get(ids, links=links) if expand else ids
580        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):
582    @classmethod
583    def match(cls, field, term, links=None):
584        """Find entities where `field`=`term` (exact match), optionally
585        expanding the results with `links`.
586
587        Relies on `Entity.query()` with a pre-built set of rules.
588        ```
589        projects = projectal.Project.match('identifier', 'zmb-005')
590        ```
591        """
592        filter = [["{}.{}".format(cls._name.upper(), field), "eq", term]]
593        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):
595    @classmethod
596    def match_startswith(cls, field, term, links=None):
597        """Find entities where `field` starts with the text `term`,
598        optionally expanding the results with `links`.
599
600        Relies on `Entity.query()` with a pre-built set of rules.
601        ```
602        projects = projectal.Project.match_startswith('name', 'Zomb')
603        ```
604        """
605        filter = [["{}.{}".format(cls._name.upper(), field), "prefix", term]]
606        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):
608    @classmethod
609    def match_endswith(cls, field, term, links=None):
610        """Find entities where `field` ends with the text `term`,
611        optionally expanding the results with `links`.
612
613        Relies on `Entity.query()` with a pre-built set of rules.
614        ```
615        projects = projectal.Project.match_endswith('identifier', '-2023')
616        ```
617        """
618        term = "(?i).*{}$".format(term)
619        filter = [["{}.{}".format(cls._name.upper(), field), "regex", term]]
620        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):
622    @classmethod
623    def match_one(cls, field, term, links=None):
624        """Convenience function for match(). Returns the first match or None."""
625        matches = cls.match(field, term, links)
626        if matches:
627            return matches[0]

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

@classmethod
def match_startswith_one(cls, field, term, links=None):
629    @classmethod
630    def match_startswith_one(cls, field, term, links=None):
631        """Convenience function for match_startswith(). Returns the first match or None."""
632        matches = cls.match_startswith(field, term, links)
633        if matches:
634            return matches[0]

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

@classmethod
def match_endswith_one(cls, field, term, links=None):
636    @classmethod
637    def match_endswith_one(cls, field, term, links=None):
638        """Convenience function for match_endswith(). Returns the first match or None."""
639        matches = cls.match_endswith(field, term, links)
640        if matches:
641            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):
643    @classmethod
644    def search(cls, fields=None, term='', case_sensitive=True, links=None):
645        """Find entities that contain the text `term` within `fields`.
646        `fields` is a list of field names to target in the search.
647
648        `case_sensitive`: Optionally turn off case sensitivity in the search.
649
650        Relies on `Entity.query()` with a pre-built set of rules.
651        ```
652        projects = projectal.Project.search(['name', 'description'], 'zombie')
653        ```
654        """
655        filter = []
656        term = '(?{}).*{}.*'.format('' if case_sensitive else '?', term)
657        for field in fields:
658            filter.append(["{}.{}".format(cls._name.upper(), field), "regex", term])
659        filter = ['_or_', filter]
660        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):
662    @classmethod
663    def query(cls, filter, links=None):
664        """Run a query on this entity with the supplied filter.
665
666        The query is already set up to target this entity type, and the
667        results will be converted into full objects when found, optionally
668        expanded with the `links` provided. You only need to supply a
669        filter to reduce the result set.
670
671        See [the filter documentation](https://projectal.com/docs/v1.1.1#section/Filter-section)
672        for a detailed overview of the kinds of filters you can construct.
673        """
674        payload = {
675            "name": "Python library entity query ({})".format(cls._name.upper()),
676            "type": "msql", "start": 0, "limit": -1,
677            "select": [
678                ["{}.uuId".format(cls._name.upper())]
679            ],
680            "filter": filter
681        }
682        ids = api.query(payload)
683        ids = [id[0] for id in ids]
684        if ids:
685            return cls.get(ids, links=links)
686        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):
688    def profile_get(self, key):
689        """Get the profile (metadata) stored for this entity at `key`."""
690        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):
692    def profile_set(self, key, data):
693        """Set the profile (metadata) stored for this entity at `key`. The contents
694        of `data` will completely overwrite the existing data dictionary."""
695        return projectal.profile.set(key, self.__class__._name.lower(), self['uuId'], data)

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

def changes(self):
713    def changes(self):
714        """Return a dict containing the fields that have changed since fetching the object.
715        Dict values contain both the old and new values. E.g.: {'old': 'original', 'new': 'current'}.
716
717        In the case of link lists, there are three values: added, removed, updated. Only links with
718        a data attribute can end up in the updated list, and the old/new dictionary is placed within
719        that data attribute. E.g. for a staff-resource link:
720        'updated': [{
721            'uuId': '24eb4c31-0f92-49d1-8b4d-507ab939003e',
722            'resourceLink': {'quantity': {'old': 2, 'new': 5}}
723        }]
724        """
725        changed = {}
726        for key in self.keys():
727            link_def = self._link_def_by_key.get(key)
728            if link_def:
729                changes = self._changes_for_link_list(link_def, key)
730                # Only add it if something in it changed
731                for action in changes.values():
732                    if len(action):
733                        changed[key] = changes
734                        break
735            elif key not in self.__old and self[key] is not None:
736                changed[key] = {'old': None, 'new': self[key]}
737            elif self.__old.get(key) != self[key]:
738                changed[key] = {'old': self.__old.get(key), 'new': self[key]}
739        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):
802    def set_readonly(self, key, value):
803        """Set a field on this Entity that will not be sent over to the
804        server on update unless modified."""
805        self[key] = value
806        self.__old[key] = value

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

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