diff --git a/pype/modules/ftrack/events/event_next_task_update.py b/pype/modules/ftrack/events/event_next_task_update.py
index 025bac0d07..284cff886b 100644
--- a/pype/modules/ftrack/events/event_next_task_update.py
+++ b/pype/modules/ftrack/events/event_next_task_update.py
@@ -1,19 +1,79 @@
-import operator
import collections
from pype.modules.ftrack import BaseEvent
class NextTaskUpdate(BaseEvent):
- def filter_entities_info(self, session, event):
+ """Change status on following Task.
+
+ Handler cares about changes of status id on Task entities. When new status
+ has state "Done" it will try to find following task and change it's status.
+ It is expected following task should be marked as "Ready to work on".
+
+ By default all tasks with same task type must have state "Done" to do any
+ changes. And when all tasks with same task type are "done" it will change
+ statuses on all tasks with next task type.
+
+ # Enable
+ Handler is based on settings, handler can be turned on/off with "enabled"
+ key.
+ ```
+ "enabled": True
+ ```
+
+ # Status mappings
+ Must have set mappings of new statuses:
+ ```
+ "mapping": {
+ # From -> To
+ "Not Ready": "Ready",
+ ...
+ }
+ ```
+
+ If current status name is not found then status change is skipped.
+
+ # Ignored statuses
+ These status names are skipping as they would be in "Done" state. Best
+ example is status "Omitted" which in most of cases is "Blocked" state but
+ it will never change.
+ ```
+ "ignored_statuses": [
+ "Omitted",
+ ...
+ ]
+ ```
+
+ # Change statuses sorted by task type and by name
+ Change behaviour of task type batching. Statuses are not checked and set
+ by batches of tasks by Task type but one by one. Tasks are sorted by
+ Task type and then by name if all previous tasks are "Done" the following
+ will change status.
+ ```
+ "name_sorting": True
+ ```
+ """
+ settings_key = "next_task_update"
+
+ def launch(self, session, event):
+ '''Propagates status from version to task when changed'''
+
+ filtered_entities_info = self.filter_entities_info(event)
+ if not filtered_entities_info:
+ return
+
+ for project_id, entities_info in filtered_entities_info.items():
+ self.process_by_project(session, event, project_id, entities_info)
+
+ def filter_entities_info(self, event):
# Filter if event contain relevant data
entities_info = event["data"].get("entities")
if not entities_info:
return
- first_filtered_entities = []
+ filtered_entities_info = collections.defaultdict(list)
for entity_info in entities_info:
- # Care only about tasks
- if entity_info.get("entityType") != "task":
+ # Care only about Task `entity_type`
+ if entity_info.get("entity_type") != "Task":
continue
# Care only about changes of status
@@ -25,204 +85,353 @@ class NextTaskUpdate(BaseEvent):
):
continue
- first_filtered_entities.append(entity_info)
+ project_id = None
+ for parent_info in reversed(entity_info["parents"]):
+ if parent_info["entityType"] == "show":
+ project_id = parent_info["entityId"]
+ break
- if not first_filtered_entities:
- return first_filtered_entities
+ if project_id:
+ filtered_entities_info[project_id].append(entity_info)
+ return filtered_entities_info
- status_ids = [
- entity_info["changes"]["statusid"]["new"]
- for entity_info in first_filtered_entities
- ]
- statuses_by_id = self.get_statuses_by_id(
- session, status_ids=status_ids
+ def process_by_project(self, session, event, project_id, _entities_info):
+ project_name = self.get_project_name_from_event(
+ session, event, project_id
)
- # Make sure `entity_type` is "Task"
- task_object_type = session.query(
- "select id, name from ObjectType where name is \"Task\""
- ).one()
-
- # Care only about tasks having status with state `Done`
- filtered_entities = []
- for entity_info in first_filtered_entities:
- if entity_info["objectTypeId"] != task_object_type["id"]:
- continue
- status_id = entity_info["changes"]["statusid"]["new"]
- status_entity = statuses_by_id[status_id]
- if status_entity["state"]["name"].lower() == "done":
- filtered_entities.append(entity_info)
-
- return filtered_entities
-
- def get_parents_by_id(self, session, entities_info):
- parent_ids = [
- "\"{}\"".format(entity_info["parentId"])
- for entity_info in entities_info
- ]
- parent_entities = session.query(
- "TypedContext where id in ({})".format(", ".join(parent_ids))
- ).all()
-
- return {
- entity["id"]: entity
- for entity in parent_entities
- }
-
- def get_tasks_by_id(self, session, parent_ids):
- joined_parent_ids = ",".join([
- "\"{}\"".format(parent_id)
- for parent_id in parent_ids
- ])
- task_entities = session.query(
- "Task where parent_id in ({})".format(joined_parent_ids)
- ).all()
-
- return {
- entity["id"]: entity
- for entity in task_entities
- }
-
- def get_statuses_by_id(self, session, task_entities=None, status_ids=None):
- if task_entities is None and status_ids is None:
- return {}
-
- if status_ids is None:
- status_ids = []
- for task_entity in task_entities:
- status_ids.append(task_entity["status_id"])
-
- if not status_ids:
- return {}
-
- status_entities = session.query(
- "Status where id in ({})".format(", ".join(status_ids))
- ).all()
-
- return {
- entity["id"]: entity
- for entity in status_entities
- }
-
- def get_sorted_task_types(self, session):
- data = {
- _type: _type.get("sort")
- for _type in session.query("Type").all()
- if _type.get("sort") is not None
- }
-
- return [
- item[0]
- for item in sorted(data.items(), key=operator.itemgetter(1))
- ]
-
- def launch(self, session, event):
- '''Propagates status from version to task when changed'''
-
- entities_info = self.filter_entities_info(session, event)
- if not entities_info:
- return
-
- parents_by_id = self.get_parents_by_id(session, entities_info)
- tasks_by_id = self.get_tasks_by_id(
- session, tuple(parents_by_id.keys())
+ # Load settings
+ project_settings = self.get_project_settings_from_event(
+ event, project_name
)
- tasks_to_parent_id = collections.defaultdict(list)
- for task_entity in tasks_by_id.values():
- tasks_to_parent_id[task_entity["parent_id"]].append(task_entity)
-
- statuses_by_id = self.get_statuses_by_id(session, tasks_by_id.values())
-
- next_status_name = "Ready"
- next_status = session.query(
- "Status where name is \"{}\"".format(next_status_name)
- ).first()
- if not next_status:
- self.log.warning("Couldn't find status with name \"{}\"".format(
- next_status_name
+ # Load status mapping from presets
+ event_settings = (
+ project_settings["ftrack"]["events"][self.settings_key]
+ )
+ if not event_settings["enabled"]:
+ self.log.debug("Project \"{}\" has disabled {}.".format(
+ project_name, self.__class__.__name__
))
return
+ statuses = session.query("Status").all()
+
+ entities_info = self.filter_by_status_state(_entities_info, statuses)
+ if not entities_info:
+ return
+
+ parent_ids = set()
+ event_task_ids_by_parent_id = collections.defaultdict(list)
for entity_info in entities_info:
parent_id = entity_info["parentId"]
- task_id = entity_info["entityId"]
- task_entity = tasks_by_id[task_id]
+ entity_id = entity_info["entityId"]
+ parent_ids.add(parent_id)
+ event_task_ids_by_parent_id[parent_id].append(entity_id)
- all_same_type_taks_done = True
- for parents_task in tasks_to_parent_id[parent_id]:
- if (
- parents_task["id"] == task_id
- or parents_task["type_id"] != task_entity["type_id"]
- ):
- continue
+ # From now it doesn't matter what was in event data
+ task_entities = session.query(
+ (
+ "select id, type_id, status_id, parent_id, link from Task"
+ " where parent_id in ({})"
+ ).format(self.join_query_keys(parent_ids))
+ ).all()
- parents_task_status = statuses_by_id[parents_task["status_id"]]
- low_status_name = parents_task_status["name"].lower()
- # Skip if task's status name "Omitted"
- if low_status_name == "omitted":
- continue
+ tasks_by_parent_id = collections.defaultdict(list)
+ for task_entity in task_entities:
+ tasks_by_parent_id[task_entity["parent_id"]].append(task_entity)
- low_state_name = parents_task_status["state"]["name"].lower()
- if low_state_name != "done":
- all_same_type_taks_done = False
- break
+ project_entity = session.get("Project", project_id)
+ self.set_next_task_statuses(
+ session,
+ tasks_by_parent_id,
+ event_task_ids_by_parent_id,
+ statuses,
+ project_entity,
+ event_settings
+ )
- if not all_same_type_taks_done:
- continue
+ def filter_by_status_state(self, entities_info, statuses):
+ statuses_by_id = {
+ status["id"]: status
+ for status in statuses
+ }
- # Prepare all task types
- sorted_task_types = self.get_sorted_task_types(session)
- sorted_task_types_len = len(sorted_task_types)
+ # Care only about tasks having status with state `Done`
+ filtered_entities_info = []
+ for entity_info in entities_info:
+ status_id = entity_info["changes"]["statusid"]["new"]
+ status_entity = statuses_by_id[status_id]
+ if status_entity["state"]["name"].lower() == "done":
+ filtered_entities_info.append(entity_info)
+ return filtered_entities_info
- from_idx = None
- for idx, task_type in enumerate(sorted_task_types):
- if task_type["id"] == task_entity["type_id"]:
- from_idx = idx + 1
- break
+ def set_next_task_statuses(
+ self,
+ session,
+ tasks_by_parent_id,
+ event_task_ids_by_parent_id,
+ statuses,
+ project_entity,
+ event_settings
+ ):
+ statuses_by_id = {
+ status["id"]: status
+ for status in statuses
+ }
- # Current task type is last in order
- if from_idx is None or from_idx >= sorted_task_types_len:
- continue
+ # Lower ignored statuses
+ ignored_statuses = set(
+ status_name.lower()
+ for status_name in event_settings["ignored_statuses"]
+ )
+ # Lower both key and value of mapped statuses
+ mapping = {
+ status_from.lower(): status_to.lower()
+ for status_from, status_to in event_settings["mapping"].items()
+ }
+ # Should use name sorting or not
+ name_sorting = event_settings["name_sorting"]
- next_task_type_id = None
- next_task_type_tasks = []
- for idx in range(from_idx, sorted_task_types_len):
- next_task_type = sorted_task_types[idx]
- for parents_task in tasks_to_parent_id[parent_id]:
- if next_task_type_id is None:
- if parents_task["type_id"] != next_task_type["id"]:
- continue
- next_task_type_id = next_task_type["id"]
+ # Collect task type ids from changed entities
+ task_type_ids = set()
+ for task_entities in tasks_by_parent_id.values():
+ for task_entity in task_entities:
+ task_type_ids.add(task_entity["type_id"])
- if parents_task["type_id"] == next_task_type_id:
- next_task_type_tasks.append(parents_task)
+ statusese_by_obj_id = self.statuses_for_tasks(
+ task_type_ids, project_entity
+ )
- if next_task_type_id is not None:
- break
+ sorted_task_type_ids = self.get_sorted_task_type_ids(session)
- for next_task_entity in next_task_type_tasks:
- if next_task_entity["status"]["name"].lower() != "not ready":
- continue
+ for parent_id, _task_entities in tasks_by_parent_id.items():
+ task_entities_by_type_id = collections.defaultdict(list)
+ for _task_entity in _task_entities:
+ type_id = _task_entity["type_id"]
+ task_entities_by_type_id[type_id].append(_task_entity)
- ent_path = "/".join(
- [ent["name"] for ent in next_task_entity["link"]]
+ event_ids = set(event_task_ids_by_parent_id[parent_id])
+ if name_sorting:
+ # Sort entities by name
+ self.sort_by_name_task_entities_by_type(
+ task_entities_by_type_id
)
- try:
- next_task_entity["status"] = next_status
- session.commit()
- self.log.info(
- "\"{}\" updated status to \"{}\"".format(
- ent_path, next_status_name
- )
+ # Sort entities by type id
+ sorted_task_entities = []
+ for type_id in sorted_task_type_ids:
+ task_entities = task_entities_by_type_id.get(type_id)
+ if task_entities:
+ sorted_task_entities.extend(task_entities)
+
+ next_tasks = self.next_tasks_with_name_sorting(
+ sorted_task_entities,
+ event_ids,
+ statuses_by_id,
+ ignored_statuses
+ )
+
+ else:
+ next_tasks = self.next_tasks_with_type_sorting(
+ task_entities_by_type_id,
+ sorted_task_type_ids,
+ event_ids,
+ statuses_by_id,
+ ignored_statuses
+ )
+
+ for task_entity in next_tasks:
+ if task_entity["status"]["state"]["name"].lower() == "done":
+ continue
+
+ task_status = statuses_by_id[task_entity["status_id"]]
+ old_status_name = task_status["name"].lower()
+ if old_status_name in ignored_statuses:
+ continue
+
+ new_task_name = mapping.get(old_status_name)
+ if not new_task_name:
+ self.log.debug(
+ "Didn't found mapping for status \"{}\".".format(
+ task_status["name"]
)
- except Exception:
- session.rollback()
- self.log.warning(
- "\"{}\" status couldnt be set to \"{}\"".format(
- ent_path, next_status_name
- ),
- exc_info=True
+ )
+ continue
+
+ ent_path = "/".join(
+ [ent["name"] for ent in task_entity["link"]]
+ )
+ type_id = task_entity["type_id"]
+ new_status = statusese_by_obj_id[type_id].get(new_task_name)
+ if new_status is None:
+ self.log.warning((
+ "\"{}\" does not have available status name \"{}\""
+ ).format(ent_path, new_task_name))
+ continue
+
+ try:
+ task_entity["status_id"] = new_status["id"]
+ session.commit()
+ self.log.info(
+ "\"{}\" updated status to \"{}\"".format(
+ ent_path, new_status["name"]
)
+ )
+ except Exception:
+ session.rollback()
+ self.log.warning(
+ "\"{}\" status couldnt be set to \"{}\"".format(
+ ent_path, new_status["name"]
+ ),
+ exc_info=True
+ )
+
+ def next_tasks_with_name_sorting(
+ self,
+ sorted_task_entities,
+ event_ids,
+ statuses_by_id,
+ ignored_statuses,
+ ):
+ # Pre sort task entities by name
+ use_next_task = False
+ next_tasks = []
+ for task_entity in sorted_task_entities:
+ if task_entity["id"] in event_ids:
+ event_ids.remove(task_entity["id"])
+ use_next_task = True
+ continue
+
+ if not use_next_task:
+ continue
+
+ task_status = statuses_by_id[task_entity["status_id"]]
+ low_status_name = task_status["name"].lower()
+ if low_status_name in ignored_statuses:
+ continue
+
+ next_tasks.append(task_entity)
+ use_next_task = False
+ if not event_ids:
+ break
+
+ return next_tasks
+
+ def check_statuses_done(
+ self, task_entities, ignored_statuses, statuses_by_id
+ ):
+ all_are_done = True
+ for task_entity in task_entities:
+ task_status = statuses_by_id[task_entity["status_id"]]
+ low_status_name = task_status["name"].lower()
+ if low_status_name in ignored_statuses:
+ continue
+
+ low_state_name = task_status["state"]["name"].lower()
+ if low_state_name != "done":
+ all_are_done = False
+ break
+ return all_are_done
+
+ def next_tasks_with_type_sorting(
+ self,
+ task_entities_by_type_id,
+ sorted_task_type_ids,
+ event_ids,
+ statuses_by_id,
+ ignored_statuses
+ ):
+ # `use_next_task` is used only if `name_sorting` is enabled!
+ next_tasks = []
+ use_next_tasks = False
+ for type_id in sorted_task_type_ids:
+ if type_id not in task_entities_by_type_id:
+ continue
+
+ task_entities = task_entities_by_type_id[type_id]
+
+ # Check if any task was in event
+ event_id_in_tasks = False
+ for task_entity in task_entities:
+ task_id = task_entity["id"]
+ if task_id in event_ids:
+ event_ids.remove(task_id)
+ event_id_in_tasks = True
+
+ if use_next_tasks:
+ # Check if next tasks are not done already
+ all_in_type_done = self.check_statuses_done(
+ task_entities, ignored_statuses, statuses_by_id
+ )
+ if all_in_type_done:
+ continue
+
+ next_tasks.extend(task_entities)
+ use_next_tasks = False
+ if not event_ids:
+ break
+
+ if not event_id_in_tasks:
+ continue
+
+ all_in_type_done = self.check_statuses_done(
+ task_entities, ignored_statuses, statuses_by_id
+ )
+ use_next_tasks = all_in_type_done
+ if all_in_type_done:
+ continue
+
+ if not event_ids:
+ break
+
+ use_next_tasks = False
+
+ return next_tasks
+
+ def statuses_for_tasks(self, task_type_ids, project_entity):
+ project_schema = project_entity["project_schema"]
+ output = {}
+ for task_type_id in task_type_ids:
+ statuses = project_schema.get_statuses("Task", task_type_id)
+ output[task_type_id] = {
+ status["name"].lower(): status
+ for status in statuses
+ }
+
+ return output
+
+ def get_sorted_task_type_ids(self, session):
+ types_by_order = collections.defaultdict(list)
+ for _type in session.query("Type").all():
+ sort_oder = _type.get("sort")
+ if sort_oder is not None:
+ types_by_order[sort_oder].append(_type["id"])
+
+ types = []
+ for sort_oder in sorted(types_by_order.keys()):
+ types.extend(types_by_order[sort_oder])
+ return types
+
+ @staticmethod
+ def sort_by_name_task_entities_by_type(task_entities_by_type_id):
+ _task_entities_by_type_id = {}
+ for type_id, task_entities in task_entities_by_type_id.items():
+ # Store tasks by name
+ task_entities_by_name = {}
+ for task_entity in task_entities:
+ task_name = task_entity["name"]
+ task_entities_by_name[task_name] = task_entity
+
+ # Store task entities by sorted names
+ sorted_task_entities = []
+ for task_name in sorted(task_entities_by_name.keys()):
+ task_entity = task_entities_by_name[task_name]
+ sorted_task_entities.append(task_entity)
+ # Store result to temp dictionary
+ _task_entities_by_type_id[type_id] = sorted_task_entities
+
+ # Override values in source object
+ for type_id, value in _task_entities_by_type_id.items():
+ task_entities_by_type_id[type_id] = value
def register(session):
diff --git a/pype/modules/ftrack/events/event_version_to_task_statuses.py b/pype/modules/ftrack/events/event_version_to_task_statuses.py
index 6a15a697e3..4a42e27336 100644
--- a/pype/modules/ftrack/events/event_version_to_task_statuses.py
+++ b/pype/modules/ftrack/events/event_version_to_task_statuses.py
@@ -47,8 +47,6 @@ class VersionToTaskStatus(BaseEvent):
def process_by_project(self, session, event, project_id, entities_info):
# Check for project data if event is enabled for event handler
- status_mapping = None
-
project_name = self.get_project_name_from_event(
session, event, project_id
)
diff --git a/pype/settings/defaults/project_settings/ftrack.json b/pype/settings/defaults/project_settings/ftrack.json
index a16295f84c..92c51d0a3b 100644
--- a/pype/settings/defaults/project_settings/ftrack.json
+++ b/pype/settings/defaults/project_settings/ftrack.json
@@ -84,8 +84,12 @@
"next_task_update": {
"enabled": true,
"mapping": {
- "Ready": "Not Ready"
- }
+ "Not Ready": "Ready"
+ },
+ "ignored_statuses": [
+ "Omitted"
+ ],
+ "name_sorting": false
}
},
"user_handlers": {
diff --git a/pype/tools/settings/settings/gui_schemas/projects_schema/schema_project_ftrack.json b/pype/tools/settings/settings/gui_schemas/projects_schema/schema_project_ftrack.json
index 1554989c55..c128d4b751 100644
--- a/pype/tools/settings/settings/gui_schemas/projects_schema/schema_project_ftrack.json
+++ b/pype/tools/settings/settings/gui_schemas/projects_schema/schema_project_ftrack.json
@@ -302,12 +302,44 @@
"key": "enabled",
"label": "Enabled"
},
+ {
+ "type": "label",
+ "label": "Change status on next task by task types order when task status state changed to \"Done\". All tasks with same Task type must be \"Done\"."
+ },
+ {
+ "type": "label",
+ "label": "Mapping of next task status changes From -> To."
+ },
{
"type": "dict-modifiable",
"key": "mapping",
"object_type": {
"type": "text"
}
+ },
+ {
+ "type": "separator"
+ },
+ {
+ "type": "label",
+ "label": "Status names that are ignored on \"Done\" check (e.g. \"Omitted\")."
+ },
+ {
+ "type": "list",
+ "key": "ignored_statuses",
+ "object_type": "text"
+ },
+ {
+ "type": "separator"
+ },
+ {
+ "type": "label",
+ "label": "Allow to break rule that all tasks with same Task type must be \"Done\" and change statuses with same type tasks ordered by name."
+ },
+ {
+ "label": "Name sorting",
+ "type": "boolean",
+ "key": "name_sorting"
}
]
}