diff --git a/pype/modules/ftrack/events/event_task_to_parent_status.py b/pype/modules/ftrack/events/event_task_to_parent_status.py index f14c52e3a6..4620f84395 100644 --- a/pype/modules/ftrack/events/event_task_to_parent_status.py +++ b/pype/modules/ftrack/events/event_task_to_parent_status.py @@ -3,293 +3,264 @@ from pype.modules.ftrack import BaseEvent class TaskStatusToParent(BaseEvent): - # Parent types where we care about changing of status - parent_types = ["shot", "asset build"] + settings_key = "status_task_to_parent" - # All parent's tasks must have status name in `task_statuses` key to apply - # status name in `new_status` - parent_status_match_all_task_statuses = [ - { - "new_status": "approved", - "task_statuses": [ - "approved", "omitted" - ] - } - ] + def launch(self, session, event): + """Propagates status from task to parent when changed.""" - # Task's status was changed to something in `task_statuses` to apply - # `new_status` on it's parent - # - this is done only if `parent_status_match_all_task_statuses` filtering - # didn't found matching status - parent_status_by_task_status = [ - { - "new_status": "in progress", - "task_statuses": [ - "in progress" - ] - } - ] + filtered_entities_info = self.filter_entities_info(event) + if not filtered_entities_info: + return - def register(self, *args, **kwargs): - result = super(TaskStatusToParent, self).register(*args, **kwargs) - # Clean up presetable attributes - _new_all_match = [] - if self.parent_status_match_all_task_statuses: - for item in self.parent_status_match_all_task_statuses: - _new_all_match.append({ - "new_status": item["new_status"].lower(), - "task_statuses": [ - status_name.lower() - for status_name in item["task_statuses"] - ] - }) - self.parent_status_match_all_task_statuses = _new_all_match + for project_id, entities_info in filtered_entities_info.items(): + self.process_by_project(session, event, project_id, entities_info) - _new_single_match = [] - if self.parent_status_by_task_status: - for item in self.parent_status_by_task_status: - _new_single_match.append({ - "new_status": item["new_status"].lower(), - "task_statuses": [ - status_name.lower() - for status_name in item["task_statuses"] - ] - }) - self.parent_status_by_task_status = _new_single_match - - self.parent_types = [ - parent_type.lower() - for parent_type in self.parent_types - ] - - return result - - def filter_entities_info(self, session, event): + def filter_entities_info(self, event): # Filter if event contain relevant data entities_info = event["data"].get("entities") if not entities_info: return - filtered_entities = [] + filtered_entity_info = collections.defaultdict(list) + status_ids = set() for entity_info in entities_info: # Care only about tasks if entity_info.get("entityType") != "task": continue # Care only about changes of status - changes = entity_info.get("changes") or {} - statusid_changes = changes.get("statusid") or {} + changes = entity_info.get("changes") + if not changes: + continue + statusid_changes = changes.get("statusid") + if not statusid_changes: + continue + + new_status_id = entity_info["changes"]["statusid"]["new"] if ( - statusid_changes.get("new") is None - or statusid_changes.get("old") is None + statusid_changes.get("old") is None + or new_status_id is None ): continue - filtered_entities.append(entity_info) + project_id = None + for parent_item in reversed(entity_info["parents"]): + if parent_item["entityType"] == "show": + project_id = parent_item["entityId"] + break - if not filtered_entities: - return + if project_id: + filtered_entity_info[project_id].append(entity_info) + status_ids.add(new_status_id) - status_ids = [ - entity_info["changes"]["statusid"]["new"] - for entity_info in filtered_entities - ] - statuses_by_id = self.get_statuses_by_id( - session, status_ids=status_ids + return filtered_entity_info + + def process_by_project(self, session, event, project_id, entities_info): + # Get project entity + project_entity = self.get_project_entity_from_event( + session, event, project_id + ) + # Load settings + project_settings = self.get_settings_for_project( + session, event, project_entity=project_entity ) - # Care only about tasks having status with state `Done` - output = [] - for entity_info in filtered_entities: - status_id = entity_info["changes"]["statusid"]["new"] - entity_info["status_entity"] = statuses_by_id[status_id] - output.append(entity_info) - return output + # Prepare loaded settings and check if can be processed + project_name = project_entity["full_name"] + result = self.prepare_settings(project_settings, project_name) + if not result: + return - def get_parents_by_id(self, session, entities_info, object_types): - task_type_id = None - valid_object_type_ids = [] + # Unpack the result + parent_object_types, all_match, single_match = result + + # Prepare valid object type ids for object types from settings + object_types = session.query("select id, name from ObjectType").all() + object_type_id_by_low_name = { + object_type["name"].lower(): object_type["id"] + for object_type in object_types + } + + valid_object_type_ids = set() + for object_type_name in parent_object_types: + if object_type_name in object_type_id_by_low_name: + valid_object_type_ids.add( + object_type_id_by_low_name[object_type_name] + ) + else: + self.log.warning( + "Unknown object type \"{}\" set on project \"{}\".".format( + object_type_name, project_name + ) + ) + + if not valid_object_type_ids: + return + + # Prepare parent ids + parent_ids = set() + for entity_info in entities_info: + parent_id = entity_info["parentId"] + if parent_id: + parent_ids.add(parent_id) + + # Query parent ids by object type ids and parent ids + parent_entities = session.query( + ( + "select id, status_id, object_type_id, link from TypedContext" + " where id in ({}) and object_type_id in ({})" + ).format( + self.join_query_keys(parent_ids), + self.join_query_keys(valid_object_type_ids) + ) + ).all() + # Skip if none of parents match the filtering + if not parent_entities: + return + + obj_ids = set() + for entity in parent_entities: + obj_ids.add(entity["object_type_id"]) + + types_mapping = { + _type.lower(): _type + for _type in session.types + } + # Map object type id by lowered and modified object type name + object_type_name_by_id = {} for object_type in object_types: - object_name_low = object_type["name"].lower() - if object_name_low == "task": - task_type_id = object_type["id"] + mapping_name = object_type["name"].lower().replace(" ", "") + obj_id = object_type["id"] + object_type_name_by_id[obj_id] = types_mapping[mapping_name] - if object_name_low in self.parent_types: - valid_object_type_ids.append(object_type["id"]) + project_schema = project_entity["project_schema"] + available_statuses_by_obj_id = {} + for obj_id in obj_ids: + obj_name = object_type_name_by_id[obj_id] + statuses = project_schema.get_statuses(obj_name) + statuses_by_low_name = { + status["name"].lower(): status + for status in statuses + } + valid = False + for name in all_match.keys(): + if name in statuses_by_low_name: + valid = True + break - parent_ids = [ - "\"{}\"".format(entity_info["parentId"]) - for entity_info in entities_info - if entity_info["objectTypeId"] == task_type_id - ] - if not parent_ids: - return {} + if not valid: + for item in single_match: + if item["new_status"] in statuses_by_low_name: + valid = True + break + if valid: + available_statuses_by_obj_id[obj_id] = statuses_by_low_name - parent_entities = session.query(( - "TypedContext where id in ({}) and object_type_id in ({})" - ).format( - ", ".join(parent_ids), ", ".join(valid_object_type_ids)) - ).all() + valid_parent_ids = set() + status_ids = set() + valid_parent_entities = [] + for entity in parent_entities: + if entity["object_type_id"] not in available_statuses_by_obj_id: + continue - return { - entity["id"]: entity - for entity in parent_entities - } + valid_parent_entities.append(entity) + valid_parent_ids.add(entity["id"]) + status_ids.add(entity["status_id"]) + + if not valid_parent_ids: + return - 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) + ( + "select id, parent_id, status_id from TypedContext" + " where parent_id in ({}) and object_type_id is \"{}\"" + ).format( + self.join_query_keys(valid_parent_ids), + object_type_id_by_low_name["task"] + ) ).all() - return { - entity["id"]: entity - for entity in task_entities - } + # This should not happen but it is safer + if not task_entities: + return - def get_statuses_by_id(self, session, task_entities=None, status_ids=None): - if task_entities is None and status_ids is None: - return {} + task_entities_by_parent_id = collections.defaultdict(list) + for task_entity in task_entities: + status_ids.add(task_entity["status_id"]) + parent_id = task_entity["parent_id"] + task_entities_by_parent_id[parent_id].append(task_entity) - if status_ids is None: - status_ids = [] - for task_entity in task_entities: - status_ids.append(task_entity["status_id"]) + status_entities = session.query(( + "select id, name from Status where id in ({})" + ).format(self.join_query_keys(status_ids))).all() - if not status_ids: - return {} - - status_entities = session.query( - "Status where id in ({})".format(", ".join(status_ids)) - ).all() - - return { + statuses_by_id = { entity["id"]: entity for entity in status_entities } - 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 - - object_types = session.query("select id, name from ObjectType").all() - parents_by_id = self.get_parents_by_id( - session, entities_info, object_types - ) - if not parents_by_id: - return - tasks_by_id = self.get_tasks_by_id( - session, tuple(parents_by_id.keys()) - ) - - # Just collect them in one variable - entities_by_id = {} - for entity_id, entity in parents_by_id.items(): - entities_by_id[entity_id] = entity - for entity_id, entity in tasks_by_id.items(): - entities_by_id[entity_id] = entity - - # Map task entities by their parents - tasks_by_parent_id = collections.defaultdict(list) - for task_entity in tasks_by_id.values(): - tasks_by_parent_id[task_entity["parent_id"]].append(task_entity) - - # Found status entities for all queried entities - statuses_by_id = self.get_statuses_by_id( - session, - entities_by_id.values() - ) - # New status determination logic new_statuses_by_parent_id = self.new_status_by_all_task_statuses( - parents_by_id.keys(), tasks_by_parent_id, statuses_by_id + task_entities_by_parent_id, statuses_by_id, all_match ) + task_entities_by_id = { + task_entity["id"]: task_entity + for task_entity in task_entities + } # Check if there are remaining any parents that does not have # determined new status yet remainder_tasks_by_parent_id = collections.defaultdict(list) for entity_info in entities_info: + entity_id = entity_info["entityId"] + if entity_id not in task_entities_by_id: + continue parent_id = entity_info["parentId"] if ( # Skip if already has determined new status parent_id in new_statuses_by_parent_id # Skip if parent is not in parent mapping # - if was not found or parent type is not interesting - or parent_id not in parents_by_id + or parent_id not in task_entities_by_parent_id ): continue remainder_tasks_by_parent_id[parent_id].append( - entities_by_id[entity_info["entityId"]] + task_entities_by_id[entity_id] ) # Try to find new status for remained parents new_statuses_by_parent_id.update( self.new_status_by_remainders( remainder_tasks_by_parent_id, - statuses_by_id + statuses_by_id, + single_match ) ) - # Make sure new_status is set to valid value - for parent_id in tuple(new_statuses_by_parent_id.keys()): - new_status_name = new_statuses_by_parent_id[parent_id] - if not new_status_name: - new_statuses_by_parent_id.pop(parent_id) - # If there are not new statuses then just skip if not new_statuses_by_parent_id: return - # Get project schema from any available entity - _entity = None - for _ent in entities_by_id.values(): - _entity = _ent - break - - project_entity = self.get_project_from_entity(_entity) - project_schema = project_entity["project_schema"] - - # Map type names by lowere type names - types_mapping = { - _type.lower(): _type - for _type in session.types + parent_entities_by_id = { + parent_entity["id"]: parent_entity + for parent_entity in valid_parent_entities } - # Map object type id by lowered and modified object type name - object_type_mapping = {} - for object_type in object_types: - mapping_name = object_type["name"].lower().replace(" ", "") - object_type_mapping[object_type["id"]] = mapping_name - - statuses_by_obj_id = {} for parent_id, new_status_name in new_statuses_by_parent_id.items(): if not new_status_name: continue - parent_entity = entities_by_id[parent_id] - obj_id = parent_entity["object_type_id"] - # Find statuses for entity type by object type name - # in project's schema and cache them - if obj_id not in statuses_by_obj_id: - mapping_name = object_type_mapping[obj_id] - mapped_name = types_mapping.get(mapping_name) - statuses = project_schema.get_statuses(mapped_name) - statuses_by_obj_id[obj_id] = { - status["name"].lower(): status - for status in statuses - } - - statuses_by_name = statuses_by_obj_id[obj_id] - new_status = statuses_by_name.get(new_status_name) + parent_entity = parent_entities_by_id[parent_id] ent_path = "/".join( [ent["name"] for ent in parent_entity["link"]] ) + + obj_id = parent_entity["object_type_id"] + statuses_by_low_name = available_statuses_by_obj_id.get(obj_id) + if not statuses_by_low_name: + continue + + new_status = statuses_by_low_name.get(new_status_name) if not new_status: self.log.warning(( "\"{}\" Couldn't change status to \"{}\"." @@ -299,18 +270,18 @@ class TaskStatusToParent(BaseEvent): )) continue - current_status_name = parent_entity["status"]["name"] + current_status = parent_entity["status"] # Do nothing if status is already set - if new_status["name"] == current_status_name: + if new_status["id"] == current_status["id"]: self.log.debug( "\"{}\" Status \"{}\" already set.".format( - ent_path, current_status_name + ent_path, current_status["name"] ) ) continue try: - parent_entity["status"] = new_status + parent_entity["status_id"] = new_status["id"] session.commit() self.log.info( "\"{}\" changed status to \"{}\"".format( @@ -326,8 +297,63 @@ class TaskStatusToParent(BaseEvent): exc_info=True ) + def prepare_settings(self, project_settings, project_name): + 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 + + _parent_object_types = event_settings["parent_object_types"] + if not _parent_object_types: + self.log.debug(( + "Project \"{}\" does not have set" + " parent object types filtering." + ).format(project_name)) + return + + _all_match = ( + event_settings["parent_status_match_all_task_statuses"] + ) + _single_match = ( + event_settings["parent_status_by_task_status"] + ) + + if not _all_match and not _single_match: + self.log.debug(( + "Project \"{}\" does not have set" + " parent status mappings." + ).format(project_name)) + return + + parent_object_types = [ + item.lower() + for item in _parent_object_types + ] + all_match = {} + for new_status_name, task_statuses in _all_match.items(): + all_match[new_status_name.lower()] = [ + status_name.lower() + for status_name in task_statuses + ] + + single_match = [] + for item in _single_match: + single_match.append({ + "new_status": item["new_status"].lower(), + "task_statuses": [ + status_name.lower() + for status_name in item["task_statuses"] + ] + }) + return parent_object_types, all_match, single_match + def new_status_by_all_task_statuses( - self, parent_ids, tasks_by_parent_id, statuses_by_id + self, tasks_by_parent_id, statuses_by_id, all_match ): """All statuses of parent entity must match specific status names. @@ -335,23 +361,23 @@ class TaskStatusToParent(BaseEvent): determined. """ output = {} - for parent_id in parent_ids: + for parent_id, task_entities in tasks_by_parent_id.items(): task_statuses_lowered = set() - for task_entity in tasks_by_parent_id[parent_id]: + for task_entity in task_entities: task_status = statuses_by_id[task_entity["status_id"]] low_status_name = task_status["name"].lower() task_statuses_lowered.add(low_status_name) new_status = None - for item in self.parent_status_match_all_task_statuses: + for _new_status, task_statuses in all_match.items(): valid_item = True for status_name_low in task_statuses_lowered: - if status_name_low not in item["task_statuses"]: + if status_name_low not in task_statuses: valid_item = False break if valid_item: - new_status = item["new_status"] + new_status = _new_status break if new_status is not None: @@ -360,7 +386,7 @@ class TaskStatusToParent(BaseEvent): return output def new_status_by_remainders( - self, remainder_tasks_by_parent_id, statuses_by_id + self, remainder_tasks_by_parent_id, statuses_by_id, single_match ): """By new task status can be determined new status of parent.""" output = {} @@ -373,15 +399,13 @@ class TaskStatusToParent(BaseEvent): # For cases there are multiple tasks in changes # - task status which match any new status item by order in the - # list `parent_status_by_task_status` is preffered - best_order = len(self.parent_status_by_task_status) + # list `single_match` is preffered + best_order = len(single_match) best_order_status = None for task_entity in task_entities: task_status = statuses_by_id[task_entity["status_id"]] low_status_name = task_status["name"].lower() - for order, item in enumerate( - self.parent_status_by_task_status - ): + for order, item in enumerate(single_match): if order >= best_order: break diff --git a/pype/settings/defaults/project_settings/ftrack.json b/pype/settings/defaults/project_settings/ftrack.json index 74f283570a..2bf11de468 100644 --- a/pype/settings/defaults/project_settings/ftrack.json +++ b/pype/settings/defaults/project_settings/ftrack.json @@ -45,20 +45,27 @@ }, "status_task_to_parent": { "enabled": true, + "parent_object_types": [ + "Shot", + "Asset Build" + ], "parent_status_match_all_task_statuses": { "Completed": [ "Approved", "Omitted" ] }, - "parent_status_by_task_status": { - "In Progress": [ - "in progress", - "change requested", - "retake", - "pending review" - ] - } + "parent_status_by_task_status": [ + { + "new_status": "In Progress", + "task_statuses": [ + "in progress", + "change requested", + "retake", + "pending review" + ] + } + ] }, "status_task_to_version": { "enabled": true, 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 588fce74c8..f5e3dcc47a 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 @@ -157,6 +157,16 @@ "key": "enabled", "label": "Enabled" }, + { + "type": "label", + "label": "List of parent object types where this is triggered (\"Shot\", \"Asset Build\", etc.). Skipped if list is empty." + }, + { + "type": "list", + "object_type": "text", + "key": "parent_object_types", + "label": "Object types" + }, { "key": "parent_status_match_all_task_statuses", "type": "dict-modifiable", @@ -167,12 +177,27 @@ } }, { + "type": "list", "key": "parent_status_by_task_status", - "type": "dict-modifiable", "label": "Change parent status if a single task matches", "object_type": { - "type": "list", - "object_type": "text" + "type": "dict", + "children": [ + { + "type": "text", + "label": "New parent status", + "key": "new_status" + }, + { + "type": "separator" + }, + { + "type": "list", + "label": "Task status", + "key": "task_statuses", + "object_type": "text" + } + ] } } ]