diff --git a/client/ayon_core/lib/attribute_definitions.py b/client/ayon_core/lib/attribute_definitions.py index 639778b16d..4877a45118 100644 --- a/client/ayon_core/lib/attribute_definitions.py +++ b/client/ayon_core/lib/attribute_definitions.py @@ -6,6 +6,7 @@ import copy import warnings from abc import ABCMeta, abstractmethod +from typing import Any, Optional import clique @@ -147,15 +148,15 @@ class AbstractAttrDef(metaclass=AbstractAttrDefMeta): def __init__( self, - key, - default, - label=None, - tooltip=None, - is_label_horizontal=None, - visible=None, - enabled=None, - hidden=None, - disabled=None, + key: str, + default: Any, + label: Optional[str] = None, + tooltip: Optional[str] = None, + is_label_horizontal: Optional[bool] = None, + visible: Optional[bool] = None, + enabled: Optional[bool] = None, + hidden: Optional[bool] = None, + disabled: Optional[bool] = None, ): if is_label_horizontal is None: is_label_horizontal = True @@ -167,53 +168,85 @@ def __init__( visible, hidden, "visible", "hidden", True ) - self.key = key - self.label = label - self.tooltip = tooltip - self.default = default - self.is_label_horizontal = is_label_horizontal - self.visible = visible - self.enabled = enabled - self._id = uuid.uuid4().hex + self.key: str = key + self.label: Optional[str] = label + self.tooltip: Optional[str] = tooltip + self.default: Any = default + self.is_label_horizontal: bool = is_label_horizontal + self.visible: bool = visible + self.enabled: bool = enabled + self._id: str = uuid.uuid4().hex self.__init__class__ = AbstractAttrDef @property - def id(self): + def id(self) -> str: return self._id + def clone(self): + data = self.serialize() + data.pop("type") + return self.deserialize(data) + @property - def hidden(self): + def hidden(self) -> bool: return not self.visible @hidden.setter - def hidden(self, value): + def hidden(self, value: bool): self.visible = not value @property - def disabled(self): + def disabled(self) -> bool: return not self.enabled @disabled.setter - def disabled(self, value): + def disabled(self, value: bool): self.enabled = not value - def __eq__(self, other): - if not isinstance(other, self.__class__): + def __eq__(self, other: Any) -> bool: + return self.compare_to_def(other) + + def __ne__(self, other: Any) -> bool: + return not self.compare_to_def(other) + + def compare_to_def( + self, + other: Any, + ignore_default: Optional[bool] = False, + ignore_enabled: Optional[bool] = False, + ignore_visible: Optional[bool] = False, + ignore_def_type_compare: Optional[bool] = False, + ) -> bool: + if not isinstance(other, self.__class__) or self.key != other.key: + return False + if not ignore_def_type_compare and not self._def_type_compare(other): return False return ( - self.key == other.key - and self.default == other.default - and self.visible == other.visible - and self.enabled == other.enabled + (ignore_default or self.default == other.default) + and (ignore_visible or self.visible == other.visible) + and (ignore_enabled or self.enabled == other.enabled) ) - def __ne__(self, other): - return not self.__eq__(other) + @abstractmethod + def is_value_valid(self, value: Any) -> bool: + """Check if value is valid. + + This should return False if value is not valid based + on definition type. + + Args: + value (Any): Value to validate based on definition type. + + Returns: + bool: True if value is valid. + + """ + pass @property @abstractmethod - def type(self): + def type(self) -> str: """Attribute definition type also used as identifier of class. Returns: @@ -260,9 +293,15 @@ def deserialize(cls, data): Data can be received using 'serialize' method. """ + if "type" in data: + data = dict(data) + data.pop("type") return cls(**data) + def _def_type_compare(self, other: "AbstractAttrDef") -> bool: + return True + # ----------------------------------------- # UI attribute definitions won't hold value @@ -272,7 +311,10 @@ class UIDef(AbstractAttrDef): is_value_def = False def __init__(self, key=None, default=None, *args, **kwargs): - super(UIDef, self).__init__(key, default, *args, **kwargs) + super().__init__(key, default, *args, **kwargs) + + def is_value_valid(self, value: Any) -> bool: + return True def convert_value(self, value): return value @@ -286,11 +328,9 @@ class UILabelDef(UIDef): type = "label" def __init__(self, label, key=None): - super(UILabelDef, self).__init__(label=label, key=key) + super().__init__(label=label, key=key) - def __eq__(self, other): - if not super(UILabelDef, self).__eq__(other): - return False + def _def_type_compare(self, other: "UILabelDef") -> bool: return self.label == other.label @@ -309,7 +349,10 @@ class UnknownDef(AbstractAttrDef): def __init__(self, key, default=None, **kwargs): kwargs["default"] = default - super(UnknownDef, self).__init__(key, **kwargs) + super().__init__(key, **kwargs) + + def is_value_valid(self, value: Any) -> bool: + return True def convert_value(self, value): return value @@ -331,6 +374,9 @@ def __init__(self, key, default=None, **kwargs): kwargs["visible"] = False super().__init__(key, **kwargs) + def is_value_valid(self, value: Any) -> bool: + return True + def convert_value(self, value): return value @@ -380,21 +426,21 @@ def __init__( elif default > maximum: default = maximum - super(NumberDef, self).__init__(key, default=default, **kwargs) + super().__init__(key, default=default, **kwargs) self.minimum = minimum self.maximum = maximum self.decimals = 0 if decimals is None else decimals - def __eq__(self, other): - if not super(NumberDef, self).__eq__(other): + def is_value_valid(self, value: Any) -> bool: + if self.decimals == 0: + if not isinstance(value, int): + return False + elif not isinstance(value, float): return False - - return ( - self.decimals == other.decimals - and self.maximum == other.maximum - and self.maximum == other.maximum - ) + if self.minimum > value > self.maximum: + return False + return True def convert_value(self, value): if isinstance(value, str): @@ -410,6 +456,13 @@ def convert_value(self, value): return int(value) return round(float(value), self.decimals) + def _def_type_compare(self, other: "NumberDef") -> bool: + return ( + self.decimals == other.decimals + and self.maximum == other.maximum + and self.maximum == other.maximum + ) + class TextDef(AbstractAttrDef): """Text definition. @@ -439,7 +492,7 @@ def __init__( if default is None: default = "" - super(TextDef, self).__init__(key, default=default, **kwargs) + super().__init__(key, default=default, **kwargs) if multiline is None: multiline = False @@ -456,14 +509,12 @@ def __init__( self.placeholder = placeholder self.regex = regex - def __eq__(self, other): - if not super(TextDef, self).__eq__(other): + def is_value_valid(self, value: Any) -> bool: + if not isinstance(value, str): return False - - return ( - self.multiline == other.multiline - and self.regex == other.regex - ) + if self.regex and not self.regex.match(value): + return False + return True def convert_value(self, value): if isinstance(value, str): @@ -471,10 +522,18 @@ def convert_value(self, value): return self.default def serialize(self): - data = super(TextDef, self).serialize() + data = super().serialize() data["regex"] = self.regex.pattern + data["multiline"] = self.multiline + data["placeholder"] = self.placeholder return data + def _def_type_compare(self, other: "TextDef") -> bool: + return ( + self.multiline == other.multiline + and self.regex == other.regex + ) + class EnumDef(AbstractAttrDef): """Enumeration of items. @@ -513,21 +572,12 @@ def __init__( elif default not in item_values: default = next(iter(item_values), None) - super(EnumDef, self).__init__(key, default=default, **kwargs) + super().__init__(key, default=default, **kwargs) self.items = items self._item_values = item_values_set self.multiselection = multiselection - def __eq__(self, other): - if not super(EnumDef, self).__eq__(other): - return False - - return ( - self.items == other.items - and self.multiselection == other.multiselection - ) - def convert_value(self, value): if not self.multiselection: if value in self._item_values: @@ -538,8 +588,19 @@ def convert_value(self, value): return copy.deepcopy(self.default) return list(self._item_values.intersection(value)) + def is_value_valid(self, value: Any) -> bool: + """Check if item is available in possible values.""" + if isinstance(value, list): + if not self.multiselection: + return False + return all(value in self._item_values for value in value) + + if self.multiselection: + return False + return value in self._item_values + def serialize(self): - data = super(EnumDef, self).serialize() + data = super().serialize() data["items"] = copy.deepcopy(self.items) data["multiselection"] = self.multiselection return data @@ -606,6 +667,12 @@ def prepare_enum_items(items): return output + def _def_type_compare(self, other: "EnumDef") -> bool: + return ( + self.items == other.items + and self.multiselection == other.multiselection + ) + class BoolDef(AbstractAttrDef): """Boolean representation. @@ -619,7 +686,10 @@ class BoolDef(AbstractAttrDef): def __init__(self, key, default=None, **kwargs): if default is None: default = False - super(BoolDef, self).__init__(key, default=default, **kwargs) + super().__init__(key, default=default, **kwargs) + + def is_value_valid(self, value: Any) -> bool: + return isinstance(value, bool) def convert_value(self, value): if isinstance(value, bool): @@ -917,10 +987,10 @@ def __init__( self.extensions = set(extensions) self.allow_sequences = allow_sequences self.extensions_label = extensions_label - super(FileDef, self).__init__(key, default=default, **kwargs) + super().__init__(key, default=default, **kwargs) def __eq__(self, other): - if not super(FileDef, self).__eq__(other): + if not super().__eq__(other): return False return ( @@ -930,6 +1000,29 @@ def __eq__(self, other): and self.allow_sequences == other.allow_sequences ) + def is_value_valid(self, value: Any) -> bool: + if self.single_item: + if not isinstance(value, dict): + return False + try: + FileDefItem.from_dict(value) + return True + except (ValueError, KeyError): + return False + + if not isinstance(value, list): + return False + + for item in value: + if not isinstance(item, dict): + return False + + try: + FileDefItem.from_dict(item) + except (ValueError, KeyError): + return False + return True + def convert_value(self, value): if isinstance(value, (str, dict)): value = [value] diff --git a/client/ayon_core/lib/events.py b/client/ayon_core/lib/events.py index 2601bc1cf4..1965906dda 100644 --- a/client/ayon_core/lib/events.py +++ b/client/ayon_core/lib/events.py @@ -566,6 +566,10 @@ def emit_event(self, event): self._process_event(event) + def clear_callbacks(self): + """Clear all registered callbacks.""" + self._registered_callbacks = [] + def _process_event(self, event): """Process event topic and trigger callbacks. diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 7706860499..adbb03a820 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -7,7 +7,16 @@ import inspect from contextlib import contextmanager import typing -from typing import Optional, Iterable, Dict +from typing import ( + Optional, + Iterable, + Tuple, + List, + Dict, + Any, + Callable, + Union, +) import pyblish.logic import pyblish.api @@ -15,6 +24,7 @@ from ayon_core.settings import get_project_settings from ayon_core.lib import is_func_signature_supported +from ayon_core.lib.events import QueuedEventSystem from ayon_core.lib.attribute_definitions import get_default_values from ayon_core.host import IPublishHost, IWorkfileHost from ayon_core.pipeline import Anatomy @@ -59,6 +69,13 @@ UpdateData = collections.namedtuple("UpdateData", ["instance", "changes"]) _NOT_SET = object() +INSTANCE_ADDED_TOPIC = "instances.added" +INSTANCE_REMOVED_TOPIC = "instances.removed" +VALUE_CHANGED_TOPIC = "values.changed" +PRE_CREATE_ATTR_DEFS_CHANGED_TOPIC = "pre.create.attr.defs.changed" +CREATE_ATTR_DEFS_CHANGED_TOPIC = "create.attr.defs.changed" +PUBLISH_ATTR_DEFS_CHANGED_TOPIC = "publish.attr.defs.changed" + def prepare_failed_convertor_operation_info(identifier, exc_info): exc_type, exc_value, exc_traceback = exc_info @@ -91,6 +108,42 @@ def prepare_failed_creator_operation_info( } +class BulkInfo: + def __init__(self): + self._count = 0 + self._data = [] + self._sender = None + + def __bool__(self): + return self._count == 0 + + def get_sender(self): + return self._sender + + def set_sender(self, sender): + if sender is not None: + self._sender = sender + + def increase(self): + self._count += 1 + + def decrease(self): + self._count -= 1 + + def append(self, item): + self._data.append(item) + + def get_data(self): + """Use this method for read-only.""" + return self._data + + def pop_data(self): + data = self._data + self._data = [] + self._sender = None + return data + + class CreateContext: """Context of instance creation. @@ -117,6 +170,7 @@ def __init__( # Prepare attribute for logger (Created on demand in `log` property) self._log = None + self._event_hub = QueuedEventSystem() # Publish context plugins attributes and it's values self._publish_attributes = PublishAttributes(self, {}) @@ -174,14 +228,26 @@ def __init__( self.publish_plugins_mismatch_targets = [] self.publish_plugins = [] self.plugins_with_defs = [] - self._attr_plugins_by_product_type = {} # Helpers for validating context of collected instances # - they can be validation for multiple instances at one time # using context manager which will trigger validation # after leaving of last context manager scope - self._bulk_counter = 0 - self._bulk_instances_to_process = [] + self._bulk_info = { + # Added instances + "add": BulkInfo(), + # Removed instances + "remove": BulkInfo(), + # Change values of instances or create context + "change": BulkInfo(), + # Pre create attribute definitions changed + "pre_create_attrs_change": BulkInfo(), + # Create attribute definitions changed + "create_attrs_change": BulkInfo(), + # Publish attribute definitions changed + "publish_attrs_change": BulkInfo(), + } + self._bulk_order = [] # Shared data across creators during collection phase self._collection_shared_data = None @@ -465,7 +531,7 @@ def reset(self, discover_publish_plugins=True): self.reset_plugins(discover_publish_plugins) self.reset_context_data() - with self.bulk_instances_collection(): + with self.bulk_add_instances(): self.reset_instances() self.find_convertor_items() self.execute_autocreators() @@ -476,7 +542,7 @@ def refresh_thumbnails(self): """Cleanup thumbnail paths. Remove all thumbnail filepaths that are empty or lead to files which - does not exists or of instances that are not available anymore. + does not exist or of instances that are not available anymore. """ invalid = set() @@ -502,6 +568,7 @@ def reset_preparation(self): self._collection_shared_data = {} self._folder_id_by_folder_path = {} self._task_names_by_folder_path = {} + self._event_hub.clear_callbacks() def reset_finalization(self): """Cleanup of attributes after reset.""" @@ -575,9 +642,6 @@ def _reset_publish_plugins(self, discover_publish_plugins): publish_plugins_discover ) - # Reset publish plugins - self._attr_plugins_by_product_type = {} - discover_result = DiscoverResult(pyblish.api.Plugin) plugins_with_defs = [] plugins_by_targets = [] @@ -603,6 +667,24 @@ def _reset_publish_plugins(self, discover_publish_plugins): if plugin not in plugins_by_targets ] + # Register create context callbacks + for plugin in plugins_with_defs: + if not inspect.ismethod(plugin.register_create_context_callbacks): + self.log.warning( + f"Plugin {plugin.__name__} does not have" + f" 'register_create_context_callbacks'" + f" defined as class method." + ) + continue + try: + plugin.register_create_context_callbacks(self) + except Exception: + self.log.error( + f"Failed to register callbacks for plugin" + f" {plugin.__name__}.", + exc_info=True + ) + self.publish_plugins_mismatch_targets = plugins_mismatch_targets self.publish_discover_result = discover_result self.publish_plugins = plugins_by_targets @@ -705,9 +787,203 @@ def reset_context_data(self): publish_attributes = original_data.get("publish_attributes") or {} - attr_plugins = self._get_publish_plugins_with_attr_for_context() self._publish_attributes = PublishAttributes( - self, publish_attributes, attr_plugins + self, publish_attributes + ) + + for plugin in self.plugins_with_defs: + if is_func_signature_supported( + plugin.convert_attribute_values, self, None + ): + plugin.convert_attribute_values(self, None) + + elif not plugin.__instanceEnabled__: + output = plugin.convert_attribute_values(publish_attributes) + if output: + publish_attributes.update(output) + + for plugin in self.plugins_with_defs: + attr_defs = plugin.get_attr_defs_for_context (self) + if not attr_defs: + continue + self._publish_attributes.set_publish_plugin_attr_defs( + plugin.__name__, attr_defs + ) + + def add_instances_added_callback(self, callback): + """Register callback for added instances. + + Event is triggered when instances are already available in context + and have set create/publish attribute definitions. + + Data structure of event:: + + ```python + { + "instances": [CreatedInstance, ...], + "create_context": CreateContext + } + ``` + + Args: + callback (Callable): Callback function that will be called when + instances are added to context. + + Returns: + EventCallback: Created callback object which can be used to + stop listening. + + """ + return self._event_hub.add_callback(INSTANCE_ADDED_TOPIC, callback) + + def add_instances_removed_callback (self, callback): + """Register callback for removed instances. + + Event is triggered when instances are already removed from context. + + Data structure of event:: + + ```python + { + "instances": [CreatedInstance, ...], + "create_context": CreateContext + } + ``` + + Args: + callback (Callable): Callback function that will be called when + instances are removed from context. + + Returns: + EventCallback: Created callback object which can be used to + stop listening. + + """ + self._event_hub.add_callback(INSTANCE_REMOVED_TOPIC, callback) + + def add_value_changed_callback(self, callback): + """Register callback to listen value changes. + + Event is triggered when any value changes on any instance or + context data. + + Data structure of event:: + + ```python + { + "changes": [ + { + "instance": CreatedInstance, + "changes": { + "folderPath": "/new/folder/path", + "creator_attributes": { + "attr_1": "value_1" + } + } + } + ], + "create_context": CreateContext + } + ``` + + Args: + callback (Callable): Callback function that will be called when + value changed. + + Returns: + EventCallback: Created callback object which can be used to + stop listening. + + """ + self._event_hub.add_callback(VALUE_CHANGED_TOPIC, callback) + + def add_pre_create_attr_defs_change_callback (self, callback): + """Register callback to listen pre-create attribute changes. + + Create plugin can trigger refresh of pre-create attributes. Usage of + this event is mainly for publisher UI. + + Data structure of event:: + + ```python + { + "identifiers": ["create_plugin_identifier"], + "create_context": CreateContext + } + ``` + + Args: + callback (Callable): Callback function that will be called when + pre-create attributes should be refreshed. + + Returns: + EventCallback: Created callback object which can be used to + stop listening. + + """ + self._event_hub.add_callback( + PRE_CREATE_ATTR_DEFS_CHANGED_TOPIC, callback + ) + + def add_create_attr_defs_change_callback (self, callback): + """Register callback to listen create attribute changes. + + Create plugin changed attribute definitions of instance. + + Data structure of event:: + + ```python + { + "instances": [CreatedInstance, ...], + "create_context": CreateContext + } + ``` + + Args: + callback (Callable): Callback function that will be called when + create attributes changed. + + Returns: + EventCallback: Created callback object which can be used to + stop listening. + + """ + self._event_hub.add_callback(CREATE_ATTR_DEFS_CHANGED_TOPIC, callback) + + def add_publish_attr_defs_change_callback (self, callback): + """Register callback to listen publish attribute changes. + + Publish plugin changed attribute definitions of instance of context. + + Data structure of event:: + + ```python + { + "instance_changes": { + None: { + "instance": None, + "plugin_names": {"PluginA"}, + } + "": { + "instance": CreatedInstance, + "plugin_names": {"PluginB", "PluginC"}, + } + }, + "create_context": CreateContext + } + ``` + + Args: + callback (Callable): Callback function that will be called when + publish attributes changed. + + Returns: + EventCallback: Created callback object which can be used to + stop listening. + + """ + self._event_hub.add_callback( + PUBLISH_ATTR_DEFS_CHANGED_TOPIC, callback ) def context_data_to_store(self): @@ -726,6 +1002,21 @@ def context_data_changes(self): self._original_context_data, self.context_data_to_store() ) + def set_context_publish_plugin_attr_defs(self, plugin_name, attr_defs): + """Set attribute definitions for CreateContext publish plugin. + + Args: + plugin_name(str): Name of publish plugin. + attr_defs(List[AbstractAttrDef]): Attribute definitions. + + """ + self.publish_attributes.set_publish_plugin_attr_defs( + plugin_name, attr_defs + ) + self.instance_publish_attr_defs_changed( + None, plugin_name + ) + def creator_adds_instance(self, instance: "CreatedInstance"): """Creator adds new instance to context. @@ -745,16 +1036,11 @@ def creator_adds_instance(self, instance: "CreatedInstance"): return self._instances_by_id[instance.id] = instance - # Prepare publish plugin attributes and set it on instance - attr_plugins = self._get_publish_plugins_with_attr_for_product_type( - instance.product_type - ) - instance.set_publish_plugins(attr_plugins) - # Add instance to be validated inside 'bulk_instances_collection' + # Add instance to be validated inside 'bulk_add_instances' # context manager if is inside bulk - with self.bulk_instances_collection(): - self._bulk_instances_to_process.append(instance) + with self.bulk_add_instances() as bulk_info: + bulk_info.append(instance) def _get_creator_in_create(self, identifier): """Creator by identifier with unified error. @@ -813,8 +1099,8 @@ def create( Raises: CreatorError: If creator was not found or folder is empty. - """ + """ creator = self._get_creator_in_create(creator_identifier) project_name = self.project_name @@ -880,52 +1166,13 @@ def create( active = bool(active) instance_data["active"] = active - return creator.create( - product_name, - instance_data, - _pre_create_data - ) - - def _create_with_unified_error( - self, identifier, creator, *args, **kwargs - ): - error_message = "Failed to run Creator with identifier \"{}\". {}" - - label = None - add_traceback = False - result = None - fail_info = None - exc_info = None - success = False - - try: - # Try to get creator and his label - if creator is None: - creator = self._get_creator_in_create(identifier) - label = getattr(creator, "label", label) - - # Run create - result = creator.create(*args, **kwargs) - success = True - - except CreatorError: - exc_info = sys.exc_info() - self.log.warning(error_message.format(identifier, exc_info[1])) - - except: # noqa: E722 - add_traceback = True - exc_info = sys.exc_info() - self.log.warning( - error_message.format(identifier, ""), - exc_info=True + with self.bulk_add_instances(): + return creator.create( + product_name, + instance_data, + _pre_create_data ) - if not success: - fail_info = prepare_failed_creator_operation_info( - identifier, label, exc_info, add_traceback - ) - return result, fail_info - def create_with_unified_error(self, identifier, *args, **kwargs): """Trigger create but raise only one error if anything fails. @@ -941,8 +1188,8 @@ def create_with_unified_error(self, identifier, *args, **kwargs): CreatorsCreateFailed: When creation fails due to any possible reason. If anything goes wrong this is only possible exception the method should raise. - """ + """ result, fail_info = self._create_with_unified_error( identifier, None, *args, **kwargs ) @@ -950,13 +1197,10 @@ def create_with_unified_error(self, identifier, *args, **kwargs): raise CreatorsCreateFailed([fail_info]) return result - def _remove_instance(self, instance): - self._instances_by_id.pop(instance.id, None) - def creator_removed_instance(self, instance: "CreatedInstance"): """When creator removes instance context should be acknowledged. - If creator removes instance conext should know about it to avoid + If creator removes instance context should know about it to avoid possible issues in the session. Args: @@ -964,7 +1208,7 @@ def creator_removed_instance(self, instance: "CreatedInstance"): from scene metadata. """ - self._remove_instance(instance) + self._remove_instances([instance]) def add_convertor_item(self, convertor_identifier, label): self.convertor_items_by_id[convertor_identifier] = ConvertorItem( @@ -975,33 +1219,167 @@ def remove_convertor_item(self, convertor_identifier): self.convertor_items_by_id.pop(convertor_identifier, None) @contextmanager - def bulk_instances_collection(self): - """Validate context of instances in bulk. + def bulk_add_instances(self, sender=None): + with self._bulk_context("add", sender) as bulk_info: + yield bulk_info + + # Set publish attributes before bulk context is exited + for instance in bulk_info.get_data(): + publish_attributes = instance.publish_attributes + # Prepare publish plugin attributes and set it on instance + for plugin in self.plugins_with_defs: + try: + if is_func_signature_supported( + plugin.convert_attribute_values, self, instance + ): + plugin.convert_attribute_values(self, instance) + + elif plugin.__instanceEnabled__: + output = plugin.convert_attribute_values( + publish_attributes + ) + if output: + publish_attributes.update(output) + + except Exception: + self.log.error( + "Failed to convert attribute values of" + f" plugin '{plugin.__name__}'", + exc_info=True + ) + + for plugin in self.plugins_with_defs: + attr_defs = None + try: + attr_defs = plugin.get_attr_defs_for_instance( + self, instance + ) + except Exception: + self.log.error( + "Failed to get attribute definitions" + f" from plugin '{plugin.__name__}'.", + exc_info=True + ) + + if not attr_defs: + continue + instance.set_publish_plugin_attr_defs( + plugin.__name__, attr_defs + ) + + @contextmanager + def bulk_instances_collection(self, sender=None): + """DEPRECATED use 'bulk_add_instances' instead.""" + # TODO add warning + with self.bulk_add_instances(sender) as bulk_info: + yield bulk_info + + @contextmanager + def bulk_remove_instances(self, sender=None): + with self._bulk_context("remove", sender) as bulk_info: + yield bulk_info + + @contextmanager + def bulk_value_changes(self, sender=None): + with self._bulk_context("change", sender) as bulk_info: + yield bulk_info - This can be used for single instance or for adding multiple instances - which is helpfull on reset. + @contextmanager + def bulk_pre_create_attr_defs_change(self, sender=None): + with self._bulk_context("pre_create_attrs_change", sender) as bulk_info: + yield bulk_info + + @contextmanager + def bulk_create_attr_defs_change(self, sender=None): + with self._bulk_context("create_attrs_change", sender) as bulk_info: + yield bulk_info + + @contextmanager + def bulk_publish_attr_defs_change(self, sender=None): + with self._bulk_context("publish_attrs_change", sender) as bulk_info: + yield bulk_info + + # --- instance change callbacks --- + def create_plugin_pre_create_attr_defs_changed(self, identifier: str): + """Create plugin pre-create attributes changed. + + Triggered by 'Creator'. + + Args: + identifier (str): Create plugin identifier. - Should not be executed from multiple threads. """ - self._bulk_counter += 1 - try: - yield - finally: - self._bulk_counter -= 1 - - # Trigger validation if there is no more context manager for bulk - # instance validation - if self._bulk_counter != 0: - return - - ( - self._bulk_instances_to_process, - instances_to_validate - ) = ( - [], - self._bulk_instances_to_process - ) - self.get_instances_context_info(instances_to_validate) + with self.bulk_pre_create_attr_defs_change() as bulk_item: + bulk_item.append(identifier) + + def instance_create_attr_defs_changed(self, instance_id: str): + """Instance attribute definitions changed. + + Triggered by instance 'CreatorAttributeValues' on instance. + + Args: + instance_id (str): Instance id. + + """ + if self._is_instance_events_ready(instance_id): + with self.bulk_create_attr_defs_change() as bulk_item: + bulk_item.append(instance_id) + + def instance_publish_attr_defs_changed( + self, instance_id: Optional[str], plugin_name: str + ): + """Instance attribute definitions changed. + + Triggered by instance 'PublishAttributeValues' on instance. + + Args: + instance_id (Optional[str]): Instance id or None for context. + plugin_name (str): Plugin name which attribute definitions were + changed. + + """ + if self._is_instance_events_ready(instance_id): + with self.bulk_publish_attr_defs_change() as bulk_item: + bulk_item.append((instance_id, plugin_name)) + + def instance_values_changed( + self, instance_id: Optional[str], new_values: Dict[str, Any] + ): + """Instance value changed. + + Triggered by `CreatedInstance, 'CreatorAttributeValues' + or 'PublishAttributeValues' on instance. + + Args: + instance_id (Optional[str]): Instance id or None for context. + new_values (Dict[str, Any]): Changed values. + + """ + if self._is_instance_events_ready(instance_id): + with self.bulk_value_changes() as bulk_item: + bulk_item.append((instance_id, new_values)) + + # --- context change callbacks --- + def publish_attribute_value_changed( + self, plugin_name: str, value: Dict[str, Any] + ): + """Context publish attribute values changed. + + Triggered by instance 'PublishAttributeValues' on context. + + Args: + plugin_name (str): Plugin name which changed value. + value (Dict[str, Any]): Changed values. + + """ + self.instance_values_changed( + None, + { + "publish_attributes": { + plugin_name: value, + }, + }, + ) def reset_instances(self): """Reload instances""" @@ -1303,18 +1681,19 @@ def _save_instance_changes(self): if failed_info: raise CreatorsSaveFailed(failed_info) - def remove_instances(self, instances): + def remove_instances(self, instances, sender=None): """Remove instances from context. All instances that don't have creator identifier leading to existing creator are just removed from context. Args: - instances(List[CreatedInstance]): Instances that should be removed. + instances (List[CreatedInstance]): Instances that should be removed. Remove logic is done using creator, which may require to do other cleanup than just remove instance from context. - """ + sender (Optional[str]): Sender of the event. + """ instances_by_identifier = collections.defaultdict(list) for instance in instances: identifier = instance.creator_identifier @@ -1322,9 +1701,14 @@ def remove_instances(self, instances): # Just remove instances from context if creator is not available missing_creators = set(instances_by_identifier) - set(self.creators) + instances = [] for identifier in missing_creators: - for instance in instances_by_identifier[identifier]: - self._remove_instance(instance) + instances.extend( + instance + for instance in instances_by_identifier[identifier] + ) + + self._remove_instances(instances, sender) error_message = "Instances removement of creator \"{}\" failed. {}" failed_info = [] @@ -1349,6 +1733,9 @@ def remove_instances(self, instances): error_message.format(identifier, exc_info[1]) ) + except (KeyboardInterrupt, SystemExit): + raise + except: # noqa: E722 failed = True add_traceback = True @@ -1368,44 +1755,6 @@ def remove_instances(self, instances): if failed_info: raise CreatorsRemoveFailed(failed_info) - def _get_publish_plugins_with_attr_for_product_type(self, product_type): - """Publish plugin attributes for passed product type. - - Attribute definitions for specific product type are cached. - - Args: - product_type(str): Instance product type for which should be - attribute definitions returned. - """ - - if product_type not in self._attr_plugins_by_product_type: - import pyblish.logic - - filtered_plugins = pyblish.logic.plugins_by_families( - self.plugins_with_defs, [product_type] - ) - plugins = [] - for plugin in filtered_plugins: - if plugin.__instanceEnabled__: - plugins.append(plugin) - self._attr_plugins_by_product_type[product_type] = plugins - - return self._attr_plugins_by_product_type[product_type] - - def _get_publish_plugins_with_attr_for_context(self): - """Publish plugins attributes for Context plugins. - - Returns: - List[pyblish.api.Plugin]: Publish plugins that have attribute - definitions for context. - """ - - plugins = [] - for plugin in self.plugins_with_defs: - if not plugin.__instanceEnabled__: - plugins.append(plugin) - return plugins - @property def collection_shared_data(self): """Access to shared data that can be used during creator's collection. @@ -1470,3 +1819,269 @@ def run_convertors(self, convertor_identifiers): if failed_info: raise ConvertorsConversionFailed(failed_info) + + def _register_event_callback(self, topic: str, callback: Callable): + return self._event_hub.add_callback(topic, callback) + + def _emit_event( + self, + topic: str, + data: Optional[Dict[str, Any]] = None, + sender: Optional[str] = None, + ): + if data is None: + data = {} + data.setdefault("create_context", self) + return self._event_hub.emit(topic, data, sender) + + def _remove_instances(self, instances, sender=None): + with self.bulk_remove_instances(sender) as bulk_info: + for instance in instances: + obj = self._instances_by_id.pop(instance.id, None) + if obj is not None: + bulk_info.append(obj) + + def _create_with_unified_error( + self, identifier, creator, *args, **kwargs + ): + error_message = "Failed to run Creator with identifier \"{}\". {}" + + label = None + add_traceback = False + result = None + fail_info = None + exc_info = None + success = False + + try: + # Try to get creator and his label + if creator is None: + creator = self._get_creator_in_create(identifier) + label = getattr(creator, "label", label) + + # Run create + with self.bulk_add_instances(): + result = creator.create(*args, **kwargs) + success = True + + except CreatorError: + exc_info = sys.exc_info() + self.log.warning(error_message.format(identifier, exc_info[1])) + + except: # noqa: E722 + add_traceback = True + exc_info = sys.exc_info() + self.log.warning( + error_message.format(identifier, ""), + exc_info=True + ) + + if not success: + fail_info = prepare_failed_creator_operation_info( + identifier, label, exc_info, add_traceback + ) + return result, fail_info + + def _is_instance_events_ready(self, instance_id: Optional[str]) -> bool: + # Context is ready + if instance_id is None: + return True + # Instance is not in yet in context + if instance_id not in self._instances_by_id: + return False + + # Instance in 'collect' bulk will be ignored + for instance in self._bulk_info["add"].get_data(): + if instance.id == instance_id: + return False + return True + + @contextmanager + def _bulk_context(self, key: str, sender: Optional[str]): + bulk_info = self._bulk_info[key] + bulk_info.set_sender(sender) + + bulk_info.increase() + if key not in self._bulk_order: + self._bulk_order.append(key) + try: + yield bulk_info + finally: + bulk_info.decrease() + if bulk_info: + self._bulk_finished(key) + + def _bulk_finished(self, key: str): + if self._bulk_order[0] != key: + return + + self._bulk_order.pop(0) + self._bulk_finish(key) + + while self._bulk_order: + key = self._bulk_order[0] + if not self._bulk_info[key]: + break + self._bulk_order.pop(0) + self._bulk_finish(key) + + def _bulk_finish(self, key: str): + bulk_info = self._bulk_info[key] + sender = bulk_info.get_sender() + data = bulk_info.pop_data() + if key == "add": + self._bulk_add_instances_finished(data, sender) + elif key == "remove": + self._bulk_remove_instances_finished(data, sender) + elif key == "change": + self._bulk_values_change_finished(data, sender) + elif key == "pre_create_attrs_change": + self._bulk_pre_create_attrs_change_finished(data, sender) + elif key == "create_attrs_change": + self._bulk_create_attrs_change_finished(data, sender) + elif key == "publish_attrs_change": + self._bulk_publish_attrs_change_finished(data, sender) + + def _bulk_add_instances_finished( + self, + instances_to_validate: List["CreatedInstance"], + sender: Optional[str] + ): + if not instances_to_validate: + return + + # Cache folder and task entities for all instances at once + self.get_instances_context_info(instances_to_validate) + + self._emit_event( + INSTANCE_ADDED_TOPIC, + { + "instances": instances_to_validate, + }, + sender, + ) + + def _bulk_remove_instances_finished( + self, + instances_to_remove: List["CreatedInstance"], + sender: Optional[str] + ): + if not instances_to_remove: + return + + self._emit_event( + INSTANCE_REMOVED_TOPIC, + { + "instances": instances_to_remove, + }, + sender, + ) + + def _bulk_values_change_finished( + self, + changes: Tuple[Union[str, None], Dict[str, Any]], + sender: Optional[str], + ): + if not changes: + return + item_data_by_id = {} + for item_id, item_changes in changes: + item_values = item_data_by_id.setdefault(item_id, {}) + if "creator_attributes" in item_changes: + current_value = item_values.setdefault( + "creator_attributes", {} + ) + current_value.update( + item_changes.pop("creator_attributes") + ) + + if "publish_attributes" in item_changes: + current_publish = item_values.setdefault( + "publish_attributes", {} + ) + for plugin_name, plugin_value in item_changes.pop( + "publish_attributes" + ).items(): + plugin_changes = current_publish.setdefault( + plugin_name, {} + ) + plugin_changes.update(plugin_value) + + item_values.update(item_changes) + + event_changes = [] + for item_id, item_changes in item_data_by_id.items(): + instance = self.get_instance_by_id(item_id) + event_changes.append({ + "instance": instance, + "changes": item_changes, + }) + + event_data = { + "changes": event_changes, + } + + self._emit_event( + VALUE_CHANGED_TOPIC, + event_data, + sender + ) + + def _bulk_pre_create_attrs_change_finished( + self, identifiers: List[str], sender: Optional[str] + ): + if not identifiers: + return + identifiers = list(set(identifiers)) + self._emit_event( + PRE_CREATE_ATTR_DEFS_CHANGED_TOPIC, + { + "identifiers": identifiers, + }, + sender, + ) + + def _bulk_create_attrs_change_finished( + self, instance_ids: List[str], sender: Optional[str] + ): + if not instance_ids: + return + + instances = [ + self.get_instance_by_id(instance_id) + for instance_id in set(instance_ids) + ] + self._emit_event( + CREATE_ATTR_DEFS_CHANGED_TOPIC, + { + "instances": instances, + }, + sender, + ) + + def _bulk_publish_attrs_change_finished( + self, + attr_info: Tuple[str, Union[str, None]], + sender: Optional[str], + ): + if not attr_info: + return + + instance_changes = {} + for instance_id, plugin_name in attr_info: + instance_data = instance_changes.setdefault( + instance_id, + { + "instance": None, + "plugin_names": set(), + } + ) + instance = self.get_instance_by_id(instance_id) + instance_data["instance"] = instance + instance_data["plugin_names"].add(plugin_name) + + self._emit_event( + PUBLISH_ATTR_DEFS_CHANGED_TOPIC, + {"instance_changes": instance_changes}, + sender, + ) diff --git a/client/ayon_core/pipeline/create/creator_plugins.py b/client/ayon_core/pipeline/create/creator_plugins.py index 61c10ee736..fe41d2fe65 100644 --- a/client/ayon_core/pipeline/create/creator_plugins.py +++ b/client/ayon_core/pipeline/create/creator_plugins.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- import copy import collections -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, Optional, Dict, Any from abc import ABC, abstractmethod @@ -19,11 +19,12 @@ from .product_name import get_product_name from .utils import get_next_versions_for_instances from .legacy_create import LegacyCreator +from .structures import CreatedInstance if TYPE_CHECKING: from ayon_core.lib import AbstractAttrDef # Avoid cyclic imports - from .context import CreateContext, CreatedInstance, UpdateData # noqa: F401 + from .context import CreateContext, UpdateData # noqa: F401 class ProductConvertorPlugin(ABC): @@ -204,6 +205,7 @@ def __init__( self.headless = headless self.apply_settings(project_settings) + self.register_callbacks() @staticmethod def _get_settings_values(project_settings, category_name, plugin_name): @@ -289,6 +291,14 @@ def apply_settings(self, project_settings): )) setattr(self, key, value) + def register_callbacks(self): + """Register callbacks for creator. + + Default implementation does nothing. It can be overridden to register + callbacks for creator. + """ + pass + @property def identifier(self): """Identifier of creator (must be unique). @@ -362,6 +372,35 @@ def log(self): self._log = Logger.get_logger(self.__class__.__name__) return self._log + def _create_instance( + self, + product_name: str, + data: Dict[str, Any], + product_type: Optional[str] = None + ) -> CreatedInstance: + """Create instance and add instance to context. + + Args: + product_name (str): Product name. + data (Dict[str, Any]): Instance data. + product_type (Optional[str]): Product type, object attribute + 'product_type' is used if not passed. + + Returns: + CreatedInstance: Created instance. + + """ + if product_type is None: + product_type = self.product_type + instance = CreatedInstance( + product_type, + product_name, + data, + creator=self, + ) + self._add_instance_to_context(instance) + return instance + def _add_instance_to_context(self, instance): """Helper method to add instance to create context. @@ -551,6 +590,16 @@ def get_instance_attr_defs(self): return self.instance_attr_defs + def get_attr_defs_for_instance(self, instance): + """Get attribute definitions for an instance. + + Args: + instance (CreatedInstance): Instance for which to get + attribute definitions. + + """ + return self.get_instance_attr_defs() + @property def collection_shared_data(self): """Access to shared data that can be used during creator's collection. @@ -782,6 +831,16 @@ def get_pre_create_attr_defs(self): """ return self.pre_create_attr_defs + def _pre_create_attr_defs_changed(self): + """Called when pre-create attribute definitions change. + + Create plugin can call this method when knows that + 'get_pre_create_attr_defs' should be called again. + """ + self.create_context.create_plugin_pre_create_attr_defs_changed( + self.identifier + ) + class HiddenCreator(BaseCreator): @abstractmethod diff --git a/client/ayon_core/pipeline/create/structures.py b/client/ayon_core/pipeline/create/structures.py index 9019b05b21..bcc9a87c49 100644 --- a/client/ayon_core/pipeline/create/structures.py +++ b/client/ayon_core/pipeline/create/structures.py @@ -1,9 +1,10 @@ import copy import collections from uuid import uuid4 -from typing import Optional +from typing import Optional, Dict, List, Any from ayon_core.lib.attribute_definitions import ( + AbstractAttrDef, UnknownDef, serialize_attr_defs, deserialize_attr_defs, @@ -79,12 +80,17 @@ class AttributeValues: Has dictionary like methods. Not all of them are allowed all the time. Args: - attr_defs(AbstractAttrDef): Definitions of value type and properties. - values(dict): Values after possible conversion. - origin_data(dict): Values loaded from host before conversion. - """ + parent (Union[CreatedInstance, PublishAttributes]): Parent object. + key (str): Key of attribute values. + attr_defs (List[AbstractAttrDef]): Definitions of value type + and properties. + values (dict): Values after possible conversion. + origin_data (dict): Values loaded from host before conversion. - def __init__(self, attr_defs, values, origin_data=None): + """ + def __init__(self, parent, key, attr_defs, values, origin_data=None): + self._parent = parent + self._key = key if origin_data is None: origin_data = copy.deepcopy(values) self._origin_data = origin_data @@ -106,7 +112,10 @@ def __init__(self, attr_defs, values, origin_data=None): self._data = {} for attr_def in attr_defs: value = values.get(attr_def.key) - if value is not None: + if value is None: + continue + converted_value = attr_def.convert_value(value) + if converted_value == value: self._data[attr_def.key] = value def __setitem__(self, key, value): @@ -139,6 +148,9 @@ def items(self): for key in self._attr_defs_by_key.keys(): yield key, self._data.get(key) + def get_attr_def(self, key, default=None): + return self._attr_defs_by_key.get(key, default) + def update(self, value): changes = {} for _key, _value in dict(value).items(): @@ -147,7 +159,11 @@ def update(self, value): self._data[_key] = _value changes[_key] = _value + if changes: + self._parent.attribute_value_changed(self._key, changes) + def pop(self, key, default=None): + has_key = key in self._data value = self._data.pop(key, default) # Remove attribute definition if is 'UnknownDef' # - gives option to get rid of unknown values @@ -155,6 +171,8 @@ def pop(self, key, default=None): if isinstance(attr_def, UnknownDef): self._attr_defs_by_key.pop(key) self._attr_defs.remove(attr_def) + elif has_key: + self._parent.attribute_value_changed(self._key, {key: None}) return value def reset_values(self): @@ -204,15 +222,11 @@ def get_serialized_attr_defs(self): class CreatorAttributeValues(AttributeValues): - """Creator specific attribute values of an instance. - - Args: - instance (CreatedInstance): Instance for which are values hold. - """ + """Creator specific attribute values of an instance.""" - def __init__(self, instance, *args, **kwargs): - self.instance = instance - super().__init__(*args, **kwargs) + @property + def instance(self): + return self._parent class PublishAttributeValues(AttributeValues): @@ -220,19 +234,11 @@ class PublishAttributeValues(AttributeValues): Values are for single plugin which can be on `CreatedInstance` or context values stored on `CreateContext`. - - Args: - publish_attributes(PublishAttributes): Wrapper for multiple publish - attributes is used as parent object. """ - def __init__(self, publish_attributes, *args, **kwargs): - self.publish_attributes = publish_attributes - super().__init__(*args, **kwargs) - @property - def parent(self): - return self.publish_attributes.parent + def publish_attributes(self): + return self._parent class PublishAttributes: @@ -245,22 +251,13 @@ class PublishAttributes: parent(CreatedInstance, CreateContext): Parent for which will be data stored and from which are data loaded. origin_data(dict): Loaded data by plugin class name. - attr_plugins(Union[List[pyblish.api.Plugin], None]): List of publish - plugins that may have defined attribute definitions. - """ - def __init__(self, parent, origin_data, attr_plugins=None): - self.parent = parent + """ + def __init__(self, parent, origin_data): + self._parent = parent self._origin_data = copy.deepcopy(origin_data) - attr_plugins = attr_plugins or [] - self.attr_plugins = attr_plugins - self._data = copy.deepcopy(origin_data) - self._plugin_names_order = [] - self._missing_plugins = [] - - self.set_publish_plugins(attr_plugins) def __getitem__(self, key): return self._data[key] @@ -277,6 +274,9 @@ def values(self): def items(self): return self._data.items() + def get(self, key, default=None): + return self._data.get(key, default) + def pop(self, key, default=None): """Remove or reset value for plugin. @@ -291,74 +291,65 @@ def pop(self, key, default=None): if key not in self._data: return default - if key in self._missing_plugins: - self._missing_plugins.remove(key) - removed_item = self._data.pop(key) - return removed_item.data_to_store() + value = self._data[key] + if not isinstance(value, AttributeValues): + self.attribute_value_changed(key, None) + return self._data.pop(key) value_item = self._data[key] # Prepare value to return output = value_item.data_to_store() # Reset values value_item.reset_values() + self.attribute_value_changed( + key, value_item.data_to_store() + ) return output - def plugin_names_order(self): - """Plugin names order by their 'order' attribute.""" - - for name in self._plugin_names_order: - yield name - def mark_as_stored(self): self._origin_data = copy.deepcopy(self.data_to_store()) def data_to_store(self): """Convert attribute values to "data to store".""" - output = {} for key, attr_value in self._data.items(): - output[key] = attr_value.data_to_store() + if isinstance(attr_value, AttributeValues): + output[key] = attr_value.data_to_store() + else: + output[key] = attr_value return output @property def origin_data(self): return copy.deepcopy(self._origin_data) - def set_publish_plugins(self, attr_plugins): - """Set publish plugins attribute definitions.""" + def attribute_value_changed(self, key, changes): + self._parent.publish_attribute_value_changed(key, changes) - self._plugin_names_order = [] - self._missing_plugins = [] - self.attr_plugins = attr_plugins or [] + def set_publish_plugin_attr_defs( + self, + plugin_name: str, + attr_defs: List[AbstractAttrDef], + value: Optional[Dict[str, Any]] = None + ): + """Set attribute definitions for plugin. - origin_data = self._origin_data - data = self._data - self._data = {} - added_keys = set() - for plugin in attr_plugins: - output = plugin.convert_attribute_values(data) - if output is not None: - data = output - attr_defs = plugin.get_attribute_defs() - if not attr_defs: - continue + Args: + plugin_name (str): Name of plugin. + attr_defs (List[AbstractAttrDef]): Attribute definitions. + value (Optional[Dict[str, Any]]): Attribute values. - key = plugin.__name__ - added_keys.add(key) - self._plugin_names_order.append(key) + """ + # TODO what if 'attr_defs' is 'None'? + if value is None: + value = self._data.get(plugin_name) - value = data.get(key) or {} - orig_value = copy.deepcopy(origin_data.get(key) or {}) - self._data[key] = PublishAttributeValues( - self, attr_defs, value, orig_value - ) + if value is None: + value = {} - for key, value in data.items(): - if key not in added_keys: - self._missing_plugins.append(key) - self._data[key] = PublishAttributeValues( - self, [], value, value - ) + self._data[plugin_name] = PublishAttributeValues( + self, plugin_name, attr_defs, value, value + ) def serialize_attributes(self): return { @@ -366,14 +357,9 @@ def serialize_attributes(self): plugin_name: attrs_value.get_serialized_attr_defs() for plugin_name, attrs_value in self._data.items() }, - "plugin_names_order": self._plugin_names_order, - "missing_plugins": self._missing_plugins } def deserialize_attributes(self, data): - self._plugin_names_order = data["plugin_names_order"] - self._missing_plugins = data["missing_plugins"] - attr_defs = deserialize_attr_defs(data["attr_defs"]) origin_data = self._origin_data @@ -386,15 +372,12 @@ def deserialize_attributes(self, data): value = data.get(plugin_name) or {} orig_value = copy.deepcopy(origin_data.get(plugin_name) or {}) self._data[plugin_name] = PublishAttributeValues( - self, attr_defs, value, orig_value + self, plugin_name, attr_defs, value, orig_value ) for key, value in data.items(): if key not in added_keys: - self._missing_plugins.append(key) - self._data[key] = PublishAttributeValues( - self, [], value, value - ) + self._data[key] = value class InstanceContextInfo: @@ -432,12 +415,7 @@ class CreatedInstance: product_name (str): Name of product that will be created. data (Dict[str, Any]): Data used for filling product name or override data from already existing instance. - creator (Union[BaseCreator, None]): Creator responsible for instance. - creator_identifier (str): Identifier of creator plugin. - creator_label (str): Creator plugin label. - group_label (str): Default group label from creator plugin. - creator_attr_defs (List[AbstractAttrDef]): Attribute definitions from - creator. + creator (BaseCreator): Creator responsible for instance. """ # Keys that can't be changed or removed from data after loading using @@ -458,17 +436,12 @@ def __init__( product_type, product_name, data, - creator=None, - creator_identifier=None, - creator_label=None, - group_label=None, - creator_attr_defs=None, + creator, ): - if creator is not None: - creator_identifier = creator.identifier - group_label = creator.get_group_label() - creator_label = creator.label - creator_attr_defs = creator.get_instance_attr_defs() + self._creator = creator + creator_identifier = creator.identifier + group_label = creator.get_group_label() + creator_label = creator.label self._creator_label = creator_label self._group_label = group_label or creator_identifier @@ -528,18 +501,12 @@ def __init__( # {key: value} creator_values = copy.deepcopy(orig_creator_attributes) - self._data["creator_attributes"] = CreatorAttributeValues( - self, - list(creator_attr_defs), - creator_values, - orig_creator_attributes - ) + self._data["creator_attributes"] = creator_values # Stored publish specific attribute values # {: {key: value}} - # - must be set using 'set_publish_plugins' self._data["publish_attributes"] = PublishAttributes( - self, orig_publish_attributes, None + self, orig_publish_attributes ) if data: self._data.update(data) @@ -547,6 +514,11 @@ def __init__( if not self._data.get("instance_id"): self._data["instance_id"] = str(uuid4()) + creator_attr_defs = creator.get_attr_defs_for_instance(self) + self.set_create_attr_defs( + creator_attr_defs, creator_values + ) + def __str__(self): return ( ": Attribute definitions for plugin. - """ + list[AbstractAttrDef]: Attribute definitions for plugin. + """ return [] @classmethod - def convert_attribute_values(cls, attribute_values): - if cls.__name__ not in attribute_values: - return attribute_values - - plugin_values = attribute_values[cls.__name__] - - attr_defs = cls.get_attribute_defs() - for attr_def in attr_defs: - key = attr_def.key - if key in plugin_values: - plugin_values[key] = attr_def.convert_value( - plugin_values[key] - ) - return attribute_values + def get_attr_defs_for_context(cls, create_context: "CreateContext"): + """Publish attribute definitions for context. + + Attributes available for all families in plugin's `families` attribute. + + Args: + create_context (CreateContext): Create context. + + Returns: + list[AbstractAttrDef]: Attribute definitions for plugin. + + """ + if cls.__instanceEnabled__: + return [] + return cls.get_attribute_defs() + + @classmethod + def instance_matches_plugin_families( + cls, instance: Optional["CreatedInstance"] + ): + """Check if instance matches families. + + Args: + instance (Optional[CreatedInstance]): Instance to check. Or None + for context. + + Returns: + bool: True if instance matches plugin families. + + """ + if instance is None: + return not cls.__instanceEnabled__ + + if not cls.__instanceEnabled__: + return False + + for _ in pyblish.logic.plugins_by_families( + [cls], [instance.product_type] + ): + return True + return False + + @classmethod + def get_attr_defs_for_instance( + cls, create_context: "CreateContext", instance: "CreatedInstance" + ): + """Publish attribute definitions for an instance. + + Attributes available for all families in plugin's `families` attribute. + + Args: + create_context (CreateContext): Create context. + instance (CreatedInstance): Instance for which attributes are + collected. + + Returns: + list[AbstractAttrDef]: Attribute definitions for plugin. + + """ + if not cls.instance_matches_plugin_families(instance): + return [] + return cls.get_attribute_defs() + + @classmethod + def convert_attribute_values( + cls, create_context: "CreateContext", instance: "CreatedInstance" + ): + """Convert attribute values for instance. + + Args: + create_context (CreateContext): Create context. + instance (CreatedInstance): Instance for which attributes are + converted. + + """ + return @staticmethod def get_attr_values_from_data_for_plugin(plugin, data): diff --git a/client/ayon_core/tools/publisher/abstract.py b/client/ayon_core/tools/publisher/abstract.py index 6bea4cc247..a6ae93cecd 100644 --- a/client/ayon_core/tools/publisher/abstract.py +++ b/client/ayon_core/tools/publisher/abstract.py @@ -15,7 +15,6 @@ from ayon_core.host import HostBase from ayon_core.pipeline.create import ( CreateContext, - CreatedInstance, ConvertorItem, ) from ayon_core.tools.common_models import ( @@ -26,7 +25,7 @@ ) if TYPE_CHECKING: - from .models import CreatorItem, PublishErrorInfo + from .models import CreatorItem, PublishErrorInfo, InstanceItem class CardMessageTypes: @@ -78,7 +77,7 @@ def emit_card_message( in future e.g. different message timeout or type (color). Args: - message (str): Message that will be showed. + message (str): Message that will be shown. message_type (Optional[str]): Message type. """ @@ -203,7 +202,7 @@ def register_event_callback(self, topic: str, callback: Callable): def is_host_valid(self) -> bool: """Host is valid for creation part. - Host must have implemented certain functionality to be able create + Host must have implemented certain functionality to be able to create in Publisher tool. Returns: @@ -266,6 +265,11 @@ def are_folder_paths_valid(self, folder_paths: Iterable[str]) -> bool: """ pass + @abstractmethod + def get_folder_id_from_path(self, folder_path: str) -> Optional[str]: + """Get folder id from folder path.""" + pass + # --- Create --- @abstractmethod def get_creator_items(self) -> Dict[str, "CreatorItem"]: @@ -277,6 +281,21 @@ def get_creator_items(self) -> Dict[str, "CreatorItem"]: """ pass + @abstractmethod + def get_creator_item_by_id( + self, identifier: str + ) -> Optional["CreatorItem"]: + """Get creator item by identifier. + + Args: + identifier (str): Create plugin identifier. + + Returns: + Optional[CreatorItem]: Creator item or None. + + """ + pass + @abstractmethod def get_creator_icon( self, identifier: str @@ -307,19 +326,19 @@ def get_convertor_items(self) -> Dict[str, ConvertorItem]: pass @abstractmethod - def get_instances(self) -> List[CreatedInstance]: + def get_instance_items(self) -> List["InstanceItem"]: """Collected/created instances. Returns: - List[CreatedInstance]: List of created instances. + List[InstanceItem]: List of created instances. """ pass @abstractmethod - def get_instances_by_id( + def get_instance_items_by_id( self, instance_ids: Optional[Iterable[str]] = None - ) -> Dict[str, Union[CreatedInstance, None]]: + ) -> Dict[str, Union["InstanceItem", None]]: pass @abstractmethod @@ -328,28 +347,56 @@ def get_instances_context_info( ): pass + @abstractmethod + def set_instances_context_info( + self, changes_by_instance_id: Dict[str, Dict[str, Any]] + ): + pass + + @abstractmethod + def set_instances_active_state( + self, active_state_by_id: Dict[str, bool] + ): + pass + @abstractmethod def get_existing_product_names(self, folder_path: str) -> List[str]: pass @abstractmethod def get_creator_attribute_definitions( - self, instances: List[CreatedInstance] - ) -> List[Tuple[AbstractAttrDef, List[CreatedInstance], List[Any]]]: + self, instance_ids: Iterable[str] + ) -> List[Tuple[AbstractAttrDef, List[str], List[Any]]]: + pass + + @abstractmethod + def set_instances_create_attr_values( + self, instance_ids: Iterable[str], key: str, value: Any + ): pass @abstractmethod def get_publish_attribute_definitions( self, - instances: List[CreatedInstance], + instance_ids: Iterable[str], include_context: bool ) -> List[Tuple[ str, List[AbstractAttrDef], - Dict[str, List[Tuple[CreatedInstance, Any]]] + Dict[str, List[Tuple[str, Any]]] ]]: pass + @abstractmethod + def set_instances_publish_attr_values( + self, + instance_ids: Iterable[str], + plugin_name: str, + key: str, + value: Any + ): + pass + @abstractmethod def get_product_name( self, @@ -383,7 +430,7 @@ def create( ): """Trigger creation by creator identifier. - Should also trigger refresh of instanes. + Should also trigger refresh of instances. Args: creator_identifier (str): Identifier of Creator plugin. @@ -446,8 +493,8 @@ def run_action(self, plugin_id: str, action_id: str): """Trigger pyblish action on a plugin. Args: - plugin_id (str): Id of publish plugin. - action_id (str): Id of publish action. + plugin_id (str): Publish plugin id. + action_id (str): Publish action id. """ pass @@ -586,7 +633,7 @@ def set_thumbnail_paths_for_instances( @abstractmethod def get_thumbnail_temp_dir_path(self) -> str: - """Return path to directory where thumbnails can be temporary stored. + """Path to directory where thumbnails can be temporarily stored. Returns: str: Path to a directory. diff --git a/client/ayon_core/tools/publisher/control.py b/client/ayon_core/tools/publisher/control.py index c7fd75b3c3..347755d557 100644 --- a/client/ayon_core/tools/publisher/control.py +++ b/client/ayon_core/tools/publisher/control.py @@ -35,7 +35,27 @@ class PublisherController( Known topics: "show.detailed.help" - Detailed help requested (UI related). "show.card.message" - Show card message request (UI related). - "instances.refresh.finished" - Instances are refreshed. + # --- Create model --- + "create.model.reset" - Reset of create model. + "instances.create.failed" - Creation failed. + "convertors.convert.failed" - Convertor failed. + "instances.save.failed" - Save failed. + "instance.thumbnail.changed" - Thumbnail changed. + "instances.collection.failed" - Collection of instances failed. + "convertors.find.failed" - Convertor find failed. + "instances.create.failed" - Create instances failed. + "instances.remove.failed" - Remove instances failed. + "create.context.added.instance" - Create instance added to context. + "create.context.value.changed" - Create instance or context value + changed. + "create.context.pre.create.attrs.changed" - Pre create attributes + changed. + "create.context.create.attrs.changed" - Create attributes changed. + "create.context.publish.attrs.changed" - Publish attributes changed. + "create.context.removed.instance" - Instance removed from context. + "create.model.instances.context.changed" - Instances changed context. + like folder, task or variant. + # --- Publish model --- "plugins.refresh.finished" - Plugins refreshed. "publish.reset.finished" - Reset finished. "controller.reset.started" - Controller reset started. @@ -172,27 +192,37 @@ def get_creator_icon(self, identifier): """ return self._create_model.get_creator_icon(identifier) + def get_instance_items(self): + """Current instances in create context.""" + return self._create_model.get_instance_items() + + # --- Legacy for TrayPublisher --- @property def instances(self): - """Current instances in create context. + return self.get_instance_items() - Deprecated: - Use 'get_instances' instead. Kept for backwards compatibility with - traypublisher. + def get_instances(self): + return self.get_instance_items() - """ - return self.get_instances() + def get_instances_by_id(self, *args, **kwargs): + return self.get_instance_items_by_id(*args, **kwargs) - def get_instances(self): - """Current instances in create context.""" - return self._create_model.get_instances() + # --- - def get_instances_by_id(self, instance_ids=None): - return self._create_model.get_instances_by_id(instance_ids) + def get_instance_items_by_id(self, instance_ids=None): + return self._create_model.get_instance_items_by_id(instance_ids) def get_instances_context_info(self, instance_ids=None): return self._create_model.get_instances_context_info(instance_ids) + def set_instances_context_info(self, changes_by_instance_id): + return self._create_model.set_instances_context_info( + changes_by_instance_id + ) + + def set_instances_active_state(self, active_state_by_id): + self._create_model.set_instances_active_state(active_state_by_id) + def get_convertor_items(self): return self._create_model.get_convertor_items() @@ -365,29 +395,41 @@ def clear_thumbnail_temp_dir_path(self): if os.path.exists(dirpath): shutil.rmtree(dirpath) - def get_creator_attribute_definitions(self, instances): + def get_creator_attribute_definitions(self, instance_ids): """Collect creator attribute definitions for multuple instances. Args: - instances(List[CreatedInstance]): List of created instances for + instance_ids (List[str]): List of created instances for which should be attribute definitions returned. """ return self._create_model.get_creator_attribute_definitions( - instances + instance_ids + ) + + def set_instances_create_attr_values(self, instance_ids, key, value): + return self._create_model.set_instances_create_attr_values( + instance_ids, key, value ) - def get_publish_attribute_definitions(self, instances, include_context): + def get_publish_attribute_definitions(self, instance_ids, include_context): """Collect publish attribute definitions for passed instances. Args: - instances(list): List of created instances for + instance_ids (List[str]): List of created instances for which should be attribute definitions returned. - include_context(bool): Add context specific attribute definitions. + include_context (bool): Add context specific attribute definitions. """ return self._create_model.get_publish_attribute_definitions( - instances, include_context + instance_ids, include_context + ) + + def set_instances_publish_attr_values( + self, instance_ids, plugin_name, key, value + ): + return self._create_model.set_instances_publish_attr_values( + instance_ids, plugin_name, key, value ) def get_product_name( diff --git a/client/ayon_core/tools/publisher/models/__init__.py b/client/ayon_core/tools/publisher/models/__init__.py index 07f061deaa..26eeb3cdbb 100644 --- a/client/ayon_core/tools/publisher/models/__init__.py +++ b/client/ayon_core/tools/publisher/models/__init__.py @@ -1,10 +1,11 @@ -from .create import CreateModel, CreatorItem +from .create import CreateModel, CreatorItem, InstanceItem from .publish import PublishModel, PublishErrorInfo __all__ = ( "CreateModel", "CreatorItem", + "InstanceItem", "PublishModel", "PublishErrorInfo", diff --git a/client/ayon_core/tools/publisher/models/create.py b/client/ayon_core/tools/publisher/models/create.py index dcd2ce4acc..9c13d8ae2f 100644 --- a/client/ayon_core/tools/publisher/models/create.py +++ b/client/ayon_core/tools/publisher/models/create.py @@ -1,11 +1,21 @@ import logging import re -from typing import Union, List, Dict, Tuple, Any, Optional, Iterable, Pattern +from typing import ( + Union, + List, + Dict, + Tuple, + Any, + Optional, + Iterable, + Pattern, +) from ayon_core.lib.attribute_definitions import ( serialize_attr_defs, deserialize_attr_defs, AbstractAttrDef, + EnumDef, ) from ayon_core.lib.profiles_filtering import filter_profiles from ayon_core.lib.attribute_definitions import UIDef @@ -17,6 +27,7 @@ Creator, CreateContext, CreatedInstance, + AttributeValues, ) from ayon_core.pipeline.create import ( CreatorsOperationFailed, @@ -192,7 +203,192 @@ def from_data(cls, data: Dict[str, Any]) -> "CreatorItem": return cls(**data) +class InstanceItem: + def __init__( + self, + instance_id: str, + creator_identifier: str, + label: str, + group_label: str, + product_type: str, + product_name: str, + variant: str, + folder_path: Optional[str], + task_name: Optional[str], + is_active: bool, + has_promised_context: bool, + ): + self._instance_id: str = instance_id + self._creator_identifier: str = creator_identifier + self._label: str = label + self._group_label: str = group_label + self._product_type: str = product_type + self._product_name: str = product_name + self._variant: str = variant + self._folder_path: Optional[str] = folder_path + self._task_name: Optional[str] = task_name + self._is_active: bool = is_active + self._has_promised_context: bool = has_promised_context + + @property + def id(self): + return self._instance_id + + @property + def creator_identifier(self): + return self._creator_identifier + + @property + def label(self): + return self._label + + @property + def group_label(self): + return self._group_label + + @property + def product_type(self): + return self._product_type + + @property + def has_promised_context(self): + return self._has_promised_context + + def get_variant(self): + return self._variant + + def set_variant(self, variant): + self._variant = variant + + def get_product_name(self): + return self._product_name + + def set_product_name(self, product_name): + self._product_name = product_name + + def get_folder_path(self): + return self._folder_path + + def set_folder_path(self, folder_path): + self._folder_path = folder_path + + def get_task_name(self): + return self._task_name + + def set_task_name(self, task_name): + self._task_name = task_name + + def get_is_active(self): + return self._is_active + + def set_is_active(self, is_active): + self._is_active = is_active + + product_name = property(get_product_name, set_product_name) + variant = property(get_variant, set_variant) + folder_path = property(get_folder_path, set_folder_path) + task_name = property(get_task_name, set_task_name) + is_active = property(get_is_active, set_is_active) + + @classmethod + def from_instance(cls, instance: CreatedInstance): + return InstanceItem( + instance.id, + instance.creator_identifier, + instance.label, + instance.group_label, + instance.product_type, + instance.product_name, + instance["variant"], + instance["folderPath"], + instance["task"], + instance["active"], + instance.has_promised_context, + ) + + +def _merge_attr_defs( + attr_def_src: AbstractAttrDef, attr_def_new: AbstractAttrDef +) -> Optional[AbstractAttrDef]: + if not attr_def_src.enabled and attr_def_new.enabled: + attr_def_src.enabled = True + if not attr_def_src.visible and attr_def_new.visible: + attr_def_src.visible = True + + if not isinstance(attr_def_src, EnumDef): + return None + if attr_def_src.items == attr_def_new.items: + return None + + src_item_values = { + item["value"] + for item in attr_def_src.items + } + for item in attr_def_new.items: + if item["value"] not in src_item_values: + attr_def_src.items.append(item) + + +def merge_attr_defs(attr_defs: List[List[AbstractAttrDef]]): + if not attr_defs: + return [] + if len(attr_defs) == 1: + return attr_defs[0] + + # Pop first and create clone of attribute definitions + defs_union: List[AbstractAttrDef] = [ + attr_def.clone() + for attr_def in attr_defs.pop(0) + ] + for instance_attr_defs in attr_defs: + idx = 0 + for attr_idx, attr_def in enumerate(instance_attr_defs): + # QUESTION should we merge NumberDef too? Use lowest min and + # biggest max... + is_enum = isinstance(attr_def, EnumDef) + match_idx = None + match_attr = None + for union_idx, union_def in enumerate(defs_union): + if is_enum and ( + not isinstance(union_def, EnumDef) + or union_def.multiselection != attr_def.multiselection + ): + continue + + if ( + attr_def.compare_to_def( + union_def, + ignore_default=True, + ignore_enabled=True, + ignore_visible=True, + ignore_def_type_compare=is_enum + ) + ): + match_idx = union_idx + match_attr = union_def + break + + if match_attr is not None: + new_attr_def = _merge_attr_defs(match_attr, attr_def) + if new_attr_def is not None: + defs_union[match_idx] = new_attr_def + idx = match_idx + 1 + continue + + defs_union.insert(idx, attr_def.clone()) + idx += 1 + return defs_union + + class CreateModel: + _CONTEXT_KEYS = { + "active", + "folderPath", + "task", + "variant", + "productName", + } + def __init__(self, controller: AbstractPublisherBackend): self._log = None self._controller: AbstractPublisherBackend = controller @@ -258,12 +454,34 @@ def reset(self): self._creator_items = None self._reset_instances() + + self._emit_event("create.model.reset") + + self._create_context.add_instances_added_callback( + self._cc_added_instance + ) + self._create_context.add_instances_removed_callback ( + self._cc_removed_instance + ) + self._create_context.add_value_changed_callback( + self._cc_value_changed + ) + self._create_context.add_pre_create_attr_defs_change_callback ( + self._cc_pre_create_attr_changed + ) + self._create_context.add_create_attr_defs_change_callback ( + self._cc_create_attr_changed + ) + self._create_context.add_publish_attr_defs_change_callback ( + self._cc_publish_attr_changed + ) + self._create_context.reset_finalization() def get_creator_items(self) -> Dict[str, CreatorItem]: """Creators that can be shown in create dialog.""" if self._creator_items is None: - self._creator_items = self._collect_creator_items() + self._refresh_creator_items() return self._creator_items def get_creator_item_by_id( @@ -287,33 +505,68 @@ def get_creator_icon( return creator_item.icon return None - def get_instances(self) -> List[CreatedInstance]: + def get_instance_items(self) -> List[InstanceItem]: """Current instances in create context.""" - return list(self._create_context.instances_by_id.values()) + return [ + InstanceItem.from_instance(instance) + for instance in self._create_context.instances_by_id.values() + ] - def get_instance_by_id( + def get_instance_item_by_id( self, instance_id: str - ) -> Union[CreatedInstance, None]: - return self._create_context.instances_by_id.get(instance_id) + ) -> Union[InstanceItem, None]: + instance = self._create_context.instances_by_id.get(instance_id) + if instance is None: + return None - def get_instances_by_id( + return InstanceItem.from_instance(instance) + + def get_instance_items_by_id( self, instance_ids: Optional[Iterable[str]] = None - ) -> Dict[str, Union[CreatedInstance, None]]: + ) -> Dict[str, Union[InstanceItem, None]]: if instance_ids is None: instance_ids = self._create_context.instances_by_id.keys() return { - instance_id: self.get_instance_by_id(instance_id) + instance_id: self.get_instance_item_by_id(instance_id) for instance_id in instance_ids } def get_instances_context_info( self, instance_ids: Optional[Iterable[str]] = None ): - instances = self.get_instances_by_id(instance_ids).values() + instances = self._get_instances_by_id(instance_ids).values() return self._create_context.get_instances_context_info( instances ) + def set_instances_context_info(self, changes_by_instance_id): + with self._create_context.bulk_value_changes(CREATE_EVENT_SOURCE): + for instance_id, changes in changes_by_instance_id.items(): + instance = self._get_instance_by_id(instance_id) + for key, value in changes.items(): + instance[key] = value + self._emit_event( + "create.model.instances.context.changed", + { + "instance_ids": list(changes_by_instance_id.keys()) + } + ) + + def set_instances_active_state( + self, active_state_by_id: Dict[str, bool] + ): + with self._create_context.bulk_value_changes(CREATE_EVENT_SOURCE): + for instance_id, active in active_state_by_id.items(): + instance = self._create_context.get_instance_by_id(instance_id) + instance["active"] = active + + self._emit_event( + "create.model.instances.context.changed", + { + "instance_ids": set(active_state_by_id.keys()) + } + ) + def get_convertor_items(self) -> Dict[str, ConvertorItem]: return self._create_context.convertor_items_by_id @@ -341,7 +594,7 @@ def get_product_name( instance = None if instance_id: - instance = self.get_instance_by_id(instance_id) + instance = self._get_instance_by_id(instance_id) project_name = self._controller.get_current_project_name() folder_item = self._controller.get_folder_item_by_path( @@ -396,9 +649,10 @@ def create( success = True try: - self._create_context.create_with_unified_error( - creator_identifier, product_name, instance_data, options - ) + with self._create_context.bulk_add_instances(): + self._create_context.create_with_unified_error( + creator_identifier, product_name, instance_data, options + ) except CreatorsOperationFailed as exc: success = False @@ -410,7 +664,6 @@ def create( } ) - self._on_create_instance_change() return success def trigger_convertor_items(self, convertor_identifiers: List[str]): @@ -498,23 +751,38 @@ def remove_instances(self, instance_ids: List[str]): # is not required. self._remove_instances_from_context(instance_ids) - self._on_create_instance_change() + def set_instances_create_attr_values(self, instance_ids, key, value): + with self._create_context.bulk_value_changes(CREATE_EVENT_SOURCE): + for instance_id in instance_ids: + instance = self._get_instance_by_id(instance_id) + creator_attributes = instance["creator_attributes"] + attr_def = creator_attributes.get_attr_def(key) + if ( + attr_def is None + or not attr_def.is_value_def + or not attr_def.visible + or not attr_def.enabled + or not attr_def.is_value_valid(value) + ): + continue + creator_attributes[key] = value def get_creator_attribute_definitions( - self, instances: List[CreatedInstance] - ) -> List[Tuple[AbstractAttrDef, List[CreatedInstance], List[Any]]]: + self, instance_ids: List[str] + ) -> List[Tuple[AbstractAttrDef, List[str], List[Any]]]: """Collect creator attribute definitions for multuple instances. Args: - instances (List[CreatedInstance]): List of created instances for + instance_ids (List[str]): List of created instances for which should be attribute definitions returned. - """ + """ # NOTE it would be great if attrdefs would have hash method implemented # so they could be used as keys in dictionary output = [] _attr_defs = {} - for instance in instances: + for instance_id in instance_ids: + instance = self._get_instance_by_id(instance_id) for attr_def in instance.creator_attribute_defs: found_idx = None for idx, _attr_def in _attr_defs.items(): @@ -525,29 +793,54 @@ def get_creator_attribute_definitions( value = None if attr_def.is_value_def: value = instance.creator_attributes[attr_def.key] + if found_idx is None: idx = len(output) - output.append((attr_def, [instance], [value])) + output.append((attr_def, [instance_id], [value])) _attr_defs[idx] = attr_def else: - item = output[found_idx] - item[1].append(instance) - item[2].append(value) + _, ids, values = output[found_idx] + ids.append(instance_id) + values.append(value) return output + def set_instances_publish_attr_values( + self, instance_ids, plugin_name, key, value + ): + with self._create_context.bulk_value_changes(CREATE_EVENT_SOURCE): + for instance_id in instance_ids: + if instance_id is None: + instance = self._create_context + else: + instance = self._get_instance_by_id(instance_id) + plugin_val = instance.publish_attributes[plugin_name] + attr_def = plugin_val.get_attr_def(key) + # Ignore if attribute is not available or enabled/visible + # on the instance, or the value is not valid for definition + if ( + attr_def is None + or not attr_def.is_value_def + or not attr_def.visible + or not attr_def.enabled + or not attr_def.is_value_valid(value) + ): + continue + + plugin_val[key] = value + def get_publish_attribute_definitions( self, - instances: List[CreatedInstance], + instance_ids: List[str], include_context: bool ) -> List[Tuple[ str, List[AbstractAttrDef], - Dict[str, List[Tuple[CreatedInstance, Any]]] + Dict[str, List[Tuple[str, Any]]] ]]: """Collect publish attribute definitions for passed instances. Args: - instances (list[CreatedInstance]): List of created instances for + instance_ids (List[str]): List of created instances for which should be attribute definitions returned. include_context (bool): Add context specific attribute definitions. @@ -556,19 +849,26 @@ def get_publish_attribute_definitions( if include_context: _tmp_items.append(self._create_context) - for instance in instances: - _tmp_items.append(instance) + for instance_id in instance_ids: + _tmp_items.append(self._get_instance_by_id(instance_id)) all_defs_by_plugin_name = {} all_plugin_values = {} for item in _tmp_items: + item_id = None + if isinstance(item, CreatedInstance): + item_id = item.id + for plugin_name, attr_val in item.publish_attributes.items(): + if not isinstance(attr_val, AttributeValues): + continue attr_defs = attr_val.attr_defs if not attr_defs: continue - - if plugin_name not in all_defs_by_plugin_name: - all_defs_by_plugin_name[plugin_name] = attr_val.attr_defs + plugin_attr_defs = all_defs_by_plugin_name.setdefault( + plugin_name, [] + ) + plugin_attr_defs.append(attr_defs) plugin_values = all_plugin_values.setdefault(plugin_name, {}) @@ -579,7 +879,11 @@ def get_publish_attribute_definitions( attr_values = plugin_values.setdefault(attr_def.key, []) value = attr_val[attr_def.key] - attr_values.append((item, value)) + attr_values.append((item_id, value)) + + attr_defs_by_plugin_name = {} + for plugin_name, attr_defs in all_defs_by_plugin_name.items(): + attr_defs_by_plugin_name[plugin_name] = merge_attr_defs(attr_defs) output = [] for plugin in self._create_context.plugins_with_defs: @@ -588,7 +892,7 @@ def get_publish_attribute_definitions( continue output.append(( plugin_name, - all_defs_by_plugin_name[plugin_name], + attr_defs_by_plugin_name[plugin_name], all_plugin_values )) return output @@ -620,8 +924,12 @@ def set_thumbnail_paths_for_instances( } ) - def _emit_event(self, topic: str, data: Optional[Dict[str, Any]] = None): - self._controller.emit_event(topic, data) + def _emit_event( + self, + topic: str, + data: Optional[Dict[str, Any]] = None + ): + self._controller.emit_event(topic, data, CREATE_EVENT_SOURCE) def _get_current_project_settings(self) -> Dict[str, Any]: """Current project settings. @@ -638,11 +946,26 @@ def _creators(self) -> Dict[str, BaseCreator]: return self._create_context.creators + def _get_instance_by_id( + self, instance_id: str + ) -> Union[CreatedInstance, None]: + return self._create_context.instances_by_id.get(instance_id) + + def _get_instances_by_id( + self, instance_ids: Optional[Iterable[str]] + ) -> Dict[str, Union[CreatedInstance, None]]: + if instance_ids is None: + instance_ids = self._create_context.instances_by_id.keys() + return { + instance_id: self._get_instance_by_id(instance_id) + for instance_id in instance_ids + } + def _reset_instances(self): """Reset create instances.""" self._create_context.reset_context_data() - with self._create_context.bulk_instances_collection(): + with self._create_context.bulk_add_instances(): try: self._create_context.reset_instances() except CreatorsOperationFailed as exc: @@ -677,8 +1000,6 @@ def _reset_instances(self): } ) - self._on_create_instance_change() - def _remove_instances_from_context(self, instance_ids: List[str]): instances_by_id = self._create_context.instances_by_id instances = [ @@ -696,9 +1017,6 @@ def _remove_instances_from_context(self, instance_ids: List[str]): } ) - def _on_create_instance_change(self): - self._emit_event("instances.refresh.finished") - def _collect_creator_items(self) -> Dict[str, CreatorItem]: # TODO add crashed initialization of create plugins to report output = {} @@ -720,6 +1038,98 @@ def _collect_creator_items(self) -> Dict[str, CreatorItem]: return output + def _refresh_creator_items(self, identifiers=None): + if identifiers is None: + self._creator_items = self._collect_creator_items() + return + + for identifier in identifiers: + if identifier not in self._creator_items: + continue + creator = self._create_context.creators.get(identifier) + if creator is None: + continue + self._creator_items[identifier] = ( + CreatorItem.from_creator(creator) + ) + + def _cc_added_instance(self, event): + instance_ids = { + instance.id + for instance in event.data["instances"] + } + self._emit_event( + "create.context.added.instance", + {"instance_ids": instance_ids}, + ) + + def _cc_removed_instance(self, event): + instance_ids = { + instance.id + for instance in event.data["instances"] + } + self._emit_event( + "create.context.removed.instance", + {"instance_ids": instance_ids}, + ) + + def _cc_value_changed(self, event): + if event.source == CREATE_EVENT_SOURCE: + return + + instance_changes = {} + context_changed_ids = set() + for item in event.data["changes"]: + instance_id = None + if item["instance"]: + instance_id = item["instance"].id + changes = item["changes"] + instance_changes[instance_id] = changes + if instance_id is None: + continue + + if self._CONTEXT_KEYS.intersection(set(changes)): + context_changed_ids.add(instance_id) + + self._emit_event( + "create.context.value.changed", + {"instance_changes": instance_changes}, + ) + if context_changed_ids: + self._emit_event( + "create.model.instances.context.changed", + {"instance_ids": list(context_changed_ids)}, + ) + + def _cc_pre_create_attr_changed(self, event): + identifiers = event["identifiers"] + self._refresh_creator_items(identifiers) + self._emit_event( + "create.context.pre.create.attrs.changed", + {"identifiers": identifiers}, + ) + + def _cc_create_attr_changed(self, event): + instance_ids = { + instance.id + for instance in event.data["instances"] + } + self._emit_event( + "create.context.create.attrs.changed", + {"instance_ids": instance_ids}, + ) + + def _cc_publish_attr_changed(self, event): + instance_changes = event.data["instance_changes"] + event_data = { + instance_id: instance_data["plugin_names"] + for instance_id, instance_data in instance_changes.items() + } + self._emit_event( + "create.context.publish.attrs.changed", + event_data, + ) + def _get_allowed_creators_pattern(self) -> Union[Pattern, None]: """Provide regex pattern for configured creator labels in this context diff --git a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py index c0e27d9c60..095a4eae7c 100644 --- a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py @@ -22,6 +22,7 @@ import re import collections +from typing import Dict from qtpy import QtWidgets, QtCore @@ -217,17 +218,24 @@ def __init__(self, group_icons, *args, **kwargs): def update_icons(self, group_icons): self._group_icons = group_icons - def update_instance_values(self, context_info_by_id): + def update_instance_values( + self, context_info_by_id, instance_items_by_id, instance_ids + ): """Trigger update on instance widgets.""" for instance_id, widget in self._widgets_by_id.items(): - widget.update_instance_values(context_info_by_id[instance_id]) + if instance_ids is not None and instance_id not in instance_ids: + continue + widget.update_instance( + instance_items_by_id[instance_id], + context_info_by_id[instance_id] + ) def update_instances(self, instances, context_info_by_id): """Update instances for the group. Args: - instances (list[CreatedInstance]): List of instances in + instances (list[InstanceItem]): List of instances in CreateContext. context_info_by_id (Dict[str, InstanceContextInfo]): Instance context info by instance id. @@ -238,7 +246,7 @@ def update_instances(self, instances, context_info_by_id): instances_by_product_name = collections.defaultdict(list) for instance in instances: instances_by_id[instance.id] = instance - product_name = instance["productName"] + product_name = instance.product_name instances_by_product_name[product_name].append(instance) # Remove instance widgets that are not in passed instances @@ -307,8 +315,9 @@ def is_selected(self): def set_selected(self, selected): """Set card as selected.""" - if selected == self._selected: + if selected is self._selected: return + self._selected = selected state = "selected" if selected else "" self.setProperty("state", state) @@ -391,9 +400,6 @@ def __init__(self, item, parent): self._icon_widget = icon_widget self._label_widget = label_widget - def update_instance_values(self, context_info): - pass - class InstanceCardWidget(CardWidget): """Card widget representing instance.""" @@ -461,7 +467,7 @@ def __init__(self, instance, context_info, group_icon, parent): self._active_checkbox = active_checkbox self._expand_btn = expand_btn - self.update_instance_values(context_info) + self._update_instance_values(context_info) def set_active_toggle_enabled(self, enabled): self._active_checkbox.setEnabled(enabled) @@ -470,23 +476,16 @@ def set_active_toggle_enabled(self, enabled): def is_active(self): return self._active_checkbox.isChecked() - def set_active(self, new_value): + def _set_active(self, new_value): """Set instance as active.""" checkbox_value = self._active_checkbox.isChecked() - instance_value = self.instance["active"] - - # First change instance value and them change checkbox - # - prevent to trigger `active_changed` signal - if instance_value != new_value: - self.instance["active"] = new_value - if checkbox_value != new_value: self._active_checkbox.setChecked(new_value) def update_instance(self, instance, context_info): """Update instance object and update UI.""" self.instance = instance - self.update_instance_values(context_info) + self._update_instance_values(context_info) def _validate_context(self, context_info): valid = context_info.is_valid @@ -494,8 +493,8 @@ def _validate_context(self, context_info): self._context_warning.setVisible(not valid) def _update_product_name(self): - variant = self.instance["variant"] - product_name = self.instance["productName"] + variant = self.instance.variant + product_name = self.instance.product_name label = self.instance.label if ( variant == self._last_variant @@ -522,10 +521,10 @@ def _update_product_name(self): QtCore.Qt.NoTextInteraction ) - def update_instance_values(self, context_info): + def _update_instance_values(self, context_info): """Update instance data""" self._update_product_name() - self.set_active(self.instance["active"]) + self._set_active(self.instance.is_active) self._validate_context(context_info) def _set_expanded(self, expanded=None): @@ -535,11 +534,10 @@ def _set_expanded(self, expanded=None): def _on_active_change(self): new_value = self._active_checkbox.isChecked() - old_value = self.instance["active"] + old_value = self.instance.is_active if new_value == old_value: return - self.instance["active"] = new_value self.active_changed.emit(self._id, new_value) def _on_expend_clicked(self): @@ -596,7 +594,7 @@ def __init__(self, controller, parent): self._context_widget = None self._convertor_items_group = None self._active_toggle_enabled = True - self._widgets_by_group = {} + self._widgets_by_group: Dict[str, InstanceGroupWidget] = {} self._ordered_groups = [] self._explicitly_selected_instance_ids = [] @@ -625,24 +623,25 @@ def _toggle_instances(self, value): return widgets = self._get_selected_widgets() - changed = False + active_state_by_id = {} for widget in widgets: if not isinstance(widget, InstanceCardWidget): continue + instance_id = widget.id is_active = widget.is_active if value == -1: - widget.set_active(not is_active) - changed = True + active_state_by_id[instance_id] = not is_active continue _value = bool(value) if is_active is not _value: - widget.set_active(_value) - changed = True + active_state_by_id[instance_id] = _value - if changed: - self.active_changed.emit() + if not active_state_by_id: + return + + self._controller.set_instances_active_state(active_state_by_id) def keyPressEvent(self, event): if event.key() == QtCore.Qt.Key_Space: @@ -702,7 +701,7 @@ def refresh(self): # Prepare instances by group and identifiers by group instances_by_group = collections.defaultdict(list) identifiers_by_group = collections.defaultdict(set) - for instance in self._controller.get_instances(): + for instance in self._controller.get_instance_items(): group_name = instance.group_label instances_by_group[group_name].append(instance) identifiers_by_group[group_name].add( @@ -817,23 +816,31 @@ def _update_convertor_items_group(self): self._convertor_items_group.update_items(convertor_items) - def refresh_instance_states(self): + def refresh_instance_states(self, instance_ids=None): """Trigger update of instances on group widgets.""" + if instance_ids is not None: + instance_ids = set(instance_ids) context_info_by_id = self._controller.get_instances_context_info() + instance_items_by_id = self._controller.get_instance_items_by_id( + instance_ids + ) for widget in self._widgets_by_group.values(): - widget.update_instance_values(context_info_by_id) + widget.update_instance_values( + context_info_by_id, instance_items_by_id, instance_ids + ) def _on_active_changed(self, group_name, instance_id, value): group_widget = self._widgets_by_group[group_name] instance_widget = group_widget.get_widget_by_item_id(instance_id) - if instance_widget.is_selected: + active_state_by_id = {} + if not instance_widget.is_selected: + active_state_by_id[instance_id] = value + else: for widget in self._get_selected_widgets(): if isinstance(widget, InstanceCardWidget): - widget.set_active(value) - else: - self._select_item_clear(instance_id, group_name, instance_widget) - self.selection_changed.emit() - self.active_changed.emit() + active_state_by_id[widget.id] = value + + self._controller.set_instances_active_state(active_state_by_id) def _on_widget_selection(self, instance_id, group_name, selection_type): """Select specific item by instance id. diff --git a/client/ayon_core/tools/publisher/widgets/create_widget.py b/client/ayon_core/tools/publisher/widgets/create_widget.py index 4c94c5c9b9..aecea2ec44 100644 --- a/client/ayon_core/tools/publisher/widgets/create_widget.py +++ b/client/ayon_core/tools/publisher/widgets/create_widget.py @@ -111,7 +111,7 @@ def __init__(self, controller, parent=None): self._folder_path = None self._product_names = None - self._selected_creator = None + self._selected_creator_identifier = None self._prereq_available = False @@ -262,6 +262,10 @@ def __init__(self, controller, parent=None): controller.register_event_callback( "controller.reset.finished", self._on_controler_reset ) + controller.register_event_callback( + "create.context.pre.create.attrs.changed", + self._pre_create_attr_changed + ) self._main_splitter_widget = main_splitter_widget @@ -512,6 +516,15 @@ def _on_controler_reset(self): # Trigger refresh only if is visible self.refresh() + def _pre_create_attr_changed(self, event): + if ( + self._selected_creator_identifier is None + or self._selected_creator_identifier not in event["identifiers"] + ): + return + + self._set_creator_by_identifier(self._selected_creator_identifier) + def _on_folder_change(self): self._refresh_product_name() if self._context_change_is_enabled(): @@ -563,12 +576,13 @@ def _set_creator(self, creator_item): self._set_creator_detailed_text(creator_item) self._pre_create_widget.set_creator_item(creator_item) - self._selected_creator = creator_item - if not creator_item: + self._selected_creator_identifier = None self._set_context_enabled(False) return + self._selected_creator_identifier = creator_item.identifier + if ( creator_item.create_allow_context_change != self._context_change_is_enabled() @@ -603,7 +617,7 @@ def _on_variant_change(self, variant_value=None): return # This should probably never happen? - if not self._selected_creator: + if not self._selected_creator_identifier: if self.product_name_input.text(): self.product_name_input.setText("") return @@ -625,11 +639,13 @@ def _on_variant_change(self, variant_value=None): folder_path = self._get_folder_path() task_name = self._get_task_name() - creator_idenfier = self._selected_creator.identifier # Calculate product name with Creator plugin try: product_name = self._controller.get_product_name( - creator_idenfier, variant_value, task_name, folder_path + self._selected_creator_identifier, + variant_value, + task_name, + folder_path ) except TaskNotSetError: self._create_btn.setEnabled(False) @@ -755,7 +771,7 @@ def _on_create(self): ) if success: - self._set_creator(self._selected_creator) + self._set_creator_by_identifier(self._selected_creator_identifier) self._variant_widget.setText(variant) self._controller.emit_card_message("Creation finished...") self._last_thumbnail_path = None diff --git a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py index ab9f2db52c..bc3353ba5e 100644 --- a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py @@ -110,7 +110,7 @@ def group_item_paint(self, painter, option, index): class InstanceListItemWidget(QtWidgets.QWidget): """Widget with instance info drawn over delegate paint. - This is required to be able use custom checkbox on custom place. + This is required to be able to use custom checkbox on custom place. """ active_changed = QtCore.Signal(str, bool) double_clicked = QtCore.Signal() @@ -118,7 +118,7 @@ class InstanceListItemWidget(QtWidgets.QWidget): def __init__(self, instance, context_info, parent): super().__init__(parent) - self.instance = instance + self._instance_id = instance.id instance_label = instance.label if instance_label is None: @@ -131,7 +131,7 @@ def __init__(self, instance, context_info, parent): product_name_label.setObjectName("ListViewProductName") active_checkbox = NiceCheckbox(parent=self) - active_checkbox.setChecked(instance["active"]) + active_checkbox.setChecked(instance.is_active) layout = QtWidgets.QHBoxLayout(self) content_margins = layout.contentsMargins() @@ -171,47 +171,34 @@ def _set_valid_property(self, valid): def is_active(self): """Instance is activated.""" - return self.instance["active"] + return self._active_checkbox.isChecked() def set_active(self, new_value): """Change active state of instance and checkbox.""" - checkbox_value = self._active_checkbox.isChecked() - instance_value = self.instance["active"] + old_value = self.is_active() if new_value is None: - new_value = not instance_value + new_value = not old_value - # First change instance value and them change checkbox - # - prevent to trigger `active_changed` signal - if instance_value != new_value: - self.instance["active"] = new_value - - if checkbox_value != new_value: + if new_value != old_value: + self._active_checkbox.blockSignals(True) self._active_checkbox.setChecked(new_value) + self._active_checkbox.blockSignals(False) def update_instance(self, instance, context_info): """Update instance object.""" - self.instance = instance - self.update_instance_values(context_info) - - def update_instance_values(self, context_info): - """Update instance data propagated to widgets.""" # Check product name - label = self.instance.label + label = instance.label if label != self._instance_label_widget.text(): self._instance_label_widget.setText(html_escape(label)) # Check active state - self.set_active(self.instance["active"]) + self.set_active(instance.is_active) # Check valid states self._set_valid_property(context_info.is_valid) def _on_active_change(self): - new_value = self._active_checkbox.isChecked() - old_value = self.instance["active"] - if new_value == old_value: - return - - self.instance["active"] = new_value - self.active_changed.emit(self.instance.id, new_value) + self.active_changed.emit( + self._instance_id, self._active_checkbox.isChecked() + ) def set_active_toggle_enabled(self, enabled): self._active_checkbox.setEnabled(enabled) @@ -245,8 +232,8 @@ def mouseDoubleClickEvent(self, event): class InstanceListGroupWidget(QtWidgets.QFrame): """Widget representing group of instances. - Has collapse/expand indicator, label of group and checkbox modifying all of - it's children. + Has collapse/expand indicator, label of group and checkbox modifying all + of its children. """ expand_changed = QtCore.Signal(str, bool) toggle_requested = QtCore.Signal(str, int) @@ -392,7 +379,7 @@ def event(self, event): def _mouse_press(self, event): """Store index of pressed group. - This is to be able change state of group and process mouse + This is to be able to change state of group and process mouse "double click" as 2x "single click". """ if event.button() != QtCore.Qt.LeftButton: @@ -588,7 +575,7 @@ def refresh(self): # Prepare instances by their groups instances_by_group_name = collections.defaultdict(list) group_names = set() - for instance in self._controller.get_instances(): + for instance in self._controller.get_instance_items(): group_label = instance.group_label group_names.add(group_label) instances_by_group_name[group_label].append(instance) @@ -612,7 +599,7 @@ def refresh(self): # Mapping of existing instances under group item existing_mapping = {} - # Get group index to be able get children indexes + # Get group index to be able to get children indexes group_index = self._instance_model.index( group_item.row(), group_item.column() ) @@ -639,10 +626,10 @@ def refresh(self): instance_id = instance.id # Handle group activity if activity is None: - activity = int(instance["active"]) + activity = int(instance.is_active) elif activity == -1: pass - elif activity != instance["active"]: + elif activity != instance.is_active: activity = -1 context_info = context_info_by_id[instance_id] @@ -658,8 +645,8 @@ def refresh(self): # Create new item and store it as new item = QtGui.QStandardItem() - item.setData(instance["productName"], SORT_VALUE_ROLE) - item.setData(instance["productName"], GROUP_ROLE) + item.setData(instance.product_name, SORT_VALUE_ROLE) + item.setData(instance.product_name, GROUP_ROLE) item.setData(instance_id, INSTANCE_ID_ROLE) new_items.append(item) new_items_with_instance.append((item, instance)) @@ -873,30 +860,40 @@ def _remove_groups_except(self, group_names): widget = self._group_widgets.pop(group_name) widget.deleteLater() - def refresh_instance_states(self): + def refresh_instance_states(self, instance_ids=None): """Trigger update of all instances.""" + if instance_ids is not None: + instance_ids = set(instance_ids) context_info_by_id = self._controller.get_instances_context_info() + instance_items_by_id = self._controller.get_instance_items_by_id( + instance_ids + ) for instance_id, widget in self._widgets_by_id.items(): - context_info = context_info_by_id[instance_id] - widget.update_instance_values(context_info) + if instance_ids is not None and instance_id not in instance_ids: + continue + widget.update_instance( + instance_items_by_id[instance_id], + context_info_by_id[instance_id], + ) def _on_active_changed(self, changed_instance_id, new_value): selected_instance_ids, _, _ = self.get_selected_items() - selected_ids = set() + active_by_id = {} found = False for instance_id in selected_instance_ids: - selected_ids.add(instance_id) + active_by_id[instance_id] = new_value if not found and instance_id == changed_instance_id: found = True if not found: - selected_ids = set() - selected_ids.add(changed_instance_id) + active_by_id = {changed_instance_id: new_value} + + self._controller.set_instances_active_state(active_by_id) - self._change_active_instances(selected_ids, new_value) + self._change_active_instances(active_by_id, new_value) group_names = set() - for instance_id in selected_ids: + for instance_id in active_by_id: group_name = self._group_by_instance_id.get(instance_id) if group_name is not None: group_names.add(group_name) @@ -908,16 +905,11 @@ def _change_active_instances(self, instance_ids, new_value): if not instance_ids: return - changed_ids = set() for instance_id in instance_ids: widget = self._widgets_by_id.get(instance_id) if widget: - changed_ids.add(instance_id) widget.set_active(new_value) - if changed_ids: - self.active_changed.emit() - def _on_selection_change(self, *_args): self.selection_changed.emit() @@ -956,14 +948,16 @@ def _on_group_toggle_request(self, group_name, state): if not group_item: return - instance_ids = set() + active_by_id = {} for row in range(group_item.rowCount()): item = group_item.child(row) instance_id = item.data(INSTANCE_ID_ROLE) if instance_id is not None: - instance_ids.add(instance_id) + active_by_id[instance_id] = active + + self._controller.set_instances_active_state(active_by_id) - self._change_active_instances(instance_ids, active) + self._change_active_instances(active_by_id, active) proxy_index = self._proxy_model.mapFromSource(group_item.index()) if not self._instance_view.isExpanded(proxy_index): diff --git a/client/ayon_core/tools/publisher/widgets/overview_widget.py b/client/ayon_core/tools/publisher/widgets/overview_widget.py index d00edb9883..a09ee80ed5 100644 --- a/client/ayon_core/tools/publisher/widgets/overview_widget.py +++ b/client/ayon_core/tools/publisher/widgets/overview_widget.py @@ -6,17 +6,15 @@ from .card_view_widgets import InstanceCardView from .list_view_widgets import InstanceListView from .widgets import ( - ProductAttributesWidget, CreateInstanceBtn, RemoveInstanceBtn, ChangeViewBtn, ) from .create_widget import CreateWidget +from .product_info import ProductInfoWidget class OverviewWidget(QtWidgets.QFrame): - active_changed = QtCore.Signal() - instance_context_changed = QtCore.Signal() create_requested = QtCore.Signal() convert_requested = QtCore.Signal() publish_tab_requested = QtCore.Signal() @@ -61,7 +59,7 @@ def __init__( product_attributes_wrap = BorderedLabelWidget( "Publish options", product_content_widget ) - product_attributes_widget = ProductAttributesWidget( + product_attributes_widget = ProductInfoWidget( controller, product_attributes_wrap ) product_attributes_wrap.set_center_widget(product_attributes_widget) @@ -126,17 +124,7 @@ def __init__( product_view_cards.double_clicked.connect( self.publish_tab_requested ) - # Active instances changed - product_list_view.active_changed.connect( - self._on_active_changed - ) - product_view_cards.active_changed.connect( - self._on_active_changed - ) # Instance context has changed - product_attributes_widget.instance_context_changed.connect( - self._on_instance_context_change - ) product_attributes_widget.convert_requested.connect( self._on_convert_requested ) @@ -152,7 +140,20 @@ def __init__( "publish.reset.finished", self._on_publish_reset ) controller.register_event_callback( - "instances.refresh.finished", self._on_instances_refresh + "create.model.reset", + self._on_create_model_reset + ) + controller.register_event_callback( + "create.context.added.instance", + self._on_instances_added + ) + controller.register_event_callback( + "create.context.removed.instance", + self._on_instances_removed + ) + controller.register_event_callback( + "create.model.instances.context.changed", + self._on_instance_context_change ) self._product_content_widget = product_content_widget @@ -303,11 +304,6 @@ def _on_product_change(self, *_args): instances, context_selected, convertor_identifiers ) - def _on_active_changed(self): - if self._refreshing_instances: - return - self.active_changed.emit() - def _on_change_anim(self, value): self._create_widget.setVisible(True) self._product_attributes_wrap.setVisible(True) @@ -353,7 +349,7 @@ def _change_visibility_for_state(self): self._current_state == "publish" ) - def _on_instance_context_change(self): + def _on_instance_context_change(self, event): current_idx = self._product_views_layout.currentIndex() for idx in range(self._product_views_layout.count()): if idx == current_idx: @@ -363,9 +359,7 @@ def _on_instance_context_change(self): widget.set_refreshed(False) current_widget = self._product_views_layout.widget(current_idx) - current_widget.refresh_instance_states() - - self.instance_context_changed.emit() + current_widget.refresh_instance_states(event["instance_ids"]) def _on_convert_requested(self): self.convert_requested.emit() @@ -436,6 +430,12 @@ def _refresh_instances(self): # Force to change instance and refresh details self._on_product_change() + # Give a change to process Resize Request + QtWidgets.QApplication.processEvents() + # Trigger update geometry of + widget = self._product_views_layout.currentWidget() + widget.updateGeometry() + def _on_publish_start(self): """Publish started.""" @@ -461,13 +461,11 @@ def _on_publish_reset(self): self._controller.is_host_valid() ) - def _on_instances_refresh(self): - """Controller refreshed instances.""" + def _on_create_model_reset(self): + self._refresh_instances() + def _on_instances_added(self): self._refresh_instances() - # Give a change to process Resize Request - QtWidgets.QApplication.processEvents() - # Trigger update geometry of - widget = self._product_views_layout.currentWidget() - widget.updateGeometry() + def _on_instances_removed(self): + self._refresh_instances() diff --git a/client/ayon_core/tools/publisher/widgets/product_attributes.py b/client/ayon_core/tools/publisher/widgets/product_attributes.py new file mode 100644 index 0000000000..61d5ca111d --- /dev/null +++ b/client/ayon_core/tools/publisher/widgets/product_attributes.py @@ -0,0 +1,376 @@ +from qtpy import QtWidgets, QtCore + +from ayon_core.lib.attribute_definitions import UnknownDef +from ayon_core.tools.attribute_defs import create_widget_for_attr_def +from ayon_core.tools.publisher.abstract import AbstractPublisherFrontend +from ayon_core.tools.publisher.constants import ( + INPUTS_LAYOUT_HSPACING, + INPUTS_LAYOUT_VSPACING, +) + + +class CreatorAttrsWidget(QtWidgets.QWidget): + """Widget showing creator specific attributes for selected instances. + + Attributes are defined on creator so are dynamic. Their look and type is + based on attribute definitions that are defined in + `~/ayon_core/lib/attribute_definitions.py` and their widget + representation in `~/ayon_core/tools/attribute_defs/*`. + + Widgets are disabled if context of instance is not valid. + + Definitions are shown for all instance no matter if they are created with + different creators. If creator have same (similar) definitions their + widgets are merged into one (different label does not count). + """ + + def __init__( + self, controller: AbstractPublisherFrontend, parent: QtWidgets.QWidget + ): + super().__init__(parent) + + scroll_area = QtWidgets.QScrollArea(self) + scroll_area.setWidgetResizable(True) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(0) + main_layout.addWidget(scroll_area, 1) + + controller.register_event_callback( + "create.context.create.attrs.changed", + self._on_instance_attr_defs_change + ) + controller.register_event_callback( + "create.context.value.changed", + self._on_instance_value_change + ) + + self._main_layout = main_layout + + self._controller: AbstractPublisherFrontend = controller + self._scroll_area = scroll_area + + self._attr_def_id_to_instances = {} + self._attr_def_id_to_attr_def = {} + self._current_instance_ids = set() + + # To store content of scroll area to prevent garbage collection + self._content_widget = None + + def set_instances_valid(self, valid): + """Change valid state of current instances.""" + + if ( + self._content_widget is not None + and self._content_widget.isEnabled() != valid + ): + self._content_widget.setEnabled(valid) + + def set_current_instances(self, instance_ids): + """Set current instances for which are attribute definitions shown.""" + + self._current_instance_ids = set(instance_ids) + self._refresh_content() + + def _refresh_content(self): + prev_content_widget = self._scroll_area.widget() + if prev_content_widget: + self._scroll_area.takeWidget() + prev_content_widget.hide() + prev_content_widget.deleteLater() + + self._content_widget = None + self._attr_def_id_to_instances = {} + self._attr_def_id_to_attr_def = {} + + result = self._controller.get_creator_attribute_definitions( + self._current_instance_ids + ) + + content_widget = QtWidgets.QWidget(self._scroll_area) + content_layout = QtWidgets.QGridLayout(content_widget) + content_layout.setColumnStretch(0, 0) + content_layout.setColumnStretch(1, 1) + content_layout.setAlignment(QtCore.Qt.AlignTop) + content_layout.setHorizontalSpacing(INPUTS_LAYOUT_HSPACING) + content_layout.setVerticalSpacing(INPUTS_LAYOUT_VSPACING) + + row = 0 + for attr_def, instance_ids, values in result: + widget = create_widget_for_attr_def(attr_def, content_widget) + if attr_def.is_value_def: + if len(values) == 1: + value = values[0] + if value is not None: + widget.set_value(values[0]) + else: + widget.set_value(values, True) + + widget.value_changed.connect(self._input_value_changed) + self._attr_def_id_to_instances[attr_def.id] = instance_ids + self._attr_def_id_to_attr_def[attr_def.id] = attr_def + + if not attr_def.visible: + continue + + expand_cols = 2 + if attr_def.is_value_def and attr_def.is_label_horizontal: + expand_cols = 1 + + col_num = 2 - expand_cols + + label = None + if attr_def.is_value_def: + label = attr_def.label or attr_def.key + if label: + label_widget = QtWidgets.QLabel(label, self) + tooltip = attr_def.tooltip + if tooltip: + label_widget.setToolTip(tooltip) + if attr_def.is_label_horizontal: + label_widget.setAlignment( + QtCore.Qt.AlignRight + | QtCore.Qt.AlignVCenter + ) + content_layout.addWidget( + label_widget, row, 0, 1, expand_cols + ) + if not attr_def.is_label_horizontal: + row += 1 + + content_layout.addWidget( + widget, row, col_num, 1, expand_cols + ) + row += 1 + + self._scroll_area.setWidget(content_widget) + self._content_widget = content_widget + + def _on_instance_attr_defs_change(self, event): + for instance_id in event.data["instance_ids"]: + if instance_id in self._current_instance_ids: + self._refresh_content() + break + + def _on_instance_value_change(self, event): + # TODO try to find more optimized way to update values instead of + # force refresh of all of them. + for instance_id, changes in event["instance_changes"].items(): + if ( + instance_id in self._current_instance_ids + and "creator_attributes" not in changes + ): + self._refresh_content() + break + + def _input_value_changed(self, value, attr_id): + instance_ids = self._attr_def_id_to_instances.get(attr_id) + attr_def = self._attr_def_id_to_attr_def.get(attr_id) + if not instance_ids or not attr_def: + return + self._controller.set_instances_create_attr_values( + instance_ids, attr_def.key, value + ) + + +class PublishPluginAttrsWidget(QtWidgets.QWidget): + """Widget showing publish plugin attributes for selected instances. + + Attributes are defined on publish plugins. Publish plugin may define + attribute definitions but must inherit `AYONPyblishPluginMixin` + (~/ayon_core/pipeline/publish). At the moment requires to implement + `get_attribute_defs` and `convert_attribute_values` class methods. + + Look and type of attributes is based on attribute definitions that are + defined in `~/ayon_core/lib/attribute_definitions.py` and their + widget representation in `~/ayon_core/tools/attribute_defs/*`. + + Widgets are disabled if context of instance is not valid. + + Definitions are shown for all instance no matter if they have different + product types. Similar definitions are merged into one (different label + does not count). + """ + + def __init__( + self, controller: AbstractPublisherFrontend, parent: QtWidgets.QWidget + ): + super().__init__(parent) + + scroll_area = QtWidgets.QScrollArea(self) + scroll_area.setWidgetResizable(True) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(0) + main_layout.addWidget(scroll_area, 1) + + controller.register_event_callback( + "create.context.publish.attrs.changed", + self._on_instance_attr_defs_change + ) + controller.register_event_callback( + "create.context.value.changed", + self._on_instance_value_change + ) + + self._current_instance_ids = set() + self._context_selected = False + + self._main_layout = main_layout + + self._controller: AbstractPublisherFrontend = controller + self._scroll_area = scroll_area + + self._attr_def_id_to_instances = {} + self._attr_def_id_to_attr_def = {} + self._attr_def_id_to_plugin_name = {} + + # Store content of scroll area to prevent garbage collection + self._content_widget = None + + def set_instances_valid(self, valid): + """Change valid state of current instances.""" + if ( + self._content_widget is not None + and self._content_widget.isEnabled() != valid + ): + self._content_widget.setEnabled(valid) + + def set_current_instances(self, instance_ids, context_selected): + """Set current instances for which are attribute definitions shown.""" + + self._current_instance_ids = set(instance_ids) + self._context_selected = context_selected + self._refresh_content() + + def _refresh_content(self): + prev_content_widget = self._scroll_area.widget() + if prev_content_widget: + self._scroll_area.takeWidget() + prev_content_widget.hide() + prev_content_widget.deleteLater() + + self._content_widget = None + + self._attr_def_id_to_instances = {} + self._attr_def_id_to_attr_def = {} + self._attr_def_id_to_plugin_name = {} + + result = self._controller.get_publish_attribute_definitions( + self._current_instance_ids, self._context_selected + ) + + content_widget = QtWidgets.QWidget(self._scroll_area) + attr_def_widget = QtWidgets.QWidget(content_widget) + attr_def_layout = QtWidgets.QGridLayout(attr_def_widget) + attr_def_layout.setColumnStretch(0, 0) + attr_def_layout.setColumnStretch(1, 1) + attr_def_layout.setHorizontalSpacing(INPUTS_LAYOUT_HSPACING) + attr_def_layout.setVerticalSpacing(INPUTS_LAYOUT_VSPACING) + + content_layout = QtWidgets.QVBoxLayout(content_widget) + content_layout.addWidget(attr_def_widget, 0) + content_layout.addStretch(1) + + row = 0 + for plugin_name, attr_defs, all_plugin_values in result: + plugin_values = all_plugin_values[plugin_name] + + for attr_def in attr_defs: + widget = create_widget_for_attr_def( + attr_def, content_widget + ) + visible_widget = attr_def.visible + # Hide unknown values of publish plugins + # - The keys in most of the cases does not represent what + # would label represent + if isinstance(attr_def, UnknownDef): + widget.setVisible(False) + visible_widget = False + + if visible_widget: + expand_cols = 2 + if attr_def.is_value_def and attr_def.is_label_horizontal: + expand_cols = 1 + + col_num = 2 - expand_cols + label = None + if attr_def.is_value_def: + label = attr_def.label or attr_def.key + if label: + label_widget = QtWidgets.QLabel(label, content_widget) + tooltip = attr_def.tooltip + if tooltip: + label_widget.setToolTip(tooltip) + if attr_def.is_label_horizontal: + label_widget.setAlignment( + QtCore.Qt.AlignRight + | QtCore.Qt.AlignVCenter + ) + attr_def_layout.addWidget( + label_widget, row, 0, 1, expand_cols + ) + if not attr_def.is_label_horizontal: + row += 1 + attr_def_layout.addWidget( + widget, row, col_num, 1, expand_cols + ) + row += 1 + + if not attr_def.is_value_def: + continue + + widget.value_changed.connect(self._input_value_changed) + + attr_values = plugin_values[attr_def.key] + multivalue = len(attr_values) > 1 + values = [] + instances = [] + for instance, value in attr_values: + values.append(value) + instances.append(instance) + + self._attr_def_id_to_attr_def[attr_def.id] = attr_def + self._attr_def_id_to_instances[attr_def.id] = instances + self._attr_def_id_to_plugin_name[attr_def.id] = plugin_name + + if multivalue: + widget.set_value(values, multivalue) + else: + widget.set_value(values[0]) + + self._scroll_area.setWidget(content_widget) + self._content_widget = content_widget + + def _input_value_changed(self, value, attr_id): + instance_ids = self._attr_def_id_to_instances.get(attr_id) + attr_def = self._attr_def_id_to_attr_def.get(attr_id) + plugin_name = self._attr_def_id_to_plugin_name.get(attr_id) + if not instance_ids or not attr_def or not plugin_name: + return + + self._controller.set_instances_publish_attr_values( + instance_ids, plugin_name, attr_def.key, value + ) + + def _on_instance_attr_defs_change(self, event): + for instance_id in event.data: + if ( + instance_id is None and self._context_selected + or instance_id in self._current_instance_ids + ): + self._refresh_content() + break + + def _on_instance_value_change(self, event): + # TODO try to find more optimized way to update values instead of + # force refresh of all of them. + for instance_id, changes in event["instance_changes"].items(): + if ( + instance_id in self._current_instance_ids + and "publish_attributes" not in changes + ): + self._refresh_content() + break diff --git a/client/ayon_core/tools/publisher/widgets/product_context.py b/client/ayon_core/tools/publisher/widgets/product_context.py new file mode 100644 index 0000000000..04c9ca7e56 --- /dev/null +++ b/client/ayon_core/tools/publisher/widgets/product_context.py @@ -0,0 +1,933 @@ +import re +import copy +import collections + +from qtpy import QtWidgets, QtCore, QtGui +import qtawesome + +from ayon_core.pipeline.create import ( + PRODUCT_NAME_ALLOWED_SYMBOLS, + TaskNotSetError, +) +from ayon_core.tools.utils import ( + PlaceholderLineEdit, + BaseClickableFrame, + set_style_property, +) +from ayon_core.tools.publisher.abstract import AbstractPublisherFrontend +from ayon_core.tools.publisher.constants import ( + VARIANT_TOOLTIP, + INPUTS_LAYOUT_HSPACING, + INPUTS_LAYOUT_VSPACING, +) + +from .folders_dialog import FoldersDialog +from .tasks_model import TasksModel +from .widgets import ClickableLineEdit, MultipleItemWidget + + +class FoldersFields(BaseClickableFrame): + """Field where folder path of selected instance/s is showed. + + Click on the field will trigger `FoldersDialog`. + """ + value_changed = QtCore.Signal() + + def __init__( + self, controller: AbstractPublisherFrontend, parent: QtWidgets.QWidget + ): + super().__init__(parent) + self.setObjectName("FolderPathInputWidget") + + # Don't use 'self' for parent! + # - this widget has specific styles + dialog = FoldersDialog(controller, parent) + + name_input = ClickableLineEdit(self) + name_input.setObjectName("FolderPathInput") + + icon_name = "fa.window-maximize" + icon = qtawesome.icon(icon_name, color="white") + icon_btn = QtWidgets.QPushButton(self) + icon_btn.setIcon(icon) + icon_btn.setObjectName("FolderPathInputButton") + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(0) + layout.addWidget(name_input, 1) + layout.addWidget(icon_btn, 0) + + # Make sure all widgets are vertically extended to highest widget + for widget in ( + name_input, + icon_btn + ): + size_policy = widget.sizePolicy() + size_policy.setVerticalPolicy( + QtWidgets.QSizePolicy.MinimumExpanding) + widget.setSizePolicy(size_policy) + name_input.clicked.connect(self._mouse_release_callback) + icon_btn.clicked.connect(self._mouse_release_callback) + dialog.finished.connect(self._on_dialog_finish) + + self._controller: AbstractPublisherFrontend = controller + self._dialog = dialog + self._name_input = name_input + self._icon_btn = icon_btn + + self._origin_value = [] + self._origin_selection = [] + self._selected_items = [] + self._has_value_changed = False + self._is_valid = True + self._multiselection_text = None + + def _on_dialog_finish(self, result): + if not result: + return + + folder_path = self._dialog.get_selected_folder_path() + if folder_path is None: + return + + self._selected_items = [folder_path] + self._has_value_changed = ( + self._origin_value != self._selected_items + ) + self.set_text(folder_path) + self._set_is_valid(True) + + self.value_changed.emit() + + def _mouse_release_callback(self): + self._dialog.set_selected_folders(self._selected_items) + self._dialog.open() + + def set_multiselection_text(self, text): + """Change text for multiselection of different folders. + + When there are selected multiple instances at once and they don't have + same folder in context. + """ + self._multiselection_text = text + + def _set_is_valid(self, valid): + if valid == self._is_valid: + return + self._is_valid = valid + state = "" + if not valid: + state = "invalid" + self._set_state_property(state) + + def _set_state_property(self, state): + set_style_property(self, "state", state) + set_style_property(self._name_input, "state", state) + set_style_property(self._icon_btn, "state", state) + + def is_valid(self): + """Is folder valid.""" + return self._is_valid + + def has_value_changed(self): + """Value of folder has changed.""" + return self._has_value_changed + + def get_selected_items(self): + """Selected folder paths.""" + return list(self._selected_items) + + def set_text(self, text): + """Set text in text field. + + Does not change selected items (folders). + """ + self._name_input.setText(text) + self._name_input.end(False) + + def set_selected_items(self, folder_paths=None): + """Set folder paths for selection of instances. + + Passed folder paths are validated and if there are 2 or more different + folder paths then multiselection text is shown. + + Args: + folder_paths (list, tuple, set, NoneType): List of folder paths. + + """ + if folder_paths is None: + folder_paths = [] + + self._has_value_changed = False + self._origin_value = list(folder_paths) + self._selected_items = list(folder_paths) + is_valid = self._controller.are_folder_paths_valid(folder_paths) + if not folder_paths: + self.set_text("") + + elif len(folder_paths) == 1: + folder_path = tuple(folder_paths)[0] + self.set_text(folder_path) + else: + multiselection_text = self._multiselection_text + if multiselection_text is None: + multiselection_text = "|".join(folder_paths) + self.set_text(multiselection_text) + + self._set_is_valid(is_valid) + + def reset_to_origin(self): + """Change to folder paths set with last `set_selected_items` call.""" + self.set_selected_items(self._origin_value) + + def confirm_value(self): + self._origin_value = copy.deepcopy(self._selected_items) + self._has_value_changed = False + + +class TasksComboboxProxy(QtCore.QSortFilterProxyModel): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._filter_empty = False + + def set_filter_empty(self, filter_empty): + if self._filter_empty is filter_empty: + return + self._filter_empty = filter_empty + self.invalidate() + + def filterAcceptsRow(self, source_row, parent_index): + if self._filter_empty: + model = self.sourceModel() + source_index = model.index( + source_row, self.filterKeyColumn(), parent_index + ) + if not source_index.data(QtCore.Qt.DisplayRole): + return False + return True + + +class TasksCombobox(QtWidgets.QComboBox): + """Combobox to show tasks for selected instances. + + Combobox gives ability to select only from intersection of task names for + folder paths in selected instances. + + If folder paths in selected instances does not have same tasks then combobox + will be empty. + """ + value_changed = QtCore.Signal() + + def __init__( + self, controller: AbstractPublisherFrontend, parent: QtWidgets.QWidget + ): + super().__init__(parent) + self.setObjectName("TasksCombobox") + + # Set empty delegate to propagate stylesheet to a combobox + delegate = QtWidgets.QStyledItemDelegate() + self.setItemDelegate(delegate) + + model = TasksModel(controller, True) + proxy_model = TasksComboboxProxy() + proxy_model.setSourceModel(model) + self.setModel(proxy_model) + + self.currentIndexChanged.connect(self._on_index_change) + + self._delegate = delegate + self._model = model + self._proxy_model = proxy_model + self._origin_value = [] + self._origin_selection = [] + self._selected_items = [] + self._has_value_changed = False + self._ignore_index_change = False + self._multiselection_text = None + self._is_valid = True + + self._text = None + + # Make sure combobox is extended horizontally + size_policy = self.sizePolicy() + size_policy.setHorizontalPolicy( + QtWidgets.QSizePolicy.MinimumExpanding) + self.setSizePolicy(size_policy) + + def set_invalid_empty_task(self, invalid=True): + self._proxy_model.set_filter_empty(invalid) + if invalid: + self._set_is_valid(False) + self.set_text( + "< One or more products require Task selected >" + ) + else: + self.set_text(None) + + def set_multiselection_text(self, text): + """Change text shown when multiple different tasks are in context.""" + self._multiselection_text = text + + def _on_index_change(self): + if self._ignore_index_change: + return + + self.set_text(None) + text = self.currentText() + idx = self.findText(text) + if idx < 0: + return + + self._set_is_valid(True) + self._selected_items = [text] + self._has_value_changed = ( + self._origin_selection != self._selected_items + ) + + self.value_changed.emit() + + def set_text(self, text): + """Set context shown in combobox without changing selected items.""" + if text == self._text: + return + + self._text = text + self.repaint() + + def paintEvent(self, event): + """Paint custom text without using QLineEdit. + + The easiest way how to draw custom text in combobox and keep combobox + properties and event handling. + """ + painter = QtGui.QPainter(self) + painter.setPen(self.palette().color(QtGui.QPalette.Text)) + opt = QtWidgets.QStyleOptionComboBox() + self.initStyleOption(opt) + if self._text is not None: + opt.currentText = self._text + + style = self.style() + style.drawComplexControl( + QtWidgets.QStyle.CC_ComboBox, opt, painter, self + ) + style.drawControl( + QtWidgets.QStyle.CE_ComboBoxLabel, opt, painter, self + ) + painter.end() + + def is_valid(self): + """Are all selected items valid.""" + return self._is_valid + + def has_value_changed(self): + """Did selection of task changed.""" + return self._has_value_changed + + def _set_is_valid(self, valid): + if valid == self._is_valid: + return + self._is_valid = valid + state = "" + if not valid: + state = "invalid" + self._set_state_property(state) + + def _set_state_property(self, state): + current_value = self.property("state") + if current_value != state: + self.setProperty("state", state) + self.style().polish(self) + + def get_selected_items(self): + """Get selected tasks. + + If value has changed then will return list with single item. + + Returns: + list: Selected tasks. + """ + return list(self._selected_items) + + def set_folder_paths(self, folder_paths): + """Set folder paths for which should show tasks.""" + self._ignore_index_change = True + + self._model.set_folder_paths(folder_paths) + self._proxy_model.set_filter_empty(False) + self._proxy_model.sort(0) + + self._ignore_index_change = False + + # It is a bug if not exactly one folder got here + if len(folder_paths) != 1: + self.set_selected_item("") + self._set_is_valid(False) + return + + folder_path = tuple(folder_paths)[0] + + is_valid = False + if self._selected_items: + is_valid = True + + valid_task_names = [] + for task_name in self._selected_items: + _is_valid = self._model.is_task_name_valid(folder_path, task_name) + if _is_valid: + valid_task_names.append(task_name) + else: + is_valid = _is_valid + + self._selected_items = valid_task_names + if len(self._selected_items) == 0: + self.set_selected_item("") + + elif len(self._selected_items) == 1: + self.set_selected_item(self._selected_items[0]) + + else: + multiselection_text = self._multiselection_text + if multiselection_text is None: + multiselection_text = "|".join(self._selected_items) + self.set_selected_item(multiselection_text) + + self._set_is_valid(is_valid) + + def confirm_value(self, folder_paths): + new_task_name = self._selected_items[0] + self._origin_value = [ + (folder_path, new_task_name) + for folder_path in folder_paths + ] + self._origin_selection = copy.deepcopy(self._selected_items) + self._has_value_changed = False + + def set_selected_items(self, folder_task_combinations=None): + """Set items for selected instances. + + Args: + folder_task_combinations (list): List of tuples. Each item in + the list contain folder path and task name. + """ + self._proxy_model.set_filter_empty(False) + self._proxy_model.sort(0) + + if folder_task_combinations is None: + folder_task_combinations = [] + + task_names = set() + task_names_by_folder_path = collections.defaultdict(set) + for folder_path, task_name in folder_task_combinations: + task_names.add(task_name) + task_names_by_folder_path[folder_path].add(task_name) + folder_paths = set(task_names_by_folder_path.keys()) + + self._ignore_index_change = True + + self._model.set_folder_paths(folder_paths) + + self._has_value_changed = False + + self._origin_value = copy.deepcopy(folder_task_combinations) + + self._origin_selection = list(task_names) + self._selected_items = list(task_names) + # Reset current index + self.setCurrentIndex(-1) + is_valid = True + if not task_names: + self.set_selected_item("") + + elif len(task_names) == 1: + task_name = tuple(task_names)[0] + idx = self.findText(task_name) + is_valid = not idx < 0 + if not is_valid and len(folder_paths) > 1: + is_valid = self._validate_task_names_by_folder_paths( + task_names_by_folder_path + ) + self.set_selected_item(task_name) + + else: + for task_name in task_names: + idx = self.findText(task_name) + is_valid = not idx < 0 + if not is_valid: + break + + if not is_valid and len(folder_paths) > 1: + is_valid = self._validate_task_names_by_folder_paths( + task_names_by_folder_path + ) + multiselection_text = self._multiselection_text + if multiselection_text is None: + multiselection_text = "|".join(task_names) + self.set_selected_item(multiselection_text) + + self._set_is_valid(is_valid) + + self._ignore_index_change = False + + self.value_changed.emit() + + def _validate_task_names_by_folder_paths(self, task_names_by_folder_path): + for folder_path, task_names in task_names_by_folder_path.items(): + for task_name in task_names: + if not self._model.is_task_name_valid(folder_path, task_name): + return False + return True + + def set_selected_item(self, item_name): + """Set task which is set on selected instance. + + Args: + item_name(str): Task name which should be selected. + """ + idx = self.findText(item_name) + # Set current index (must be set to -1 if is invalid) + self.setCurrentIndex(idx) + self.set_text(item_name) + + def reset_to_origin(self): + """Change to task names set with last `set_selected_items` call.""" + self.set_selected_items(self._origin_value) + + +class VariantInputWidget(PlaceholderLineEdit): + """Input widget for variant.""" + value_changed = QtCore.Signal() + + def __init__(self, parent): + super().__init__(parent) + + self.setObjectName("VariantInput") + self.setToolTip(VARIANT_TOOLTIP) + + name_pattern = "^[{}]*$".format(PRODUCT_NAME_ALLOWED_SYMBOLS) + self._name_pattern = name_pattern + self._compiled_name_pattern = re.compile(name_pattern) + + self._origin_value = [] + self._current_value = [] + + self._ignore_value_change = False + self._has_value_changed = False + self._multiselection_text = None + + self._is_valid = True + + self.textChanged.connect(self._on_text_change) + + def is_valid(self): + """Is variant text valid.""" + return self._is_valid + + def has_value_changed(self): + """Value of variant has changed.""" + return self._has_value_changed + + def _set_state_property(self, state): + current_value = self.property("state") + if current_value != state: + self.setProperty("state", state) + self.style().polish(self) + + def set_multiselection_text(self, text): + """Change text of multiselection.""" + self._multiselection_text = text + + def confirm_value(self): + self._origin_value = copy.deepcopy(self._current_value) + self._has_value_changed = False + + def _set_is_valid(self, valid): + if valid == self._is_valid: + return + self._is_valid = valid + state = "" + if not valid: + state = "invalid" + self._set_state_property(state) + + def _on_text_change(self): + if self._ignore_value_change: + return + + is_valid = bool(self._compiled_name_pattern.match(self.text())) + self._set_is_valid(is_valid) + + self._current_value = [self.text()] + self._has_value_changed = self._current_value != self._origin_value + + self.value_changed.emit() + + def reset_to_origin(self): + """Set origin value of selected instances.""" + self.set_value(self._origin_value) + + def get_value(self): + """Get current value. + + Origin value returned if didn't change. + """ + return copy.deepcopy(self._current_value) + + def set_value(self, variants=None): + """Set value of currently selected instances.""" + if variants is None: + variants = [] + + self._ignore_value_change = True + + self._has_value_changed = False + + self._origin_value = list(variants) + self._current_value = list(variants) + + self.setPlaceholderText("") + if not variants: + self.setText("") + + elif len(variants) == 1: + self.setText(self._current_value[0]) + + else: + multiselection_text = self._multiselection_text + if multiselection_text is None: + multiselection_text = "|".join(variants) + self.setText("") + self.setPlaceholderText(multiselection_text) + + self._ignore_value_change = False + + +class GlobalAttrsWidget(QtWidgets.QWidget): + """Global attributes mainly to define context and product name of instances. + + product name is or may be affected on context. Gives abiity to modify + context and product name of instance. This change is not autopromoted but + must be submitted. + + Warning: Until artist hit `Submit` changes must not be propagated to + instance data. + + Global attributes contain these widgets: + Variant: [ text input ] + Folder: [ folder dialog ] + Task: [ combobox ] + Product type: [ immutable ] + product name: [ immutable ] + [Submit] [Cancel] + """ + + multiselection_text = "< Multiselection >" + unknown_value = "N/A" + + def __init__( + self, controller: AbstractPublisherFrontend, parent: QtWidgets.QWidget + ): + super().__init__(parent) + + self._controller: AbstractPublisherFrontend = controller + self._current_instances_by_id = {} + self._invalid_task_item_ids = set() + + variant_input = VariantInputWidget(self) + folder_value_widget = FoldersFields(controller, self) + task_value_widget = TasksCombobox(controller, self) + product_type_value_widget = MultipleItemWidget(self) + product_value_widget = MultipleItemWidget(self) + + variant_input.set_multiselection_text(self.multiselection_text) + folder_value_widget.set_multiselection_text(self.multiselection_text) + task_value_widget.set_multiselection_text(self.multiselection_text) + + variant_input.set_value() + folder_value_widget.set_selected_items() + task_value_widget.set_selected_items() + product_type_value_widget.set_value() + product_value_widget.set_value() + + submit_btn = QtWidgets.QPushButton("Confirm", self) + cancel_btn = QtWidgets.QPushButton("Cancel", self) + submit_btn.setEnabled(False) + cancel_btn.setEnabled(False) + + btns_layout = QtWidgets.QHBoxLayout() + btns_layout.setContentsMargins(0, 0, 0, 0) + btns_layout.addStretch(1) + btns_layout.setSpacing(5) + btns_layout.addWidget(submit_btn) + btns_layout.addWidget(cancel_btn) + + main_layout = QtWidgets.QFormLayout(self) + main_layout.setHorizontalSpacing(INPUTS_LAYOUT_HSPACING) + main_layout.setVerticalSpacing(INPUTS_LAYOUT_VSPACING) + main_layout.addRow("Variant", variant_input) + main_layout.addRow("Folder", folder_value_widget) + main_layout.addRow("Task", task_value_widget) + main_layout.addRow("Product type", product_type_value_widget) + main_layout.addRow("Product name", product_value_widget) + main_layout.addRow(btns_layout) + + variant_input.value_changed.connect(self._on_variant_change) + folder_value_widget.value_changed.connect(self._on_folder_change) + task_value_widget.value_changed.connect(self._on_task_change) + submit_btn.clicked.connect(self._on_submit) + cancel_btn.clicked.connect(self._on_cancel) + + controller.register_event_callback( + "create.context.value.changed", + self._on_instance_value_change + ) + + self.variant_input = variant_input + self.folder_value_widget = folder_value_widget + self.task_value_widget = task_value_widget + self.product_type_value_widget = product_type_value_widget + self.product_value_widget = product_value_widget + self.submit_btn = submit_btn + self.cancel_btn = cancel_btn + + def _on_submit(self): + """Commit changes for selected instances.""" + + variant_value = None + folder_path = None + task_name = None + if self.variant_input.has_value_changed(): + variant_value = self.variant_input.get_value()[0] + + if self.folder_value_widget.has_value_changed(): + folder_path = self.folder_value_widget.get_selected_items()[0] + + if self.task_value_widget.has_value_changed(): + task_name = self.task_value_widget.get_selected_items()[0] + + product_names = set() + invalid_tasks = False + folder_paths = [] + changes_by_id = {} + for item in self._current_instances_by_id.values(): + # Ignore instances that have promised context + if item.has_promised_context: + continue + + instance_changes = {} + new_variant_value = item.variant + new_folder_path = item.folder_path + new_task_name = item.task_name + if variant_value is not None: + instance_changes["variant"] = variant_value + new_variant_value = variant_value + + if folder_path is not None: + instance_changes["folderPath"] = folder_path + new_folder_path = folder_path + + if task_name is not None: + instance_changes["task"] = task_name or None + new_task_name = task_name or None + + folder_paths.append(new_folder_path) + try: + new_product_name = self._controller.get_product_name( + item.creator_identifier, + new_variant_value, + new_task_name, + new_folder_path, + item.id, + ) + self._invalid_task_item_ids.discard(item.id) + + except TaskNotSetError: + self._invalid_task_item_ids.add(item.id) + invalid_tasks = True + product_names.add(item.product_name) + continue + + product_names.add(new_product_name) + if item.product_name != new_product_name: + instance_changes["productName"] = new_product_name + + if instance_changes: + changes_by_id[item.id] = instance_changes + + if invalid_tasks: + self.task_value_widget.set_invalid_empty_task() + + self.product_value_widget.set_value(product_names) + + self._set_btns_enabled(False) + self._set_btns_visible(invalid_tasks) + + if variant_value is not None: + self.variant_input.confirm_value() + + if folder_path is not None: + self.folder_value_widget.confirm_value() + + if task_name is not None: + self.task_value_widget.confirm_value(folder_paths) + + self._controller.set_instances_context_info(changes_by_id) + self._refresh_items() + + def _on_cancel(self): + """Cancel changes and set back to their irigin value.""" + + self.variant_input.reset_to_origin() + self.folder_value_widget.reset_to_origin() + self.task_value_widget.reset_to_origin() + self._set_btns_enabled(False) + + def _on_value_change(self): + any_invalid = ( + not self.variant_input.is_valid() + or not self.folder_value_widget.is_valid() + or not self.task_value_widget.is_valid() + ) + any_changed = ( + self.variant_input.has_value_changed() + or self.folder_value_widget.has_value_changed() + or self.task_value_widget.has_value_changed() + ) + self._set_btns_visible(any_changed or any_invalid) + self.cancel_btn.setEnabled(any_changed) + self.submit_btn.setEnabled(not any_invalid) + + def _on_variant_change(self): + self._on_value_change() + + def _on_folder_change(self): + folder_paths = self.folder_value_widget.get_selected_items() + self.task_value_widget.set_folder_paths(folder_paths) + self._on_value_change() + + def _on_task_change(self): + self._on_value_change() + + def _set_btns_visible(self, visible): + self.cancel_btn.setVisible(visible) + self.submit_btn.setVisible(visible) + + def _set_btns_enabled(self, enabled): + self.cancel_btn.setEnabled(enabled) + self.submit_btn.setEnabled(enabled) + + def set_current_instances(self, instances): + """Set currently selected instances. + + Args: + instances (List[InstanceItem]): List of selected instances. + Empty instances tells that nothing or context is selected. + """ + self._set_btns_visible(False) + + self._current_instances_by_id = { + instance.id: instance + for instance in instances + } + self._invalid_task_item_ids = set() + self._refresh_content() + + def _refresh_items(self): + instance_ids = set(self._current_instances_by_id.keys()) + self._current_instances_by_id = ( + self._controller.get_instance_items_by_id(instance_ids) + ) + + def _refresh_content(self): + folder_paths = set() + variants = set() + product_types = set() + product_names = set() + + editable = True + if len(self._current_instances_by_id) == 0: + editable = False + + folder_task_combinations = [] + context_editable = None + invalid_tasks = False + for item in self._current_instances_by_id.values(): + if not item.has_promised_context: + context_editable = True + elif context_editable is None: + context_editable = False + if item.id in self._invalid_task_item_ids: + invalid_tasks = True + + # NOTE I'm not sure how this can even happen? + if item.creator_identifier is None: + editable = False + + variants.add(item.variant or self.unknown_value) + product_types.add(item.product_type or self.unknown_value) + folder_path = item.folder_path or self.unknown_value + task_name = item.task_name or "" + folder_paths.add(folder_path) + folder_task_combinations.append((folder_path, task_name)) + product_names.add(item.product_name or self.unknown_value) + + if not editable: + context_editable = False + elif context_editable is None: + context_editable = True + + self.variant_input.set_value(variants) + + # Set context of folder widget + self.folder_value_widget.set_selected_items(folder_paths) + # Set context of task widget + self.task_value_widget.set_selected_items(folder_task_combinations) + self.product_type_value_widget.set_value(product_types) + self.product_value_widget.set_value(product_names) + + self.variant_input.setEnabled(editable) + self.folder_value_widget.setEnabled(context_editable) + self.task_value_widget.setEnabled(context_editable) + + if invalid_tasks: + self.task_value_widget.set_invalid_empty_task() + + if not editable: + folder_tooltip = "Select instances to change folder path." + task_tooltip = "Select instances to change task name." + elif not context_editable: + folder_tooltip = "Folder path is defined by Create plugin." + task_tooltip = "Task is defined by Create plugin." + else: + folder_tooltip = "Change folder path of selected instances." + task_tooltip = "Change task of selected instances." + + self.folder_value_widget.setToolTip(folder_tooltip) + self.task_value_widget.setToolTip(task_tooltip) + + def _on_instance_value_change(self, event): + if not self._current_instances_by_id: + return + + changed = False + for instance_id, changes in event["instance_changes"].items(): + if instance_id not in self._current_instances_by_id: + continue + + for key in ( + "folderPath", + "task", + "variant", + "productType", + "productName", + ): + if key in changes: + changed = True + break + if changed: + break + + if changed: + self._refresh_items() + self._refresh_content() diff --git a/client/ayon_core/tools/publisher/widgets/product_info.py b/client/ayon_core/tools/publisher/widgets/product_info.py new file mode 100644 index 0000000000..27b7aacf38 --- /dev/null +++ b/client/ayon_core/tools/publisher/widgets/product_info.py @@ -0,0 +1,288 @@ +import os +import uuid +import shutil + +from qtpy import QtWidgets, QtCore + +from ayon_core.tools.publisher.abstract import AbstractPublisherFrontend + +from .thumbnail_widget import ThumbnailWidget +from .product_context import GlobalAttrsWidget +from .product_attributes import ( + CreatorAttrsWidget, + PublishPluginAttrsWidget, +) + + +class ProductInfoWidget(QtWidgets.QWidget): + """Wrapper widget where attributes of instance/s are modified. + ┌─────────────────┬─────────────┐ + │ Global │ │ + │ attributes │ Thumbnail │ TOP + │ │ │ + ├─────────────┬───┴─────────────┤ + │ Creator │ Publish │ + │ attributes │ plugin │ BOTTOM + │ │ attributes │ + └───────────────────────────────┘ + """ + convert_requested = QtCore.Signal() + + def __init__( + self, controller: AbstractPublisherFrontend, parent: QtWidgets.QWidget + ): + super().__init__(parent) + + # TOP PART + top_widget = QtWidgets.QWidget(self) + + # Global attributes + global_attrs_widget = GlobalAttrsWidget(controller, top_widget) + thumbnail_widget = ThumbnailWidget(controller, top_widget) + + top_layout = QtWidgets.QHBoxLayout(top_widget) + top_layout.setContentsMargins(0, 0, 0, 0) + top_layout.addWidget(global_attrs_widget, 7) + top_layout.addWidget(thumbnail_widget, 3) + + # BOTTOM PART + bottom_widget = QtWidgets.QWidget(self) + + # Wrap Creator attributes to widget to be able add convert button + creator_widget = QtWidgets.QWidget(bottom_widget) + + # Convert button widget (with layout to handle stretch) + convert_widget = QtWidgets.QWidget(creator_widget) + convert_label = QtWidgets.QLabel(creator_widget) + # Set the label text with 'setText' to apply html + convert_label.setText( + ( + "Found old publishable products" + " incompatible with new publisher." + "

