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

Implementation of the Task API.

@classmethod
def create( cls, holder, entities, batch_linking=True, disable_system_features=True, enable_system_features_on_exit=True):
 77    @classmethod
 78    def create(
 79        cls,
 80        holder,
 81        entities,
 82        batch_linking=True,
 83        disable_system_features=True,
 84        enable_system_features_on_exit=True,
 85    ):
 86        """Create a Task
 87
 88        `holder`: An instance or the `uuId` of the owner
 89
 90        `entities`: `dict` containing the fields of the entity to be created,
 91        or a list of such `dict`s to create in bulk.
 92        """
 93        holder_id = holder["uuId"] if isinstance(holder, dict) else holder
 94        params = "?holder=" + holder_id
 95        out = super().create(
 96            entities,
 97            params,
 98            batch_linking,
 99            disable_system_features,
100            enable_system_features_on_exit,
101        )
102
103        # Tasks should always refer to their parent and project. We don't get this information
104        # from the creation api method, but we can insert them ourselves because we know what
105        # they are.
106        def add_fields(obj):
107            obj.set_readonly("projectRef", holder_id)
108            obj.set_readonly("parent", obj.get("parent", holder_id))
109
110        if isinstance(out, dict):
111            add_fields(out)
112        if isinstance(out, list):
113            for obj in out:
114                add_fields(obj)
115        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):
117    @classmethod
118    def get(cls, entities, links=None, deleted_at=None):
119        r = super().get(entities, links, deleted_at)
120        if not links:
121            return r
122        # When Predecessor Task links are fetched,
123        # make sure the key matches the name expected
124        # by the predecessor linking REST end point
125        if PredecessorTaskLinker._link_name.casefold() in (
126            link.casefold() for link in links
127        ):
128            if isinstance(r, dict):
129                r["taskList"] = r.pop("planList", [])
130                r._Entity__old = copy.deepcopy(r)
131            else:
132                for entity in r:
133                    entity["taskList"] = entity.pop("planList", [])
134                    entity._Entity__old = copy.deepcopy(entity)
135        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):
304    def update_order(self, order_at_uuId, order_as=True):
305        url = "/api/task/update?order-at={}&order-as={}".format(
306            order_at_uuId, "true" if order_as else "false"
307        )
308        return api.put(url, [{"uuId": self["uuId"]}])
def parents(self):
340    def parents(self):
341        """
342        Return an ordered list of [name, uuId] pairs of this task's parents, up to
343        (but not including) the root of the project.
344        """
345        payload = {
346            "name": "Task Parents",
347            "type": "msql",
348            "start": 0,
349            "limit": -1,
350            "holder": "{}".format(self["uuId"]),
351            "select": [
352                ["TASK(one).PARENT_ALL_TASK.name"],
353                ["TASK(one).PARENT_ALL_TASK.uuId"],
354            ],
355        }
356        list = api.query(payload)
357        # Results come back in reverse order. Flip them around
358        list.reverse()
359        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):
361    def project_uuId(self):
362        """Return the `uuId` of the Project that holds this Task."""
363        payload = {
364            "name": "Project that holds this task",
365            "type": "msql",
366            "start": 0,
367            "limit": 1,
368            "holder": "{}".format(self["uuId"]),
369            "select": [["TASK.PROJECT.uuId"]],
370        }
371        projects = api.query(payload)
372        for t in projects:
373            return t[0]
374        return None

Return the uuId of the Project that holds this Task.

@classmethod
def add_task_template(cls, project, template):
376    @classmethod
377    def add_task_template(cls, project, template):
378        """Insert TaskTemplate `template` into Project `project`"""
379        url = "/api/task/task_template/add?override=false&group=false"
380        payload = {"uuId": project["uuId"], "templateList": [template]}
381        api.post(url, payload)

Insert TaskTemplate template into Project project

def reset_duration(self, calendars=None):
383    def reset_duration(self, calendars=None):
384        """Set this task's duration based on its start and end dates while
385        taking into account the calendar for weekends and scheduled time off.
386
387        calendars is expected to be the list of calendar objects for the
388        location of the project that holds this task. You may provide this
389        list yourself for efficiency (recommended) - if not provided, it
390        will be fetched for you by issuing requests to the server.
391        """
392        if not calendars:
393            if "projectRef" not in self:
394                task = projectal.Task.get(self)
395                project_ref = task["projectRef"]
396            else:
397                project_ref = self["projectRef"]
398            project = projectal.Project.get(project_ref, links=["LOCATION"])
399            for location in project.get("locationList", []):
400                calendars = location.calendar()
401                break
402
403        start = self.get("startTime")
404        end = self.get("closeTime")
405        if not start or start == DateLimit.Min:
406            return 0
407        if not end or end == DateLimit.Max:
408            return 0
409
410        # Build a list of weekday names that are non-working
411        base_non_working = set()
412        location_non_working = {}
413        location_working = set()
414        for calendar in calendars:
415            if calendar["name"] == "base_calendar":
416                for item in calendar["calendarList"]:
417                    if not item["isWorking"]:
418                        base_non_working.add(item["type"])
419
420            if calendar["name"] == "location":
421                for item in calendar["calendarList"]:
422                    start_date = datetime.date.fromisoformat(item["startDate"])
423                    end_date = datetime.date.fromisoformat(item["endDate"])
424                    if not item["isWorking"]:
425                        delta = start_date - end_date
426                        location_non_working[item["startDate"]] = delta.days + 1
427                    else:
428                        location_working = {
429                            (start_date + datetime.timedelta(days=x)).strftime(
430                                "%Y-%m-%d"
431                            )
432                            for x in range((end_date - start_date).days + 1)
433                        }
434
435        start = datetime.datetime.fromtimestamp(start / 1000)
436        end = datetime.datetime.fromtimestamp(end / 1000)
437        minutes = 0
438        current = start
439        while current <= end:
440            if (
441                current.strftime("%A") in base_non_working
442                and current.strftime("%Y-%m-%d") not in location_working
443            ):
444                current += datetime.timedelta(days=1)
445                continue
446            if current.strftime("%Y-%m-%d") in location_non_working:
447                days = location_non_working[current.strftime("%Y-%m-%d")]
448                current += datetime.timedelta(days=days)
449                continue
450            minutes += 8 * 60
451            current += datetime.timedelta(days=1)
452
453        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.