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