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(self): 608 """Calls `update()` on this instance of the entity, saving 609 it to the database.""" 610 return self.__class__.update(self) 611 612 @classmethod 613 def delete(cls, entities): 614 """ 615 Delete one or more entities of the same type. The entity 616 type is determined by the subclass calling this method. 617 618 `entities`: See `Entity.get()` for expected formats. 619 620 ``` 621 # Example usage: 622 ids = ['1b21e445-f29a...', '1b21e445-f29a...', '1b21e445-f29a...'] 623 projectal.Customer.delete(ids) 624 ``` 625 """ 626 if isinstance(entities, str): 627 # String input is a uuId 628 payload = [{"uuId": entities}] 629 elif isinstance(entities, dict): 630 # Dict input needs to be a list 631 payload = [entities] 632 elif isinstance(entities, list): 633 # List input can be a list of uuIds or list of dicts 634 # If uuIds (strings), convert to list of dicts 635 if len(entities) > 0 and isinstance(entities[0], str): 636 payload = [{"uuId": uuId} for uuId in entities] 637 else: 638 # Already expected format 639 payload = entities 640 else: 641 # We have a list of dicts already, the expected format 642 payload = entities 643 644 # We only need to send over the uuIds 645 payload = [{"uuId": e["uuId"]} for e in payload] 646 if not payload: 647 return True 648 for i in range(0, len(payload), projectal.chunk_size_write): 649 chunk = payload[i : i + projectal.chunk_size_write] 650 api.delete("/api/{}/delete".format(cls._path), chunk) 651 return True 652 653 def __delete(self): 654 """Let an instance delete itself.""" 655 return self.__class__.delete(self) 656 657 def clone(self, entity): 658 """ 659 Clones an entity and returns its `uuId`. 660 661 Each entity has its own set of required values when cloning. 662 Check the API documentation of that entity for details. 663 """ 664 url = "/api/{}/clone?reference={}".format(self._path, self["uuId"]) 665 response = api.post(url, entity) 666 return response["jobClue"]["uuId"] 667 668 @classmethod 669 def history(cls, UUID, start=0, limit=-1, order="desc", epoch=None, event=None): 670 """ 671 Returns an ordered list of all changes made to the entity. 672 673 `UUID`: the UUID of the entity. 674 675 `start`: Start index for pagination (default: `0`). 676 677 `limit`: Number of results to include for pagination. Use 678 `-1` to return the entire history (default: `-1`). 679 680 `order`: `asc` or `desc` (default: `desc` (index 0 is newest)) 681 682 `epoch`: only return the history UP TO epoch date 683 684 `event`: 685 """ 686 url = "/api/{}/history?holder={}&".format(cls._path, UUID) 687 params = [] 688 params.append("start={}".format(start)) 689 params.append("limit={}".format(limit)) 690 params.append("order={}".format(order)) 691 params.append("epoch={}".format(epoch)) if epoch else None 692 params.append("event={}".format(event)) if event else None 693 url += "&".join(params) 694 return api.get(url) 695 696 def __history(self, **kwargs): 697 """Get history of instance.""" 698 return self.__class__.history(self["uuId"], **kwargs) 699 700 @classmethod 701 def list(cls, expand=False, links=None): 702 """Return a list of all entity UUIDs of this type. 703 704 You may pass in `expand=True` to get full Entity objects 705 instead, but be aware this may be very slow if you have 706 thousands of objects. 707 708 If you are expanding the objects, you may further expand 709 the results with `links`. 710 """ 711 712 payload = { 713 "name": "List all entities of type {}".format(cls._name.upper()), 714 "type": "msql", 715 "start": 0, 716 "limit": -1, 717 "select": [["{}.uuId".format(cls._name.upper())]], 718 } 719 ids = api.query(payload) 720 ids = [id[0] for id in ids] 721 if ids: 722 return cls.get(ids, links=links) if expand else ids 723 return [] 724 725 @classmethod 726 def match(cls, field, term, links=None): 727 """Find entities where `field`=`term` (exact match), optionally 728 expanding the results with `links`. 729 730 Relies on `Entity.query()` with a pre-built set of rules. 731 ``` 732 projects = projectal.Project.match('identifier', 'zmb-005') 733 ``` 734 """ 735 filter = [["{}.{}".format(cls._name.upper(), field), "eq", term]] 736 return cls.query(filter, links) 737 738 @classmethod 739 def match_startswith(cls, field, term, links=None): 740 """Find entities where `field` starts with the text `term`, 741 optionally expanding the results with `links`. 742 743 Relies on `Entity.query()` with a pre-built set of rules. 744 ``` 745 projects = projectal.Project.match_startswith('name', 'Zomb') 746 ``` 747 """ 748 filter = [["{}.{}".format(cls._name.upper(), field), "prefix", term]] 749 return cls.query(filter, links) 750 751 @classmethod 752 def match_endswith(cls, field, term, links=None): 753 """Find entities where `field` ends with the text `term`, 754 optionally expanding the results with `links`. 755 756 Relies on `Entity.query()` with a pre-built set of rules. 757 ``` 758 projects = projectal.Project.match_endswith('identifier', '-2023') 759 ``` 760 """ 761 term = "(?i).*{}$".format(term) 762 filter = [["{}.{}".format(cls._name.upper(), field), "regex", term]] 763 return cls.query(filter, links) 764 765 @classmethod 766 def match_one(cls, field, term, links=None): 767 """Convenience function for match(). Returns the first match or None.""" 768 matches = cls.match(field, term, links) 769 if matches: 770 return matches[0] 771 772 @classmethod 773 def match_startswith_one(cls, field, term, links=None): 774 """Convenience function for match_startswith(). Returns the first match or None.""" 775 matches = cls.match_startswith(field, term, links) 776 if matches: 777 return matches[0] 778 779 @classmethod 780 def match_endswith_one(cls, field, term, links=None): 781 """Convenience function for match_endswith(). Returns the first match or None.""" 782 matches = cls.match_endswith(field, term, links) 783 if matches: 784 return matches[0] 785 786 @classmethod 787 def search(cls, fields=None, term="", case_sensitive=True, links=None): 788 """Find entities that contain the text `term` within `fields`. 789 `fields` is a list of field names to target in the search. 790 791 `case_sensitive`: Optionally turn off case sensitivity in the search. 792 793 Relies on `Entity.query()` with a pre-built set of rules. 794 ``` 795 projects = projectal.Project.search(['name', 'description'], 'zombie') 796 ``` 797 """ 798 filter = [] 799 term = "(?{}).*{}.*".format("" if case_sensitive else "?", term) 800 for field in fields: 801 filter.append(["{}.{}".format(cls._name.upper(), field), "regex", term]) 802 filter = ["_or_", filter] 803 return cls.query(filter, links) 804 805 @classmethod 806 def query(cls, filter, links=None, timeout=30): 807 """Run a query on this entity with the supplied filter. 808 809 The query is already set up to target this entity type, and the 810 results will be converted into full objects when found, optionally 811 expanded with the `links` provided. You only need to supply a 812 filter to reduce the result set. 813 814 See [the filter documentation](https://projectal.com/docs/v1.1.1#section/Filter-section) 815 for a detailed overview of the kinds of filters you can construct. 816 """ 817 payload = { 818 "name": "Python library entity query ({})".format(cls._name.upper()), 819 "type": "msql", 820 "start": 0, 821 "limit": -1, 822 "select": [["{}.uuId".format(cls._name.upper())]], 823 "filter": filter, 824 "timeout": timeout, 825 } 826 ids = api.query(payload) 827 ids = [id[0] for id in ids] 828 if ids: 829 return cls.get(ids, links=links) 830 return [] 831 832 def profile_get(self, key): 833 """Get the profile (metadata) stored for this entity at `key`.""" 834 return projectal.profile.get(key, self.__class__._name.lower(), self["uuId"]) 835 836 def profile_set(self, key, data): 837 """Set the profile (metadata) stored for this entity at `key`. The contents 838 of `data` will completely overwrite the existing data dictionary.""" 839 return projectal.profile.set( 840 key, self.__class__._name.lower(), self["uuId"], data 841 ) 842 843 def __type_links(self): 844 """Find links and turn their dicts into typed objects matching their Entity type.""" 845 846 for key, _def in self._link_def_by_key.items(): 847 if key in self: 848 cls = getattr(projectal, _def["entity"]) 849 if _def["type"] == list: 850 as_obj = [] 851 for link in self[key]: 852 as_obj.append(cls(link)) 853 elif _def["type"] == dict: 854 as_obj = cls(self[key]) 855 else: 856 raise projectal.UsageException("Unexpected link type") 857 self[key] = as_obj 858 859 def changes(self): 860 """Return a dict containing the fields that have changed since fetching the object. 861 Dict values contain both the old and new values. E.g.: {'old': 'original', 'new': 'current'}. 862 863 In the case of link lists, there are three values: added, removed, updated. Only links with 864 a data attribute can end up in the updated list, and the old/new dictionary is placed within 865 that data attribute. E.g. for a staff-resource link: 866 'updated': [{ 867 'uuId': '24eb4c31-0f92-49d1-8b4d-507ab939003e', 868 'resourceLink': {'quantity': {'old': 2, 'new': 5}} 869 }] 870 """ 871 changed = {} 872 for key in self.keys(): 873 link_def = self._link_def_by_key.get(key) 874 if link_def: 875 changes = self._changes_for_link_list(link_def, key) 876 # Only add it if something in it changed 877 for action in changes.values(): 878 if len(action): 879 changed[key] = changes 880 break 881 elif key not in self.__old and self[key] is not None: 882 changed[key] = {"old": None, "new": self[key]} 883 elif self.__old.get(key) != self[key]: 884 changed[key] = {"old": self.__old.get(key), "new": self[key]} 885 return changed 886 887 def _changes_for_link_list(self, link_def, key): 888 changes = self.__apply_list(link_def, report_only=True) 889 data_key = link_def["data_name"] 890 891 # For linked entities, we will only report their UUID, name (if it has one), 892 # and the content of their data attribute (if it has one). 893 def get_slim_list(entities): 894 slim = [] 895 if isinstance(entities, dict): 896 entities = [entities] 897 for e in entities: 898 fields = {"uuId": e["uuId"]} 899 name = e.get("name") 900 if name: 901 fields["name"] = e["name"] 902 if data_key and e[data_key]: 903 fields[data_key] = e[data_key] 904 slim.append(fields) 905 return slim 906 907 out = { 908 "added": get_slim_list(changes.get("add", [])), 909 "updated": [], 910 "removed": get_slim_list(changes.get("remove", [])), 911 } 912 913 updated = changes.get("update", []) 914 if updated: 915 before_map = {} 916 for entity in self.__old.get(key): 917 before_map[entity["uuId"]] = entity 918 919 for entity in updated: 920 old_data = before_map[entity["uuId"]][data_key] 921 new_data = entity[data_key] 922 diff = {} 923 for key in new_data.keys(): 924 if key not in old_data and new_data[key] is not None: 925 diff[key] = {"old": None, "new": new_data[key]} 926 elif old_data.get(key) != new_data[key]: 927 diff[key] = {"old": old_data.get(key), "new": new_data[key]} 928 out["updated"].append({"uuId": entity["uuId"], data_key: diff}) 929 return out 930 931 def _changes_internal(self): 932 """Return a dict containing only the fields that have changed and their current value, 933 without any link data. 934 935 This method is used internally to strip payloads down to only the fields that have changed. 936 """ 937 changed = {} 938 for key in self.keys(): 939 # We don't deal with link or link data changes here. We only want standard fields. 940 if key in self._link_def_by_key: 941 continue 942 if key not in self.__old and self[key] is not None: 943 changed[key] = self[key] 944 elif self.__old.get(key) != self[key]: 945 changed[key] = self[key] 946 return changed 947 948 def set_readonly(self, key, value): 949 """Set a field on this Entity that will not be sent over to the 950 server on update unless modified.""" 951 self[key] = value 952 self.__old[key] = value 953 954 # --- Link management --- 955 956 @staticmethod 957 def __link_data_differs(have_link, want_link, data_key): 958 if data_key: 959 if "uuId" in have_link[data_key]: 960 del have_link[data_key]["uuId"] 961 if "uuId" in want_link[data_key]: 962 del want_link[data_key]["uuId"] 963 return have_link[data_key] != want_link[data_key] 964 965 # Links without data never differ 966 return False 967 968 def __apply_link_changes(self, batch_linking=True): 969 """Send each link list to the conflict resolver. If we detect 970 that the entity was not fetched with that link, we do the fetch 971 first and use the result as the basis for comparison.""" 972 973 # Find which lists belong to links but were not fetched so we can fetch them 974 need = [] 975 find_list = [] 976 if not self._is_new: 977 for link in self._link_def_by_key.values(): 978 if link["link_key"] in self and link["name"] not in self._with_links: 979 need.append(link["name"]) 980 find_list.append(link["link_key"]) 981 982 if len(need): 983 logging.warning( 984 "Entity links were modified but entity not fetched with links. " 985 "For better performance, include the links when getting the entity." 986 ) 987 logging.warning( 988 "Fetching {} again with missing links: {}".format( 989 self._name.upper(), ",".join(need) 990 ) 991 ) 992 new = self.__fetch(self, links=need) 993 for _list in find_list: 994 self.__old[_list] = copy.deepcopy(new.get(_list, [])) 995 996 # if batch_linking is enabled, builds a list of link requests 997 # for each link definition of the calling entity then returns the list 998 request_list = [] 999 for link_def in self._link_def_by_key.values(): 1000 link_def_requests = self.__apply_list(link_def, batch_linking=batch_linking) 1001 if batch_linking: 1002 request_list.extend(link_def_requests) 1003 return request_list 1004 1005 def __apply_list(self, link_def, report_only=False, batch_linking=True): 1006 """Automatically resolve differences and issue the correct sequence of 1007 link/unlink/relink for the link list to result in the supplied list 1008 of entities. 1009 1010 report_only will not make any changes to the data or issue network requests. 1011 Instead, it returns the three lists of changes (add, update, delete). 1012 """ 1013 to_add = [] 1014 to_remove = [] 1015 to_update = [] 1016 should_only_have = set() 1017 link_key = link_def["link_key"] 1018 1019 if link_def["type"] == list: 1020 want_entities = self.get(link_key, []) 1021 have_entities = self.__old.get(link_key, []) 1022 1023 if not isinstance(want_entities, list): 1024 raise api.UsageException( 1025 "Expecting '{}' to be {}. Found {} instead.".format( 1026 link_key, 1027 link_def["type"].__name__, 1028 type(want_entities).__name__, 1029 ) 1030 ) 1031 1032 for want_entity in want_entities: 1033 if want_entity["uuId"] in should_only_have: 1034 raise api.UsageException( 1035 "Duplicate {} in {}".format(link_def["name"], link_key) 1036 ) 1037 should_only_have.add(want_entity["uuId"]) 1038 have = False 1039 for have_entity in have_entities: 1040 if have_entity["uuId"] == want_entity["uuId"]: 1041 have = True 1042 data_name = link_def.get("data_name") 1043 if data_name and self.__link_data_differs( 1044 have_entity, want_entity, data_name 1045 ): 1046 to_update.append(want_entity) 1047 if not have: 1048 to_add.append(want_entity) 1049 for have_entity in have_entities: 1050 if have_entity["uuId"] not in should_only_have: 1051 to_remove.append(have_entity) 1052 elif link_def["type"] == dict: 1053 # Note: dict type does not implement updates as we have no dict links 1054 # that support update (yet?). 1055 want_entity = self.get(link_key, None) 1056 have_entity = self.__old.get(link_key, None) 1057 1058 if want_entity is not None and not isinstance(want_entity, dict): 1059 raise api.UsageException( 1060 "Expecting '{}' to be {}. Found {} instead.".format( 1061 link_key, link_def["type"].__name__, type(have_entity).__name__ 1062 ) 1063 ) 1064 1065 if want_entity: 1066 if have_entity: 1067 if want_entity["uuId"] != have_entity["uuId"]: 1068 to_remove = have_entity 1069 to_add = want_entity 1070 else: 1071 to_add = want_entity 1072 if not want_entity: 1073 if have_entity: 1074 to_remove = have_entity 1075 1076 want_entities = want_entity 1077 else: 1078 # Would be an error in this library if we reach here 1079 raise projectal.UnsupportedException("This type does not support linking") 1080 1081 # if batch_linking is enabled, builds a list of requests 1082 # from each link method 1083 if not report_only: 1084 request_list = [] 1085 if to_remove: 1086 delete_requests = self._link( 1087 link_def["name"], 1088 to_remove, 1089 "delete", 1090 update_cache=False, 1091 batch_linking=batch_linking, 1092 ) 1093 request_list.extend(delete_requests) 1094 if to_update: 1095 update_requests = self._link( 1096 link_def["name"], 1097 to_update, 1098 "update", 1099 update_cache=False, 1100 batch_linking=batch_linking, 1101 ) 1102 request_list.extend(update_requests) 1103 if to_add: 1104 add_requests = self._link( 1105 link_def["name"], 1106 to_add, 1107 "add", 1108 update_cache=False, 1109 batch_linking=batch_linking, 1110 ) 1111 request_list.extend(add_requests) 1112 self.__old[link_key] = copy.deepcopy(want_entities) 1113 return request_list 1114 else: 1115 changes = {} 1116 if to_remove: 1117 changes["remove"] = to_remove 1118 if to_update: 1119 changes["update"] = to_update 1120 if to_add: 1121 changes["add"] = to_add 1122 return changes 1123 1124 @classmethod 1125 def get_link_definitions(cls): 1126 return cls({})._link_def_by_name 1127 1128 # --- --- 1129 1130 def entity_name(self): 1131 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(self): 609 """Calls `update()` on this instance of the entity, saving 610 it to the database.""" 611 return self.__class__.update(self) 612 613 @classmethod 614 def delete(cls, entities): 615 """ 616 Delete one or more entities of the same type. The entity 617 type is determined by the subclass calling this method. 618 619 `entities`: See `Entity.get()` for expected formats. 620 621 ``` 622 # Example usage: 623 ids = ['1b21e445-f29a...', '1b21e445-f29a...', '1b21e445-f29a...'] 624 projectal.Customer.delete(ids) 625 ``` 626 """ 627 if isinstance(entities, str): 628 # String input is a uuId 629 payload = [{"uuId": entities}] 630 elif isinstance(entities, dict): 631 # Dict input needs to be a list 632 payload = [entities] 633 elif isinstance(entities, list): 634 # List input can be a list of uuIds or list of dicts 635 # If uuIds (strings), convert to list of dicts 636 if len(entities) > 0 and isinstance(entities[0], str): 637 payload = [{"uuId": uuId} for uuId in entities] 638 else: 639 # Already expected format 640 payload = entities 641 else: 642 # We have a list of dicts already, the expected format 643 payload = entities 644 645 # We only need to send over the uuIds 646 payload = [{"uuId": e["uuId"]} for e in payload] 647 if not payload: 648 return True 649 for i in range(0, len(payload), projectal.chunk_size_write): 650 chunk = payload[i : i + projectal.chunk_size_write] 651 api.delete("/api/{}/delete".format(cls._path), chunk) 652 return True 653 654 def __delete(self): 655 """Let an instance delete itself.""" 656 return self.__class__.delete(self) 657 658 def clone(self, entity): 659 """ 660 Clones an entity and returns its `uuId`. 661 662 Each entity has its own set of required values when cloning. 663 Check the API documentation of that entity for details. 664 """ 665 url = "/api/{}/clone?reference={}".format(self._path, self["uuId"]) 666 response = api.post(url, entity) 667 return response["jobClue"]["uuId"] 668 669 @classmethod 670 def history(cls, UUID, start=0, limit=-1, order="desc", epoch=None, event=None): 671 """ 672 Returns an ordered list of all changes made to the entity. 673 674 `UUID`: the UUID of the entity. 675 676 `start`: Start index for pagination (default: `0`). 677 678 `limit`: Number of results to include for pagination. Use 679 `-1` to return the entire history (default: `-1`). 680 681 `order`: `asc` or `desc` (default: `desc` (index 0 is newest)) 682 683 `epoch`: only return the history UP TO epoch date 684 685 `event`: 686 """ 687 url = "/api/{}/history?holder={}&".format(cls._path, UUID) 688 params = [] 689 params.append("start={}".format(start)) 690 params.append("limit={}".format(limit)) 691 params.append("order={}".format(order)) 692 params.append("epoch={}".format(epoch)) if epoch else None 693 params.append("event={}".format(event)) if event else None 694 url += "&".join(params) 695 return api.get(url) 696 697 def __history(self, **kwargs): 698 """Get history of instance.""" 699 return self.__class__.history(self["uuId"], **kwargs) 700 701 @classmethod 702 def list(cls, expand=False, links=None): 703 """Return a list of all entity UUIDs of this type. 704 705 You may pass in `expand=True` to get full Entity objects 706 instead, but be aware this may be very slow if you have 707 thousands of objects. 708 709 If you are expanding the objects, you may further expand 710 the results with `links`. 711 """ 712 713 payload = { 714 "name": "List all entities of type {}".format(cls._name.upper()), 715 "type": "msql", 716 "start": 0, 717 "limit": -1, 718 "select": [["{}.uuId".format(cls._name.upper())]], 719 } 720 ids = api.query(payload) 721 ids = [id[0] for id in ids] 722 if ids: 723 return cls.get(ids, links=links) if expand else ids 724 return [] 725 726 @classmethod 727 def match(cls, field, term, links=None): 728 """Find entities where `field`=`term` (exact match), optionally 729 expanding the results with `links`. 730 731 Relies on `Entity.query()` with a pre-built set of rules. 732 ``` 733 projects = projectal.Project.match('identifier', 'zmb-005') 734 ``` 735 """ 736 filter = [["{}.{}".format(cls._name.upper(), field), "eq", term]] 737 return cls.query(filter, links) 738 739 @classmethod 740 def match_startswith(cls, field, term, links=None): 741 """Find entities where `field` starts with the text `term`, 742 optionally expanding the results with `links`. 743 744 Relies on `Entity.query()` with a pre-built set of rules. 745 ``` 746 projects = projectal.Project.match_startswith('name', 'Zomb') 747 ``` 748 """ 749 filter = [["{}.{}".format(cls._name.upper(), field), "prefix", term]] 750 return cls.query(filter, links) 751 752 @classmethod 753 def match_endswith(cls, field, term, links=None): 754 """Find entities where `field` ends with the text `term`, 755 optionally expanding the results with `links`. 756 757 Relies on `Entity.query()` with a pre-built set of rules. 758 ``` 759 projects = projectal.Project.match_endswith('identifier', '-2023') 760 ``` 761 """ 762 term = "(?i).*{}$".format(term) 763 filter = [["{}.{}".format(cls._name.upper(), field), "regex", term]] 764 return cls.query(filter, links) 765 766 @classmethod 767 def match_one(cls, field, term, links=None): 768 """Convenience function for match(). Returns the first match or None.""" 769 matches = cls.match(field, term, links) 770 if matches: 771 return matches[0] 772 773 @classmethod 774 def match_startswith_one(cls, field, term, links=None): 775 """Convenience function for match_startswith(). Returns the first match or None.""" 776 matches = cls.match_startswith(field, term, links) 777 if matches: 778 return matches[0] 779 780 @classmethod 781 def match_endswith_one(cls, field, term, links=None): 782 """Convenience function for match_endswith(). Returns the first match or None.""" 783 matches = cls.match_endswith(field, term, links) 784 if matches: 785 return matches[0] 786 787 @classmethod 788 def search(cls, fields=None, term="", case_sensitive=True, links=None): 789 """Find entities that contain the text `term` within `fields`. 790 `fields` is a list of field names to target in the search. 791 792 `case_sensitive`: Optionally turn off case sensitivity in the search. 793 794 Relies on `Entity.query()` with a pre-built set of rules. 795 ``` 796 projects = projectal.Project.search(['name', 'description'], 'zombie') 797 ``` 798 """ 799 filter = [] 800 term = "(?{}).*{}.*".format("" if case_sensitive else "?", term) 801 for field in fields: 802 filter.append(["{}.{}".format(cls._name.upper(), field), "regex", term]) 803 filter = ["_or_", filter] 804 return cls.query(filter, links) 805 806 @classmethod 807 def query(cls, filter, links=None, timeout=30): 808 """Run a query on this entity with the supplied filter. 809 810 The query is already set up to target this entity type, and the 811 results will be converted into full objects when found, optionally 812 expanded with the `links` provided. You only need to supply a 813 filter to reduce the result set. 814 815 See [the filter documentation](https://projectal.com/docs/v1.1.1#section/Filter-section) 816 for a detailed overview of the kinds of filters you can construct. 817 """ 818 payload = { 819 "name": "Python library entity query ({})".format(cls._name.upper()), 820 "type": "msql", 821 "start": 0, 822 "limit": -1, 823 "select": [["{}.uuId".format(cls._name.upper())]], 824 "filter": filter, 825 "timeout": timeout, 826 } 827 ids = api.query(payload) 828 ids = [id[0] for id in ids] 829 if ids: 830 return cls.get(ids, links=links) 831 return [] 832 833 def profile_get(self, key): 834 """Get the profile (metadata) stored for this entity at `key`.""" 835 return projectal.profile.get(key, self.__class__._name.lower(), self["uuId"]) 836 837 def profile_set(self, key, data): 838 """Set the profile (metadata) stored for this entity at `key`. The contents 839 of `data` will completely overwrite the existing data dictionary.""" 840 return projectal.profile.set( 841 key, self.__class__._name.lower(), self["uuId"], data 842 ) 843 844 def __type_links(self): 845 """Find links and turn their dicts into typed objects matching their Entity type.""" 846 847 for key, _def in self._link_def_by_key.items(): 848 if key in self: 849 cls = getattr(projectal, _def["entity"]) 850 if _def["type"] == list: 851 as_obj = [] 852 for link in self[key]: 853 as_obj.append(cls(link)) 854 elif _def["type"] == dict: 855 as_obj = cls(self[key]) 856 else: 857 raise projectal.UsageException("Unexpected link type") 858 self[key] = as_obj 859 860 def changes(self): 861 """Return a dict containing the fields that have changed since fetching the object. 862 Dict values contain both the old and new values. E.g.: {'old': 'original', 'new': 'current'}. 863 864 In the case of link lists, there are three values: added, removed, updated. Only links with 865 a data attribute can end up in the updated list, and the old/new dictionary is placed within 866 that data attribute. E.g. for a staff-resource link: 867 'updated': [{ 868 'uuId': '24eb4c31-0f92-49d1-8b4d-507ab939003e', 869 'resourceLink': {'quantity': {'old': 2, 'new': 5}} 870 }] 871 """ 872 changed = {} 873 for key in self.keys(): 874 link_def = self._link_def_by_key.get(key) 875 if link_def: 876 changes = self._changes_for_link_list(link_def, key) 877 # Only add it if something in it changed 878 for action in changes.values(): 879 if len(action): 880 changed[key] = changes 881 break 882 elif key not in self.__old and self[key] is not None: 883 changed[key] = {"old": None, "new": self[key]} 884 elif self.__old.get(key) != self[key]: 885 changed[key] = {"old": self.__old.get(key), "new": self[key]} 886 return changed 887 888 def _changes_for_link_list(self, link_def, key): 889 changes = self.__apply_list(link_def, report_only=True) 890 data_key = link_def["data_name"] 891 892 # For linked entities, we will only report their UUID, name (if it has one), 893 # and the content of their data attribute (if it has one). 894 def get_slim_list(entities): 895 slim = [] 896 if isinstance(entities, dict): 897 entities = [entities] 898 for e in entities: 899 fields = {"uuId": e["uuId"]} 900 name = e.get("name") 901 if name: 902 fields["name"] = e["name"] 903 if data_key and e[data_key]: 904 fields[data_key] = e[data_key] 905 slim.append(fields) 906 return slim 907 908 out = { 909 "added": get_slim_list(changes.get("add", [])), 910 "updated": [], 911 "removed": get_slim_list(changes.get("remove", [])), 912 } 913 914 updated = changes.get("update", []) 915 if updated: 916 before_map = {} 917 for entity in self.__old.get(key): 918 before_map[entity["uuId"]] = entity 919 920 for entity in updated: 921 old_data = before_map[entity["uuId"]][data_key] 922 new_data = entity[data_key] 923 diff = {} 924 for key in new_data.keys(): 925 if key not in old_data and new_data[key] is not None: 926 diff[key] = {"old": None, "new": new_data[key]} 927 elif old_data.get(key) != new_data[key]: 928 diff[key] = {"old": old_data.get(key), "new": new_data[key]} 929 out["updated"].append({"uuId": entity["uuId"], data_key: diff}) 930 return out 931 932 def _changes_internal(self): 933 """Return a dict containing only the fields that have changed and their current value, 934 without any link data. 935 936 This method is used internally to strip payloads down to only the fields that have changed. 937 """ 938 changed = {} 939 for key in self.keys(): 940 # We don't deal with link or link data changes here. We only want standard fields. 941 if key in self._link_def_by_key: 942 continue 943 if key not in self.__old and self[key] is not None: 944 changed[key] = self[key] 945 elif self.__old.get(key) != self[key]: 946 changed[key] = self[key] 947 return changed 948 949 def set_readonly(self, key, value): 950 """Set a field on this Entity that will not be sent over to the 951 server on update unless modified.""" 952 self[key] = value 953 self.__old[key] = value 954 955 # --- Link management --- 956 957 @staticmethod 958 def __link_data_differs(have_link, want_link, data_key): 959 if data_key: 960 if "uuId" in have_link[data_key]: 961 del have_link[data_key]["uuId"] 962 if "uuId" in want_link[data_key]: 963 del want_link[data_key]["uuId"] 964 return have_link[data_key] != want_link[data_key] 965 966 # Links without data never differ 967 return False 968 969 def __apply_link_changes(self, batch_linking=True): 970 """Send each link list to the conflict resolver. If we detect 971 that the entity was not fetched with that link, we do the fetch 972 first and use the result as the basis for comparison.""" 973 974 # Find which lists belong to links but were not fetched so we can fetch them 975 need = [] 976 find_list = [] 977 if not self._is_new: 978 for link in self._link_def_by_key.values(): 979 if link["link_key"] in self and link["name"] not in self._with_links: 980 need.append(link["name"]) 981 find_list.append(link["link_key"]) 982 983 if len(need): 984 logging.warning( 985 "Entity links were modified but entity not fetched with links. " 986 "For better performance, include the links when getting the entity." 987 ) 988 logging.warning( 989 "Fetching {} again with missing links: {}".format( 990 self._name.upper(), ",".join(need) 991 ) 992 ) 993 new = self.__fetch(self, links=need) 994 for _list in find_list: 995 self.__old[_list] = copy.deepcopy(new.get(_list, [])) 996 997 # if batch_linking is enabled, builds a list of link requests 998 # for each link definition of the calling entity then returns the list 999 request_list = [] 1000 for link_def in self._link_def_by_key.values(): 1001 link_def_requests = self.__apply_list(link_def, batch_linking=batch_linking) 1002 if batch_linking: 1003 request_list.extend(link_def_requests) 1004 return request_list 1005 1006 def __apply_list(self, link_def, report_only=False, batch_linking=True): 1007 """Automatically resolve differences and issue the correct sequence of 1008 link/unlink/relink for the link list to result in the supplied list 1009 of entities. 1010 1011 report_only will not make any changes to the data or issue network requests. 1012 Instead, it returns the three lists of changes (add, update, delete). 1013 """ 1014 to_add = [] 1015 to_remove = [] 1016 to_update = [] 1017 should_only_have = set() 1018 link_key = link_def["link_key"] 1019 1020 if link_def["type"] == list: 1021 want_entities = self.get(link_key, []) 1022 have_entities = self.__old.get(link_key, []) 1023 1024 if not isinstance(want_entities, list): 1025 raise api.UsageException( 1026 "Expecting '{}' to be {}. Found {} instead.".format( 1027 link_key, 1028 link_def["type"].__name__, 1029 type(want_entities).__name__, 1030 ) 1031 ) 1032 1033 for want_entity in want_entities: 1034 if want_entity["uuId"] in should_only_have: 1035 raise api.UsageException( 1036 "Duplicate {} in {}".format(link_def["name"], link_key) 1037 ) 1038 should_only_have.add(want_entity["uuId"]) 1039 have = False 1040 for have_entity in have_entities: 1041 if have_entity["uuId"] == want_entity["uuId"]: 1042 have = True 1043 data_name = link_def.get("data_name") 1044 if data_name and self.__link_data_differs( 1045 have_entity, want_entity, data_name 1046 ): 1047 to_update.append(want_entity) 1048 if not have: 1049 to_add.append(want_entity) 1050 for have_entity in have_entities: 1051 if have_entity["uuId"] not in should_only_have: 1052 to_remove.append(have_entity) 1053 elif link_def["type"] == dict: 1054 # Note: dict type does not implement updates as we have no dict links 1055 # that support update (yet?). 1056 want_entity = self.get(link_key, None) 1057 have_entity = self.__old.get(link_key, None) 1058 1059 if want_entity is not None and not isinstance(want_entity, dict): 1060 raise api.UsageException( 1061 "Expecting '{}' to be {}. Found {} instead.".format( 1062 link_key, link_def["type"].__name__, type(have_entity).__name__ 1063 ) 1064 ) 1065 1066 if want_entity: 1067 if have_entity: 1068 if want_entity["uuId"] != have_entity["uuId"]: 1069 to_remove = have_entity 1070 to_add = want_entity 1071 else: 1072 to_add = want_entity 1073 if not want_entity: 1074 if have_entity: 1075 to_remove = have_entity 1076 1077 want_entities = want_entity 1078 else: 1079 # Would be an error in this library if we reach here 1080 raise projectal.UnsupportedException("This type does not support linking") 1081 1082 # if batch_linking is enabled, builds a list of requests 1083 # from each link method 1084 if not report_only: 1085 request_list = [] 1086 if to_remove: 1087 delete_requests = self._link( 1088 link_def["name"], 1089 to_remove, 1090 "delete", 1091 update_cache=False, 1092 batch_linking=batch_linking, 1093 ) 1094 request_list.extend(delete_requests) 1095 if to_update: 1096 update_requests = self._link( 1097 link_def["name"], 1098 to_update, 1099 "update", 1100 update_cache=False, 1101 batch_linking=batch_linking, 1102 ) 1103 request_list.extend(update_requests) 1104 if to_add: 1105 add_requests = self._link( 1106 link_def["name"], 1107 to_add, 1108 "add", 1109 update_cache=False, 1110 batch_linking=batch_linking, 1111 ) 1112 request_list.extend(add_requests) 1113 self.__old[link_key] = copy.deepcopy(want_entities) 1114 return request_list 1115 else: 1116 changes = {} 1117 if to_remove: 1118 changes["remove"] = to_remove 1119 if to_update: 1120 changes["update"] = to_update 1121 if to_add: 1122 changes["add"] = to_add 1123 return changes 1124 1125 @classmethod 1126 def get_link_definitions(cls): 1127 return cls({})._link_def_by_name 1128 1129 # --- --- 1130 1131 def entity_name(self): 1132 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 uuId
s
of the entities you want to get (see bottom for examples):
str
or list ofstr
dict
or list ofdict
(withuuId
key)
links
: A case-insensitive list of entity names to fetch with
this entity. For performance reasons, links are only returned
on demand.
Links follow a common naming convention in the output with
a _List suffix. E.g.:
links=['company', 'location']
will appear as companyList
and
locationList
in the response.
# Example usage:
# str
projectal.Project.get('1b21e445-f29a-4a9f-95ff-fe253a3e1b11')
# list of str
ids = ['1b21e445-f29a...', '1b21e445-f29a...', '1b21e445-f29a...']
projectal.Project.get(ids)
# dict
project = project.Project.create({'name': 'MyProject'})
# project = {'uuId': '1b21e445-f29a...', 'name': 'MyProject', ...}
projectal.Project.get(project)
# list of dicts (e.g. from a query)
# projects = [{'uuId': '1b21e445-f29a...'}, {'uuId': '1b21e445-f29a...'}, ...]
project.Project.get(projects)
# str with links
projectal.Project.get('1b21e445-f29a...', 'links=['company', 'location']')
deleted_at
: Include this parameter to get a deleted entity.
This value should be a UTC timestamp from a webhook delete event.
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 dict
s to update many entities in bulk.
'batch_linking': Enabled by default, batches any link updates required into composite API requests. If disabled a request will be executed for each link update. Recommended to leave enabled to increase performance.
Returns True
if all entities update successfully.
# Example usage:
rebate = projectal.Rebate.create({'name': 'Rebate2022', 'rebate': 0.2})
rebate['name'] = 'Rebate2024'
projectal.Rebate.update(rebate)
# Returns True. New rebate name has been saved.
613 @classmethod 614 def delete(cls, entities): 615 """ 616 Delete one or more entities of the same type. The entity 617 type is determined by the subclass calling this method. 618 619 `entities`: See `Entity.get()` for expected formats. 620 621 ``` 622 # Example usage: 623 ids = ['1b21e445-f29a...', '1b21e445-f29a...', '1b21e445-f29a...'] 624 projectal.Customer.delete(ids) 625 ``` 626 """ 627 if isinstance(entities, str): 628 # String input is a uuId 629 payload = [{"uuId": entities}] 630 elif isinstance(entities, dict): 631 # Dict input needs to be a list 632 payload = [entities] 633 elif isinstance(entities, list): 634 # List input can be a list of uuIds or list of dicts 635 # If uuIds (strings), convert to list of dicts 636 if len(entities) > 0 and isinstance(entities[0], str): 637 payload = [{"uuId": uuId} for uuId in entities] 638 else: 639 # Already expected format 640 payload = entities 641 else: 642 # We have a list of dicts already, the expected format 643 payload = entities 644 645 # We only need to send over the uuIds 646 payload = [{"uuId": e["uuId"]} for e in payload] 647 if not payload: 648 return True 649 for i in range(0, len(payload), projectal.chunk_size_write): 650 chunk = payload[i : i + projectal.chunk_size_write] 651 api.delete("/api/{}/delete".format(cls._path), chunk) 652 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)
669 @classmethod 670 def history(cls, UUID, start=0, limit=-1, order="desc", epoch=None, event=None): 671 """ 672 Returns an ordered list of all changes made to the entity. 673 674 `UUID`: the UUID of the entity. 675 676 `start`: Start index for pagination (default: `0`). 677 678 `limit`: Number of results to include for pagination. Use 679 `-1` to return the entire history (default: `-1`). 680 681 `order`: `asc` or `desc` (default: `desc` (index 0 is newest)) 682 683 `epoch`: only return the history UP TO epoch date 684 685 `event`: 686 """ 687 url = "/api/{}/history?holder={}&".format(cls._path, UUID) 688 params = [] 689 params.append("start={}".format(start)) 690 params.append("limit={}".format(limit)) 691 params.append("order={}".format(order)) 692 params.append("epoch={}".format(epoch)) if epoch else None 693 params.append("event={}".format(event)) if event else None 694 url += "&".join(params) 695 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 dict
s to create many entities in bulk.
params
: Optional URL parameters that may apply to the
entity's API (e.g: ?holder=1234
).
'batch_linking': Enabled by default, batches any link updates required into composite API requests. If disabled a request will be executed for each link update. Recommended to leave enabled to increase performance.
If input was a dict
, returns an entity subclass. If input was
a list of dict
s, returns a list of entity subclasses.
# Example usage:
projectal.Customer.create({'name': 'NewCustomer'})
# returns Customer object
608 def save(self): 609 """Calls `update()` on this instance of the entity, saving 610 it to the database.""" 611 return self.__class__.update(self)
Calls update()
on this instance of the entity, saving
it to the database.
658 def clone(self, entity): 659 """ 660 Clones an entity and returns its `uuId`. 661 662 Each entity has its own set of required values when cloning. 663 Check the API documentation of that entity for details. 664 """ 665 url = "/api/{}/clone?reference={}".format(self._path, self["uuId"]) 666 response = api.post(url, entity) 667 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.
701 @classmethod 702 def list(cls, expand=False, links=None): 703 """Return a list of all entity UUIDs of this type. 704 705 You may pass in `expand=True` to get full Entity objects 706 instead, but be aware this may be very slow if you have 707 thousands of objects. 708 709 If you are expanding the objects, you may further expand 710 the results with `links`. 711 """ 712 713 payload = { 714 "name": "List all entities of type {}".format(cls._name.upper()), 715 "type": "msql", 716 "start": 0, 717 "limit": -1, 718 "select": [["{}.uuId".format(cls._name.upper())]], 719 } 720 ids = api.query(payload) 721 ids = [id[0] for id in ids] 722 if ids: 723 return cls.get(ids, links=links) if expand else ids 724 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
.
726 @classmethod 727 def match(cls, field, term, links=None): 728 """Find entities where `field`=`term` (exact match), optionally 729 expanding the results with `links`. 730 731 Relies on `Entity.query()` with a pre-built set of rules. 732 ``` 733 projects = projectal.Project.match('identifier', 'zmb-005') 734 ``` 735 """ 736 filter = [["{}.{}".format(cls._name.upper(), field), "eq", term]] 737 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')
739 @classmethod 740 def match_startswith(cls, field, term, links=None): 741 """Find entities where `field` starts with the text `term`, 742 optionally expanding the results with `links`. 743 744 Relies on `Entity.query()` with a pre-built set of rules. 745 ``` 746 projects = projectal.Project.match_startswith('name', 'Zomb') 747 ``` 748 """ 749 filter = [["{}.{}".format(cls._name.upper(), field), "prefix", term]] 750 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')
752 @classmethod 753 def match_endswith(cls, field, term, links=None): 754 """Find entities where `field` ends with the text `term`, 755 optionally expanding the results with `links`. 756 757 Relies on `Entity.query()` with a pre-built set of rules. 758 ``` 759 projects = projectal.Project.match_endswith('identifier', '-2023') 760 ``` 761 """ 762 term = "(?i).*{}$".format(term) 763 filter = [["{}.{}".format(cls._name.upper(), field), "regex", term]] 764 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')
766 @classmethod 767 def match_one(cls, field, term, links=None): 768 """Convenience function for match(). Returns the first match or None.""" 769 matches = cls.match(field, term, links) 770 if matches: 771 return matches[0]
Convenience function for match(). Returns the first match or None.
773 @classmethod 774 def match_startswith_one(cls, field, term, links=None): 775 """Convenience function for match_startswith(). Returns the first match or None.""" 776 matches = cls.match_startswith(field, term, links) 777 if matches: 778 return matches[0]
Convenience function for match_startswith(). Returns the first match or None.
780 @classmethod 781 def match_endswith_one(cls, field, term, links=None): 782 """Convenience function for match_endswith(). Returns the first match or None.""" 783 matches = cls.match_endswith(field, term, links) 784 if matches: 785 return matches[0]
Convenience function for match_endswith(). Returns the first match or None.
787 @classmethod 788 def search(cls, fields=None, term="", case_sensitive=True, links=None): 789 """Find entities that contain the text `term` within `fields`. 790 `fields` is a list of field names to target in the search. 791 792 `case_sensitive`: Optionally turn off case sensitivity in the search. 793 794 Relies on `Entity.query()` with a pre-built set of rules. 795 ``` 796 projects = projectal.Project.search(['name', 'description'], 'zombie') 797 ``` 798 """ 799 filter = [] 800 term = "(?{}).*{}.*".format("" if case_sensitive else "?", term) 801 for field in fields: 802 filter.append(["{}.{}".format(cls._name.upper(), field), "regex", term]) 803 filter = ["_or_", filter] 804 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')
806 @classmethod 807 def query(cls, filter, links=None, timeout=30): 808 """Run a query on this entity with the supplied filter. 809 810 The query is already set up to target this entity type, and the 811 results will be converted into full objects when found, optionally 812 expanded with the `links` provided. You only need to supply a 813 filter to reduce the result set. 814 815 See [the filter documentation](https://projectal.com/docs/v1.1.1#section/Filter-section) 816 for a detailed overview of the kinds of filters you can construct. 817 """ 818 payload = { 819 "name": "Python library entity query ({})".format(cls._name.upper()), 820 "type": "msql", 821 "start": 0, 822 "limit": -1, 823 "select": [["{}.uuId".format(cls._name.upper())]], 824 "filter": filter, 825 "timeout": timeout, 826 } 827 ids = api.query(payload) 828 ids = [id[0] for id in ids] 829 if ids: 830 return cls.get(ids, links=links) 831 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.
833 def profile_get(self, key): 834 """Get the profile (metadata) stored for this entity at `key`.""" 835 return projectal.profile.get(key, self.__class__._name.lower(), self["uuId"])
Get the profile (metadata) stored for this entity at key
.
837 def profile_set(self, key, data): 838 """Set the profile (metadata) stored for this entity at `key`. The contents 839 of `data` will completely overwrite the existing data dictionary.""" 840 return projectal.profile.set( 841 key, self.__class__._name.lower(), self["uuId"], data 842 )
Set the profile (metadata) stored for this entity at key
. The contents
of data
will completely overwrite the existing data dictionary.
860 def changes(self): 861 """Return a dict containing the fields that have changed since fetching the object. 862 Dict values contain both the old and new values. E.g.: {'old': 'original', 'new': 'current'}. 863 864 In the case of link lists, there are three values: added, removed, updated. Only links with 865 a data attribute can end up in the updated list, and the old/new dictionary is placed within 866 that data attribute. E.g. for a staff-resource link: 867 'updated': [{ 868 'uuId': '24eb4c31-0f92-49d1-8b4d-507ab939003e', 869 'resourceLink': {'quantity': {'old': 2, 'new': 5}} 870 }] 871 """ 872 changed = {} 873 for key in self.keys(): 874 link_def = self._link_def_by_key.get(key) 875 if link_def: 876 changes = self._changes_for_link_list(link_def, key) 877 # Only add it if something in it changed 878 for action in changes.values(): 879 if len(action): 880 changed[key] = changes 881 break 882 elif key not in self.__old and self[key] is not None: 883 changed[key] = {"old": None, "new": self[key]} 884 elif self.__old.get(key) != self[key]: 885 changed[key] = {"old": self.__old.get(key), "new": self[key]} 886 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}} }]
949 def set_readonly(self, key, value): 950 """Set a field on this Entity that will not be sent over to the 951 server on update unless modified.""" 952 self[key] = value 953 self.__old[key] = value
Set a field on this Entity that will not be sent over to the server on update unless modified.