Merge pull request #880 from ynput/enhancement/878-publisher-allow-context-promise

Create: Allow context promise for editorial workflow
This commit is contained in:
Jakub Trllo 2024-09-12 11:02:45 +02:00 committed by GitHub
commit da90754ed2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 243 additions and 124 deletions

View file

@ -6,7 +6,8 @@ import traceback
import collections
import inspect
from contextlib import contextmanager
from typing import Optional
import typing
from typing import Optional, Iterable, Dict
import pyblish.logic
import pyblish.api
@ -31,13 +32,15 @@ from .exceptions import (
HostMissRequiredMethod,
)
from .changes import TrackChangesItem
from .structures import PublishAttributes, ConvertorItem
from .structures import PublishAttributes, ConvertorItem, InstanceContextInfo
from .creator_plugins import (
Creator,
AutoCreator,
discover_creator_plugins,
discover_convertor_plugins,
)
if typing.TYPE_CHECKING:
from .structures import CreatedInstance
# Import of functions and classes that were moved to different file
# TODO Should be removed in future release - Added 24/08/28, 0.4.3-dev.1
@ -183,6 +186,10 @@ class CreateContext:
# Shared data across creators during collection phase
self._collection_shared_data = None
# Context validation cache
self._folder_id_by_folder_path = {}
self._task_names_by_folder_path = {}
self.thumbnail_paths_by_instance_id = {}
# Trigger reset if was enabled
@ -202,17 +209,19 @@ class CreateContext:
"""Access to global publish attributes."""
return self._publish_attributes
def get_instance_by_id(self, instance_id):
def get_instance_by_id(
self, instance_id: str
) -> Optional["CreatedInstance"]:
"""Receive instance by id.
Args:
instance_id (str): Instance id.
Returns:
Union[CreatedInstance, None]: Instance or None if instance with
Optional[CreatedInstance]: Instance or None if instance with
given id is not available.
"""
"""
return self._instances_by_id.get(instance_id)
def get_sorted_creators(self, identifiers=None):
@ -224,8 +233,8 @@ class CreateContext:
Returns:
List[BaseCreator]: Sorted creator plugins by 'order' value.
"""
"""
if identifiers is not None:
identifiers = set(identifiers)
creators = [
@ -491,6 +500,8 @@ class CreateContext:
# Give ability to store shared data for collection phase
self._collection_shared_data = {}
self._folder_id_by_folder_path = {}
self._task_names_by_folder_path = {}
def reset_finalization(self):
"""Cleanup of attributes after reset."""
@ -715,7 +726,7 @@ class CreateContext:
self._original_context_data, self.context_data_to_store()
)
def creator_adds_instance(self, instance):
def creator_adds_instance(self, instance: "CreatedInstance"):
"""Creator adds new instance to context.
Instances should be added only from creators.
@ -942,7 +953,7 @@ class CreateContext:
def _remove_instance(self, instance):
self._instances_by_id.pop(instance.id, None)
def creator_removed_instance(self, instance):
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
@ -990,7 +1001,7 @@ class CreateContext:
[],
self._bulk_instances_to_process
)
self.validate_instances_context(instances_to_validate)
self.get_instances_context_info(instances_to_validate)
def reset_instances(self):
"""Reload instances"""
@ -1079,26 +1090,70 @@ class CreateContext:
if failed_info:
raise CreatorsCreateFailed(failed_info)
def validate_instances_context(self, instances=None):
"""Validate 'folder' and 'task' instance context."""
def get_instances_context_info(
self, instances: Optional[Iterable["CreatedInstance"]] = None
) -> Dict[str, InstanceContextInfo]:
"""Validate 'folder' and 'task' instance context.
Args:
instances (Optional[Iterable[CreatedInstance]]): Instances to
validate. If not provided all instances are validated.
Returns:
Dict[str, InstanceContextInfo]: Validation results by instance id.
"""
# Use all instances from context if 'instances' are not passed
if instances is None:
instances = tuple(self._instances_by_id.values())
instances = self._instances_by_id.values()
instances = tuple(instances)
info_by_instance_id = {
instance.id: InstanceContextInfo(
instance.get("folderPath"),
instance.get("task"),
False,
False,
)
for instance in instances
}
# Skip if instances are empty
if not instances:
return
if not info_by_instance_id:
return info_by_instance_id
project_name = self.project_name
task_names_by_folder_path = {}
to_validate = []
task_names_by_folder_path = collections.defaultdict(set)
for instance in instances:
folder_path = instance.get("folderPath")
task_name = instance.get("task")
if folder_path:
task_names_by_folder_path[folder_path] = set()
if task_name:
task_names_by_folder_path[folder_path].add(task_name)
context_info = info_by_instance_id[instance.id]
if instance.has_promised_context:
context_info.folder_is_valid = True
context_info.task_is_valid = True
continue
# TODO allow context promise
folder_path = context_info.folder_path
if not folder_path:
continue
if folder_path in self._folder_id_by_folder_path:
folder_id = self._folder_id_by_folder_path[folder_path]
if folder_id is None:
continue
context_info.folder_is_valid = True
task_name = context_info.task_name
if task_name is not None:
tasks_cache = self._task_names_by_folder_path.get(folder_path)
if tasks_cache is not None:
context_info.task_is_valid = task_name in tasks_cache
continue
to_validate.append(instance)
task_names_by_folder_path[folder_path].add(task_name)
if not to_validate:
return info_by_instance_id
# Backwards compatibility for cases where folder name is set instead
# of folder path
@ -1120,7 +1175,9 @@ class CreateContext:
fields={"id", "path"}
):
folder_id = folder_entity["id"]
folder_paths_by_id[folder_id] = folder_entity["path"]
folder_path = folder_entity["path"]
folder_paths_by_id[folder_id] = folder_path
self._folder_id_by_folder_path[folder_path] = folder_id
folder_entities_by_name = collections.defaultdict(list)
if folder_names:
@ -1131,8 +1188,10 @@ class CreateContext:
):
folder_id = folder_entity["id"]
folder_name = folder_entity["name"]
folder_paths_by_id[folder_id] = folder_entity["path"]
folder_path = folder_entity["path"]
folder_paths_by_id[folder_id] = folder_path
folder_entities_by_name[folder_name].append(folder_entity)
self._folder_id_by_folder_path[folder_path] = folder_id
tasks_entities = ayon_api.get_tasks(
project_name,
@ -1145,12 +1204,11 @@ class CreateContext:
folder_id = task_entity["folderId"]
folder_path = folder_paths_by_id[folder_id]
task_names_by_folder_path[folder_path].add(task_entity["name"])
self._task_names_by_folder_path.update(task_names_by_folder_path)
for instance in instances:
if not instance.has_valid_folder or not instance.has_valid_task:
continue
for instance in to_validate:
folder_path = instance["folderPath"]
task_name = instance.get("task")
if folder_path and "/" not in folder_path:
folder_entities = folder_entities_by_name.get(folder_path)
if len(folder_entities) == 1:
@ -1158,15 +1216,16 @@ class CreateContext:
instance["folderPath"] = folder_path
if folder_path not in task_names_by_folder_path:
instance.set_folder_invalid(True)
continue
context_info = info_by_instance_id[instance.id]
context_info.folder_is_valid = True
task_name = instance["task"]
if not task_name:
continue
if task_name not in task_names_by_folder_path[folder_path]:
instance.set_task_invalid(True)
if (
not task_name
or task_name in task_names_by_folder_path[folder_path]
):
context_info.task_is_valid = True
return info_by_instance_id
def save_changes(self):
"""Save changes. Update all changed values."""

View file

@ -1,6 +1,7 @@
import copy
import collections
from uuid import uuid4
from typing import Optional
from ayon_core.lib.attribute_definitions import (
UnknownDef,
@ -396,6 +397,24 @@ class PublishAttributes:
)
class InstanceContextInfo:
def __init__(
self,
folder_path: Optional[str],
task_name: Optional[str],
folder_is_valid: bool,
task_is_valid: bool,
):
self.folder_path: Optional[str] = folder_path
self.task_name: Optional[str] = task_name
self.folder_is_valid: bool = folder_is_valid
self.task_is_valid: bool = task_is_valid
@property
def is_valid(self) -> bool:
return self.folder_is_valid and self.task_is_valid
class CreatedInstance:
"""Instance entity with data that will be stored to workfile.
@ -528,9 +547,6 @@ class CreatedInstance:
if not self._data.get("instance_id"):
self._data["instance_id"] = str(uuid4())
self._folder_is_valid = self.has_set_folder
self._task_is_valid = self.has_set_task
def __str__(self):
return (
"<CreatedInstance {product[name]}"
@ -699,6 +715,17 @@ class CreatedInstance:
def publish_attributes(self):
return self._data["publish_attributes"]
@property
def has_promised_context(self) -> bool:
"""Get context data that are promised to be set by creator.
Returns:
bool: Has context that won't bo validated. Artist can't change
value when set to True.
"""
return self._data.get("has_promised_context", False)
def data_to_store(self):
"""Collect data that contain json parsable types.
@ -826,46 +853,3 @@ class CreatedInstance:
obj.publish_attributes.deserialize_attributes(publish_attributes)
return obj
# Context validation related methods/properties
@property
def has_set_folder(self):
"""Folder path is set in data."""
return "folderPath" in self._data
@property
def has_set_task(self):
"""Task name is set in data."""
return "task" in self._data
@property
def has_valid_context(self):
"""Context data are valid for publishing."""
return self.has_valid_folder and self.has_valid_task
@property
def has_valid_folder(self):
"""Folder set in context exists in project."""
if not self.has_set_folder:
return False
return self._folder_is_valid
@property
def has_valid_task(self):
"""Task set in context exists in project."""
if not self.has_set_task:
return False
return self._task_is_valid
def set_folder_invalid(self, invalid):
# TODO replace with `set_folder_path`
self._folder_is_valid = not invalid
def set_task_invalid(self, invalid):
# TODO replace with `set_task_name`
self._task_is_valid = not invalid

View file

@ -322,6 +322,12 @@ class AbstractPublisherFrontend(AbstractPublisherCommon):
) -> Dict[str, Union[CreatedInstance, None]]:
pass
@abstractmethod
def get_instances_context_info(
self, instance_ids: Optional[Iterable[str]] = None
):
pass
@abstractmethod
def get_existing_product_names(self, folder_path: str) -> List[str]:
pass