Press the update products button" + " to automatically update them" + " to be able to publish again." + ) + ) + convert_label.setWordWrap(True) + convert_label.setAlignment(QtCore.Qt.AlignCenter) + + convert_btn = QtWidgets.QPushButton( + "Update products", convert_widget + ) + convert_separator = QtWidgets.QFrame(convert_widget) + convert_separator.setObjectName("Separator") + convert_separator.setMinimumHeight(1) + convert_separator.setMaximumHeight(1) + + convert_layout = QtWidgets.QGridLayout(convert_widget) + convert_layout.setContentsMargins(5, 0, 5, 0) + convert_layout.setVerticalSpacing(10) + convert_layout.addWidget(convert_label, 0, 0, 1, 3) + convert_layout.addWidget(convert_btn, 1, 1) + convert_layout.addWidget(convert_separator, 2, 0, 1, 3) + convert_layout.setColumnStretch(0, 1) + convert_layout.setColumnStretch(1, 0) + convert_layout.setColumnStretch(2, 1) + + # Creator attributes widget + creator_attrs_widget = CreatorAttrsWidget( + controller, creator_widget + ) + creator_layout = QtWidgets.QVBoxLayout(creator_widget) + creator_layout.setContentsMargins(0, 0, 0, 0) + creator_layout.addWidget(convert_widget, 0) + creator_layout.addWidget(creator_attrs_widget, 1) + + publish_attrs_widget = PublishPluginAttrsWidget( + controller, bottom_widget + ) + + bottom_separator = QtWidgets.QWidget(bottom_widget) + bottom_separator.setObjectName("Separator") + bottom_separator.setMinimumWidth(1) + + bottom_layout = QtWidgets.QHBoxLayout(bottom_widget) + bottom_layout.setContentsMargins(0, 0, 0, 0) + bottom_layout.addWidget(creator_widget, 1) + bottom_layout.addWidget(bottom_separator, 0) + bottom_layout.addWidget(publish_attrs_widget, 1) + + top_bottom = QtWidgets.QWidget(self) + top_bottom.setObjectName("Separator") + top_bottom.setMinimumHeight(1) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(top_widget, 0) + layout.addWidget(top_bottom, 0) + layout.addWidget(bottom_widget, 1) + + self._convertor_identifiers = None + self._current_instances = [] + self._context_selected = False + self._all_instances_valid = True + + convert_btn.clicked.connect(self._on_convert_click) + thumbnail_widget.thumbnail_created.connect(self._on_thumbnail_create) + thumbnail_widget.thumbnail_cleared.connect(self._on_thumbnail_clear) + + controller.register_event_callback( + "create.model.instances.context.changed", + self._on_instance_context_change + ) + controller.register_event_callback( + "instance.thumbnail.changed", + self._on_thumbnail_changed + ) + + self._controller: AbstractPublisherFrontend = controller + + self._convert_widget = convert_widget + + self.global_attrs_widget = global_attrs_widget + + self.creator_attrs_widget = creator_attrs_widget + self.publish_attrs_widget = publish_attrs_widget + self._thumbnail_widget = thumbnail_widget + + self.top_bottom = top_bottom + self.bottom_separator = bottom_separator + + def set_current_instances( + self, instances, context_selected, convertor_identifiers + ): + """Change currently selected items. + + Args: + instances (List[InstanceItem]): List of currently selected + instances. + context_selected (bool): Is context selected. + convertor_identifiers (List[str]): Identifiers of convert items. + + """ + s_convertor_identifiers = set(convertor_identifiers) + self._current_instances = instances + self._context_selected = context_selected + self._convertor_identifiers = s_convertor_identifiers + self._refresh_instances() + + def _refresh_instances(self): + instance_ids = { + instance.id + for instance in self._current_instances + } + context_info_by_id = self._controller.get_instances_context_info( + instance_ids + ) + + all_valid = True + for context_info in context_info_by_id.values(): + if not context_info.is_valid: + all_valid = False + break + + self._all_instances_valid = all_valid + + self._convert_widget.setVisible(len(self._convertor_identifiers) > 0) + self.global_attrs_widget.set_current_instances( + self._current_instances + ) + self.creator_attrs_widget.set_current_instances(instance_ids) + self.publish_attrs_widget.set_current_instances( + instance_ids, self._context_selected + ) + self.creator_attrs_widget.set_instances_valid(all_valid) + self.publish_attrs_widget.set_instances_valid(all_valid) + + self._update_thumbnails() + + def _on_instance_context_change(self): + instance_ids = { + instance.id + for instance in self._current_instances + } + context_info_by_id = self._controller.get_instances_context_info( + instance_ids + ) + all_valid = True + for instance_id, context_info in context_info_by_id.items(): + if not context_info.is_valid: + all_valid = False + break + + self._all_instances_valid = all_valid + self.creator_attrs_widget.set_instances_valid(all_valid) + self.publish_attrs_widget.set_instances_valid(all_valid) + + def _on_convert_click(self): + self.convert_requested.emit() + + def _on_thumbnail_create(self, path): + instance_ids = [ + instance.id + for instance in self._current_instances + ] + if self._context_selected: + instance_ids.append(None) + + if not instance_ids: + return + + mapping = {} + if len(instance_ids) == 1: + mapping[instance_ids[0]] = path + + else: + for instance_id in instance_ids: + root = os.path.dirname(path) + ext = os.path.splitext(path)[-1] + dst_path = os.path.join(root, str(uuid.uuid4()) + ext) + shutil.copy(path, dst_path) + mapping[instance_id] = dst_path + + self._controller.set_thumbnail_paths_for_instances(mapping) + + def _on_thumbnail_clear(self): + instance_ids = [ + instance.id + for instance in self._current_instances + ] + if self._context_selected: + instance_ids.append(None) + + if not instance_ids: + return + + mapping = { + instance_id: None + for instance_id in instance_ids + } + self._controller.set_thumbnail_paths_for_instances(mapping) + + def _on_thumbnail_changed(self, event): + self._update_thumbnails() + + def _update_thumbnails(self): + instance_ids = [ + instance.id + for instance in self._current_instances + ] + if self._context_selected: + instance_ids.append(None) + + if not instance_ids: + self._thumbnail_widget.setVisible(False) + self._thumbnail_widget.set_current_thumbnails(None) + return + + mapping = self._controller.get_thumbnail_paths_for_instances( + instance_ids + ) + thumbnail_paths = [] + for instance_id in instance_ids: + path = mapping[instance_id] + if path: + thumbnail_paths.append(path) + + self._thumbnail_widget.setVisible(True) + self._thumbnail_widget.set_current_thumbnails(thumbnail_paths) diff --git a/client/ayon_core/tools/publisher/widgets/widgets.py b/client/ayon_core/tools/publisher/widgets/widgets.py index b0f32dfcfc..a9d34c4c66 100644 --- a/client/ayon_core/tools/publisher/widgets/widgets.py +++ b/client/ayon_core/tools/publisher/widgets/widgets.py @@ -1,41 +1,18 @@ # -*- coding: utf-8 -*- import os -import re -import copy import functools -import uuid -import shutil -import collections from qtpy import QtWidgets, QtCore, QtGui import qtawesome -from ayon_core.lib.attribute_definitions import UnknownDef from ayon_core.style import get_objected_colors -from ayon_core.pipeline.create import ( - PRODUCT_NAME_ALLOWED_SYMBOLS, - TaskNotSetError, -) -from ayon_core.tools.attribute_defs import create_widget_for_attr_def from ayon_core.tools import resources from ayon_core.tools.flickcharm import FlickCharm from ayon_core.tools.utils import ( - PlaceholderLineEdit, IconButton, PixmapLabel, - BaseClickableFrame, - set_style_property, -) -from ayon_core.tools.publisher.abstract import AbstractPublisherFrontend -from ayon_core.tools.publisher.constants import ( - VARIANT_TOOLTIP, - ResetKeySequence, - INPUTS_LAYOUT_HSPACING, - INPUTS_LAYOUT_VSPACING, ) +from ayon_core.tools.publisher.constants import ResetKeySequence -from .thumbnail_widget import ThumbnailWidget -from .folders_dialog import FoldersDialog -from .tasks_model import TasksModel from .icons import ( get_pixmap, get_icon_path @@ -321,7 +298,6 @@ def __init__(self, parent=None): class AbstractInstanceView(QtWidgets.QWidget): """Abstract class for instance view in creation part.""" selection_changed = QtCore.Signal() - active_changed = QtCore.Signal() # Refreshed attribute is not changed by view itself # - widget which triggers `refresh` is changing the state # TODO store that information in widget which cares about refreshing @@ -426,583 +402,6 @@ def mouseDoubleClickEvent(self, event): event.accept() -class FoldersFields(BaseClickableFrame): - """Field where folder path of selected instance/s is showed. - - Click on the field will trigger `FoldersDialog`. - """ - value_changed = QtCore.Signal() - - def __init__( - self, controller: AbstractPublisherFrontend, parent: QtWidgets.QWidget - ): - super().__init__(parent) - self.setObjectName("FolderPathInputWidget") - - # Don't use 'self' for parent! - # - this widget has specific styles - dialog = FoldersDialog(controller, parent) - - name_input = ClickableLineEdit(self) - name_input.setObjectName("FolderPathInput") - - icon_name = "fa.window-maximize" - icon = qtawesome.icon(icon_name, color="white") - icon_btn = QtWidgets.QPushButton(self) - icon_btn.setIcon(icon) - icon_btn.setObjectName("FolderPathInputButton") - - layout = QtWidgets.QHBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - layout.addWidget(name_input, 1) - layout.addWidget(icon_btn, 0) - - # Make sure all widgets are vertically extended to highest widget - for widget in ( - name_input, - icon_btn - ): - size_policy = widget.sizePolicy() - size_policy.setVerticalPolicy( - QtWidgets.QSizePolicy.MinimumExpanding) - widget.setSizePolicy(size_policy) - name_input.clicked.connect(self._mouse_release_callback) - icon_btn.clicked.connect(self._mouse_release_callback) - dialog.finished.connect(self._on_dialog_finish) - - self._controller: AbstractPublisherFrontend = controller - self._dialog = dialog - self._name_input = name_input - self._icon_btn = icon_btn - - self._origin_value = [] - self._origin_selection = [] - self._selected_items = [] - self._has_value_changed = False - self._is_valid = True - self._multiselection_text = None - - def _on_dialog_finish(self, result): - if not result: - return - - folder_path = self._dialog.get_selected_folder_path() - if folder_path is None: - return - - self._selected_items = [folder_path] - self._has_value_changed = ( - self._origin_value != self._selected_items - ) - self.set_text(folder_path) - self._set_is_valid(True) - - self.value_changed.emit() - - def _mouse_release_callback(self): - self._dialog.set_selected_folders(self._selected_items) - self._dialog.open() - - def set_multiselection_text(self, text): - """Change text for multiselection of different folders. - - When there are selected multiple instances at once and they don't have - same folder in context. - """ - self._multiselection_text = text - - def _set_is_valid(self, valid): - if valid == self._is_valid: - return - self._is_valid = valid - state = "" - if not valid: - state = "invalid" - self._set_state_property(state) - - def _set_state_property(self, state): - set_style_property(self, "state", state) - set_style_property(self._name_input, "state", state) - set_style_property(self._icon_btn, "state", state) - - def is_valid(self): - """Is folder valid.""" - return self._is_valid - - def has_value_changed(self): - """Value of folder has changed.""" - return self._has_value_changed - - def get_selected_items(self): - """Selected folder paths.""" - return list(self._selected_items) - - def set_text(self, text): - """Set text in text field. - - Does not change selected items (folders). - """ - self._name_input.setText(text) - self._name_input.end(False) - - def set_selected_items(self, folder_paths=None): - """Set folder paths for selection of instances. - - Passed folder paths are validated and if there are 2 or more different - folder paths then multiselection text is shown. - - Args: - folder_paths (list, tuple, set, NoneType): List of folder paths. - - """ - if folder_paths is None: - folder_paths = [] - - self._has_value_changed = False - self._origin_value = list(folder_paths) - self._selected_items = list(folder_paths) - is_valid = self._controller.are_folder_paths_valid(folder_paths) - if not folder_paths: - self.set_text("") - - elif len(folder_paths) == 1: - folder_path = tuple(folder_paths)[0] - self.set_text(folder_path) - else: - multiselection_text = self._multiselection_text - if multiselection_text is None: - multiselection_text = "|".join(folder_paths) - self.set_text(multiselection_text) - - self._set_is_valid(is_valid) - - def reset_to_origin(self): - """Change to folder paths set with last `set_selected_items` call.""" - self.set_selected_items(self._origin_value) - - def confirm_value(self): - self._origin_value = copy.deepcopy(self._selected_items) - self._has_value_changed = False - - -class TasksComboboxProxy(QtCore.QSortFilterProxyModel): - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self._filter_empty = False - - def set_filter_empty(self, filter_empty): - if self._filter_empty is filter_empty: - return - self._filter_empty = filter_empty - self.invalidate() - - def filterAcceptsRow(self, source_row, parent_index): - if self._filter_empty: - model = self.sourceModel() - source_index = model.index( - source_row, self.filterKeyColumn(), parent_index - ) - if not source_index.data(QtCore.Qt.DisplayRole): - return False - return True - - -class TasksCombobox(QtWidgets.QComboBox): - """Combobox to show tasks for selected instances. - - Combobox gives ability to select only from intersection of task names for - folder paths in selected instances. - - If folder paths in selected instances does not have same tasks then combobox - will be empty. - """ - value_changed = QtCore.Signal() - - def __init__( - self, controller: AbstractPublisherFrontend, parent: QtWidgets.QWidget - ): - super().__init__(parent) - self.setObjectName("TasksCombobox") - - # Set empty delegate to propagate stylesheet to a combobox - delegate = QtWidgets.QStyledItemDelegate() - self.setItemDelegate(delegate) - - model = TasksModel(controller, True) - proxy_model = TasksComboboxProxy() - proxy_model.setSourceModel(model) - self.setModel(proxy_model) - - self.currentIndexChanged.connect(self._on_index_change) - - self._delegate = delegate - self._model = model - self._proxy_model = proxy_model - self._origin_value = [] - self._origin_selection = [] - self._selected_items = [] - self._has_value_changed = False - self._ignore_index_change = False - self._multiselection_text = None - self._is_valid = True - - self._text = None - - # Make sure combobox is extended horizontally - size_policy = self.sizePolicy() - size_policy.setHorizontalPolicy( - QtWidgets.QSizePolicy.MinimumExpanding) - self.setSizePolicy(size_policy) - - def set_invalid_empty_task(self, invalid=True): - self._proxy_model.set_filter_empty(invalid) - if invalid: - self._set_is_valid(False) - self.set_text( - "< One or more products require Task selected >" - ) - else: - self.set_text(None) - - def set_multiselection_text(self, text): - """Change text shown when multiple different tasks are in context.""" - self._multiselection_text = text - - def _on_index_change(self): - if self._ignore_index_change: - return - - self.set_text(None) - text = self.currentText() - idx = self.findText(text) - if idx < 0: - return - - self._set_is_valid(True) - self._selected_items = [text] - self._has_value_changed = ( - self._origin_selection != self._selected_items - ) - - self.value_changed.emit() - - def set_text(self, text): - """Set context shown in combobox without changing selected items.""" - if text == self._text: - return - - self._text = text - self.repaint() - - def paintEvent(self, event): - """Paint custom text without using QLineEdit. - - The easiest way how to draw custom text in combobox and keep combobox - properties and event handling. - """ - painter = QtGui.QPainter(self) - painter.setPen(self.palette().color(QtGui.QPalette.Text)) - opt = QtWidgets.QStyleOptionComboBox() - self.initStyleOption(opt) - if self._text is not None: - opt.currentText = self._text - - style = self.style() - style.drawComplexControl( - QtWidgets.QStyle.CC_ComboBox, opt, painter, self - ) - style.drawControl( - QtWidgets.QStyle.CE_ComboBoxLabel, opt, painter, self - ) - painter.end() - - def is_valid(self): - """Are all selected items valid.""" - return self._is_valid - - def has_value_changed(self): - """Did selection of task changed.""" - return self._has_value_changed - - def _set_is_valid(self, valid): - if valid == self._is_valid: - return - self._is_valid = valid - state = "" - if not valid: - state = "invalid" - self._set_state_property(state) - - def _set_state_property(self, state): - current_value = self.property("state") - if current_value != state: - self.setProperty("state", state) - self.style().polish(self) - - def get_selected_items(self): - """Get selected tasks. - - If value has changed then will return list with single item. - - Returns: - list: Selected tasks. - """ - return list(self._selected_items) - - def set_folder_paths(self, folder_paths): - """Set folder paths for which should show tasks.""" - self._ignore_index_change = True - - self._model.set_folder_paths(folder_paths) - self._proxy_model.set_filter_empty(False) - self._proxy_model.sort(0) - - self._ignore_index_change = False - - # It is a bug if not exactly one folder got here - if len(folder_paths) != 1: - self.set_selected_item("") - self._set_is_valid(False) - return - - folder_path = tuple(folder_paths)[0] - - is_valid = False - if self._selected_items: - is_valid = True - - valid_task_names = [] - for task_name in self._selected_items: - _is_valid = self._model.is_task_name_valid(folder_path, task_name) - if _is_valid: - valid_task_names.append(task_name) - else: - is_valid = _is_valid - - self._selected_items = valid_task_names - if len(self._selected_items) == 0: - self.set_selected_item("") - - elif len(self._selected_items) == 1: - self.set_selected_item(self._selected_items[0]) - - else: - multiselection_text = self._multiselection_text - if multiselection_text is None: - multiselection_text = "|".join(self._selected_items) - self.set_selected_item(multiselection_text) - - self._set_is_valid(is_valid) - - def confirm_value(self, folder_paths): - new_task_name = self._selected_items[0] - self._origin_value = [ - (folder_path, new_task_name) - for folder_path in folder_paths - ] - self._origin_selection = copy.deepcopy(self._selected_items) - self._has_value_changed = False - - def set_selected_items(self, folder_task_combinations=None): - """Set items for selected instances. - - Args: - folder_task_combinations (list): List of tuples. Each item in - the list contain folder path and task name. - """ - self._proxy_model.set_filter_empty(False) - self._proxy_model.sort(0) - - if folder_task_combinations is None: - folder_task_combinations = [] - - task_names = set() - task_names_by_folder_path = collections.defaultdict(set) - for folder_path, task_name in folder_task_combinations: - task_names.add(task_name) - task_names_by_folder_path[folder_path].add(task_name) - folder_paths = set(task_names_by_folder_path.keys()) - - self._ignore_index_change = True - - self._model.set_folder_paths(folder_paths) - - self._has_value_changed = False - - self._origin_value = copy.deepcopy(folder_task_combinations) - - self._origin_selection = list(task_names) - self._selected_items = list(task_names) - # Reset current index - self.setCurrentIndex(-1) - is_valid = True - if not task_names: - self.set_selected_item("") - - elif len(task_names) == 1: - task_name = tuple(task_names)[0] - idx = self.findText(task_name) - is_valid = not idx < 0 - if not is_valid and len(folder_paths) > 1: - is_valid = self._validate_task_names_by_folder_paths( - task_names_by_folder_path - ) - self.set_selected_item(task_name) - - else: - for task_name in task_names: - idx = self.findText(task_name) - is_valid = not idx < 0 - if not is_valid: - break - - if not is_valid and len(folder_paths) > 1: - is_valid = self._validate_task_names_by_folder_paths( - task_names_by_folder_path - ) - multiselection_text = self._multiselection_text - if multiselection_text is None: - multiselection_text = "|".join(task_names) - self.set_selected_item(multiselection_text) - - self._set_is_valid(is_valid) - - self._ignore_index_change = False - - self.value_changed.emit() - - def _validate_task_names_by_folder_paths(self, task_names_by_folder_path): - for folder_path, task_names in task_names_by_folder_path.items(): - for task_name in task_names: - if not self._model.is_task_name_valid(folder_path, task_name): - return False - return True - - def set_selected_item(self, item_name): - """Set task which is set on selected instance. - - Args: - item_name(str): Task name which should be selected. - """ - idx = self.findText(item_name) - # Set current index (must be set to -1 if is invalid) - self.setCurrentIndex(idx) - self.set_text(item_name) - - def reset_to_origin(self): - """Change to task names set with last `set_selected_items` call.""" - self.set_selected_items(self._origin_value) - - -class VariantInputWidget(PlaceholderLineEdit): - """Input widget for variant.""" - value_changed = QtCore.Signal() - - def __init__(self, parent): - super().__init__(parent) - - self.setObjectName("VariantInput") - self.setToolTip(VARIANT_TOOLTIP) - - name_pattern = "^[{}]*$".format(PRODUCT_NAME_ALLOWED_SYMBOLS) - self._name_pattern = name_pattern - self._compiled_name_pattern = re.compile(name_pattern) - - self._origin_value = [] - self._current_value = [] - - self._ignore_value_change = False - self._has_value_changed = False - self._multiselection_text = None - - self._is_valid = True - - self.textChanged.connect(self._on_text_change) - - def is_valid(self): - """Is variant text valid.""" - return self._is_valid - - def has_value_changed(self): - """Value of variant has changed.""" - return self._has_value_changed - - def _set_state_property(self, state): - current_value = self.property("state") - if current_value != state: - self.setProperty("state", state) - self.style().polish(self) - - def set_multiselection_text(self, text): - """Change text of multiselection.""" - self._multiselection_text = text - - def confirm_value(self): - self._origin_value = copy.deepcopy(self._current_value) - self._has_value_changed = False - - def _set_is_valid(self, valid): - if valid == self._is_valid: - return - self._is_valid = valid - state = "" - if not valid: - state = "invalid" - self._set_state_property(state) - - def _on_text_change(self): - if self._ignore_value_change: - return - - is_valid = bool(self._compiled_name_pattern.match(self.text())) - self._set_is_valid(is_valid) - - self._current_value = [self.text()] - self._has_value_changed = self._current_value != self._origin_value - - self.value_changed.emit() - - def reset_to_origin(self): - """Set origin value of selected instances.""" - self.set_value(self._origin_value) - - def get_value(self): - """Get current value. - - Origin value returned if didn't change. - """ - return copy.deepcopy(self._current_value) - - def set_value(self, variants=None): - """Set value of currently selected instances.""" - if variants is None: - variants = [] - - self._ignore_value_change = True - - self._has_value_changed = False - - self._origin_value = list(variants) - self._current_value = list(variants) - - self.setPlaceholderText("") - if not variants: - self.setText("") - - elif len(variants) == 1: - self.setText(self._current_value[0]) - - else: - multiselection_text = self._multiselection_text - if multiselection_text is None: - multiselection_text = "|".join(variants) - self.setText("") - self.setPlaceholderText(multiselection_text) - - self._ignore_value_change = False - - class MultipleItemWidget(QtWidgets.QWidget): """Widget for immutable text which can have more than one value. @@ -1080,855 +479,6 @@ def set_value(self, value=None): self._model.appendRow(item) -class GlobalAttrsWidget(QtWidgets.QWidget): - """Global attributes mainly to define context and product name of instances. - - product name is or may be affected on context. Gives abiity to modify - context and product name of instance. This change is not autopromoted but - must be submitted. - - Warning: Until artist hit `Submit` changes must not be propagated to - instance data. - - Global attributes contain these widgets: - Variant: [ text input ] - Folder: [ folder dialog ] - Task: [ combobox ] - Product type: [ immutable ] - product name: [ immutable ] - [Submit] [Cancel] - """ - instance_context_changed = QtCore.Signal() - - multiselection_text = "< Multiselection >" - unknown_value = "N/A" - - def __init__( - self, controller: AbstractPublisherFrontend, parent: QtWidgets.QWidget - ): - super().__init__(parent) - - self._controller: AbstractPublisherFrontend = controller - self._current_instances = [] - - variant_input = VariantInputWidget(self) - folder_value_widget = FoldersFields(controller, self) - task_value_widget = TasksCombobox(controller, self) - product_type_value_widget = MultipleItemWidget(self) - product_value_widget = MultipleItemWidget(self) - - variant_input.set_multiselection_text(self.multiselection_text) - folder_value_widget.set_multiselection_text(self.multiselection_text) - task_value_widget.set_multiselection_text(self.multiselection_text) - - variant_input.set_value() - folder_value_widget.set_selected_items() - task_value_widget.set_selected_items() - product_type_value_widget.set_value() - product_value_widget.set_value() - - submit_btn = QtWidgets.QPushButton("Confirm", self) - cancel_btn = QtWidgets.QPushButton("Cancel", self) - submit_btn.setEnabled(False) - cancel_btn.setEnabled(False) - - btns_layout = QtWidgets.QHBoxLayout() - btns_layout.setContentsMargins(0, 0, 0, 0) - btns_layout.addStretch(1) - btns_layout.setSpacing(5) - btns_layout.addWidget(submit_btn) - btns_layout.addWidget(cancel_btn) - - main_layout = QtWidgets.QFormLayout(self) - main_layout.setHorizontalSpacing(INPUTS_LAYOUT_HSPACING) - main_layout.setVerticalSpacing(INPUTS_LAYOUT_VSPACING) - main_layout.addRow("Variant", variant_input) - main_layout.addRow("Folder", folder_value_widget) - main_layout.addRow("Task", task_value_widget) - main_layout.addRow("Product type", product_type_value_widget) - main_layout.addRow("Product name", product_value_widget) - main_layout.addRow(btns_layout) - - variant_input.value_changed.connect(self._on_variant_change) - folder_value_widget.value_changed.connect(self._on_folder_change) - task_value_widget.value_changed.connect(self._on_task_change) - submit_btn.clicked.connect(self._on_submit) - cancel_btn.clicked.connect(self._on_cancel) - - self.variant_input = variant_input - self.folder_value_widget = folder_value_widget - self.task_value_widget = task_value_widget - self.product_type_value_widget = product_type_value_widget - self.product_value_widget = product_value_widget - self.submit_btn = submit_btn - self.cancel_btn = cancel_btn - - def _on_submit(self): - """Commit changes for selected instances.""" - - variant_value = None - folder_path = None - task_name = None - if self.variant_input.has_value_changed(): - variant_value = self.variant_input.get_value()[0] - - if self.folder_value_widget.has_value_changed(): - folder_path = self.folder_value_widget.get_selected_items()[0] - - if self.task_value_widget.has_value_changed(): - task_name = self.task_value_widget.get_selected_items()[0] - - product_names = set() - invalid_tasks = False - folder_paths = [] - for instance in self._current_instances: - # Ignore instances that have promised context - if instance.has_promised_context: - continue - - new_variant_value = instance.get("variant") - new_folder_path = instance.get("folderPath") - new_task_name = instance.get("task") - if variant_value is not None: - new_variant_value = variant_value - - if folder_path is not None: - new_folder_path = folder_path - - if task_name is not None: - new_task_name = task_name - - folder_paths.append(new_folder_path) - try: - new_product_name = self._controller.get_product_name( - instance.creator_identifier, - new_variant_value, - new_task_name, - new_folder_path, - instance.id, - ) - - except TaskNotSetError: - invalid_tasks = True - product_names.add(instance["productName"]) - continue - - product_names.add(new_product_name) - if variant_value is not None: - instance["variant"] = variant_value - - if folder_path is not None: - instance["folderPath"] = folder_path - - if task_name is not None: - instance["task"] = task_name or None - - instance["productName"] = new_product_name - - if invalid_tasks: - self.task_value_widget.set_invalid_empty_task() - - self.product_value_widget.set_value(product_names) - - self._set_btns_enabled(False) - self._set_btns_visible(invalid_tasks) - - if variant_value is not None: - self.variant_input.confirm_value() - - if folder_path is not None: - self.folder_value_widget.confirm_value() - - if task_name is not None: - self.task_value_widget.confirm_value(folder_paths) - - self.instance_context_changed.emit() - - def _on_cancel(self): - """Cancel changes and set back to their irigin value.""" - - self.variant_input.reset_to_origin() - self.folder_value_widget.reset_to_origin() - self.task_value_widget.reset_to_origin() - self._set_btns_enabled(False) - - def _on_value_change(self): - any_invalid = ( - not self.variant_input.is_valid() - or not self.folder_value_widget.is_valid() - or not self.task_value_widget.is_valid() - ) - any_changed = ( - self.variant_input.has_value_changed() - or self.folder_value_widget.has_value_changed() - or self.task_value_widget.has_value_changed() - ) - self._set_btns_visible(any_changed or any_invalid) - self.cancel_btn.setEnabled(any_changed) - self.submit_btn.setEnabled(not any_invalid) - - def _on_variant_change(self): - self._on_value_change() - - def _on_folder_change(self): - folder_paths = self.folder_value_widget.get_selected_items() - self.task_value_widget.set_folder_paths(folder_paths) - self._on_value_change() - - def _on_task_change(self): - self._on_value_change() - - def _set_btns_visible(self, visible): - self.cancel_btn.setVisible(visible) - self.submit_btn.setVisible(visible) - - def _set_btns_enabled(self, enabled): - self.cancel_btn.setEnabled(enabled) - self.submit_btn.setEnabled(enabled) - - def set_current_instances(self, instances): - """Set currently selected instances. - - Args: - instances(List[CreatedInstance]): List of selected instances. - Empty instances tells that nothing or context is selected. - """ - self._set_btns_visible(False) - - self._current_instances = instances - - folder_paths = set() - variants = set() - product_types = set() - product_names = set() - - editable = True - if len(instances) == 0: - editable = False - - folder_task_combinations = [] - context_editable = None - for instance in instances: - if not instance.has_promised_context: - context_editable = True - elif context_editable is None: - context_editable = False - - # NOTE I'm not sure how this can even happen? - if instance.creator_identifier is None: - editable = False - - variants.add(instance.get("variant") or self.unknown_value) - product_types.add(instance.get("productType") or self.unknown_value) - folder_path = instance.get("folderPath") or self.unknown_value - task_name = instance.get("task") or "" - folder_paths.add(folder_path) - folder_task_combinations.append((folder_path, task_name)) - product_names.add(instance.get("productName") or self.unknown_value) - - if not editable: - context_editable = False - elif context_editable is None: - context_editable = True - - self.variant_input.set_value(variants) - - # Set context of folder widget - self.folder_value_widget.set_selected_items(folder_paths) - # Set context of task widget - self.task_value_widget.set_selected_items(folder_task_combinations) - self.product_type_value_widget.set_value(product_types) - self.product_value_widget.set_value(product_names) - - self.variant_input.setEnabled(editable) - self.folder_value_widget.setEnabled(context_editable) - self.task_value_widget.setEnabled(context_editable) - - if not editable: - folder_tooltip = "Select instances to change folder path." - task_tooltip = "Select instances to change task name." - elif not context_editable: - folder_tooltip = "Folder path is defined by Create plugin." - task_tooltip = "Task is defined by Create plugin." - else: - folder_tooltip = "Change folder path of selected instances." - task_tooltip = "Change task of selected instances." - - self.folder_value_widget.setToolTip(folder_tooltip) - self.task_value_widget.setToolTip(task_tooltip) - - -class CreatorAttrsWidget(QtWidgets.QWidget): - """Widget showing creator specific attributes for selected instances. - - Attributes are defined on creator so are dynamic. Their look and type is - based on attribute definitions that are defined in - `~/ayon_core/lib/attribute_definitions.py` and their widget - representation in `~/ayon_core/tools/attribute_defs/*`. - - Widgets are disabled if context of instance is not valid. - - Definitions are shown for all instance no matter if they are created with - different creators. If creator have same (similar) definitions their - widgets are merged into one (different label does not count). - """ - - def __init__( - self, controller: AbstractPublisherFrontend, parent: QtWidgets.QWidget - ): - super().__init__(parent) - - scroll_area = QtWidgets.QScrollArea(self) - scroll_area.setWidgetResizable(True) - - main_layout = QtWidgets.QHBoxLayout(self) - main_layout.setContentsMargins(0, 0, 0, 0) - main_layout.setSpacing(0) - main_layout.addWidget(scroll_area, 1) - - self._main_layout = main_layout - - self._controller: AbstractPublisherFrontend = controller - self._scroll_area = scroll_area - - self._attr_def_id_to_instances = {} - self._attr_def_id_to_attr_def = {} - - # To store content of scroll area to prevent garbage collection - self._content_widget = None - - def set_instances_valid(self, valid): - """Change valid state of current instances.""" - - if ( - self._content_widget is not None - and self._content_widget.isEnabled() != valid - ): - self._content_widget.setEnabled(valid) - - def set_current_instances(self, instances): - """Set current instances for which are attribute definitions shown.""" - - prev_content_widget = self._scroll_area.widget() - if prev_content_widget: - self._scroll_area.takeWidget() - prev_content_widget.hide() - prev_content_widget.deleteLater() - - self._content_widget = None - self._attr_def_id_to_instances = {} - self._attr_def_id_to_attr_def = {} - - result = self._controller.get_creator_attribute_definitions( - instances - ) - - content_widget = QtWidgets.QWidget(self._scroll_area) - content_layout = QtWidgets.QGridLayout(content_widget) - content_layout.setColumnStretch(0, 0) - content_layout.setColumnStretch(1, 1) - content_layout.setAlignment(QtCore.Qt.AlignTop) - content_layout.setHorizontalSpacing(INPUTS_LAYOUT_HSPACING) - content_layout.setVerticalSpacing(INPUTS_LAYOUT_VSPACING) - - row = 0 - for attr_def, attr_instances, values in result: - widget = create_widget_for_attr_def(attr_def, content_widget) - if attr_def.is_value_def: - if len(values) == 1: - value = values[0] - if value is not None: - widget.set_value(values[0]) - else: - widget.set_value(values, True) - - widget.value_changed.connect(self._input_value_changed) - self._attr_def_id_to_instances[attr_def.id] = attr_instances - self._attr_def_id_to_attr_def[attr_def.id] = attr_def - - if not attr_def.visible: - continue - - expand_cols = 2 - if attr_def.is_value_def and attr_def.is_label_horizontal: - expand_cols = 1 - - col_num = 2 - expand_cols - - label = None - if attr_def.is_value_def: - label = attr_def.label or attr_def.key - if label: - label_widget = QtWidgets.QLabel(label, self) - tooltip = attr_def.tooltip - if tooltip: - label_widget.setToolTip(tooltip) - if attr_def.is_label_horizontal: - label_widget.setAlignment( - QtCore.Qt.AlignRight - | QtCore.Qt.AlignVCenter - ) - content_layout.addWidget( - label_widget, row, 0, 1, expand_cols - ) - if not attr_def.is_label_horizontal: - row += 1 - - content_layout.addWidget( - widget, row, col_num, 1, expand_cols - ) - row += 1 - - self._scroll_area.setWidget(content_widget) - self._content_widget = content_widget - - def _input_value_changed(self, value, attr_id): - instances = self._attr_def_id_to_instances.get(attr_id) - attr_def = self._attr_def_id_to_attr_def.get(attr_id) - if not instances or not attr_def: - return - - for instance in instances: - creator_attributes = instance["creator_attributes"] - if attr_def.key in creator_attributes: - creator_attributes[attr_def.key] = value - - -class PublishPluginAttrsWidget(QtWidgets.QWidget): - """Widget showing publsish plugin attributes for selected instances. - - Attributes are defined on publish plugins. Publihs plugin may define - attribute definitions but must inherit `AYONPyblishPluginMixin` - (~/ayon_core/pipeline/publish). At the moment requires to implement - `get_attribute_defs` and `convert_attribute_values` class methods. - - Look and type of attributes is based on attribute definitions that are - defined in `~/ayon_core/lib/attribute_definitions.py` and their - widget representation in `~/ayon_core/tools/attribute_defs/*`. - - Widgets are disabled if context of instance is not valid. - - Definitions are shown for all instance no matter if they have different - product types. Similar definitions are merged into one (different label - does not count). - """ - - def __init__( - self, controller: AbstractPublisherFrontend, parent: QtWidgets.QWidget - ): - super().__init__(parent) - - scroll_area = QtWidgets.QScrollArea(self) - scroll_area.setWidgetResizable(True) - - main_layout = QtWidgets.QHBoxLayout(self) - main_layout.setContentsMargins(0, 0, 0, 0) - main_layout.setSpacing(0) - main_layout.addWidget(scroll_area, 1) - - self._main_layout = main_layout - - self._controller: AbstractPublisherFrontend = controller - self._scroll_area = scroll_area - - self._attr_def_id_to_instances = {} - self._attr_def_id_to_attr_def = {} - self._attr_def_id_to_plugin_name = {} - - # Store content of scroll area to prevent garbage collection - self._content_widget = None - - def set_instances_valid(self, valid): - """Change valid state of current instances.""" - if ( - self._content_widget is not None - and self._content_widget.isEnabled() != valid - ): - self._content_widget.setEnabled(valid) - - def set_current_instances(self, instances, context_selected): - """Set current instances for which are attribute definitions shown.""" - - prev_content_widget = self._scroll_area.widget() - if prev_content_widget: - self._scroll_area.takeWidget() - prev_content_widget.hide() - prev_content_widget.deleteLater() - - self._content_widget = None - - self._attr_def_id_to_instances = {} - self._attr_def_id_to_attr_def = {} - self._attr_def_id_to_plugin_name = {} - - result = self._controller.get_publish_attribute_definitions( - instances, context_selected - ) - - content_widget = QtWidgets.QWidget(self._scroll_area) - attr_def_widget = QtWidgets.QWidget(content_widget) - attr_def_layout = QtWidgets.QGridLayout(attr_def_widget) - attr_def_layout.setColumnStretch(0, 0) - attr_def_layout.setColumnStretch(1, 1) - attr_def_layout.setHorizontalSpacing(INPUTS_LAYOUT_HSPACING) - attr_def_layout.setVerticalSpacing(INPUTS_LAYOUT_VSPACING) - - content_layout = QtWidgets.QVBoxLayout(content_widget) - content_layout.addWidget(attr_def_widget, 0) - content_layout.addStretch(1) - - row = 0 - for plugin_name, attr_defs, all_plugin_values in result: - plugin_values = all_plugin_values[plugin_name] - - for attr_def in attr_defs: - widget = create_widget_for_attr_def( - attr_def, content_widget - ) - hidden_widget = not attr_def.visible - # Hide unknown values of publish plugins - # - The keys in most of cases does not represent what would - # label represent - if isinstance(attr_def, UnknownDef): - widget.setVisible(False) - hidden_widget = True - - if not hidden_widget: - expand_cols = 2 - if attr_def.is_value_def and attr_def.is_label_horizontal: - expand_cols = 1 - - col_num = 2 - expand_cols - label = None - if attr_def.is_value_def: - label = attr_def.label or attr_def.key - if label: - label_widget = QtWidgets.QLabel(label, content_widget) - tooltip = attr_def.tooltip - if tooltip: - label_widget.setToolTip(tooltip) - if attr_def.is_label_horizontal: - label_widget.setAlignment( - QtCore.Qt.AlignRight - | QtCore.Qt.AlignVCenter - ) - attr_def_layout.addWidget( - label_widget, row, 0, 1, expand_cols - ) - if not attr_def.is_label_horizontal: - row += 1 - attr_def_layout.addWidget( - widget, row, col_num, 1, expand_cols - ) - row += 1 - - if not attr_def.is_value_def: - continue - - widget.value_changed.connect(self._input_value_changed) - - attr_values = plugin_values[attr_def.key] - multivalue = len(attr_values) > 1 - values = [] - instances = [] - for instance, value in attr_values: - values.append(value) - instances.append(instance) - - self._attr_def_id_to_attr_def[attr_def.id] = attr_def - self._attr_def_id_to_instances[attr_def.id] = instances - self._attr_def_id_to_plugin_name[attr_def.id] = plugin_name - - if multivalue: - widget.set_value(values, multivalue) - else: - widget.set_value(values[0]) - - self._scroll_area.setWidget(content_widget) - self._content_widget = content_widget - - def _input_value_changed(self, value, attr_id): - instances = self._attr_def_id_to_instances.get(attr_id) - attr_def = self._attr_def_id_to_attr_def.get(attr_id) - plugin_name = self._attr_def_id_to_plugin_name.get(attr_id) - if not instances or not attr_def or not plugin_name: - return - - for instance in instances: - plugin_val = instance.publish_attributes[plugin_name] - plugin_val[attr_def.key] = value - - -class ProductAttributesWidget(QtWidgets.QWidget): - """Wrapper widget where attributes of instance/s are modified. - ┌─────────────────┬─────────────┐ - │ Global │ │ - │ attributes │ Thumbnail │ TOP - │ │ │ - ├─────────────┬───┴─────────────┤ - │ Creator │ Publish │ - │ attributes │ plugin │ BOTTOM - │ │ attributes │ - └───────────────────────────────┘ - """ - instance_context_changed = QtCore.Signal() - convert_requested = QtCore.Signal() - - def __init__( - self, controller: AbstractPublisherFrontend, parent: QtWidgets.QWidget - ): - super().__init__(parent) - - # TOP PART - top_widget = QtWidgets.QWidget(self) - - # Global attributes - global_attrs_widget = GlobalAttrsWidget(controller, top_widget) - thumbnail_widget = ThumbnailWidget(controller, top_widget) - - top_layout = QtWidgets.QHBoxLayout(top_widget) - top_layout.setContentsMargins(0, 0, 0, 0) - top_layout.addWidget(global_attrs_widget, 7) - top_layout.addWidget(thumbnail_widget, 3) - - # BOTTOM PART - bottom_widget = QtWidgets.QWidget(self) - - # Wrap Creator attributes to widget to be able add convert button - creator_widget = QtWidgets.QWidget(bottom_widget) - - # Convert button widget (with layout to handle stretch) - convert_widget = QtWidgets.QWidget(creator_widget) - convert_label = QtWidgets.QLabel(creator_widget) - # Set the label text with 'setText' to apply html - convert_label.setText( - ( - "Found old publishable products" - " incompatible with new publisher." - "

