diff --git a/client/ayon_core/pipeline/create/context.py b/client/ayon_core/pipeline/create/context.py index 05531afd05..929cc59d2a 100644 --- a/client/ayon_core/pipeline/create/context.py +++ b/client/ayon_core/pipeline/create/context.py @@ -79,6 +79,7 @@ _NOT_SET = object() INSTANCE_ADDED_TOPIC = "instances.added" INSTANCE_REMOVED_TOPIC = "instances.removed" VALUE_CHANGED_TOPIC = "values.changed" +INSTANCE_REQUIREMENT_CHANGED_TOPIC = "instance.requirement.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" @@ -257,6 +258,10 @@ class CreateContext: "create_attrs_change": BulkInfo(), # Publish attribute definitions changed "publish_attrs_change": BulkInfo(), + # Instance requirement changed + # - right now used only for 'mandatory' but can be extended + # in future + "requirement_change": BulkInfo(), } self._bulk_order = [] @@ -867,7 +872,7 @@ class CreateContext: Event is triggered when instances are already available in context and have set create/publish attribute definitions. - Data structure of event:: + Data structure of event: ```python { @@ -894,7 +899,7 @@ class CreateContext: Event is triggered when instances are already removed from context. - Data structure of event:: + Data structure of event: ```python { @@ -922,7 +927,7 @@ class CreateContext: Event is triggered when any value changes on any instance or context data. - Data structure of event:: + Data structure of event: ```python { @@ -960,7 +965,7 @@ class CreateContext: Create plugin can trigger refresh of pre-create attributes. Usage of this event is mainly for publisher UI. - Data structure of event:: + Data structure of event: ```python { @@ -989,7 +994,7 @@ class CreateContext: Create plugin changed attribute definitions of instance. - Data structure of event:: + Data structure of event: ```python { @@ -1018,7 +1023,7 @@ class CreateContext: Publish plugin changed attribute definitions of instance of context. - Data structure of event:: + Data structure of event: ```python { @@ -1049,6 +1054,35 @@ class CreateContext: PUBLISH_ATTR_DEFS_CHANGED_TOPIC, callback ) + def add_instance_requirement_change_callback( + self, callback: Callable + ) -> "EventCallback": + """Register callback to listen to instance requirement changes. + + Instance changed requirement of active state. + + Data structure of event: + + ```python + { + "instances": [CreatedInstance, ...], + "create_context": CreateContext + } + ``` + + Args: + callback (Callable): Callback function that will be called when + instance requirement changed. + + Returns: + EventCallback: Created callback object which can be used to + stop listening. + + """ + return self._event_hub.add_callback( + INSTANCE_REQUIREMENT_CHANGED_TOPIC, callback + ) + def context_data_to_store(self) -> dict[str, Any]: """Data that should be stored by host function. @@ -1323,6 +1357,13 @@ class CreateContext: ) as bulk_info: yield bulk_info + @contextmanager + def bulk_instance_requirement_change(self, sender: Optional[str] = None): + with self._bulk_context( + "requirement_change", sender + ) as bulk_info: + yield bulk_info + @contextmanager def bulk_publish_attr_defs_change(self, sender: Optional[str] = None): with self._bulk_context("publish_attrs_change", sender) as bulk_info: @@ -1390,6 +1431,19 @@ class CreateContext: with self.bulk_value_changes() as bulk_item: bulk_item.append((instance_id, new_values)) + def instance_requirement_changed(self, instance_id: str) -> None: + """Instance requirement changed. + + Triggered by `CreatedInstance`. + + Args: + instance_id (Optional[str]): Instance id. + + """ + if self._is_instance_events_ready(instance_id): + with self.bulk_instance_requirement_change() as bulk_item: + bulk_item.append(instance_id) + # --- context change callbacks --- def publish_attribute_value_changed( self, plugin_name: str, value: dict[str, Any] @@ -2249,6 +2303,8 @@ class CreateContext: self._bulk_create_attrs_change_finished(data, sender) elif key == "publish_attrs_change": self._bulk_publish_attrs_change_finished(data, sender) + elif key == "requirement_change": + self._bulk_instance_requirement_change_finished(data, sender) def _bulk_add_instances_finished( self, @@ -2443,3 +2499,22 @@ class CreateContext: {"instance_changes": instance_changes}, sender, ) + + def _bulk_instance_requirement_change_finished( + self, + instance_ids: list[str], + sender: Optional[str], + ) -> None: + if not instance_ids: + return + + instances = [ + self.get_instance_by_id(instance_id) + for instance_id in set(instance_ids) + ] + + self._emit_event( + INSTANCE_REQUIREMENT_CHANGED_TOPIC, + {"instances": instances}, + sender, + ) diff --git a/client/ayon_core/pipeline/create/structures.py b/client/ayon_core/pipeline/create/structures.py index d7ba6b9c24..a4c68d2502 100644 --- a/client/ayon_core/pipeline/create/structures.py +++ b/client/ayon_core/pipeline/create/structures.py @@ -507,6 +507,7 @@ class CreatedInstance: if transient_data is None: transient_data = {} self._transient_data = transient_data + self._is_mandatory = False # Create a copy of passed data to avoid changing them on the fly data = copy.deepcopy(data or {}) @@ -605,6 +606,12 @@ class CreatedInstance: if key in self._data and self._data[key] == value: return + if self.is_mandatory and key == "active" and value is not True: + raise ImmutableKeyError( + key, + "Instance is mandatory and can't be disabled." + ) + self._data[key] = value self._create_context.instance_values_changed( self.id, {key: value} @@ -718,6 +725,33 @@ class CreatedInstance: return self._transient_data + @property + def is_mandatory(self) -> bool: + """Check if instance is mandatory. + + Returns: + bool: True if instance is mandatory, False otherwise. + + """ + return self._is_mandatory + + def set_mandatory(self, value: bool) -> None: + """Set instance as mandatory or not. + + Mandatory instance can't be disabled in UI. + + Args: + value (bool): True if instance should be mandatory, False + otherwise. + + """ + if value is self._is_mandatory: + return + self._is_mandatory = value + if value is True: + self["active"] = True + self._create_context.instance_requirement_changed(self.id) + def changes(self): """Calculate and return changes.""" diff --git a/client/ayon_core/tools/publisher/control.py b/client/ayon_core/tools/publisher/control.py index ef2e122692..038816c6fc 100644 --- a/client/ayon_core/tools/publisher/control.py +++ b/client/ayon_core/tools/publisher/control.py @@ -53,6 +53,8 @@ class PublisherController( changed. "create.context.create.attrs.changed" - Create attributes changed. "create.context.publish.attrs.changed" - Publish attributes changed. + "create.context.instance.requirement.changed" - Instance requirement + changed. "create.context.removed.instance" - Instance removed from context. "create.model.instances.context.changed" - Instances changed context. like folder, task or variant. diff --git a/client/ayon_core/tools/publisher/models/create.py b/client/ayon_core/tools/publisher/models/create.py index 900168eaef..75ed2c73fe 100644 --- a/client/ayon_core/tools/publisher/models/create.py +++ b/client/ayon_core/tools/publisher/models/create.py @@ -217,6 +217,7 @@ class InstanceItem: folder_path: Optional[str], task_name: Optional[str], is_active: bool, + is_mandatory: bool, has_promised_context: bool, ): self._instance_id: str = instance_id @@ -229,6 +230,7 @@ class InstanceItem: self._folder_path: Optional[str] = folder_path self._task_name: Optional[str] = task_name self._is_active: bool = is_active + self._is_mandatory: bool = is_mandatory self._has_promised_context: bool = has_promised_context @property @@ -251,6 +253,10 @@ class InstanceItem: def product_type(self): return self._product_type + @property + def is_mandatory(self): + return self._is_mandatory + @property def has_promised_context(self): return self._has_promised_context @@ -304,6 +310,7 @@ class InstanceItem: instance["folderPath"], instance["task"], instance["active"], + instance.is_mandatory, instance.has_promised_context, ) @@ -476,6 +483,9 @@ class CreateModel: self._create_context.add_publish_attr_defs_change_callback( self._cc_publish_attr_changed ) + self._create_context.add_instance_requirement_change_callback( + self._cc_instance_requirement_changed + ) self._create_context.reset_finalization() @@ -1171,6 +1181,16 @@ class CreateModel: event_data, ) + def _cc_instance_requirement_changed(self, event): + instance_ids = { + instance.id + for instance in event.data["instances"] + } + self._emit_event( + "create.model.instance.requirement.changed", + {"instance_ids": instance_ids}, + ) + 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 2f633b3149..8a4eddf058 100644 --- a/client/ayon_core/tools/publisher/widgets/card_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/card_view_widgets.py @@ -482,6 +482,9 @@ class InstanceCardWidget(CardWidget): if checkbox_value != new_value: self._active_checkbox.setChecked(new_value) + def _set_is_mandatory(self, is_mandatory: bool) -> None: + self._active_checkbox.setVisible(not is_mandatory) + def update_instance(self, instance, context_info): """Update instance object and update UI.""" self.instance = instance @@ -525,6 +528,7 @@ class InstanceCardWidget(CardWidget): """Update instance data""" self._update_product_name() self._set_active(self.instance.is_active) + self._set_is_mandatory(self.instance.is_mandatory) self._validate_context(context_info) def _set_expanded(self, expanded=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 bc3353ba5e..969bec11e5 100644 --- a/client/ayon_core/tools/publisher/widgets/list_view_widgets.py +++ b/client/ayon_core/tools/publisher/widgets/list_view_widgets.py @@ -132,6 +132,7 @@ class InstanceListItemWidget(QtWidgets.QWidget): active_checkbox = NiceCheckbox(parent=self) active_checkbox.setChecked(instance.is_active) + active_checkbox.setVisible(not instance.is_mandatory) layout = QtWidgets.QHBoxLayout(self) content_margins = layout.contentsMargins() @@ -151,6 +152,8 @@ class InstanceListItemWidget(QtWidgets.QWidget): self._has_valid_context = None + self._checkbox_enabled = not instance.is_mandatory + self._set_valid_property(context_info.is_valid) def mouseDoubleClickEvent(self, event): @@ -184,6 +187,10 @@ class InstanceListItemWidget(QtWidgets.QWidget): self._active_checkbox.setChecked(new_value) self._active_checkbox.blockSignals(False) + def is_checkbox_enabled(self) -> bool: + """Checkbox can be changed by user.""" + return self._checkbox_enabled + def update_instance(self, instance, context_info): """Update instance object.""" # Check product name @@ -192,6 +199,7 @@ class InstanceListItemWidget(QtWidgets.QWidget): self._instance_label_widget.setText(html_escape(label)) # Check active state self.set_active(instance.is_active) + self._set_is_mandatory(instance.is_mandatory) # Check valid states self._set_valid_property(context_info.is_valid) @@ -203,6 +211,10 @@ class InstanceListItemWidget(QtWidgets.QWidget): def set_active_toggle_enabled(self, enabled): self._active_checkbox.setEnabled(enabled) + def _set_is_mandatory(self, is_mandatory: bool) -> None: + self._checkbox_enabled = not is_mandatory + self._active_checkbox.setVisible(not is_mandatory) + class ListContextWidget(QtWidgets.QFrame): """Context (or global attributes) widget.""" @@ -949,11 +961,17 @@ class InstanceListView(AbstractInstanceView): return active_by_id = {} + all_changed = True 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: + widget = self._widgets_by_id.get(instance_id) + if widget is None: + continue + if widget.is_checkbox_enabled(): active_by_id[instance_id] = active + else: + all_changed = False self._controller.set_instances_active_state(active_by_id) @@ -963,6 +981,10 @@ class InstanceListView(AbstractInstanceView): if not self._instance_view.isExpanded(proxy_index): self._instance_view.expand(proxy_index) + if not all_changed: + # If not all instances were changed, update group checkstate + self._update_group_checkstate(group_name) + def has_items(self): if self._convertor_group_widget is not None: return True diff --git a/client/ayon_core/tools/publisher/widgets/overview_widget.py b/client/ayon_core/tools/publisher/widgets/overview_widget.py index c6c3b774f0..46395328e0 100644 --- a/client/ayon_core/tools/publisher/widgets/overview_widget.py +++ b/client/ayon_core/tools/publisher/widgets/overview_widget.py @@ -155,6 +155,10 @@ class OverviewWidget(QtWidgets.QFrame): "create.model.instances.context.changed", self._on_instance_context_change ) + controller.register_event_callback( + "create.model.instance.requirement.changed", + self._on_instance_requirement_changed + ) self._product_content_widget = product_content_widget self._product_content_layout = product_content_layout @@ -352,6 +356,12 @@ class OverviewWidget(QtWidgets.QFrame): ) def _on_instance_context_change(self, event): + self._refresh_instance_states(event["instance_ids"]) + + def _on_instance_requirement_changed(self, event): + self._refresh_instance_states(event["instance_ids"]) + + def _refresh_instance_states(self, instance_ids): current_idx = self._product_views_layout.currentIndex() for idx in range(self._product_views_layout.count()): if idx == current_idx: @@ -361,7 +371,7 @@ class OverviewWidget(QtWidgets.QFrame): widget.set_refreshed(False) current_widget = self._product_views_layout.widget(current_idx) - current_widget.refresh_instance_states(event["instance_ids"]) + current_widget.refresh_instance_states(instance_ids) def _on_convert_requested(self): self.convert_requested.emit()