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.

@classmethod
def create(cls, holder, entities):
 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 dicts to create in bulk.

@classmethod
def get(cls, entities, links=None, deleted_at=None):
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 uuIds of the entities you want to get (see bottom for examples):

  • str or list of str
  • dict or list of dict (with uuId 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.

def update_order(self, order_at_uuId, order_as=True):
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"]}])
def parents(self):
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.

def project_uuId(self):
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.

@classmethod
def add_task_template(cls, project, template):
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

def reset_duration(self, calendars=None):
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.