projectal.entities.task API documentation

# projectal.entities.task

View Source

      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

class Task(projectal.entity.Entity, projectal.linkers.ResourceLinker, projectal.linkers.SkillLinker, projectal.linkers.FileLinker, projectal.linkers.StageLinker, projectal.linkers.StaffLinker, projectal.linkers.RebateLinker, projectal.linkers.NoteLinker, projectal.linkers.TagLinker, projectal.linkers.PredecessorTaskLinker): View Source

     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): View Source

     75    @classmethod
     76    def create(cls, holder, entities):
     77        """Create a Task
     78
     79        `holder`: An instance or the `uuId` of the owner
     80
     81        `entities`: `dict` containing the fields of the entity to be created,
     82        or a list of such `dict`s to create in bulk.
     83        """
     84        holder_id = holder["uuId"] if isinstance(holder, dict) else holder
     85        params = "?holder=" + holder_id
     86        out = super().create(entities, params)
     87
     88        # Tasks should always refer to their parent and project. We don't get this information
     89        # from the creation api method, but we can insert them ourselves because we know what
     90        # they are.
     91        def add_fields(obj):
     92            obj.set_readonly("projectRef", holder_id)
     93            obj.set_readonly("parent", obj.get("parent", holder_id))
     94
     95        if isinstance(out, dict):
     96            add_fields(out)
     97        if isinstance(out, list):
     98            for obj in out:
     99                add_fields(obj)
    100        return out

Create a Task

`holder`: An instance or the `uuId` of the owner

`entities`: `dict` containing the fields of the entity to be created, or a list of such `dict`s to create in bulk.

@classmethod
def get(cls, entities, links=None, deleted_at=None): View Source

    102    @classmethod
    103    def get(cls, entities, links=None, deleted_at=None):
    104        r = super().get(entities, links, deleted_at)
    105        if not links:
    106            return r
    107        # When Predecessor Task links are fetched,
    108        # make sure the key matches the name expected
    109        # by the predecessor linking REST end point
    110        if PredecessorTaskLinker._link_name.casefold() in (
    111            link.casefold() for link in links
    112        ):
    113            if isinstance(r, dict):
    114                r["taskList"] = r.pop("planList", [])
    115                r._Entity__old = copy.deepcopy(r)
    116            else:
    117                for entity in r:
    118                    entity["taskList"] = entity.pop("planList", [])
    119                    entity._Entity__old = copy.deepcopy(entity)
    120        return r

Get one or more entities of the same type. The entity type is determined by the subclass calling this method.

`entities`: One of several formats containing the `uuId`s of the entities you want to get (see bottom for examples):

- `str` or list 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): View Source

    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 link_predecessor_task(self, predecessor_task): View Source

    295    def link_predecessor_task(self, predecessor_task):
    296        return self.__plan(self, predecessor_task, "add")

def relink_predecessor_task(self, predecessor_task): View Source

    298    def relink_predecessor_task(self, predecessor_task):
    299        return self.__plan(self, predecessor_task, "update")

def unlink_predecessor_task(self, predecessor_task): View Source

    301    def unlink_predecessor_task(self, predecessor_task):
    302        return self.__plan(self, predecessor_task, "delete")

def parents(self): View Source

    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): View Source

    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): View Source

    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): View Source

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

Set this task's duration based on its start and end dates while taking into account the calendar for weekends and scheduled time off.

calendars is expected to be the list of calendar objects for the location of the project that holds this task. You may provide this list yourself for efficiency (recommended) - if not provided, it will be fetched for you by issuing requests to the server.

##### Inherited Members

projectal.entity.Entity
  update
  delete
  history
  save
  clone
  list
  match
  match_startswith
  match_endswith
  match_one
  match_startswith_one
  match_endswith_one
  search
  query
  profile_get
  profile_set
  changes
  set_readonly
  get_link_definitions
  entity_name
projectal.linkers.ResourceLinker
  link_resource
  relink_resource
  unlink_resource
projectal.linkers.SkillLinker
  link_skill
  relink_skill
  unlink_skill
projectal.linkers.FileLinker
  link_file
  unlink_file
projectal.linkers.StageLinker
  link_stage
  unlink_stage
projectal.linkers.StaffLinker
  link_staff
  relink_staff
  unlink_staff
projectal.linkers.RebateLinker
  link_rebate
  unlink_rebate
projectal.linkers.TagLinker
  link_tag
  unlink_tag
builtins.dict
  setdefault
  pop
  popitem
  keys
  items
  values
  fromkeys
  clear
  copy