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 # Reduce the list to only modified entities and their modified fields. 490 # Only do this to an Entity subclass - the consumer may have passed 491 # in a dict of changes on their own. 492 payload = [] 493 494 for e in e_list: 495 if isinstance(e, Entity): 496 changes = e._changes_internal() 497 if changes: 498 changes['uuId'] = e['uuId'] 499 payload.append(changes) 500 else: 501 payload.append(e) 502 if payload: 503 for i in range(0, len(payload), projectal.chunk_size_write): 504 chunk = payload[i:i + projectal.chunk_size_write] 505 api.put('/api/{}/update'.format(cls._path), chunk) 506 507 # Detect and apply any link changes 508 # if batch_linking is enabled, builds a list of link requests 509 # from the changes of each entity, then executes 510 # composite API requests with those changes 511 link_request_batch = [] 512 for e in e_list: 513 if isinstance(e, Entity): 514 requests = e.__apply_link_changes(batch_linking=batch_linking) 515 link_request_batch.extend(requests) 516 517 if len(link_request_batch) > 0 and batch_linking: 518 for i in range(0, len(link_request_batch), 100): 519 chunk = link_request_batch[i:i + 100] 520 api.post('/api/composite', chunk) 521 522 return True 523 524 def __update(self, *args, **kwargs): 525 """Use the dict update for instances.""" 526 return super(Entity, self).update(*args, **kwargs) 527 528 def save(self): 529 """Calls `update()` on this instance of the entity, saving 530 it to the database.""" 531 return self.__class__.update(self) 532 533 @classmethod 534 def delete(cls, entities): 535 """ 536 Delete one or more entities of the same type. The entity 537 type is determined by the subclass calling this method. 538 539 `entities`: See `Entity.get()` for expected formats. 540 541 ``` 542 # Example usage: 543 ids = ['1b21e445-f29a...', '1b21e445-f29a...', '1b21e445-f29a...'] 544 projectal.Customer.delete(ids) 545 ``` 546 """ 547 if isinstance(entities, str): 548 # String input is a uuId 549 payload = [{'uuId': entities}] 550 elif isinstance(entities, dict): 551 # Dict input needs to be a list 552 payload = [entities] 553 elif isinstance(entities, list): 554 # List input can be a list of uuIds or list of dicts 555 # If uuIds (strings), convert to list of dicts 556 if len(entities) > 0 and isinstance(entities[0], str): 557 payload = [{'uuId': uuId} for uuId in entities] 558 else: 559 # Already expected format 560 payload = entities 561 else: 562 # We have a list of dicts already, the expected format 563 payload = entities 564 565 # We only need to send over the uuIds 566 payload = [{'uuId': e['uuId']} for e in payload] 567 if not payload: 568 return True 569 for i in range(0, len(payload), projectal.chunk_size_write): 570 chunk = payload[i:i + projectal.chunk_size_write] 571 api.delete('/api/{}/delete'.format(cls._path), chunk) 572 return True 573 574 def __delete(self): 575 """Let an instance delete itself.""" 576 return self.__class__.delete(self) 577 578 def clone(self, entity): 579 """ 580 Clones an entity and returns its `uuId`. 581 582 Each entity has its own set of required values when cloning. 583 Check the API documentation of that entity for details. 584 """ 585 url = '/api/{}/clone?reference={}'.format(self._path, self['uuId']) 586 response = api.post(url, entity) 587 return response['jobClue']['uuId'] 588 589 @classmethod 590 def history(cls, UUID, start=0, limit=-1, order='desc', epoch=None, event=None): 591 """ 592 Returns an ordered list of all changes made to the entity. 593 594 `UUID`: the UUID of the entity. 595 596 `start`: Start index for pagination (default: `0`). 597 598 `limit`: Number of results to include for pagination. Use 599 `-1` to return the entire history (default: `-1`). 600 601 `order`: `asc` or `desc` (default: `desc` (index 0 is newest)) 602 603 `epoch`: only return the history UP TO epoch date 604 605 `event`: 606 """ 607 url = '/api/{}/history?holder={}&'.format(cls._path, UUID) 608 params = [] 609 params.append('start={}'.format(start)) 610 params.append('limit={}'.format(limit)) 611 params.append('order={}'.format(order)) 612 params.append('epoch={}'.format(epoch)) if epoch else None 613 params.append('event={}'.format(event)) if event else None 614 url += '&'.join(params) 615 return api.get(url) 616 617 def __history(self, **kwargs): 618 """Get history of instance.""" 619 return self.__class__.history(self['uuId'], **kwargs) 620 621 @classmethod 622 def list(cls, expand=False, links=None): 623 """Return a list of all entity UUIDs of this type. 624 625 You may pass in `expand=True` to get full Entity objects 626 instead, but be aware this may be very slow if you have 627 thousands of objects. 628 629 If you are expanding the objects, you may further expand 630 the results with `links`. 631 """ 632 633 payload = { 634 "name": "List all entities of type {}".format(cls._name.upper()), 635 "type": "msql", "start": 0, "limit": -1, 636 "select": [ 637 ["{}.uuId".format(cls._name.upper())] 638 ], 639 } 640 ids = api.query(payload) 641 ids = [id[0] for id in ids] 642 if ids: 643 return cls.get(ids, links=links) if expand else ids 644 return [] 645 646 @classmethod 647 def match(cls, field, term, links=None): 648 """Find entities where `field`=`term` (exact match), optionally 649 expanding the results with `links`. 650 651 Relies on `Entity.query()` with a pre-built set of rules. 652 ``` 653 projects = projectal.Project.match('identifier', 'zmb-005') 654 ``` 655 """ 656 filter = [["{}.{}".format(cls._name.upper(), field), "eq", term]] 657 return cls.query(filter, links) 658 659 @classmethod 660 def match_startswith(cls, field, term, links=None): 661 """Find entities where `field` starts with the text `term`, 662 optionally expanding the results with `links`. 663 664 Relies on `Entity.query()` with a pre-built set of rules. 665 ``` 666 projects = projectal.Project.match_startswith('name', 'Zomb') 667 ``` 668 """ 669 filter = [["{}.{}".format(cls._name.upper(), field), "prefix", term]] 670 return cls.query(filter, links) 671 672 @classmethod 673 def match_endswith(cls, field, term, links=None): 674 """Find entities where `field` ends with the text `term`, 675 optionally expanding the results with `links`. 676 677 Relies on `Entity.query()` with a pre-built set of rules. 678 ``` 679 projects = projectal.Project.match_endswith('identifier', '-2023') 680 ``` 681 """ 682 term = "(?i).*{}$".format(term) 683 filter = [["{}.{}".format(cls._name.upper(), field), "regex", term]] 684 return cls.query(filter, links) 685 686 @classmethod 687 def match_one(cls, field, term, links=None): 688 """Convenience function for match(). Returns the first match or None.""" 689 matches = cls.match(field, term, links) 690 if matches: 691 return matches[0] 692 693 @classmethod 694 def match_startswith_one(cls, field, term, links=None): 695 """Convenience function for match_startswith(). Returns the first match or None.""" 696 matches = cls.match_startswith(field, term, links) 697 if matches: 698 return matches[0] 699 700 @classmethod 701 def match_endswith_one(cls, field, term, links=None): 702 """Convenience function for match_endswith(). Returns the first match or None.""" 703 matches = cls.match_endswith(field, term, links) 704 if matches: 705 return matches[0] 706 707 @classmethod 708 def search(cls, fields=None, term='', case_sensitive=True, links=None): 709 """Find entities that contain the text `term` within `fields`. 710 `fields` is a list of field names to target in the search. 711 712 `case_sensitive`: Optionally turn off case sensitivity in the search. 713 714 Relies on `Entity.query()` with a pre-built set of rules. 715 ``` 716 projects = projectal.Project.search(['name', 'description'], 'zombie') 717 ``` 718 """ 719 filter = [] 720 term = '(?{}).*{}.*'.format('' if case_sensitive else '?', term) 721 for field in fields: 722 filter.append(["{}.{}".format(cls._name.upper(), field), "regex", term]) 723 filter = ['_or_', filter] 724 return cls.query(filter, links) 725 726 @classmethod 727 def query(cls, filter, links=None): 728 """Run a query on this entity with the supplied filter. 729 730 The query is already set up to target this entity type, and the 731 results will be converted into full objects when found, optionally 732 expanded with the `links` provided. You only need to supply a 733 filter to reduce the result set. 734 735 See [the filter documentation](https://projectal.com/docs/v1.1.1#section/Filter-section) 736 for a detailed overview of the kinds of filters you can construct. 737 """ 738 payload = { 739 "name": "Python library entity query ({})".format(cls._name.upper()), 740 "type": "msql", "start": 0, "limit": -1, 741 "select": [ 742 ["{}.uuId".format(cls._name.upper())] 743 ], 744 "filter": filter 745 } 746 ids = api.query(payload) 747 ids = [id[0] for id in ids] 748 if ids: 749 return cls.get(ids, links=links) 750 return [] 751 752 def profile_get(self, key): 753 """Get the profile (metadata) stored for this entity at `key`.""" 754 return projectal.profile.get(key, self.__class__._name.lower(), self['uuId']) 755 756 def profile_set(self, key, data): 757 """Set the profile (metadata) stored for this entity at `key`. The contents 758 of `data` will completely overwrite the existing data dictionary.""" 759 return projectal.profile.set(key, self.__class__._name.lower(), self['uuId'], data) 760 761 def __type_links(self): 762 """Find links and turn their dicts into typed objects matching their Entity type.""" 763 764 for key, _def in self._link_def_by_key.items(): 765 if key in self: 766 cls = getattr(projectal, _def['entity']) 767 if _def['type'] == list: 768 as_obj = [] 769 for link in self[key]: 770 as_obj.append(cls(link)) 771 elif _def['type'] == dict: 772 as_obj = cls(self[key]) 773 else: 774 raise projectal.UsageException("Unexpected link type") 775 self[key] = as_obj 776 777 def changes(self): 778 """Return a dict containing the fields that have changed since fetching the object. 779 Dict values contain both the old and new values. E.g.: {'old': 'original', 'new': 'current'}. 780 781 In the case of link lists, there are three values: added, removed, updated. Only links with 782 a data attribute can end up in the updated list, and the old/new dictionary is placed within 783 that data attribute. E.g. for a staff-resource link: 784 'updated': [{ 785 'uuId': '24eb4c31-0f92-49d1-8b4d-507ab939003e', 786 'resourceLink': {'quantity': {'old': 2, 'new': 5}} 787 }] 788 """ 789 changed = {} 790 for key in self.keys(): 791 link_def = self._link_def_by_key.get(key) 792 if link_def: 793 changes = self._changes_for_link_list(link_def, key) 794 # Only add it if something in it changed 795 for action in changes.values(): 796 if len(action): 797 changed[key] = changes 798 break 799 elif key not in self.__old and self[key] is not None: 800 changed[key] = {'old': None, 'new': self[key]} 801 elif self.__old.get(key) != self[key]: 802 changed[key] = {'old': self.__old.get(key), 'new': self[key]} 803 return changed 804 805 def _changes_for_link_list(self, link_def, key): 806 changes = self.__apply_list(link_def, report_only=True) 807 data_key = link_def['data_name'] 808 809 # For linked entities, we will only report their UUID, name (if it has one), 810 # and the content of their data attribute (if it has one). 811 def get_slim_list(entities): 812 slim = [] 813 if isinstance(entities, dict): 814 entities = [entities] 815 for e in entities: 816 fields = {'uuId': e['uuId']} 817 name = e.get('name') 818 if name: 819 fields['name'] = e['name'] 820 if data_key and e[data_key]: 821 fields[data_key] = e[data_key] 822 slim.append(fields) 823 return slim 824 825 out = { 826 'added': get_slim_list(changes.get('add', [])), 827 'updated': [], 828 'removed': get_slim_list(changes.get('remove', [])), 829 } 830 831 updated = changes.get('update', []) 832 if updated: 833 before_map = {} 834 for entity in self.__old.get(key): 835 before_map[entity['uuId']] = entity 836 837 for entity in updated: 838 old_data = before_map[entity['uuId']][data_key] 839 new_data = entity[data_key] 840 diff = {} 841 for key in new_data.keys(): 842 if key not in old_data and new_data[key] is not None: 843 diff[key] = {'old': None, 'new': new_data[key]} 844 elif old_data.get(key) != new_data[key]: 845 diff[key] = {'old': old_data.get(key), 'new': new_data[key]} 846 out['updated'].append({'uuId': entity['uuId'], data_key: diff}) 847 return out 848 849 def _changes_internal(self): 850 """Return a dict containing only the fields that have changed and their current value, 851 without any link data. 852 853 This method is used internally to strip payloads down to only the fields that have changed. 854 """ 855 changed = {} 856 for key in self.keys(): 857 # We don't deal with link or link data changes here. We only want standard fields. 858 if key in self._link_def_by_key: 859 continue 860 if key not in self.__old and self[key] is not None: 861 changed[key] = self[key] 862 elif self.__old.get(key) != self[key]: 863 changed[key] = self[key] 864 return changed 865 866 def set_readonly(self, key, value): 867 """Set a field on this Entity that will not be sent over to the 868 server on update unless modified.""" 869 self[key] = value 870 self.__old[key] = value 871 872 # --- Link management --- 873 874 @staticmethod 875 def __link_data_differs(have_link, want_link, data_key): 876 877 if data_key: 878 if 'uuId' in have_link[data_key]: 879 del have_link[data_key]['uuId'] 880 if 'uuId' in want_link[data_key]: 881 del want_link[data_key]['uuId'] 882 return have_link[data_key] != want_link[data_key] 883 884 # Links without data never differ 885 return False 886 887 def __apply_link_changes(self, batch_linking=True): 888 """Send each link list to the conflict resolver. If we detect 889 that the entity was not fetched with that link, we do the fetch 890 first and use the result as the basis for comparison.""" 891 892 # Find which lists belong to links but were not fetched so we can fetch them 893 need = [] 894 find_list = [] 895 if not self._is_new: 896 for link in self._link_def_by_key.values(): 897 if link['link_key'] in self and link['name'] not in self._with_links: 898 need.append(link['name']) 899 find_list.append(link['link_key']) 900 901 if len(need): 902 logging.warning("Entity links were modified but entity not fetched with links. " 903 "For better performance, include the links when getting the entity.") 904 logging.warning("Fetching {} again with missing links: {}".format(self._name.upper(), ','.join(need))) 905 new = self.__fetch(self, links=need) 906 for _list in find_list: 907 self.__old[_list] = copy.deepcopy(new.get(_list, [])) 908 909 # if batch_linking is enabled, builds a list of link requests 910 # for each link definition of the calling entity then returns the list 911 request_list = [] 912 for link_def in self._link_def_by_key.values(): 913 link_def_requests = self.__apply_list(link_def, batch_linking=batch_linking) 914 if batch_linking: 915 request_list.extend(link_def_requests) 916 return request_list 917 918 def __apply_list(self, link_def, report_only=False, batch_linking=True): 919 """Automatically resolve differences and issue the correct sequence of 920 link/unlink/relink for the link list to result in the supplied list 921 of entities. 922 923 report_only will not make any changes to the data or issue network requests. 924 Instead, it returns the three lists of changes (add, update, delete). 925 """ 926 to_add = [] 927 to_remove = [] 928 to_update = [] 929 should_only_have = set() 930 link_key = link_def['link_key'] 931 932 if link_def['type'] == list: 933 want_entities = self.get(link_key, []) 934 have_entities = self.__old.get(link_key, []) 935 936 if not isinstance(want_entities, list): 937 raise api.UsageException("Expecting '{}' to be {}. Found {} instead.".format( 938 link_key, link_def['type'].__name__, type(want_entities).__name__)) 939 940 for want_entity in want_entities: 941 if want_entity['uuId'] in should_only_have: 942 raise api.UsageException("Duplicate {} in {}".format(link_def['name'], link_key)) 943 should_only_have.add(want_entity['uuId']) 944 have = False 945 for have_entity in have_entities: 946 if have_entity['uuId'] == want_entity['uuId']: 947 have = True 948 data_name = link_def.get('data_name') 949 if data_name and self.__link_data_differs(have_entity, want_entity, data_name): 950 to_update.append(want_entity) 951 if not have: 952 to_add.append(want_entity) 953 for have_entity in have_entities: 954 if have_entity['uuId'] not in should_only_have: 955 to_remove.append(have_entity) 956 elif link_def['type'] == dict: 957 # Note: dict type does not implement updates as we have no dict links 958 # that support update (yet?). 959 want_entity = self.get(link_key, None) 960 have_entity = self.__old.get(link_key, None) 961 962 if want_entity is not None and not isinstance(want_entity, dict): 963 raise api.UsageException("Expecting '{}' to be {}. Found {} instead.".format( 964 link_key, link_def['type'].__name__, type(have_entity).__name__)) 965 966 if want_entity: 967 if have_entity: 968 if want_entity['uuId'] != have_entity['uuId']: 969 to_remove = have_entity 970 to_add = want_entity 971 else: 972 to_add = want_entity 973 if not want_entity: 974 if have_entity: 975 to_remove = have_entity 976 977 want_entities = want_entity 978 else: 979 # Would be an error in this library if we reach here 980 raise projectal.UnsupportedException("This type does not support linking") 981 982 # if batch_linking is enabled, builds a list of requests 983 # from each link method 984 if not report_only: 985 request_list = [] 986 if to_remove: 987 delete_requests = self._link( 988 link_def['name'], to_remove, 'delete', 989 update_cache=False, batch_linking=batch_linking 990 ) 991 request_list.extend(delete_requests) 992 if to_update: 993 update_requests = self._link( 994 link_def['name'], to_update, 'update', 995 update_cache=False, batch_linking=batch_linking 996 ) 997 request_list.extend(update_requests) 998 if to_add: 999 add_requests = self._link( 1000 link_def['name'], to_add, 'add', 1001 update_cache=False, batch_linking=batch_linking 1002 ) 1003 request_list.extend(add_requests) 1004 self.__old[link_key] = copy.deepcopy(want_entities) 1005 return request_list 1006 else: 1007 changes = {} 1008 if to_remove: 1009 changes['remove'] = to_remove 1010 if to_update: 1011 changes['update'] = to_update 1012 if to_add: 1013 changes['add'] = to_add 1014 return changes 1015 1016 @classmethod 1017 def get_link_definitions(cls): 1018 return cls({})._link_def_by_name 1019 # --- --- 1020 1021 def entity_name(self): 1022 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 # Reduce the list to only modified entities and their modified fields. 491 # Only do this to an Entity subclass - the consumer may have passed 492 # in a dict of changes on their own. 493 payload = [] 494 495 for e in e_list: 496 if isinstance(e, Entity): 497 changes = e._changes_internal() 498 if changes: 499 changes['uuId'] = e['uuId'] 500 payload.append(changes) 501 else: 502 payload.append(e) 503 if payload: 504 for i in range(0, len(payload), projectal.chunk_size_write): 505 chunk = payload[i:i + projectal.chunk_size_write] 506 api.put('/api/{}/update'.format(cls._path), chunk) 507 508 # Detect and apply any link changes 509 # if batch_linking is enabled, builds a list of link requests 510 # from the changes of each entity, then executes 511 # composite API requests with those changes 512 link_request_batch = [] 513 for e in e_list: 514 if isinstance(e, Entity): 515 requests = e.__apply_link_changes(batch_linking=batch_linking) 516 link_request_batch.extend(requests) 517 518 if len(link_request_batch) > 0 and batch_linking: 519 for i in range(0, len(link_request_batch), 100): 520 chunk = link_request_batch[i:i + 100] 521 api.post('/api/composite', chunk) 522 523 return True 524 525 def __update(self, *args, **kwargs): 526 """Use the dict update for instances.""" 527 return super(Entity, self).update(*args, **kwargs) 528 529 def save(self): 530 """Calls `update()` on this instance of the entity, saving 531 it to the database.""" 532 return self.__class__.update(self) 533 534 @classmethod 535 def delete(cls, entities): 536 """ 537 Delete one or more entities of the same type. The entity 538 type is determined by the subclass calling this method. 539 540 `entities`: See `Entity.get()` for expected formats. 541 542 ``` 543 # Example usage: 544 ids = ['1b21e445-f29a...', '1b21e445-f29a...', '1b21e445-f29a...'] 545 projectal.Customer.delete(ids) 546 ``` 547 """ 548 if isinstance(entities, str): 549 # String input is a uuId 550 payload = [{'uuId': entities}] 551 elif isinstance(entities, dict): 552 # Dict input needs to be a list 553 payload = [entities] 554 elif isinstance(entities, list): 555 # List input can be a list of uuIds or list of dicts 556 # If uuIds (strings), convert to list of dicts 557 if len(entities) > 0 and isinstance(entities[0], str): 558 payload = [{'uuId': uuId} for uuId in entities] 559 else: 560 # Already expected format 561 payload = entities 562 else: 563 # We have a list of dicts already, the expected format 564 payload = entities 565 566 # We only need to send over the uuIds 567 payload = [{'uuId': e['uuId']} for e in payload] 568 if not payload: 569 return True 570 for i in range(0, len(payload), projectal.chunk_size_write): 571 chunk = payload[i:i + projectal.chunk_size_write] 572 api.delete('/api/{}/delete'.format(cls._path), chunk) 573 return True 574 575 def __delete(self): 576 """Let an instance delete itself.""" 577 return self.__class__.delete(self) 578 579 def clone(self, entity): 580 """ 581 Clones an entity and returns its `uuId`. 582 583 Each entity has its own set of required values when cloning. 584 Check the API documentation of that entity for details. 585 """ 586 url = '/api/{}/clone?reference={}'.format(self._path, self['uuId']) 587 response = api.post(url, entity) 588 return response['jobClue']['uuId'] 589 590 @classmethod 591 def history(cls, UUID, start=0, limit=-1, order='desc', epoch=None, event=None): 592 """ 593 Returns an ordered list of all changes made to the entity. 594 595 `UUID`: the UUID of the entity. 596 597 `start`: Start index for pagination (default: `0`). 598 599 `limit`: Number of results to include for pagination. Use 600 `-1` to return the entire history (default: `-1`). 601 602 `order`: `asc` or `desc` (default: `desc` (index 0 is newest)) 603 604 `epoch`: only return the history UP TO epoch date 605 606 `event`: 607 """ 608 url = '/api/{}/history?holder={}&'.format(cls._path, UUID) 609 params = [] 610 params.append('start={}'.format(start)) 611 params.append('limit={}'.format(limit)) 612 params.append('order={}'.format(order)) 613 params.append('epoch={}'.format(epoch)) if epoch else None 614 params.append('event={}'.format(event)) if event else None 615 url += '&'.join(params) 616 return api.get(url) 617 618 def __history(self, **kwargs): 619 """Get history of instance.""" 620 return self.__class__.history(self['uuId'], **kwargs) 621 622 @classmethod 623 def list(cls, expand=False, links=None): 624 """Return a list of all entity UUIDs of this type. 625 626 You may pass in `expand=True` to get full Entity objects 627 instead, but be aware this may be very slow if you have 628 thousands of objects. 629 630 If you are expanding the objects, you may further expand 631 the results with `links`. 632 """ 633 634 payload = { 635 "name": "List all entities of type {}".format(cls._name.upper()), 636 "type": "msql", "start": 0, "limit": -1, 637 "select": [ 638 ["{}.uuId".format(cls._name.upper())] 639 ], 640 } 641 ids = api.query(payload) 642 ids = [id[0] for id in ids] 643 if ids: 644 return cls.get(ids, links=links) if expand else ids 645 return [] 646 647 @classmethod 648 def match(cls, field, term, links=None): 649 """Find entities where `field`=`term` (exact match), optionally 650 expanding the results with `links`. 651 652 Relies on `Entity.query()` with a pre-built set of rules. 653 ``` 654 projects = projectal.Project.match('identifier', 'zmb-005') 655 ``` 656 """ 657 filter = [["{}.{}".format(cls._name.upper(), field), "eq", term]] 658 return cls.query(filter, links) 659 660 @classmethod 661 def match_startswith(cls, field, term, links=None): 662 """Find entities where `field` starts with the text `term`, 663 optionally expanding the results with `links`. 664 665 Relies on `Entity.query()` with a pre-built set of rules. 666 ``` 667 projects = projectal.Project.match_startswith('name', 'Zomb') 668 ``` 669 """ 670 filter = [["{}.{}".format(cls._name.upper(), field), "prefix", term]] 671 return cls.query(filter, links) 672 673 @classmethod 674 def match_endswith(cls, field, term, links=None): 675 """Find entities where `field` ends with the text `term`, 676 optionally expanding the results with `links`. 677 678 Relies on `Entity.query()` with a pre-built set of rules. 679 ``` 680 projects = projectal.Project.match_endswith('identifier', '-2023') 681 ``` 682 """ 683 term = "(?i).*{}$".format(term) 684 filter = [["{}.{}".format(cls._name.upper(), field), "regex", term]] 685 return cls.query(filter, links) 686 687 @classmethod 688 def match_one(cls, field, term, links=None): 689 """Convenience function for match(). Returns the first match or None.""" 690 matches = cls.match(field, term, links) 691 if matches: 692 return matches[0] 693 694 @classmethod 695 def match_startswith_one(cls, field, term, links=None): 696 """Convenience function for match_startswith(). Returns the first match or None.""" 697 matches = cls.match_startswith(field, term, links) 698 if matches: 699 return matches[0] 700 701 @classmethod 702 def match_endswith_one(cls, field, term, links=None): 703 """Convenience function for match_endswith(). Returns the first match or None.""" 704 matches = cls.match_endswith(field, term, links) 705 if matches: 706 return matches[0] 707 708 @classmethod 709 def search(cls, fields=None, term='', case_sensitive=True, links=None): 710 """Find entities that contain the text `term` within `fields`. 711 `fields` is a list of field names to target in the search. 712 713 `case_sensitive`: Optionally turn off case sensitivity in the search. 714 715 Relies on `Entity.query()` with a pre-built set of rules. 716 ``` 717 projects = projectal.Project.search(['name', 'description'], 'zombie') 718 ``` 719 """ 720 filter = [] 721 term = '(?{}).*{}.*'.format('' if case_sensitive else '?', term) 722 for field in fields: 723 filter.append(["{}.{}".format(cls._name.upper(), field), "regex", term]) 724 filter = ['_or_', filter] 725 return cls.query(filter, links) 726 727 @classmethod 728 def query(cls, filter, links=None): 729 """Run a query on this entity with the supplied filter. 730 731 The query is already set up to target this entity type, and the 732 results will be converted into full objects when found, optionally 733 expanded with the `links` provided. You only need to supply a 734 filter to reduce the result set. 735 736 See [the filter documentation](https://projectal.com/docs/v1.1.1#section/Filter-section) 737 for a detailed overview of the kinds of filters you can construct. 738 """ 739 payload = { 740 "name": "Python library entity query ({})".format(cls._name.upper()), 741 "type": "msql", "start": 0, "limit": -1, 742 "select": [ 743 ["{}.uuId".format(cls._name.upper())] 744 ], 745 "filter": filter 746 } 747 ids = api.query(payload) 748 ids = [id[0] for id in ids] 749 if ids: 750 return cls.get(ids, links=links) 751 return [] 752 753 def profile_get(self, key): 754 """Get the profile (metadata) stored for this entity at `key`.""" 755 return projectal.profile.get(key, self.__class__._name.lower(), self['uuId']) 756 757 def profile_set(self, key, data): 758 """Set the profile (metadata) stored for this entity at `key`. The contents 759 of `data` will completely overwrite the existing data dictionary.""" 760 return projectal.profile.set(key, self.__class__._name.lower(), self['uuId'], data) 761 762 def __type_links(self): 763 """Find links and turn their dicts into typed objects matching their Entity type.""" 764 765 for key, _def in self._link_def_by_key.items(): 766 if key in self: 767 cls = getattr(projectal, _def['entity']) 768 if _def['type'] == list: 769 as_obj = [] 770 for link in self[key]: 771 as_obj.append(cls(link)) 772 elif _def['type'] == dict: 773 as_obj = cls(self[key]) 774 else: 775 raise projectal.UsageException("Unexpected link type") 776 self[key] = as_obj 777 778 def changes(self): 779 """Return a dict containing the fields that have changed since fetching the object. 780 Dict values contain both the old and new values. E.g.: {'old': 'original', 'new': 'current'}. 781 782 In the case of link lists, there are three values: added, removed, updated. Only links with 783 a data attribute can end up in the updated list, and the old/new dictionary is placed within 784 that data attribute. E.g. for a staff-resource link: 785 'updated': [{ 786 'uuId': '24eb4c31-0f92-49d1-8b4d-507ab939003e', 787 'resourceLink': {'quantity': {'old': 2, 'new': 5}} 788 }] 789 """ 790 changed = {} 791 for key in self.keys(): 792 link_def = self._link_def_by_key.get(key) 793 if link_def: 794 changes = self._changes_for_link_list(link_def, key) 795 # Only add it if something in it changed 796 for action in changes.values(): 797 if len(action): 798 changed[key] = changes 799 break 800 elif key not in self.__old and self[key] is not None: 801 changed[key] = {'old': None, 'new': self[key]} 802 elif self.__old.get(key) != self[key]: 803 changed[key] = {'old': self.__old.get(key), 'new': self[key]} 804 return changed 805 806 def _changes_for_link_list(self, link_def, key): 807 changes = self.__apply_list(link_def, report_only=True) 808 data_key = link_def['data_name'] 809 810 # For linked entities, we will only report their UUID, name (if it has one), 811 # and the content of their data attribute (if it has one). 812 def get_slim_list(entities): 813 slim = [] 814 if isinstance(entities, dict): 815 entities = [entities] 816 for e in entities: 817 fields = {'uuId': e['uuId']} 818 name = e.get('name') 819 if name: 820 fields['name'] = e['name'] 821 if data_key and e[data_key]: 822 fields[data_key] = e[data_key] 823 slim.append(fields) 824 return slim 825 826 out = { 827 'added': get_slim_list(changes.get('add', [])), 828 'updated': [], 829 'removed': get_slim_list(changes.get('remove', [])), 830 } 831 832 updated = changes.get('update', []) 833 if updated: 834 before_map = {} 835 for entity in self.__old.get(key): 836 before_map[entity['uuId']] = entity 837 838 for entity in updated: 839 old_data = before_map[entity['uuId']][data_key] 840 new_data = entity[data_key] 841 diff = {} 842 for key in new_data.keys(): 843 if key not in old_data and new_data[key] is not None: 844 diff[key] = {'old': None, 'new': new_data[key]} 845 elif old_data.get(key) != new_data[key]: 846 diff[key] = {'old': old_data.get(key), 'new': new_data[key]} 847 out['updated'].append({'uuId': entity['uuId'], data_key: diff}) 848 return out 849 850 def _changes_internal(self): 851 """Return a dict containing only the fields that have changed and their current value, 852 without any link data. 853 854 This method is used internally to strip payloads down to only the fields that have changed. 855 """ 856 changed = {} 857 for key in self.keys(): 858 # We don't deal with link or link data changes here. We only want standard fields. 859 if key in self._link_def_by_key: 860 continue 861 if key not in self.__old and self[key] is not None: 862 changed[key] = self[key] 863 elif self.__old.get(key) != self[key]: 864 changed[key] = self[key] 865 return changed 866 867 def set_readonly(self, key, value): 868 """Set a field on this Entity that will not be sent over to the 869 server on update unless modified.""" 870 self[key] = value 871 self.__old[key] = value 872 873 # --- Link management --- 874 875 @staticmethod 876 def __link_data_differs(have_link, want_link, data_key): 877 878 if data_key: 879 if 'uuId' in have_link[data_key]: 880 del have_link[data_key]['uuId'] 881 if 'uuId' in want_link[data_key]: 882 del want_link[data_key]['uuId'] 883 return have_link[data_key] != want_link[data_key] 884 885 # Links without data never differ 886 return False 887 888 def __apply_link_changes(self, batch_linking=True): 889 """Send each link list to the conflict resolver. If we detect 890 that the entity was not fetched with that link, we do the fetch 891 first and use the result as the basis for comparison.""" 892 893 # Find which lists belong to links but were not fetched so we can fetch them 894 need = [] 895 find_list = [] 896 if not self._is_new: 897 for link in self._link_def_by_key.values(): 898 if link['link_key'] in self and link['name'] not in self._with_links: 899 need.append(link['name']) 900 find_list.append(link['link_key']) 901 902 if len(need): 903 logging.warning("Entity links were modified but entity not fetched with links. " 904 "For better performance, include the links when getting the entity.") 905 logging.warning("Fetching {} again with missing links: {}".format(self._name.upper(), ','.join(need))) 906 new = self.__fetch(self, links=need) 907 for _list in find_list: 908 self.__old[_list] = copy.deepcopy(new.get(_list, [])) 909 910 # if batch_linking is enabled, builds a list of link requests 911 # for each link definition of the calling entity then returns the list 912 request_list = [] 913 for link_def in self._link_def_by_key.values(): 914 link_def_requests = self.__apply_list(link_def, batch_linking=batch_linking) 915 if batch_linking: 916 request_list.extend(link_def_requests) 917 return request_list 918 919 def __apply_list(self, link_def, report_only=False, batch_linking=True): 920 """Automatically resolve differences and issue the correct sequence of 921 link/unlink/relink for the link list to result in the supplied list 922 of entities. 923 924 report_only will not make any changes to the data or issue network requests. 925 Instead, it returns the three lists of changes (add, update, delete). 926 """ 927 to_add = [] 928 to_remove = [] 929 to_update = [] 930 should_only_have = set() 931 link_key = link_def['link_key'] 932 933 if link_def['type'] == list: 934 want_entities = self.get(link_key, []) 935 have_entities = self.__old.get(link_key, []) 936 937 if not isinstance(want_entities, list): 938 raise api.UsageException("Expecting '{}' to be {}. Found {} instead.".format( 939 link_key, link_def['type'].__name__, type(want_entities).__name__)) 940 941 for want_entity in want_entities: 942 if want_entity['uuId'] in should_only_have: 943 raise api.UsageException("Duplicate {} in {}".format(link_def['name'], link_key)) 944 should_only_have.add(want_entity['uuId']) 945 have = False 946 for have_entity in have_entities: 947 if have_entity['uuId'] == want_entity['uuId']: 948 have = True 949 data_name = link_def.get('data_name') 950 if data_name and self.__link_data_differs(have_entity, want_entity, data_name): 951 to_update.append(want_entity) 952 if not have: 953 to_add.append(want_entity) 954 for have_entity in have_entities: 955 if have_entity['uuId'] not in should_only_have: 956 to_remove.append(have_entity) 957 elif link_def['type'] == dict: 958 # Note: dict type does not implement updates as we have no dict links 959 # that support update (yet?). 960 want_entity = self.get(link_key, None) 961 have_entity = self.__old.get(link_key, None) 962 963 if want_entity is not None and not isinstance(want_entity, dict): 964 raise api.UsageException("Expecting '{}' to be {}. Found {} instead.".format( 965 link_key, link_def['type'].__name__, type(have_entity).__name__)) 966 967 if want_entity: 968 if have_entity: 969 if want_entity['uuId'] != have_entity['uuId']: 970 to_remove = have_entity 971 to_add = want_entity 972 else: 973 to_add = want_entity 974 if not want_entity: 975 if have_entity: 976 to_remove = have_entity 977 978 want_entities = want_entity 979 else: 980 # Would be an error in this library if we reach here 981 raise projectal.UnsupportedException("This type does not support linking") 982 983 # if batch_linking is enabled, builds a list of requests 984 # from each link method 985 if not report_only: 986 request_list = [] 987 if to_remove: 988 delete_requests = self._link( 989 link_def['name'], to_remove, 'delete', 990 update_cache=False, batch_linking=batch_linking 991 ) 992 request_list.extend(delete_requests) 993 if to_update: 994 update_requests = self._link( 995 link_def['name'], to_update, 'update', 996 update_cache=False, batch_linking=batch_linking 997 ) 998 request_list.extend(update_requests) 999 if to_add: 1000 add_requests = self._link( 1001 link_def['name'], to_add, 'add', 1002 update_cache=False, batch_linking=batch_linking 1003 ) 1004 request_list.extend(add_requests) 1005 self.__old[link_key] = copy.deepcopy(want_entities) 1006 return request_list 1007 else: 1008 changes = {} 1009 if to_remove: 1010 changes['remove'] = to_remove 1011 if to_update: 1012 changes['update'] = to_update 1013 if to_add: 1014 changes['add'] = to_add 1015 return changes 1016 1017 @classmethod 1018 def get_link_definitions(cls): 1019 return cls({})._link_def_by_name 1020 # --- --- 1021 1022 def entity_name(self): 1023 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 # Reduce the list to only modified entities and their modified fields. 491 # Only do this to an Entity subclass - the consumer may have passed 492 # in a dict of changes on their own. 493 payload = [] 494 495 for e in e_list: 496 if isinstance(e, Entity): 497 changes = e._changes_internal() 498 if changes: 499 changes['uuId'] = e['uuId'] 500 payload.append(changes) 501 else: 502 payload.append(e) 503 if payload: 504 for i in range(0, len(payload), projectal.chunk_size_write): 505 chunk = payload[i:i + projectal.chunk_size_write] 506 api.put('/api/{}/update'.format(cls._path), chunk) 507 508 # Detect and apply any link changes 509 # if batch_linking is enabled, builds a list of link requests 510 # from the changes of each entity, then executes 511 # composite API requests with those changes 512 link_request_batch = [] 513 for e in e_list: 514 if isinstance(e, Entity): 515 requests = e.__apply_link_changes(batch_linking=batch_linking) 516 link_request_batch.extend(requests) 517 518 if len(link_request_batch) > 0 and batch_linking: 519 for i in range(0, len(link_request_batch), 100): 520 chunk = link_request_batch[i:i + 100] 521 api.post('/api/composite', chunk) 522 523 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.
534 @classmethod 535 def delete(cls, entities): 536 """ 537 Delete one or more entities of the same type. The entity 538 type is determined by the subclass calling this method. 539 540 `entities`: See `Entity.get()` for expected formats. 541 542 ``` 543 # Example usage: 544 ids = ['1b21e445-f29a...', '1b21e445-f29a...', '1b21e445-f29a...'] 545 projectal.Customer.delete(ids) 546 ``` 547 """ 548 if isinstance(entities, str): 549 # String input is a uuId 550 payload = [{'uuId': entities}] 551 elif isinstance(entities, dict): 552 # Dict input needs to be a list 553 payload = [entities] 554 elif isinstance(entities, list): 555 # List input can be a list of uuIds or list of dicts 556 # If uuIds (strings), convert to list of dicts 557 if len(entities) > 0 and isinstance(entities[0], str): 558 payload = [{'uuId': uuId} for uuId in entities] 559 else: 560 # Already expected format 561 payload = entities 562 else: 563 # We have a list of dicts already, the expected format 564 payload = entities 565 566 # We only need to send over the uuIds 567 payload = [{'uuId': e['uuId']} for e in payload] 568 if not payload: 569 return True 570 for i in range(0, len(payload), projectal.chunk_size_write): 571 chunk = payload[i:i + projectal.chunk_size_write] 572 api.delete('/api/{}/delete'.format(cls._path), chunk) 573 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)
590 @classmethod 591 def history(cls, UUID, start=0, limit=-1, order='desc', epoch=None, event=None): 592 """ 593 Returns an ordered list of all changes made to the entity. 594 595 `UUID`: the UUID of the entity. 596 597 `start`: Start index for pagination (default: `0`). 598 599 `limit`: Number of results to include for pagination. Use 600 `-1` to return the entire history (default: `-1`). 601 602 `order`: `asc` or `desc` (default: `desc` (index 0 is newest)) 603 604 `epoch`: only return the history UP TO epoch date 605 606 `event`: 607 """ 608 url = '/api/{}/history?holder={}&'.format(cls._path, UUID) 609 params = [] 610 params.append('start={}'.format(start)) 611 params.append('limit={}'.format(limit)) 612 params.append('order={}'.format(order)) 613 params.append('epoch={}'.format(epoch)) if epoch else None 614 params.append('event={}'.format(event)) if event else None 615 url += '&'.join(params) 616 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
529 def save(self): 530 """Calls `update()` on this instance of the entity, saving 531 it to the database.""" 532 return self.__class__.update(self)
Calls update()
on this instance of the entity, saving
it to the database.
579 def clone(self, entity): 580 """ 581 Clones an entity and returns its `uuId`. 582 583 Each entity has its own set of required values when cloning. 584 Check the API documentation of that entity for details. 585 """ 586 url = '/api/{}/clone?reference={}'.format(self._path, self['uuId']) 587 response = api.post(url, entity) 588 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.
622 @classmethod 623 def list(cls, expand=False, links=None): 624 """Return a list of all entity UUIDs of this type. 625 626 You may pass in `expand=True` to get full Entity objects 627 instead, but be aware this may be very slow if you have 628 thousands of objects. 629 630 If you are expanding the objects, you may further expand 631 the results with `links`. 632 """ 633 634 payload = { 635 "name": "List all entities of type {}".format(cls._name.upper()), 636 "type": "msql", "start": 0, "limit": -1, 637 "select": [ 638 ["{}.uuId".format(cls._name.upper())] 639 ], 640 } 641 ids = api.query(payload) 642 ids = [id[0] for id in ids] 643 if ids: 644 return cls.get(ids, links=links) if expand else ids 645 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
.
647 @classmethod 648 def match(cls, field, term, links=None): 649 """Find entities where `field`=`term` (exact match), optionally 650 expanding the results with `links`. 651 652 Relies on `Entity.query()` with a pre-built set of rules. 653 ``` 654 projects = projectal.Project.match('identifier', 'zmb-005') 655 ``` 656 """ 657 filter = [["{}.{}".format(cls._name.upper(), field), "eq", term]] 658 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')
660 @classmethod 661 def match_startswith(cls, field, term, links=None): 662 """Find entities where `field` starts with the text `term`, 663 optionally expanding the results with `links`. 664 665 Relies on `Entity.query()` with a pre-built set of rules. 666 ``` 667 projects = projectal.Project.match_startswith('name', 'Zomb') 668 ``` 669 """ 670 filter = [["{}.{}".format(cls._name.upper(), field), "prefix", term]] 671 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')
673 @classmethod 674 def match_endswith(cls, field, term, links=None): 675 """Find entities where `field` ends with the text `term`, 676 optionally expanding the results with `links`. 677 678 Relies on `Entity.query()` with a pre-built set of rules. 679 ``` 680 projects = projectal.Project.match_endswith('identifier', '-2023') 681 ``` 682 """ 683 term = "(?i).*{}$".format(term) 684 filter = [["{}.{}".format(cls._name.upper(), field), "regex", term]] 685 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')
687 @classmethod 688 def match_one(cls, field, term, links=None): 689 """Convenience function for match(). Returns the first match or None.""" 690 matches = cls.match(field, term, links) 691 if matches: 692 return matches[0]
Convenience function for match(). Returns the first match or None.
694 @classmethod 695 def match_startswith_one(cls, field, term, links=None): 696 """Convenience function for match_startswith(). Returns the first match or None.""" 697 matches = cls.match_startswith(field, term, links) 698 if matches: 699 return matches[0]
Convenience function for match_startswith(). Returns the first match or None.
701 @classmethod 702 def match_endswith_one(cls, field, term, links=None): 703 """Convenience function for match_endswith(). Returns the first match or None.""" 704 matches = cls.match_endswith(field, term, links) 705 if matches: 706 return matches[0]
Convenience function for match_endswith(). Returns the first match or None.
708 @classmethod 709 def search(cls, fields=None, term='', case_sensitive=True, links=None): 710 """Find entities that contain the text `term` within `fields`. 711 `fields` is a list of field names to target in the search. 712 713 `case_sensitive`: Optionally turn off case sensitivity in the search. 714 715 Relies on `Entity.query()` with a pre-built set of rules. 716 ``` 717 projects = projectal.Project.search(['name', 'description'], 'zombie') 718 ``` 719 """ 720 filter = [] 721 term = '(?{}).*{}.*'.format('' if case_sensitive else '?', term) 722 for field in fields: 723 filter.append(["{}.{}".format(cls._name.upper(), field), "regex", term]) 724 filter = ['_or_', filter] 725 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')
727 @classmethod 728 def query(cls, filter, links=None): 729 """Run a query on this entity with the supplied filter. 730 731 The query is already set up to target this entity type, and the 732 results will be converted into full objects when found, optionally 733 expanded with the `links` provided. You only need to supply a 734 filter to reduce the result set. 735 736 See [the filter documentation](https://projectal.com/docs/v1.1.1#section/Filter-section) 737 for a detailed overview of the kinds of filters you can construct. 738 """ 739 payload = { 740 "name": "Python library entity query ({})".format(cls._name.upper()), 741 "type": "msql", "start": 0, "limit": -1, 742 "select": [ 743 ["{}.uuId".format(cls._name.upper())] 744 ], 745 "filter": filter 746 } 747 ids = api.query(payload) 748 ids = [id[0] for id in ids] 749 if ids: 750 return cls.get(ids, links=links) 751 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.
753 def profile_get(self, key): 754 """Get the profile (metadata) stored for this entity at `key`.""" 755 return projectal.profile.get(key, self.__class__._name.lower(), self['uuId'])
Get the profile (metadata) stored for this entity at key
.
757 def profile_set(self, key, data): 758 """Set the profile (metadata) stored for this entity at `key`. The contents 759 of `data` will completely overwrite the existing data dictionary.""" 760 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.
778 def changes(self): 779 """Return a dict containing the fields that have changed since fetching the object. 780 Dict values contain both the old and new values. E.g.: {'old': 'original', 'new': 'current'}. 781 782 In the case of link lists, there are three values: added, removed, updated. Only links with 783 a data attribute can end up in the updated list, and the old/new dictionary is placed within 784 that data attribute. E.g. for a staff-resource link: 785 'updated': [{ 786 'uuId': '24eb4c31-0f92-49d1-8b4d-507ab939003e', 787 'resourceLink': {'quantity': {'old': 2, 'new': 5}} 788 }] 789 """ 790 changed = {} 791 for key in self.keys(): 792 link_def = self._link_def_by_key.get(key) 793 if link_def: 794 changes = self._changes_for_link_list(link_def, key) 795 # Only add it if something in it changed 796 for action in changes.values(): 797 if len(action): 798 changed[key] = changes 799 break 800 elif key not in self.__old and self[key] is not None: 801 changed[key] = {'old': None, 'new': self[key]} 802 elif self.__old.get(key) != self[key]: 803 changed[key] = {'old': self.__old.get(key), 'new': self[key]} 804 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}} }]
867 def set_readonly(self, key, value): 868 """Set a field on this Entity that will not be sent over to the 869 server on update unless modified.""" 870 self[key] = value 871 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