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