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" } ] }