projectal.entities.task
1import datetime 2import copy 3import sys 4import projectal 5from projectal.entity import Entity 6from projectal.enums import DateLimit 7from projectal.linkers import * 8 9 10class Task( 11 Entity, 12 ResourceLinker, 13 SkillLinker, 14 FileLinker, 15 StageLinker, 16 StaffLinker, 17 RebateLinker, 18 NoteLinker, 19 TagLinker, 20 PredecessorTaskLinker, 21): 22 """ 23 Implementation of the [Task](https://projectal.com/docs/latest/#tag/Task) API. 24 """ 25 26 _path = "task" 27 _name = "task" 28 _links = [ 29 ResourceLinker, 30 SkillLinker, 31 FileLinker, 32 StageLinker, 33 StaffLinker, 34 RebateLinker, 35 NoteLinker, 36 TagLinker, 37 ] 38 _links_reverse = [ 39 PredecessorTaskLinker, 40 ] 41 42 def _add_link_def(self, cls, reverse=False): 43 """ 44 Each entity is accompanied by a dict with details about how to 45 get access to the data of the link within the object. Subclasses 46 can pass in customizations to this dict when their APIs differ. 47 48 reverse denotes a reverse linker, where extra work is done to 49 reverse the relationship of the link internally so that it works. 50 The backend only offers one side of the relationship. 51 """ 52 d = { 53 "name": cls._link_name, 54 "link_key": cls._link_key or cls._link_name + "List", 55 "data_name": cls._link_data_name, 56 "type": cls._link_type, 57 "entity": cls._link_entity or cls._link_name.capitalize(), 58 "reverse": reverse, 59 } 60 self._link_def_by_key[d["link_key"]] = d 61 self._link_def_by_name[d["name"]] = d 62 if cls._link_name == "predecessor_task": 63 d_after_reverse = copy.deepcopy(d) 64 d_after_reverse["reverse"] = False 65 self._link_def_by_name["task"] = d_after_reverse 66 # We need this to be present in the link def so that 67 # returned predecessor tasks can be typed as Tasks 68 d_for_pred_link_typing = copy.deepcopy(d) 69 d_for_pred_link_typing["link_key"] = "planList" 70 self._link_def_by_key[ 71 d_for_pred_link_typing["link_key"] 72 ] = d_for_pred_link_typing 73 74 @classmethod 75 def create(cls, holder, entities): 76 """Create a Task 77 78 `holder`: An instance or the `uuId` of the owner 79 80 `entities`: `dict` containing the fields of the entity to be created, 81 or a list of such `dict`s to create in bulk. 82 """ 83 holder_id = holder["uuId"] if isinstance(holder, dict) else holder 84 params = "?holder=" + holder_id 85 out = super().create(entities, params) 86 87 # Tasks should always refer to their parent and project. We don't get this information 88 # from the creation api method, but we can insert them ourselves because we know what 89 # they are. 90 def add_fields(obj): 91 obj.set_readonly("projectRef", holder_id) 92 obj.set_readonly("parent", obj.get("parent", holder_id)) 93 94 if isinstance(out, dict): 95 add_fields(out) 96 if isinstance(out, list): 97 for obj in out: 98 add_fields(obj) 99 return out 100 101 @classmethod 102 def get(cls, entities, links=None, deleted_at=None): 103 r = super().get(entities, links, deleted_at) 104 if not links: 105 return r 106 # When Predecessor Task links are fetched, 107 # make sure the key matches the name expected 108 # by the predecessor linking REST end point 109 if PredecessorTaskLinker._link_name.casefold() in ( 110 link.casefold() for link in links 111 ): 112 if isinstance(r, dict): 113 r["taskList"] = r.pop("planList", []) 114 r._Entity__old = copy.deepcopy(r) 115 else: 116 for entity in r: 117 entity["taskList"] = entity.pop("planList", []) 118 entity._Entity__old = copy.deepcopy(entity) 119 return r 120 121 # Override here to correctly format the URL for the Predecessor Task link case 122 def _link( 123 self, to_entity_name, to_link, operation, update_cache=True, batch_linking=True 124 ): 125 """ 126 `to_entity_name`: Destination entity name (e.g. 'staff') 127 128 `to_link`: List of Entities of the same type (and optional data) to link to 129 130 `operation`: `add`, `update`, `delete` 131 132 'update_cache': also modify the entity's internal representation of the links 133 to match the operation that was done. Set this to False when replacing the 134 list with a new one (i.e., when calling save() instead of a linker method). 135 136 'batch_linking': Enabled by default, batches any link 137 updates required into composite API requests. If disabled 138 a request will be executed for each link update. 139 Recommended to leave enabled to increase performance. 140 """ 141 142 link_def = self._link_def_by_name[to_entity_name] 143 to_key = link_def["link_key"] 144 145 if isinstance(to_link, dict) and link_def["type"] == list: 146 # Convert input dict to list when link type is a list (we allow linking to single entity for convenience) 147 to_link = [to_link] 148 149 # For cases where user passed in dict instead of Entity, we turn them into 150 # Entity on their behalf. 151 typed_list = [] 152 target_cls = getattr(sys.modules["projectal.entities"], link_def["entity"]) 153 for link in to_link: 154 if not isinstance(link, target_cls): 155 typed_list.append(target_cls(link)) 156 else: 157 typed_list.append(link) 158 to_link = typed_list 159 else: 160 # For everything else, we expect types to match. 161 if not isinstance(to_link, link_def["type"]): 162 raise api.UsageException( 163 "Expected link type to be {}. Got {}.".format( 164 link_def["type"], type(to_link) 165 ) 166 ) 167 168 if not to_link: 169 return 170 171 url = "" 172 payload = {} 173 request_list = [] 174 # Is it a reverse linker? If so, invert the relationship 175 if link_def["reverse"]: 176 for link in to_link: 177 # Sets the data attribute on the correct 178 # link entity 179 if link_def["name"] == "predecessor_task": 180 data_name = link_def.get("data_name") 181 self[data_name] = copy.deepcopy(link[data_name]) 182 request_list.extend( 183 link._link( 184 self._name, 185 self, 186 operation, 187 update_cache, 188 batch_linking=batch_linking, 189 ) 190 ) 191 else: 192 # Only keep UUID and the data attribute, if it has one 193 def strip_payload(link): 194 single = {"uuId": link["uuId"]} 195 data_name = link_def.get("data_name") 196 if data_name and data_name in link: 197 single[data_name] = copy.deepcopy(link[data_name]) 198 # limiting data attribute removal to only planLink 199 # in case of side effects 200 if data_name == "planLink": 201 del link[data_name] 202 return single 203 204 # If batch linking is enabled and the entity to link is a list of entities, 205 # a separate request must be constructed for each one because the final composite 206 # request permits only one input per call 207 if to_entity_name == "predecessor_task" or to_entity_name == "task": 208 url = "/api/{}/plan/TASK/{}".format(self._path, operation) 209 else: 210 url = "/api/{}/link/{}/{}".format(self._path, to_entity_name, operation) 211 to_link_payload = None 212 if isinstance(to_link, list): 213 to_link_payload = [] 214 for link in to_link: 215 if batch_linking: 216 request_list.append( 217 { 218 "method": "POST", 219 "invoke": url, 220 "body": { 221 "uuId": self["uuId"], 222 to_key: [strip_payload(link)], 223 }, 224 } 225 ) 226 else: 227 to_link_payload.append(strip_payload(link)) 228 if isinstance(to_link, dict): 229 if batch_linking: 230 request_list.append( 231 { 232 "method": "POST", 233 "invoke": url, 234 "body": { 235 "uuId": self["uuId"], 236 to_key: strip_payload(to_link), 237 }, 238 } 239 ) 240 else: 241 to_link_payload = strip_payload(to_link) 242 243 if not batch_linking: 244 payload = {"uuId": self["uuId"], to_key: to_link_payload} 245 api.post(url, payload=payload) 246 247 if not update_cache: 248 return request_list 249 250 # Set the initial state if first add. We need the type to be set to correctly update the cache 251 if operation == "add" and self.get(to_key, None) is None: 252 if link_def.get("type") == dict: 253 self[to_key] = {} 254 elif link_def.get("type") == list: 255 self[to_key] = [] 256 257 # Modify the entity object's cache of links to match the changes we pushed to the server. 258 if isinstance(self.get(to_key, []), list): 259 if operation == "add": 260 # Sometimes the backend doesn't return a list when it has none. Create it. 261 if to_key not in self: 262 self[to_key] = [] 263 264 for to_entity in to_link: 265 self[to_key].append(to_entity) 266 else: 267 for to_entity in to_link: 268 # Find it in original list 269 for i, old in enumerate(self.get(to_key, [])): 270 if old["uuId"] == to_entity["uuId"]: 271 if operation == "update": 272 self[to_key][i] = to_entity 273 elif operation == "delete": 274 del self[to_key][i] 275 if isinstance(self.get(to_key, None), dict): 276 if operation in ["add", "update"]: 277 self[to_key] = to_link 278 elif operation == "delete": 279 self[to_key] = None 280 281 # Update the "old" record of the link on the entity to avoid 282 # flagging it for changes (link lists are not meant to be user editable). 283 if to_key in self: 284 self._Entity__old[to_key] = self[to_key] 285 286 return request_list 287 288 def update_order(self, order_at_uuId, order_as=True): 289 url = "/api/task/update?order-at={}&order-as={}".format( 290 order_at_uuId, "true" if order_as else "false" 291 ) 292 return api.put(url, [{"uuId": self["uuId"]}]) 293 294 def link_predecessor_task(self, predecessor_task): 295 return self.__plan(self, predecessor_task, "add") 296 297 def relink_predecessor_task(self, predecessor_task): 298 return self.__plan(self, predecessor_task, "update") 299 300 def unlink_predecessor_task(self, predecessor_task): 301 return self.__plan(self, predecessor_task, "delete") 302 303 @classmethod 304 def __plan(cls, from_task, to_task, operation): 305 url = "/api/task/plan/task/{}".format(operation) 306 # Invert the link relationship to match the linker 307 if isinstance(to_task, dict): 308 from_task_copy = copy.deepcopy(from_task) 309 from_task_copy[PredecessorTaskLinker._link_data_name] = copy.deepcopy( 310 to_task[PredecessorTaskLinker._link_data_name] 311 ) 312 payload = {"uuId": to_task["uuId"], "taskList": [from_task_copy]} 313 api.post(url, payload=payload) 314 elif isinstance(to_task, list): 315 for task in to_task: 316 from_task_copy = copy.deepcopy(from_task) 317 from_task_copy[PredecessorTaskLinker._link_data_name] = copy.deepcopy( 318 task[PredecessorTaskLinker._link_data_name] 319 ) 320 payload = {"uuId": task["uuId"], "taskList": [from_task_copy]} 321 api.post(url, payload=payload) 322 return True 323 324 def parents(self): 325 """ 326 Return an ordered list of [name, uuId] pairs of this task's parents, up to 327 (but not including) the root of the project. 328 """ 329 payload = { 330 "name": "Task Parents", 331 "type": "msql", 332 "start": 0, 333 "limit": -1, 334 "holder": "{}".format(self["uuId"]), 335 "select": [ 336 ["TASK(one).PARENT_ALL_TASK.name"], 337 ["TASK(one).PARENT_ALL_TASK.uuId"], 338 ], 339 } 340 list = api.query(payload) 341 # Results come back in reverse order. Flip them around 342 list.reverse() 343 return list 344 345 def project_uuId(self): 346 """Return the `uuId` of the Project that holds this Task.""" 347 payload = { 348 "name": "Project that holds this task", 349 "type": "msql", 350 "start": 0, 351 "limit": 1, 352 "holder": "{}".format(self["uuId"]), 353 "select": [["TASK.PROJECT.uuId"]], 354 } 355 projects = api.query(payload) 356 for t in projects: 357 return t[0] 358 return None 359 360 @classmethod 361 def add_task_template(cls, project, template): 362 """Insert TaskTemplate `template` into Project `project`""" 363 url = "/api/task/task_template/add?override=false&group=false" 364 payload = {"uuId": project["uuId"], "templateList": [template]} 365 api.post(url, payload) 366 367 def reset_duration(self, calendars=None): 368 """Set this task's duration based on its start and end dates while 369 taking into account the calendar for weekends and scheduled time off. 370 371 calendars is expected to be the list of calendar objects for the 372 location of the project that holds this task. You may provide this 373 list yourself for efficiency (recommended) - if not provided, it 374 will be fetched for you by issuing requests to the server. 375 """ 376 if not calendars: 377 if "projectRef" not in self: 378 task = projectal.Task.get(self) 379 project_ref = task["projectRef"] 380 else: 381 project_ref = self["projectRef"] 382 project = projectal.Project.get(project_ref, links=["LOCATION"]) 383 for location in project.get("locationList", []): 384 calendars = location.calendar() 385 break 386 387 start = self.get("startTime") 388 end = self.get("closeTime") 389 if not start or start == DateLimit.Min: 390 return 0 391 if not end or end == DateLimit.Max: 392 return 0 393 394 # Build a list of weekday names that are non-working 395 base_non_working = set() 396 location_non_working = {} 397 location_working = set() 398 for calendar in calendars: 399 if calendar["name"] == "base_calendar": 400 for item in calendar["calendarList"]: 401 if not item["isWorking"]: 402 base_non_working.add(item["type"]) 403 404 if calendar["name"] == "location": 405 for item in calendar["calendarList"]: 406 start_date = datetime.date.fromisoformat(item["startDate"]) 407 end_date = datetime.date.fromisoformat(item["endDate"]) 408 if not item["isWorking"]: 409 delta = start_date - end_date 410 location_non_working[item["startDate"]] = delta.days + 1 411 else: 412 location_working = { 413 (start_date + datetime.timedelta(days=x)).strftime( 414 "%Y-%m-%d" 415 ) 416 for x in range((end_date - start_date).days + 1) 417 } 418 419 start = datetime.datetime.fromtimestamp(start / 1000) 420 end = datetime.datetime.fromtimestamp(end / 1000) 421 minutes = 0 422 current = start 423 while current <= end: 424 if ( 425 current.strftime("%A") in base_non_working 426 and current.strftime("%Y-%m-%d") not in location_working 427 ): 428 current += datetime.timedelta(days=1) 429 continue 430 if current.strftime("%Y-%m-%d") in location_non_working: 431 days = location_non_working[current.strftime("%Y-%m-%d")] 432 current += datetime.timedelta(days=days) 433 continue 434 minutes += 8 * 60 435 current += datetime.timedelta(days=1) 436 437 self["duration"] = minutes
11class Task( 12 Entity, 13 ResourceLinker, 14 SkillLinker, 15 FileLinker, 16 StageLinker, 17 StaffLinker, 18 RebateLinker, 19 NoteLinker, 20 TagLinker, 21 PredecessorTaskLinker, 22): 23 """ 24 Implementation of the [Task](https://projectal.com/docs/latest/#tag/Task) API. 25 """ 26 27 _path = "task" 28 _name = "task" 29 _links = [ 30 ResourceLinker, 31 SkillLinker, 32 FileLinker, 33 StageLinker, 34 StaffLinker, 35 RebateLinker, 36 NoteLinker, 37 TagLinker, 38 ] 39 _links_reverse = [ 40 PredecessorTaskLinker, 41 ] 42 43 def _add_link_def(self, cls, reverse=False): 44 """ 45 Each entity is accompanied by a dict with details about how to 46 get access to the data of the link within the object. Subclasses 47 can pass in customizations to this dict when their APIs differ. 48 49 reverse denotes a reverse linker, where extra work is done to 50 reverse the relationship of the link internally so that it works. 51 The backend only offers one side of the relationship. 52 """ 53 d = { 54 "name": cls._link_name, 55 "link_key": cls._link_key or cls._link_name + "List", 56 "data_name": cls._link_data_name, 57 "type": cls._link_type, 58 "entity": cls._link_entity or cls._link_name.capitalize(), 59 "reverse": reverse, 60 } 61 self._link_def_by_key[d["link_key"]] = d 62 self._link_def_by_name[d["name"]] = d 63 if cls._link_name == "predecessor_task": 64 d_after_reverse = copy.deepcopy(d) 65 d_after_reverse["reverse"] = False 66 self._link_def_by_name["task"] = d_after_reverse 67 # We need this to be present in the link def so that 68 # returned predecessor tasks can be typed as Tasks 69 d_for_pred_link_typing = copy.deepcopy(d) 70 d_for_pred_link_typing["link_key"] = "planList" 71 self._link_def_by_key[ 72 d_for_pred_link_typing["link_key"] 73 ] = d_for_pred_link_typing 74 75 @classmethod 76 def create(cls, holder, entities): 77 """Create a Task 78 79 `holder`: An instance or the `uuId` of the owner 80 81 `entities`: `dict` containing the fields of the entity to be created, 82 or a list of such `dict`s to create in bulk. 83 """ 84 holder_id = holder["uuId"] if isinstance(holder, dict) else holder 85 params = "?holder=" + holder_id 86 out = super().create(entities, params) 87 88 # Tasks should always refer to their parent and project. We don't get this information 89 # from the creation api method, but we can insert them ourselves because we know what 90 # they are. 91 def add_fields(obj): 92 obj.set_readonly("projectRef", holder_id) 93 obj.set_readonly("parent", obj.get("parent", holder_id)) 94 95 if isinstance(out, dict): 96 add_fields(out) 97 if isinstance(out, list): 98 for obj in out: 99 add_fields(obj) 100 return out 101 102 @classmethod 103 def get(cls, entities, links=None, deleted_at=None): 104 r = super().get(entities, links, deleted_at) 105 if not links: 106 return r 107 # When Predecessor Task links are fetched, 108 # make sure the key matches the name expected 109 # by the predecessor linking REST end point 110 if PredecessorTaskLinker._link_name.casefold() in ( 111 link.casefold() for link in links 112 ): 113 if isinstance(r, dict): 114 r["taskList"] = r.pop("planList", []) 115 r._Entity__old = copy.deepcopy(r) 116 else: 117 for entity in r: 118 entity["taskList"] = entity.pop("planList", []) 119 entity._Entity__old = copy.deepcopy(entity) 120 return r 121 122 # Override here to correctly format the URL for the Predecessor Task link case 123 def _link( 124 self, to_entity_name, to_link, operation, update_cache=True, batch_linking=True 125 ): 126 """ 127 `to_entity_name`: Destination entity name (e.g. 'staff') 128 129 `to_link`: List of Entities of the same type (and optional data) to link to 130 131 `operation`: `add`, `update`, `delete` 132 133 'update_cache': also modify the entity's internal representation of the links 134 to match the operation that was done. Set this to False when replacing the 135 list with a new one (i.e., when calling save() instead of a linker method). 136 137 'batch_linking': Enabled by default, batches any link 138 updates required into composite API requests. If disabled 139 a request will be executed for each link update. 140 Recommended to leave enabled to increase performance. 141 """ 142 143 link_def = self._link_def_by_name[to_entity_name] 144 to_key = link_def["link_key"] 145 146 if isinstance(to_link, dict) and link_def["type"] == list: 147 # Convert input dict to list when link type is a list (we allow linking to single entity for convenience) 148 to_link = [to_link] 149 150 # For cases where user passed in dict instead of Entity, we turn them into 151 # Entity on their behalf. 152 typed_list = [] 153 target_cls = getattr(sys.modules["projectal.entities"], link_def["entity"]) 154 for link in to_link: 155 if not isinstance(link, target_cls): 156 typed_list.append(target_cls(link)) 157 else: 158 typed_list.append(link) 159 to_link = typed_list 160 else: 161 # For everything else, we expect types to match. 162 if not isinstance(to_link, link_def["type"]): 163 raise api.UsageException( 164 "Expected link type to be {}. Got {}.".format( 165 link_def["type"], type(to_link) 166 ) 167 ) 168 169 if not to_link: 170 return 171 172 url = "" 173 payload = {} 174 request_list = [] 175 # Is it a reverse linker? If so, invert the relationship 176 if link_def["reverse"]: 177 for link in to_link: 178 # Sets the data attribute on the correct 179 # link entity 180 if link_def["name"] == "predecessor_task": 181 data_name = link_def.get("data_name") 182 self[data_name] = copy.deepcopy(link[data_name]) 183 request_list.extend( 184 link._link( 185 self._name, 186 self, 187 operation, 188 update_cache, 189 batch_linking=batch_linking, 190 ) 191 ) 192 else: 193 # Only keep UUID and the data attribute, if it has one 194 def strip_payload(link): 195 single = {"uuId": link["uuId"]} 196 data_name = link_def.get("data_name") 197 if data_name and data_name in link: 198 single[data_name] = copy.deepcopy(link[data_name]) 199 # limiting data attribute removal to only planLink 200 # in case of side effects 201 if data_name == "planLink": 202 del link[data_name] 203 return single 204 205 # If batch linking is enabled and the entity to link is a list of entities, 206 # a separate request must be constructed for each one because the final composite 207 # request permits only one input per call 208 if to_entity_name == "predecessor_task" or to_entity_name == "task": 209 url = "/api/{}/plan/TASK/{}".format(self._path, operation) 210 else: 211 url = "/api/{}/link/{}/{}".format(self._path, to_entity_name, operation) 212 to_link_payload = None 213 if isinstance(to_link, list): 214 to_link_payload = [] 215 for link in to_link: 216 if batch_linking: 217 request_list.append( 218 { 219 "method": "POST", 220 "invoke": url, 221 "body": { 222 "uuId": self["uuId"], 223 to_key: [strip_payload(link)], 224 }, 225 } 226 ) 227 else: 228 to_link_payload.append(strip_payload(link)) 229 if isinstance(to_link, dict): 230 if batch_linking: 231 request_list.append( 232 { 233 "method": "POST", 234 "invoke": url, 235 "body": { 236 "uuId": self["uuId"], 237 to_key: strip_payload(to_link), 238 }, 239 } 240 ) 241 else: 242 to_link_payload = strip_payload(to_link) 243 244 if not batch_linking: 245 payload = {"uuId": self["uuId"], to_key: to_link_payload} 246 api.post(url, payload=payload) 247 248 if not update_cache: 249 return request_list 250 251 # Set the initial state if first add. We need the type to be set to correctly update the cache 252 if operation == "add" and self.get(to_key, None) is None: 253 if link_def.get("type") == dict: 254 self[to_key] = {} 255 elif link_def.get("type") == list: 256 self[to_key] = [] 257 258 # Modify the entity object's cache of links to match the changes we pushed to the server. 259 if isinstance(self.get(to_key, []), list): 260 if operation == "add": 261 # Sometimes the backend doesn't return a list when it has none. Create it. 262 if to_key not in self: 263 self[to_key] = [] 264 265 for to_entity in to_link: 266 self[to_key].append(to_entity) 267 else: 268 for to_entity in to_link: 269 # Find it in original list 270 for i, old in enumerate(self.get(to_key, [])): 271 if old["uuId"] == to_entity["uuId"]: 272 if operation == "update": 273 self[to_key][i] = to_entity 274 elif operation == "delete": 275 del self[to_key][i] 276 if isinstance(self.get(to_key, None), dict): 277 if operation in ["add", "update"]: 278 self[to_key] = to_link 279 elif operation == "delete": 280 self[to_key] = None 281 282 # Update the "old" record of the link on the entity to avoid 283 # flagging it for changes (link lists are not meant to be user editable). 284 if to_key in self: 285 self._Entity__old[to_key] = self[to_key] 286 287 return request_list 288 289 def update_order(self, order_at_uuId, order_as=True): 290 url = "/api/task/update?order-at={}&order-as={}".format( 291 order_at_uuId, "true" if order_as else "false" 292 ) 293 return api.put(url, [{"uuId": self["uuId"]}]) 294 295 def link_predecessor_task(self, predecessor_task): 296 return self.__plan(self, predecessor_task, "add") 297 298 def relink_predecessor_task(self, predecessor_task): 299 return self.__plan(self, predecessor_task, "update") 300 301 def unlink_predecessor_task(self, predecessor_task): 302 return self.__plan(self, predecessor_task, "delete") 303 304 @classmethod 305 def __plan(cls, from_task, to_task, operation): 306 url = "/api/task/plan/task/{}".format(operation) 307 # Invert the link relationship to match the linker 308 if isinstance(to_task, dict): 309 from_task_copy = copy.deepcopy(from_task) 310 from_task_copy[PredecessorTaskLinker._link_data_name] = copy.deepcopy( 311 to_task[PredecessorTaskLinker._link_data_name] 312 ) 313 payload = {"uuId": to_task["uuId"], "taskList": [from_task_copy]} 314 api.post(url, payload=payload) 315 elif isinstance(to_task, list): 316 for task in to_task: 317 from_task_copy = copy.deepcopy(from_task) 318 from_task_copy[PredecessorTaskLinker._link_data_name] = copy.deepcopy( 319 task[PredecessorTaskLinker._link_data_name] 320 ) 321 payload = {"uuId": task["uuId"], "taskList": [from_task_copy]} 322 api.post(url, payload=payload) 323 return True 324 325 def parents(self): 326 """ 327 Return an ordered list of [name, uuId] pairs of this task's parents, up to 328 (but not including) the root of the project. 329 """ 330 payload = { 331 "name": "Task Parents", 332 "type": "msql", 333 "start": 0, 334 "limit": -1, 335 "holder": "{}".format(self["uuId"]), 336 "select": [ 337 ["TASK(one).PARENT_ALL_TASK.name"], 338 ["TASK(one).PARENT_ALL_TASK.uuId"], 339 ], 340 } 341 list = api.query(payload) 342 # Results come back in reverse order. Flip them around 343 list.reverse() 344 return list 345 346 def project_uuId(self): 347 """Return the `uuId` of the Project that holds this Task.""" 348 payload = { 349 "name": "Project that holds this task", 350 "type": "msql", 351 "start": 0, 352 "limit": 1, 353 "holder": "{}".format(self["uuId"]), 354 "select": [["TASK.PROJECT.uuId"]], 355 } 356 projects = api.query(payload) 357 for t in projects: 358 return t[0] 359 return None 360 361 @classmethod 362 def add_task_template(cls, project, template): 363 """Insert TaskTemplate `template` into Project `project`""" 364 url = "/api/task/task_template/add?override=false&group=false" 365 payload = {"uuId": project["uuId"], "templateList": [template]} 366 api.post(url, payload) 367 368 def reset_duration(self, calendars=None): 369 """Set this task's duration based on its start and end dates while 370 taking into account the calendar for weekends and scheduled time off. 371 372 calendars is expected to be the list of calendar objects for the 373 location of the project that holds this task. You may provide this 374 list yourself for efficiency (recommended) - if not provided, it 375 will be fetched for you by issuing requests to the server. 376 """ 377 if not calendars: 378 if "projectRef" not in self: 379 task = projectal.Task.get(self) 380 project_ref = task["projectRef"] 381 else: 382 project_ref = self["projectRef"] 383 project = projectal.Project.get(project_ref, links=["LOCATION"]) 384 for location in project.get("locationList", []): 385 calendars = location.calendar() 386 break 387 388 start = self.get("startTime") 389 end = self.get("closeTime") 390 if not start or start == DateLimit.Min: 391 return 0 392 if not end or end == DateLimit.Max: 393 return 0 394 395 # Build a list of weekday names that are non-working 396 base_non_working = set() 397 location_non_working = {} 398 location_working = set() 399 for calendar in calendars: 400 if calendar["name"] == "base_calendar": 401 for item in calendar["calendarList"]: 402 if not item["isWorking"]: 403 base_non_working.add(item["type"]) 404 405 if calendar["name"] == "location": 406 for item in calendar["calendarList"]: 407 start_date = datetime.date.fromisoformat(item["startDate"]) 408 end_date = datetime.date.fromisoformat(item["endDate"]) 409 if not item["isWorking"]: 410 delta = start_date - end_date 411 location_non_working[item["startDate"]] = delta.days + 1 412 else: 413 location_working = { 414 (start_date + datetime.timedelta(days=x)).strftime( 415 "%Y-%m-%d" 416 ) 417 for x in range((end_date - start_date).days + 1) 418 } 419 420 start = datetime.datetime.fromtimestamp(start / 1000) 421 end = datetime.datetime.fromtimestamp(end / 1000) 422 minutes = 0 423 current = start 424 while current <= end: 425 if ( 426 current.strftime("%A") in base_non_working 427 and current.strftime("%Y-%m-%d") not in location_working 428 ): 429 current += datetime.timedelta(days=1) 430 continue 431 if current.strftime("%Y-%m-%d") in location_non_working: 432 days = location_non_working[current.strftime("%Y-%m-%d")] 433 current += datetime.timedelta(days=days) 434 continue 435 minutes += 8 * 60 436 current += datetime.timedelta(days=1) 437 438 self["duration"] = minutes
Implementation of the Task API.
75 @classmethod 76 def create(cls, holder, entities): 77 """Create a Task 78 79 `holder`: An instance or the `uuId` of the owner 80 81 `entities`: `dict` containing the fields of the entity to be created, 82 or a list of such `dict`s to create in bulk. 83 """ 84 holder_id = holder["uuId"] if isinstance(holder, dict) else holder 85 params = "?holder=" + holder_id 86 out = super().create(entities, params) 87 88 # Tasks should always refer to their parent and project. We don't get this information 89 # from the creation api method, but we can insert them ourselves because we know what 90 # they are. 91 def add_fields(obj): 92 obj.set_readonly("projectRef", holder_id) 93 obj.set_readonly("parent", obj.get("parent", holder_id)) 94 95 if isinstance(out, dict): 96 add_fields(out) 97 if isinstance(out, list): 98 for obj in out: 99 add_fields(obj) 100 return out
Create a Task
holder
: An instance or the uuId
of the owner
entities
: dict
containing the fields of the entity to be created,
or a list of such dict
s to create in bulk.
102 @classmethod 103 def get(cls, entities, links=None, deleted_at=None): 104 r = super().get(entities, links, deleted_at) 105 if not links: 106 return r 107 # When Predecessor Task links are fetched, 108 # make sure the key matches the name expected 109 # by the predecessor linking REST end point 110 if PredecessorTaskLinker._link_name.casefold() in ( 111 link.casefold() for link in links 112 ): 113 if isinstance(r, dict): 114 r["taskList"] = r.pop("planList", []) 115 r._Entity__old = copy.deepcopy(r) 116 else: 117 for entity in r: 118 entity["taskList"] = entity.pop("planList", []) 119 entity._Entity__old = copy.deepcopy(entity) 120 return r
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.
325 def parents(self): 326 """ 327 Return an ordered list of [name, uuId] pairs of this task's parents, up to 328 (but not including) the root of the project. 329 """ 330 payload = { 331 "name": "Task Parents", 332 "type": "msql", 333 "start": 0, 334 "limit": -1, 335 "holder": "{}".format(self["uuId"]), 336 "select": [ 337 ["TASK(one).PARENT_ALL_TASK.name"], 338 ["TASK(one).PARENT_ALL_TASK.uuId"], 339 ], 340 } 341 list = api.query(payload) 342 # Results come back in reverse order. Flip them around 343 list.reverse() 344 return list
Return an ordered list of [name, uuId] pairs of this task's parents, up to (but not including) the root of the project.
346 def project_uuId(self): 347 """Return the `uuId` of the Project that holds this Task.""" 348 payload = { 349 "name": "Project that holds this task", 350 "type": "msql", 351 "start": 0, 352 "limit": 1, 353 "holder": "{}".format(self["uuId"]), 354 "select": [["TASK.PROJECT.uuId"]], 355 } 356 projects = api.query(payload) 357 for t in projects: 358 return t[0] 359 return None
Return the uuId
of the Project that holds this Task.
361 @classmethod 362 def add_task_template(cls, project, template): 363 """Insert TaskTemplate `template` into Project `project`""" 364 url = "/api/task/task_template/add?override=false&group=false" 365 payload = {"uuId": project["uuId"], "templateList": [template]} 366 api.post(url, payload)
Insert TaskTemplate template
into Project project
368 def reset_duration(self, calendars=None): 369 """Set this task's duration based on its start and end dates while 370 taking into account the calendar for weekends and scheduled time off. 371 372 calendars is expected to be the list of calendar objects for the 373 location of the project that holds this task. You may provide this 374 list yourself for efficiency (recommended) - if not provided, it 375 will be fetched for you by issuing requests to the server. 376 """ 377 if not calendars: 378 if "projectRef" not in self: 379 task = projectal.Task.get(self) 380 project_ref = task["projectRef"] 381 else: 382 project_ref = self["projectRef"] 383 project = projectal.Project.get(project_ref, links=["LOCATION"]) 384 for location in project.get("locationList", []): 385 calendars = location.calendar() 386 break 387 388 start = self.get("startTime") 389 end = self.get("closeTime") 390 if not start or start == DateLimit.Min: 391 return 0 392 if not end or end == DateLimit.Max: 393 return 0 394 395 # Build a list of weekday names that are non-working 396 base_non_working = set() 397 location_non_working = {} 398 location_working = set() 399 for calendar in calendars: 400 if calendar["name"] == "base_calendar": 401 for item in calendar["calendarList"]: 402 if not item["isWorking"]: 403 base_non_working.add(item["type"]) 404 405 if calendar["name"] == "location": 406 for item in calendar["calendarList"]: 407 start_date = datetime.date.fromisoformat(item["startDate"]) 408 end_date = datetime.date.fromisoformat(item["endDate"]) 409 if not item["isWorking"]: 410 delta = start_date - end_date 411 location_non_working[item["startDate"]] = delta.days + 1 412 else: 413 location_working = { 414 (start_date + datetime.timedelta(days=x)).strftime( 415 "%Y-%m-%d" 416 ) 417 for x in range((end_date - start_date).days + 1) 418 } 419 420 start = datetime.datetime.fromtimestamp(start / 1000) 421 end = datetime.datetime.fromtimestamp(end / 1000) 422 minutes = 0 423 current = start 424 while current <= end: 425 if ( 426 current.strftime("%A") in base_non_working 427 and current.strftime("%Y-%m-%d") not in location_working 428 ): 429 current += datetime.timedelta(days=1) 430 continue 431 if current.strftime("%Y-%m-%d") in location_non_working: 432 days = location_non_working[current.strftime("%Y-%m-%d")] 433 current += datetime.timedelta(days=days) 434 continue 435 minutes += 8 * 60 436 current += datetime.timedelta(days=1) 437 438 self["duration"] = minutes
Set this task's duration based on its start and end dates while taking into account the calendar for weekends and scheduled time off.
calendars is expected to be the list of calendar objects for the location of the project that holds this task. You may provide this list yourself for efficiency (recommended) - if not provided, it will be fetched for you by issuing requests to the server.
Inherited Members
- projectal.entity.Entity
- update
- delete
- history
- save
- clone
- list
- match
- match_startswith
- match_endswith
- match_one
- match_startswith_one
- match_endswith_one
- search
- query
- profile_get
- profile_set
- changes
- set_readonly
- get_link_definitions
- entity_name
- builtins.dict
- setdefault
- pop
- popitem
- keys
- items
- values
- fromkeys
- clear
- copy