diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fe6de4bfa..96b90cd53e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,44 +1,63 @@ # Changelog -## [3.2.0-nightly.2](https://github.com/pypeclub/OpenPype/tree/HEAD) +## [3.2.0-nightly.4](https://github.com/pypeclub/OpenPype/tree/HEAD) -[Full Changelog](https://github.com/pypeclub/OpenPype/compare/2.18.3...HEAD) +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/2.18.4...HEAD) **🚀 Enhancements** +- Application executables with environment variables [\#1757](https://github.com/pypeclub/OpenPype/pull/1757) +- Settings Hosts enum [\#1739](https://github.com/pypeclub/OpenPype/pull/1739) - Validate containers settings [\#1736](https://github.com/pypeclub/OpenPype/pull/1736) +- PS - added loader from sequence [\#1726](https://github.com/pypeclub/OpenPype/pull/1726) - Autoupdate launcher [\#1725](https://github.com/pypeclub/OpenPype/pull/1725) - Subset template and TVPaint subset template docs [\#1717](https://github.com/pypeclub/OpenPype/pull/1717) +- Toggle Ftrack upload in StandalonePublisher [\#1708](https://github.com/pypeclub/OpenPype/pull/1708) - Overscan color extract review [\#1701](https://github.com/pypeclub/OpenPype/pull/1701) - Nuke: Prerender Frame Range by default [\#1699](https://github.com/pypeclub/OpenPype/pull/1699) - Smoother edges of color triangle [\#1695](https://github.com/pypeclub/OpenPype/pull/1695) **🐛 Bug fixes** +- Backend acre module commit update [\#1745](https://github.com/pypeclub/OpenPype/pull/1745) +- hiero: precollect instances failing when audio selected [\#1743](https://github.com/pypeclub/OpenPype/pull/1743) +- Hiero: creator instance error [\#1742](https://github.com/pypeclub/OpenPype/pull/1742) +- Nuke: fixing render creator for no selection format failing [\#1741](https://github.com/pypeclub/OpenPype/pull/1741) - Local settings UI crash on missing defaults [\#1737](https://github.com/pypeclub/OpenPype/pull/1737) +- TVPaint white background on thumbnail [\#1735](https://github.com/pypeclub/OpenPype/pull/1735) - Ftrack missing custom attribute message [\#1734](https://github.com/pypeclub/OpenPype/pull/1734) - Launcher project changes [\#1733](https://github.com/pypeclub/OpenPype/pull/1733) +- Ftrack sync status [\#1732](https://github.com/pypeclub/OpenPype/pull/1732) - TVPaint use layer name for default variant [\#1724](https://github.com/pypeclub/OpenPype/pull/1724) - Default subset template for TVPaint review and workfile families [\#1716](https://github.com/pypeclub/OpenPype/pull/1716) - Maya: Extract review hotfix [\#1714](https://github.com/pypeclub/OpenPype/pull/1714) - Settings: Imageio improving granularity [\#1711](https://github.com/pypeclub/OpenPype/pull/1711) - Application without executables [\#1679](https://github.com/pypeclub/OpenPype/pull/1679) -## [2.18.3](https://github.com/pypeclub/OpenPype/tree/2.18.3) (2021-06-18) +**Merged pull requests:** -[Full Changelog](https://github.com/pypeclub/OpenPype/compare/2.18.2...2.18.3) +- TVPaint ftrack family [\#1755](https://github.com/pypeclub/OpenPype/pull/1755) -**🐛 Bug fixes** +## [2.18.4](https://github.com/pypeclub/OpenPype/tree/2.18.4) (2021-06-24) -- Nuke: broken publishing rendered frames [\#1707](https://github.com/pypeclub/OpenPype/pull/1707) -- Remove publish highlight icon in AE [\#1664](https://github.com/pypeclub/OpenPype/pull/1664) +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/2.18.3...2.18.4) + +**Merged pull requests:** + +- celaction fixes [\#1754](https://github.com/pypeclub/OpenPype/pull/1754) +- celaciton: audio subset changed data structure [\#1750](https://github.com/pypeclub/OpenPype/pull/1750) + +## [2.18.3](https://github.com/pypeclub/OpenPype/tree/2.18.3) (2021-06-23) + +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.2.0-nightly.2...2.18.3) + +**🐛 Bug fixes** + +- Tools names forwards compatibility [\#1727](https://github.com/pypeclub/OpenPype/pull/1727) **Merged pull requests:** -- Sync main 2.x back to 2.x develop [\#1715](https://github.com/pypeclub/OpenPype/pull/1715) - global: removing obsolete ftrack validator plugin [\#1710](https://github.com/pypeclub/OpenPype/pull/1710) -- \#683 - Validate frame range in Standalone Publisher [\#1680](https://github.com/pypeclub/OpenPype/pull/1680) -- Maya: Split model content validator [\#1654](https://github.com/pypeclub/OpenPype/pull/1654) ## [2.18.2](https://github.com/pypeclub/OpenPype/tree/2.18.2) (2021-06-16) @@ -80,6 +99,7 @@ **🐛 Bug fixes** +- Nuke: broken publishing rendered frames [\#1707](https://github.com/pypeclub/OpenPype/pull/1707) - Standalone publisher Thumbnail export args [\#1705](https://github.com/pypeclub/OpenPype/pull/1705) - Bad zip can break OpenPype start [\#1691](https://github.com/pypeclub/OpenPype/pull/1691) - Hiero: published whole edit mov [\#1687](https://github.com/pypeclub/OpenPype/pull/1687) @@ -96,6 +116,7 @@ - update dependencies [\#1697](https://github.com/pypeclub/OpenPype/pull/1697) - Bump normalize-url from 4.5.0 to 4.5.1 in /website [\#1686](https://github.com/pypeclub/OpenPype/pull/1686) +- Use poetry to build / publish OpenPype wheel [\#1636](https://github.com/pypeclub/OpenPype/pull/1636) # Changelog diff --git a/openpype/hosts/standalonepublisher/plugins/publish/extract_thumbnail.py b/openpype/hosts/standalonepublisher/plugins/publish/extract_thumbnail.py index 963d47956a..0792254716 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/extract_thumbnail.py @@ -66,7 +66,6 @@ class ExtractThumbnailSP(pyblish.api.InstancePlugin): else: # Convert to jpeg if not yet full_input_path = os.path.join(thumbnail_repre["stagingDir"], file) - full_input_path = '"{}"'.format(full_input_path) self.log.info("input {}".format(full_input_path)) full_thumbnail_path = tempfile.mkstemp(suffix=".jpg")[1] diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_instances.py b/openpype/hosts/tvpaint/plugins/publish/collect_instances.py index 4468bfae40..e496b144cd 100644 --- a/openpype/hosts/tvpaint/plugins/publish/collect_instances.py +++ b/openpype/hosts/tvpaint/plugins/publish/collect_instances.py @@ -103,8 +103,6 @@ class CollectInstances(pyblish.api.ContextPlugin): instance.data["layers"] = copy.deepcopy( context.data["layersData"] ) - # Add ftrack family - instance.data["families"].append("ftrack") elif family == "renderLayer": instance = self.create_render_layer_instance( @@ -186,9 +184,6 @@ class CollectInstances(pyblish.api.ContextPlugin): instance_data["layers"] = group_layers - # Add ftrack family - instance_data["families"].append("ftrack") - return context.create_instance(**instance_data) def create_render_pass_instance(self, context, instance_data): diff --git a/openpype/lib/anatomy.py b/openpype/lib/anatomy.py index c16c6e2e99..7a4a55363c 100644 --- a/openpype/lib/anatomy.py +++ b/openpype/lib/anatomy.py @@ -733,6 +733,9 @@ class Templates: continue default_key_values[key] = templates.pop(key) + # Pop "others" key before before expected keys are processed + other_templates = templates.pop("others") or {} + keys_by_subkey = {} for sub_key, sub_value in templates.items(): key_values = {} @@ -740,7 +743,6 @@ class Templates: key_values.update(sub_value) keys_by_subkey[sub_key] = cls.prepare_inner_keys(key_values) - other_templates = templates.get("others") or {} for sub_key, sub_value in other_templates.items(): if sub_key in keys_by_subkey: log.warning(( diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index bed57d7022..9866400928 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -460,6 +460,12 @@ class ApplicationExecutable: if os.path.exists(_executable): executable = _executable + # Try to format executable with environments + try: + executable = executable.format(**os.environ) + except Exception: + pass + self.executable_path = executable def __str__(self): @@ -1153,6 +1159,9 @@ def prepare_host_environments(data, implementation_envs=True): def apply_project_environments_value(project_name, env, project_settings=None): """Apply project specific environments on passed environments. + The enviornments are applied on passed `env` argument value so it is not + required to apply changes back. + Args: project_name (str): Name of project for which environemnts should be received. @@ -1161,6 +1170,9 @@ def apply_project_environments_value(project_name, env, project_settings=None): project_settings (dict): Project settings for passed project name. Optional if project settings are already prepared. + Returns: + dict: Passed env values with applied project environments. + Raises: KeyError: If project settings do not contain keys for project specific environments. @@ -1171,10 +1183,9 @@ def apply_project_environments_value(project_name, env, project_settings=None): project_settings = get_project_settings(project_name) env_value = project_settings["global"]["project_environments"] - if not env_value: - return env - parsed = acre.parse(env_value) - return _merge_env(parsed, env) + if env_value: + env.update(_merge_env(acre.parse(env_value), env)) + return env def prepare_context_environments(data): @@ -1203,9 +1214,8 @@ def prepare_context_environments(data): # Load project specific environments project_name = project_doc["name"] - data["env"] = apply_project_environments_value( - project_name, data["env"] - ) + # Apply project specific environments on current env value + apply_project_environments_value(project_name, data["env"]) app = data["app"] workdir_data = get_workdir_data( diff --git a/openpype/modules/ftrack/event_handlers_server/event_push_frame_values_to_task.py b/openpype/modules/ftrack/event_handlers_server/event_push_frame_values_to_task.py index c0b3137455..81719258e1 100644 --- a/openpype/modules/ftrack/event_handlers_server/event_push_frame_values_to_task.py +++ b/openpype/modules/ftrack/event_handlers_server/event_push_frame_values_to_task.py @@ -2,7 +2,10 @@ import collections import datetime import ftrack_api -from openpype.modules.ftrack.lib import BaseEvent +from openpype.modules.ftrack.lib import ( + BaseEvent, + query_custom_attributes +) class PushFrameValuesToTaskEvent(BaseEvent): @@ -55,10 +58,6 @@ class PushFrameValuesToTaskEvent(BaseEvent): if entity_info.get("entityType") != "task": continue - # Skip `Task` entity type - if entity_info["entity_type"].lower() == "task": - continue - # Care only about changes of status changes = entity_info.get("changes") if not changes: @@ -74,6 +73,14 @@ class PushFrameValuesToTaskEvent(BaseEvent): if project_id is None: continue + # Skip `Task` entity type if parent didn't change + if entity_info["entity_type"].lower() == "task": + if ( + "parent_id" not in changes + or changes["parent_id"]["new"] is None + ): + continue + if project_id not in entities_info_by_project_id: entities_info_by_project_id[project_id] = [] entities_info_by_project_id[project_id].append(entity_info) @@ -117,11 +124,24 @@ class PushFrameValuesToTaskEvent(BaseEvent): )) return + interest_attributes = set(interest_attributes) + interest_entity_types = set(interest_entity_types) + + # Separate value changes and task parent changes + _entities_info = [] + task_parent_changes = [] + for entity_info in entities_info: + if entity_info["entity_type"].lower() == "task": + task_parent_changes.append(entity_info) + else: + _entities_info.append(entity_info) + entities_info = _entities_info + # Filter entities info with changes interesting_data, changed_keys_by_object_id = self.filter_changes( session, event, entities_info, interest_attributes ) - if not interesting_data: + if not interesting_data and not task_parent_changes: return # Prepare object types @@ -131,6 +151,289 @@ class PushFrameValuesToTaskEvent(BaseEvent): name_low = object_type["name"].lower() object_types_by_name[name_low] = object_type + # NOTE it would be nice to check if `interesting_data` do not contain + # value changs of tasks that were created or moved + # - it is a complex way how to find out + if interesting_data: + self.process_attribute_changes( + session, object_types_by_name, + interesting_data, changed_keys_by_object_id, + interest_entity_types, interest_attributes + ) + + if task_parent_changes: + self.process_task_parent_change( + session, object_types_by_name, task_parent_changes, + interest_entity_types, interest_attributes + ) + + def process_task_parent_change( + self, session, object_types_by_name, task_parent_changes, + interest_entity_types, interest_attributes + ): + """Push custom attribute values if task parent has changed. + + Parent is changed if task is created or if is moved under different + entity. We don't care about all task changes only about those that + have it's parent in interest types (from settings). + + Tasks hierarchical value should be unset or set based on parents + real hierarchical value and non hierarchical custom attribute value + should be set to hierarchical value. + """ + # Store task ids which were created or moved under parent with entity + # type defined in settings (interest_entity_types). + task_ids = set() + # Store parent ids of matching task ids + matching_parent_ids = set() + # Store all entity ids of all entities to be able query hierarchical + # values. + whole_hierarchy_ids = set() + # Store parent id of each entity id + parent_id_by_entity_id = {} + for entity_info in task_parent_changes: + # Ignore entities with less parents than 2 + # NOTE entity itself is also part of "parents" value + parents = entity_info.get("parents") or [] + if len(parents) < 2: + continue + + parent_info = parents[1] + # Check if parent has entity type we care about. + if parent_info["entity_type"] not in interest_entity_types: + continue + + task_ids.add(entity_info["entityId"]) + matching_parent_ids.add(parent_info["entityId"]) + + # Store whole hierarchi of task entity + prev_id = None + for item in parents: + item_id = item["entityId"] + whole_hierarchy_ids.add(item_id) + + if prev_id is None: + prev_id = item_id + continue + + parent_id_by_entity_id[prev_id] = item_id + if item["entityType"] == "show": + break + prev_id = item_id + + # Just skip if nothing is interesting for our settings + if not matching_parent_ids: + return + + # Query object type ids of parent ids for custom attribute + # definitions query + entities = session.query( + "select object_type_id from TypedContext where id in ({})".format( + self.join_query_keys(matching_parent_ids) + ) + ) + + # Prepare task object id + task_object_id = object_types_by_name["task"]["id"] + + # All object ids for which we're querying custom attribute definitions + object_type_ids = set() + object_type_ids.add(task_object_id) + for entity in entities: + object_type_ids.add(entity["object_type_id"]) + + attrs_by_obj_id, hier_attrs = self.attrs_configurations( + session, object_type_ids, interest_attributes + ) + + # Skip if all task attributes are not available + task_attrs = attrs_by_obj_id.get(task_object_id) + if not task_attrs: + return + + # Skip attributes that is not in both hierarchical and nonhierarchical + # TODO be able to push values if hierarchical is available + for key in interest_attributes: + if key not in hier_attrs: + task_attrs.pop(key, None) + + elif key not in task_attrs: + hier_attrs.pop(key) + + # Skip if nothing remained + if not task_attrs: + return + + # Do some preparations for custom attribute values query + attr_key_by_id = {} + nonhier_id_by_key = {} + hier_attr_ids = [] + for key, attr_id in hier_attrs.items(): + attr_key_by_id[attr_id] = key + hier_attr_ids.append(attr_id) + + conf_ids = list(hier_attr_ids) + for key, attr_id in task_attrs.items(): + attr_key_by_id[attr_id] = key + nonhier_id_by_key[key] = attr_id + conf_ids.append(attr_id) + + # Query custom attribute values + # - result does not contain values for all entities only result of + # query callback to ftrack server + result = query_custom_attributes( + session, conf_ids, whole_hierarchy_ids + ) + + # Prepare variables where result will be stored + # - hierachical values should not contain attribute with value by + # default + hier_values_by_entity_id = { + entity_id: {} + for entity_id in whole_hierarchy_ids + } + # - real values of custom attributes + values_by_entity_id = { + entity_id: { + attr_id: None + for attr_id in conf_ids + } + for entity_id in whole_hierarchy_ids + } + for item in result: + attr_id = item["configuration_id"] + entity_id = item["entity_id"] + value = item["value"] + + values_by_entity_id[entity_id][attr_id] = value + + if attr_id in hier_attr_ids and value is not None: + hier_values_by_entity_id[entity_id][attr_id] = value + + # Prepare values for all task entities + # - going through all parents and storing first value value + # - store None to those that are already known that do not have set + # value at all + for task_id in tuple(task_ids): + for attr_id in hier_attr_ids: + entity_ids = [] + value = None + entity_id = task_id + while value is None: + entity_value = hier_values_by_entity_id[entity_id] + if attr_id in entity_value: + value = entity_value[attr_id] + if value is None: + break + + if value is None: + entity_ids.append(entity_id) + + entity_id = parent_id_by_entity_id.get(entity_id) + if entity_id is None: + break + + for entity_id in entity_ids: + hier_values_by_entity_id[entity_id][attr_id] = value + + # Prepare changes to commit + changes = [] + for task_id in tuple(task_ids): + parent_id = parent_id_by_entity_id[task_id] + for attr_id in hier_attr_ids: + attr_key = attr_key_by_id[attr_id] + nonhier_id = nonhier_id_by_key[attr_key] + + # Real value of hierarchical attribute on parent + # - If is none then should be unset + real_parent_value = values_by_entity_id[parent_id][attr_id] + # Current hierarchical value of a task + # - Will be compared to real parent value + hier_value = hier_values_by_entity_id[task_id][attr_id] + + # Parent value that can be inherited from it's parent entity + parent_value = hier_values_by_entity_id[parent_id][attr_id] + # Task value of nonhierarchical custom attribute + nonhier_value = values_by_entity_id[task_id][nonhier_id] + + if real_parent_value != hier_value: + changes.append({ + "new_value": real_parent_value, + "attr_id": attr_id, + "entity_id": task_id, + "attr_key": attr_key + }) + + if parent_value != nonhier_value: + changes.append({ + "new_value": parent_value, + "attr_id": nonhier_id, + "entity_id": task_id, + "attr_key": attr_key + }) + + self._commit_changes(session, changes) + + def _commit_changes(self, session, changes): + uncommited_changes = False + for idx, item in enumerate(changes): + new_value = item["new_value"] + attr_id = item["attr_id"] + entity_id = item["entity_id"] + attr_key = item["attr_key"] + + entity_key = collections.OrderedDict() + entity_key["configuration_id"] = attr_id + entity_key["entity_id"] = entity_id + self._cached_changes.append({ + "attr_key": attr_key, + "entity_id": entity_id, + "value": new_value, + "time": datetime.datetime.now() + }) + if new_value is None: + op = ftrack_api.operation.DeleteEntityOperation( + "CustomAttributeValue", + entity_key + ) + else: + op = ftrack_api.operation.UpdateEntityOperation( + "ContextCustomAttributeValue", + entity_key, + "value", + ftrack_api.symbol.NOT_SET, + new_value + ) + + session.recorded_operations.push(op) + self.log.info(( + "Changing Custom Attribute \"{}\" to value" + " \"{}\" on entity: {}" + ).format(attr_key, new_value, entity_id)) + + if (idx + 1) % 20 == 0: + uncommited_changes = False + try: + session.commit() + except Exception: + session.rollback() + self.log.warning( + "Changing of values failed.", exc_info=True + ) + else: + uncommited_changes = True + if uncommited_changes: + try: + session.commit() + except Exception: + session.rollback() + self.log.warning("Changing of values failed.", exc_info=True) + + def process_attribute_changes( + self, session, object_types_by_name, + interesting_data, changed_keys_by_object_id, + interest_entity_types, interest_attributes + ): # Prepare task object id task_object_id = object_types_by_name["task"]["id"] @@ -216,13 +519,13 @@ class PushFrameValuesToTaskEvent(BaseEvent): task_entity_ids.add(task_id) parent_id_by_task_id[task_id] = task_entity["parent_id"] - self.finalize( + self.finalize_attribute_changes( session, interesting_data, changed_keys, attrs_by_obj_id, hier_attrs, task_entity_ids, parent_id_by_task_id ) - def finalize( + def finalize_attribute_changes( self, session, interesting_data, changed_keys, attrs_by_obj_id, hier_attrs, task_entity_ids, parent_id_by_task_id @@ -248,6 +551,7 @@ class PushFrameValuesToTaskEvent(BaseEvent): session, attr_ids, entity_ids, task_entity_ids, hier_attrs ) + changes = [] for entity_id, current_values in current_values_by_id.items(): parent_id = parent_id_by_task_id.get(entity_id) if not parent_id: @@ -272,39 +576,13 @@ class PushFrameValuesToTaskEvent(BaseEvent): if new_value == old_value: continue - entity_key = collections.OrderedDict() - entity_key["configuration_id"] = attr_id - entity_key["entity_id"] = entity_id - self._cached_changes.append({ - "attr_key": attr_key, + changes.append({ + "new_value": new_value, + "attr_id": attr_id, "entity_id": entity_id, - "value": new_value, - "time": datetime.datetime.now() + "attr_key": attr_key }) - if new_value is None: - op = ftrack_api.operation.DeleteEntityOperation( - "CustomAttributeValue", - entity_key - ) - else: - op = ftrack_api.operation.UpdateEntityOperation( - "ContextCustomAttributeValue", - entity_key, - "value", - ftrack_api.symbol.NOT_SET, - new_value - ) - - session.recorded_operations.push(op) - self.log.info(( - "Changing Custom Attribute \"{}\" to value" - " \"{}\" on entity: {}" - ).format(attr_key, new_value, entity_id)) - try: - session.commit() - except Exception: - session.rollback() - self.log.warning("Changing of values failed.", exc_info=True) + self._commit_changes(session, changes) def filter_changes( self, session, event, entities_info, interest_attributes diff --git a/openpype/modules/ftrack/lib/__init__.py b/openpype/modules/ftrack/lib/__init__.py index ce6d5284b6..9dc2d67279 100644 --- a/openpype/modules/ftrack/lib/__init__.py +++ b/openpype/modules/ftrack/lib/__init__.py @@ -13,7 +13,8 @@ from .custom_attributes import ( default_custom_attributes_definition, app_definitions_from_app_manager, tool_definitions_from_app_manager, - get_openpype_attr + get_openpype_attr, + query_custom_attributes ) from . import avalon_sync @@ -37,6 +38,7 @@ __all__ = ( "app_definitions_from_app_manager", "tool_definitions_from_app_manager", "get_openpype_attr", + "query_custom_attributes", "avalon_sync", diff --git a/openpype/modules/ftrack/lib/custom_attributes.py b/openpype/modules/ftrack/lib/custom_attributes.py index f6b82c90b1..53facd4ab2 100644 --- a/openpype/modules/ftrack/lib/custom_attributes.py +++ b/openpype/modules/ftrack/lib/custom_attributes.py @@ -81,3 +81,60 @@ def get_openpype_attr(session, split_hierarchical=True, query_keys=None): return custom_attributes, hier_custom_attributes return custom_attributes + + +def join_query_keys(keys): + """Helper to join keys to query.""" + return ",".join(["\"{}\"".format(key) for key in keys]) + + +def query_custom_attributes(session, conf_ids, entity_ids, table_name=None): + """Query custom attribute values from ftrack database. + + Using ftrack call method result may differ based on used table name and + version of ftrack server. + + Args: + session(ftrack_api.Session): Connected ftrack session. + conf_id(list, set, tuple): Configuration(attribute) ids which are + queried. + entity_ids(list, set, tuple): Entity ids for which are values queried. + table_name(str): Table nam from which values are queried. Not + recommended to change until you know what it means. + """ + output = [] + # Just skip + if not conf_ids or not entity_ids: + return output + + if table_name is None: + table_name = "ContextCustomAttributeValue" + + # Prepare values to query + attributes_joined = join_query_keys(conf_ids) + attributes_len = len(conf_ids) + + # Query values in chunks + chunk_size = int(5000 / attributes_len) + # Make sure entity_ids is `list` for chunk selection + entity_ids = list(entity_ids) + for idx in range(0, len(entity_ids), chunk_size): + entity_ids_joined = join_query_keys( + entity_ids[idx:idx + chunk_size] + ) + + call_expr = [{ + "action": "query", + "expression": ( + "select value, entity_id from {}" + " where entity_id in ({}) and configuration_id in ({})" + ).format(table_name, entity_ids_joined, attributes_joined) + }] + if hasattr(session, "call"): + [result] = session.call(call_expr) + else: + [result] = session._call(call_expr) + + for item in result["data"]: + output.append(item) + return output diff --git a/openpype/settings/defaults/project_settings/ftrack.json b/openpype/settings/defaults/project_settings/ftrack.json index 03ecf024a6..88f4e1e2e7 100644 --- a/openpype/settings/defaults/project_settings/ftrack.json +++ b/openpype/settings/defaults/project_settings/ftrack.json @@ -259,6 +259,26 @@ "tasks": [], "add_ftrack_family": true, "advanced_filtering": [] + }, + { + "hosts": [ + "tvpaint" + ], + "families": [ + "renderPass" + ], + "tasks": [], + "add_ftrack_family": false, + "advanced_filtering": [] + }, + { + "hosts": [ + "tvpaint" + ], + "families": [], + "tasks": [], + "add_ftrack_family": true, + "advanced_filtering": [] } ] }, diff --git a/openpype/settings/entities/base_entity.py b/openpype/settings/entities/base_entity.py index c6bff1ff47..1b0dd372fa 100644 --- a/openpype/settings/entities/base_entity.py +++ b/openpype/settings/entities/base_entity.py @@ -279,6 +279,11 @@ class BaseItemEntity(BaseEntity): self, "Dynamic entity can't require restart." ) + @abstractproperty + def root_key(self): + """Root is represented as this dictionary key.""" + pass + @abstractmethod def set_override_state(self, state): """Set override state and trigger it on children. @@ -866,6 +871,10 @@ class ItemEntity(BaseItemEntity): """Call save on root item.""" self.root_item.save() + @property + def root_key(self): + return self.root_item.root_key + def schema_validations(self): if not self.label and self.use_label_wrap: reason = ( diff --git a/openpype/settings/entities/root_entities.py b/openpype/settings/entities/root_entities.py index 401d3980c9..5ed78fd401 100644 --- a/openpype/settings/entities/root_entities.py +++ b/openpype/settings/entities/root_entities.py @@ -189,11 +189,10 @@ class RootEntity(BaseItemEntity): if not KEY_REGEX.match(key): raise InvalidKeySymbols(self.path, key) + @abstractmethod def get_entity_from_path(self, path): - """Return system settings entity.""" - raise NotImplementedError(( - "Method `get_entity_from_path` not available for \"{}\"" - ).format(self.__class__.__name__)) + """Return entity matching passed path.""" + pass def create_schema_object(self, schema_data, *args, **kwargs): """Create entity by entered schema data. @@ -491,6 +490,8 @@ class SystemSettings(RootEntity): schema_data (dict): Pass schema data to entity. This is for development and debugging purposes. """ + root_key = SYSTEM_SETTINGS_KEY + def __init__( self, set_studio_state=True, reset=True, schema_data=None ): @@ -503,6 +504,18 @@ class SystemSettings(RootEntity): if set_studio_state: self.set_studio_state() + def get_entity_from_path(self, path): + """Return system settings entity.""" + path_parts = path.split("/") + first_part = path_parts[0] + output = self + if first_part == self.root_key: + path_parts.pop(0) + + for path_part in path_parts: + output = output[path_part] + return output + def _reset_values(self): default_value = get_default_settings()[SYSTEM_SETTINGS_KEY] for key, child_obj in self.non_gui_children.items(): @@ -600,6 +613,8 @@ class ProjectSettings(RootEntity): schema_data (dict): Pass schema data to entity. This is for development and debugging purposes. """ + root_key = PROJECT_SETTINGS_KEY + def __init__( self, project_name=None, diff --git a/openpype/tools/settings/settings/base.py b/openpype/tools/settings/settings/base.py index 03f920b7dc..eb5f82ab9a 100644 --- a/openpype/tools/settings/settings/base.py +++ b/openpype/tools/settings/settings/base.py @@ -1,3 +1,5 @@ +import json + from Qt import QtWidgets, QtGui, QtCore from openpype.tools.settings import CHILD_OFFSET from .widgets import ExpandingWidget @@ -125,6 +127,117 @@ class BaseWidget(QtWidgets.QWidget): actions_mapping[action] = remove_from_project_override menu.addAction(action) + def _copy_value_actions(self, menu): + def copy_value(): + mime_data = QtCore.QMimeData() + + if self.entity.is_dynamic_item or self.entity.is_in_dynamic_item: + entity_path = None + else: + entity_path = "/".join( + [self.entity.root_key, self.entity.path] + ) + + value = self.entity.value + # Copy for settings tool + settings_data = { + "root_key": self.entity.root_key, + "value": value, + "path": entity_path + } + settings_encoded_data = QtCore.QByteArray() + settings_stream = QtCore.QDataStream( + settings_encoded_data, QtCore.QIODevice.WriteOnly + ) + settings_stream.writeQString(json.dumps(settings_data)) + mime_data.setData( + "application/copy_settings_value", settings_encoded_data + ) + + # Copy as json + json_encoded_data = None + if isinstance(value, (dict, list)): + json_encoded_data = QtCore.QByteArray() + json_stream = QtCore.QDataStream( + json_encoded_data, QtCore.QIODevice.WriteOnly + ) + json_stream.writeQString(json.dumps(value)) + + mime_data.setData("application/json", json_encoded_data) + + # Copy as text + if json_encoded_data is None: + # Store value as string + mime_data.setText(str(value)) + else: + # Store data as json string + mime_data.setText(json.dumps(value, indent=4)) + + QtWidgets.QApplication.clipboard().setMimeData(mime_data) + + action = QtWidgets.QAction("Copy", menu) + return [(action, copy_value)] + + def _paste_value_actions(self, menu): + output = [] + # Allow paste of value only if were copied from this UI + mime_data = QtWidgets.QApplication.clipboard().mimeData() + mime_value = mime_data.data("application/copy_settings_value") + # Skip if there is nothing to do + if not mime_value: + return output + + settings_stream = QtCore.QDataStream( + mime_value, QtCore.QIODevice.ReadOnly + ) + mime_data_value_str = settings_stream.readQString() + mime_data_value = json.loads(mime_data_value_str) + + value = mime_data_value["value"] + path = mime_data_value["path"] + root_key = mime_data_value["root_key"] + + # Try to find matching entity to be able paste values to same spot + # - entity can't by dynamic or in dynamic item + # - must be in same root entity as source copy + # Can't copy system settings <-> project settings + matching_entity = None + if path and root_key == self.entity.root_key: + try: + matching_entity = self.entity.get_entity_from_path(path) + except Exception: + pass + + def _set_entity_value(_entity, _value): + try: + _entity.set(_value) + except Exception: + dialog = QtWidgets.QMessageBox(self) + dialog.setWindowTitle("Value does not match settings schema") + dialog.setIcon(QtWidgets.QMessageBox.Warning) + dialog.setText(( + "Pasted value does not seem to match schema of destination" + " settings entity." + )) + dialog.exec_() + + # Simple paste value method + def paste_value(): + _set_entity_value(self.entity, value) + + action = QtWidgets.QAction("Paste", menu) + output.append((action, paste_value)) + + # Paste value to matchin entity + def paste_value_to_path(): + _set_entity_value(matching_entity, value) + + if matching_entity is not None: + action = QtWidgets.QAction("Paste to same place", menu) + output.append((action, paste_value_to_path)) + + return output + def show_actions_menu(self, event=None): if event and event.button() != QtCore.Qt.RightButton: return @@ -144,6 +257,15 @@ class BaseWidget(QtWidgets.QWidget): self._add_to_project_override_action(menu, actions_mapping) self._remove_from_project_override_action(menu, actions_mapping) + ui_actions = [] + ui_actions.extend(self._copy_value_actions(menu)) + ui_actions.extend(self._paste_value_actions(menu)) + if ui_actions: + menu.addSeparator() + for action, callback in ui_actions: + menu.addAction(action) + actions_mapping[action] = callback + if not actions_mapping: action = QtWidgets.QAction("< No action >") actions_mapping[action] = None diff --git a/openpype/tools/settings/settings/categories.py b/openpype/tools/settings/settings/categories.py index 34ab4c464a..392c749211 100644 --- a/openpype/tools/settings/settings/categories.py +++ b/openpype/tools/settings/settings/categories.py @@ -183,6 +183,12 @@ class SettingsCategoryWidget(QtWidgets.QWidget): footer_widget = QtWidgets.QWidget(configurations_widget) footer_layout = QtWidgets.QHBoxLayout(footer_widget) + refresh_icon = qtawesome.icon("fa.refresh", color="white") + refresh_btn = QtWidgets.QPushButton(footer_widget) + refresh_btn.setIcon(refresh_icon) + + footer_layout.addWidget(refresh_btn, 0) + if self.user_role == "developer": self._add_developer_ui(footer_layout) @@ -205,8 +211,10 @@ class SettingsCategoryWidget(QtWidgets.QWidget): main_layout.addWidget(configurations_widget, 1) save_btn.clicked.connect(self._save) + refresh_btn.clicked.connect(self._on_refresh) self.save_btn = save_btn + self.refresh_btn = refresh_btn self.require_restart_label = require_restart_label self.scroll_widget = scroll_widget self.content_layout = content_layout @@ -220,10 +228,6 @@ class SettingsCategoryWidget(QtWidgets.QWidget): return def _add_developer_ui(self, footer_layout): - refresh_icon = qtawesome.icon("fa.refresh", color="white") - refresh_button = QtWidgets.QPushButton() - refresh_button.setIcon(refresh_icon) - modify_defaults_widget = QtWidgets.QWidget() modify_defaults_checkbox = QtWidgets.QCheckBox(modify_defaults_widget) modify_defaults_checkbox.setChecked(self._hide_studio_overrides) @@ -235,10 +239,8 @@ class SettingsCategoryWidget(QtWidgets.QWidget): modify_defaults_layout.addWidget(label_widget) modify_defaults_layout.addWidget(modify_defaults_checkbox) - footer_layout.addWidget(refresh_button, 0) footer_layout.addWidget(modify_defaults_widget, 0) - refresh_button.clicked.connect(self._on_refresh) modify_defaults_checkbox.stateChanged.connect( self._on_modify_defaults ) diff --git a/openpype/tools/workfiles/app.py b/openpype/tools/workfiles/app.py index c79e55a143..d567e26d74 100644 --- a/openpype/tools/workfiles/app.py +++ b/openpype/tools/workfiles/app.py @@ -944,10 +944,8 @@ class Window(QtWidgets.QMainWindow): split_widget.addWidget(tasks_widget) split_widget.addWidget(files_widget) split_widget.addWidget(side_panel) - split_widget.setStretchFactor(0, 1) - split_widget.setStretchFactor(1, 1) - split_widget.setStretchFactor(2, 3) - split_widget.setStretchFactor(3, 1) + split_widget.setSizes([255, 160, 455, 175]) + body_layout.addWidget(split_widget) # Add top margin for tasks to align it visually with files as @@ -976,7 +974,7 @@ class Window(QtWidgets.QMainWindow): # Force focus on the open button by default, required for Houdini. files_widget.btn_open.setFocus() - self.resize(1000, 600) + self.resize(1200, 600) def keyPressEvent(self, event): """Custom keyPressEvent. diff --git a/openpype/version.py b/openpype/version.py index f527bd4d6e..fcd3b2afca 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.2.0-nightly.2" +__version__ = "3.2.0-nightly.4" diff --git a/repos/avalon-core b/repos/avalon-core index efde026e5a..d8be0bdb37 160000 --- a/repos/avalon-core +++ b/repos/avalon-core @@ -1 +1 @@ -Subproject commit efde026e5aad72dac0e69848005419e2c4f067f2 +Subproject commit d8be0bdb37961e32243f1de0eb9696e86acf7443