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