View file

@ -190,6 +190,9 @@ class PublisherController(
def get_instances_by_id(self, instance_ids=None):
return self._create_model.get_instances_by_id(instance_ids)
def get_instances_context_info(self, instance_ids=None):
return self._create_model.get_instances_context_info(instance_ids)
def get_convertor_items(self):
return self._create_model.get_convertor_items()

View file

@ -306,6 +306,14 @@ class CreateModel:
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()
return self._create_context.get_instances_context_info(
instances
)
def get_convertor_items(self) -> Dict[str, ConvertorItem]:
return self._create_context.convertor_items_by_id

View file

@ -217,20 +217,22 @@ class InstanceGroupWidget(BaseGroupWidget):
def update_icons(self, group_icons):
self._group_icons = group_icons
def update_instance_values(self):
def update_instance_values(self, context_info_by_id):
"""Trigger update on instance widgets."""
for widget in self._widgets_by_id.values():
widget.update_instance_values()
for instance_id, widget in self._widgets_by_id.items():
widget.update_instance_values(context_info_by_id[instance_id])
def update_instances(self, instances):
def update_instances(self, instances, context_info_by_id):
"""Update instances for the group.
Args:
instances(list<CreatedInstance>): List of instances in
instances (list[CreatedInstance]): List of instances in
CreateContext.
"""
context_info_by_id (Dict[str, InstanceContextInfo]): Instance
context info by instance id.
"""
# Store instances by id and by product name
instances_by_id = {}
instances_by_product_name = collections.defaultdict(list)
@ -249,13 +251,14 @@ class InstanceGroupWidget(BaseGroupWidget):
widget_idx = 1
for product_names in sorted_product_names:
for instance in instances_by_product_name[product_names]:
context_info = context_info_by_id[instance.id]
if instance.id in self._widgets_by_id:
widget = self._widgets_by_id[instance.id]
widget.update_instance(instance)
widget.update_instance(instance, context_info)
else:
group_icon = self._group_icons[instance.creator_identifier]
widget = InstanceCardWidget(
instance, group_icon, self
instance, context_info, group_icon, self
)
widget.selected.connect(self._on_widget_selection)
widget.active_changed.connect(self._on_active_changed)
@ -388,7 +391,7 @@ class ConvertorItemCardWidget(CardWidget):
self._icon_widget = icon_widget
self._label_widget = label_widget
def update_instance_values(self):
def update_instance_values(self, context_info):
pass
@ -397,7 +400,7 @@ class InstanceCardWidget(CardWidget):
active_changed = QtCore.Signal(str, bool)
def __init__(self, instance, group_icon, parent):
def __init__(self, instance, context_info, group_icon, parent):
super().__init__(parent)
self._id = instance.id
@ -458,7 +461,7 @@ class InstanceCardWidget(CardWidget):
self._active_checkbox = active_checkbox
self._expand_btn = expand_btn
self.update_instance_values()
self.update_instance_values(context_info)
def set_active_toggle_enabled(self, enabled):
self._active_checkbox.setEnabled(enabled)
@ -480,13 +483,13 @@ class InstanceCardWidget(CardWidget):
if checkbox_value != new_value:
self._active_checkbox.setChecked(new_value)
def update_instance(self, instance):
def update_instance(self, instance, context_info):
"""Update instance object and update UI."""
self.instance = instance
self.update_instance_values()
self.update_instance_values(context_info)
def _validate_context(self):
valid = self.instance.has_valid_context
def _validate_context(self, context_info):
valid = context_info.is_valid
self._icon_widget.setVisible(valid)
self._context_warning.setVisible(not valid)
@ -519,11 +522,11 @@ class InstanceCardWidget(CardWidget):
QtCore.Qt.NoTextInteraction
)
def update_instance_values(self):
def update_instance_values(self, context_info):
"""Update instance data"""
self._update_product_name()
self.set_active(self.instance["active"])
self._validate_context()
self._validate_context(context_info)
def _set_expanded(self, expanded=None):
if expanded is None:
@ -694,6 +697,8 @@ class InstanceCardView(AbstractInstanceView):
self._update_convertor_items_group()
context_info_by_id = self._controller.get_instances_context_info()
# Prepare instances by group and identifiers by group
instances_by_group = collections.defaultdict(list)
identifiers_by_group = collections.defaultdict(set)
@ -747,7 +752,7 @@ class InstanceCardView(AbstractInstanceView):
widget_idx += 1
group_widget.update_instances(
instances_by_group[group_name]
instances_by_group[group_name], context_info_by_id
)
group_widget.set_active_toggle_enabled(
self._active_toggle_enabled
@ -814,8 +819,9 @@ class InstanceCardView(AbstractInstanceView):
def refresh_instance_states(self):
"""Trigger update of instances on group widgets."""
context_info_by_id = self._controller.get_instances_context_info()
for widget in self._widgets_by_group.values():
widget.update_instance_values()
widget.update_instance_values(context_info_by_id)
def _on_active_changed(self, group_name, instance_id, value):
group_widget = self._widgets_by_group[group_name]

View file

@ -115,7 +115,7 @@ class InstanceListItemWidget(QtWidgets.QWidget):
active_changed = QtCore.Signal(str, bool)
double_clicked = QtCore.Signal()
def __init__(self, instance, parent):
def __init__(self, instance, context_info, parent):
super().__init__(parent)
self.instance = instance
@ -151,7 +151,7 @@ class InstanceListItemWidget(QtWidgets.QWidget):
self._has_valid_context = None
self._set_valid_property(instance.has_valid_context)
self._set_valid_property(context_info.is_valid)
def mouseDoubleClickEvent(self, event):
widget = self.childAt(event.pos())
@ -188,12 +188,12 @@ class InstanceListItemWidget(QtWidgets.QWidget):
if checkbox_value != new_value:
self._active_checkbox.setChecked(new_value)
def update_instance(self, instance):
def update_instance(self, instance, context_info):
"""Update instance object."""
self.instance = instance
self.update_instance_values()
self.update_instance_values(context_info)
def update_instance_values(self):
def update_instance_values(self, context_info):
"""Update instance data propagated to widgets."""
# Check product name
label = self.instance.label
@ -202,7 +202,7 @@ class InstanceListItemWidget(QtWidgets.QWidget):
# Check active state
self.set_active(self.instance["active"])
# Check valid states
self._set_valid_property(self.instance.has_valid_context)
self._set_valid_property(context_info.is_valid)
def _on_active_change(self):
new_value = self._active_checkbox.isChecked()
@ -583,6 +583,8 @@ class InstanceListView(AbstractInstanceView):
self._update_convertor_items_group()
context_info_by_id = self._controller.get_instances_context_info()
# Prepare instances by their groups
instances_by_group_name = collections.defaultdict(list)
group_names = set()
@ -643,13 +645,15 @@ class InstanceListView(AbstractInstanceView):
elif activity != instance["active"]:
activity = -1
context_info = context_info_by_id[instance_id]
self._group_by_instance_id[instance_id] = group_name
# Remove instance id from `to_remove` if already exists and
# trigger update of widget
if instance_id in to_remove:
to_remove.remove(instance_id)
widget = self._widgets_by_id[instance_id]
widget.update_instance(instance)
widget.update_instance(instance, context_info)
continue
# Create new item and store it as new
@ -695,7 +699,8 @@ class InstanceListView(AbstractInstanceView):
group_item.appendRows(new_items)
for item, instance in new_items_with_instance:
if not instance.has_valid_context:
context_info = context_info_by_id[instance.id]
if not context_info.is_valid:
expand_groups.add(group_name)
item_index = self._instance_model.index(
item.row(),
@ -704,7 +709,7 @@ class InstanceListView(AbstractInstanceView):
)
proxy_index = self._proxy_model.mapFromSource(item_index)
widget = InstanceListItemWidget(
instance, self._instance_view
instance, context_info, self._instance_view
)
widget.set_active_toggle_enabled(
self._active_toggle_enabled
@ -870,8 +875,10 @@ class InstanceListView(AbstractInstanceView):
def refresh_instance_states(self):
"""Trigger update of all instances."""
for widget in self._widgets_by_id.values():
widget.update_instance_values()
context_info_by_id = self._controller.get_instances_context_info()
for instance_id, widget in self._widgets_by_id.items():
context_info = context_info_by_id[instance_id]
widget.update_instance_values(context_info)
def _on_active_changed(self, changed_instance_id, new_value):
selected_instance_ids, _, _ = self.get_selected_items()

View file

@ -1182,6 +1182,10 @@ class GlobalAttrsWidget(QtWidgets.QWidget):
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")
@ -1206,7 +1210,6 @@ class GlobalAttrsWidget(QtWidgets.QWidget):
except TaskNotSetError:
invalid_tasks = True
instance.set_task_invalid(True)
product_names.add(instance["productName"])
continue
@ -1216,11 +1219,9 @@ class GlobalAttrsWidget(QtWidgets.QWidget):
if folder_path is not None:
instance["folderPath"] = folder_path
instance.set_folder_invalid(False)
if task_name is not None:
instance["task"] = task_name or None
instance.set_task_invalid(False)
instance["productName"] = new_product_name
@ -1306,7 +1307,13 @@ class GlobalAttrsWidget(QtWidgets.QWidget):
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
@ -1319,6 +1326,11 @@ class GlobalAttrsWidget(QtWidgets.QWidget):
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
@ -1329,8 +1341,21 @@ class GlobalAttrsWidget(QtWidgets.QWidget):
self.product_value_widget.set_value(product_names)
self.variant_input.setEnabled(editable)
self.folder_value_widget.setEnabled(editable)
self.task_value_widget.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):
@ -1768,9 +1793,16 @@ class ProductAttributesWidget(QtWidgets.QWidget):
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 in self._current_instances:
if not instance.has_valid_context:
for instance_id, context_info in context_info_by_id.items():
if not context_info.is_valid:
all_valid = False
break
@ -1795,9 +1827,17 @@ class ProductAttributesWidget(QtWidgets.QWidget):
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 instance in instances:
if not instance.has_valid_context:
for context_info in context_info_by_id.values():
if not context_info.is_valid:
all_valid = False
break

View file

@ -913,12 +913,18 @@ class PublisherWindow(QtWidgets.QDialog):
self._set_footer_enabled(True)
return
active_instances_by_id = {
instance.id: instance
for instance in self._controller.get_instances()
if instance["active"]
}
context_info_by_id = self._controller.get_instances_context_info(
active_instances_by_id.keys()
)
all_valid = None
for instance in self._controller.get_instances():
if not instance["active"]:
continue
if not instance.has_valid_context:
for instance_id, instance in active_instances_by_id.items():
context_info = context_info_by_id[instance_id]
if not context_info.is_valid:
all_valid = False
break