Press the update products button" - " to automatically update them" - " to be able to publish again." - ) - ) - convert_label.setWordWrap(True) - convert_label.setAlignment(QtCore.Qt.AlignCenter) - - convert_btn = QtWidgets.QPushButton( - "Update products", convert_widget - ) - convert_separator = QtWidgets.QFrame(convert_widget) - convert_separator.setObjectName("Separator") - convert_separator.setMinimumHeight(1) - convert_separator.setMaximumHeight(1) - - convert_layout = QtWidgets.QGridLayout(convert_widget) - convert_layout.setContentsMargins(5, 0, 5, 0) - convert_layout.setVerticalSpacing(10) - convert_layout.addWidget(convert_label, 0, 0, 1, 3) - convert_layout.addWidget(convert_btn, 1, 1) - convert_layout.addWidget(convert_separator, 2, 0, 1, 3) - convert_layout.setColumnStretch(0, 1) - convert_layout.setColumnStretch(1, 0) - convert_layout.setColumnStretch(2, 1) - - # Creator attributes widget - creator_attrs_widget = CreatorAttrsWidget( - controller, creator_widget - ) - creator_layout = QtWidgets.QVBoxLayout(creator_widget) - creator_layout.setContentsMargins(0, 0, 0, 0) - creator_layout.addWidget(convert_widget, 0) - creator_layout.addWidget(creator_attrs_widget, 1) - - publish_attrs_widget = PublishPluginAttrsWidget( - controller, bottom_widget - ) - - bottom_separator = QtWidgets.QWidget(bottom_widget) - bottom_separator.setObjectName("Separator") - bottom_separator.setMinimumWidth(1) - - bottom_layout = QtWidgets.QHBoxLayout(bottom_widget) - bottom_layout.setContentsMargins(0, 0, 0, 0) - bottom_layout.addWidget(creator_widget, 1) - bottom_layout.addWidget(bottom_separator, 0) - bottom_layout.addWidget(publish_attrs_widget, 1) - - top_bottom = QtWidgets.QWidget(self) - top_bottom.setObjectName("Separator") - top_bottom.setMinimumHeight(1) - - layout = QtWidgets.QVBoxLayout(self) - layout.addWidget(top_widget, 0) - layout.addWidget(top_bottom, 0) - layout.addWidget(bottom_widget, 1) - - self._convertor_identifiers = None - self._current_instances = None - self._context_selected = False - self._all_instances_valid = True - - global_attrs_widget.instance_context_changed.connect( - self._on_instance_context_changed - ) - convert_btn.clicked.connect(self._on_convert_click) - thumbnail_widget.thumbnail_created.connect(self._on_thumbnail_create) - thumbnail_widget.thumbnail_cleared.connect(self._on_thumbnail_clear) - - controller.register_event_callback( - "instance.thumbnail.changed", self._on_thumbnail_changed - ) - - self._controller: AbstractPublisherFrontend = controller - - self._convert_widget = convert_widget - - self.global_attrs_widget = global_attrs_widget - - self.creator_attrs_widget = creator_attrs_widget - self.publish_attrs_widget = publish_attrs_widget - self._thumbnail_widget = thumbnail_widget - - self.top_bottom = top_bottom - self.bottom_separator = bottom_separator - - def _on_instance_context_changed(self): - instance_ids = { - instance.id - for instance in self._current_instances - } - context_info_by_id = self._controller.get_instances_context_info( - instance_ids - ) - all_valid = True - for instance_id, context_info in context_info_by_id.items(): - if not context_info.is_valid: - all_valid = False - break - - self._all_instances_valid = all_valid - self.creator_attrs_widget.set_instances_valid(all_valid) - self.publish_attrs_widget.set_instances_valid(all_valid) - - self.instance_context_changed.emit() - - def _on_convert_click(self): - self.convert_requested.emit() - - def set_current_instances( - self, instances, context_selected, convertor_identifiers - ): - """Change currently selected items. - - Args: - instances(List[CreatedInstance]): List of currently selected - instances. - context_selected(bool): Is context selected. - convertor_identifiers(List[str]): Identifiers of convert items. - """ - - instance_ids = { - instance.id - for instance in instances - } - context_info_by_id = self._controller.get_instances_context_info( - instance_ids - ) - - all_valid = True - for context_info in context_info_by_id.values(): - if not context_info.is_valid: - all_valid = False - break - - s_convertor_identifiers = set(convertor_identifiers) - self._convertor_identifiers = s_convertor_identifiers - self._current_instances = instances - self._context_selected = context_selected - self._all_instances_valid = all_valid - - self._convert_widget.setVisible(len(s_convertor_identifiers) > 0) - self.global_attrs_widget.set_current_instances(instances) - self.creator_attrs_widget.set_current_instances(instances) - self.publish_attrs_widget.set_current_instances( - instances, context_selected - ) - self.creator_attrs_widget.set_instances_valid(all_valid) - self.publish_attrs_widget.set_instances_valid(all_valid) - - self._update_thumbnails() - - def _on_thumbnail_create(self, path): - instance_ids = [ - instance.id - for instance in self._current_instances - ] - if self._context_selected: - instance_ids.append(None) - - if not instance_ids: - return - - mapping = {} - if len(instance_ids) == 1: - mapping[instance_ids[0]] = path - - else: - for instance_id in instance_ids: - root = os.path.dirname(path) - ext = os.path.splitext(path)[-1] - dst_path = os.path.join(root, str(uuid.uuid4()) + ext) - shutil.copy(path, dst_path) - mapping[instance_id] = dst_path - - self._controller.set_thumbnail_paths_for_instances(mapping) - - def _on_thumbnail_clear(self): - instance_ids = [ - instance.id - for instance in self._current_instances - ] - if self._context_selected: - instance_ids.append(None) - - if not instance_ids: - return - - mapping = { - instance_id: None - for instance_id in instance_ids - } - self._controller.set_thumbnail_paths_for_instances(mapping) - - def _on_thumbnail_changed(self, event): - self._update_thumbnails() - - def _update_thumbnails(self): - instance_ids = [ - instance.id - for instance in self._current_instances - ] - if self._context_selected: - instance_ids.append(None) - - if not instance_ids: - self._thumbnail_widget.setVisible(False) - self._thumbnail_widget.set_current_thumbnails(None) - return - - mapping = self._controller.get_thumbnail_paths_for_instances( - instance_ids - ) - thumbnail_paths = [] - for instance_id in instance_ids: - path = mapping[instance_id] - if path: - thumbnail_paths.append(path) - - self._thumbnail_widget.setVisible(True) - self._thumbnail_widget.set_current_thumbnails(thumbnail_paths) - - class CreateNextPageOverlay(QtWidgets.QWidget): clicked = QtCore.Signal() diff --git a/client/ayon_core/tools/publisher/window.py b/client/ayon_core/tools/publisher/window.py index 434c2ca602..a912495d4e 100644 --- a/client/ayon_core/tools/publisher/window.py +++ b/client/ayon_core/tools/publisher/window.py @@ -253,12 +253,6 @@ def __init__( help_btn.clicked.connect(self._on_help_click) tabs_widget.tab_changed.connect(self._on_tab_change) - overview_widget.active_changed.connect( - self._on_context_or_active_change - ) - overview_widget.instance_context_changed.connect( - self._on_context_or_active_change - ) overview_widget.create_requested.connect( self._on_create_request ) @@ -281,7 +275,19 @@ def __init__( ) controller.register_event_callback( - "instances.refresh.finished", self._on_instances_refresh + "create.model.reset", self._on_create_model_reset + ) + controller.register_event_callback( + "create.context.added.instance", + self._event_callback_validate_instances + ) + controller.register_event_callback( + "create.context.removed.instance", + self._event_callback_validate_instances + ) + controller.register_event_callback( + "create.model.instances.context.changed", + self._event_callback_validate_instances ) controller.register_event_callback( "publish.reset.finished", self._on_publish_reset @@ -918,8 +924,8 @@ def _validate_create_instances(self): active_instances_by_id = { instance.id: instance - for instance in self._controller.get_instances() - if instance["active"] + for instance in self._controller.get_instance_items() + if instance.is_active } context_info_by_id = self._controller.get_instances_context_info( active_instances_by_id.keys() @@ -936,13 +942,16 @@ def _validate_create_instances(self): self._set_footer_enabled(bool(all_valid)) - def _on_instances_refresh(self): + def _on_create_model_reset(self): self._validate_create_instances() context_title = self._controller.get_context_title() self.set_context_label(context_title) self._update_publish_details_widget() + def _event_callback_validate_instances(self, _event): + self._validate_create_instances() + def _set_comment_input_visiblity(self, visible): self._comment_input.setVisible(visible) self._footer_spacer.setVisible(not visible)