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