Merge branch 'develop' into enhancement/OP-7069_Validate-Containers

This commit is contained in:
Kayla Man 2023-10-13 20:50:49 +08:00
commit d1dced43e4
24 changed files with 3899 additions and 203 deletions

View file

@ -50,6 +50,8 @@ from .utils import (
get_colorspace_list
)
from .actions import SelectInvalidAction
__all__ = (
"file_extensions",
"has_unsaved_changes",
@ -92,5 +94,7 @@ __all__ = (
"create_write_node",
"colorspace_exists_on_node",
"get_colorspace_list"
"get_colorspace_list",
"SelectInvalidAction",
)

View file

@ -20,33 +20,31 @@ class SelectInvalidAction(pyblish.api.Action):
def process(self, context, plugin):
try:
import nuke
except ImportError:
raise ImportError("Current host is not Nuke")
errored_instances = get_errored_instances_from_context(context,
plugin=plugin)
# Get the errored instances for the plug-in
errored_instances = get_errored_instances_from_context(
context, plugin)
# Get the invalid nodes for the plug-ins
self.log.info("Finding invalid nodes..")
invalid = list()
invalid_nodes = set()
for instance in errored_instances:
invalid_nodes = plugin.get_invalid(instance)
invalid = plugin.get_invalid(instance)
if invalid_nodes:
if isinstance(invalid_nodes, (list, tuple)):
invalid.append(invalid_nodes[0])
else:
self.log.warning("Plug-in returned to be invalid, "
"but has no selectable nodes.")
if not invalid:
continue
# Ensure unique (process each node only once)
invalid = list(set(invalid))
select_node = instance.data.get("transientData", {}).get("node")
if not select_node:
raise RuntimeError(
"No transientData['node'] found on instance: {}".format(
instance)
)
if invalid:
self.log.info("Selecting invalid nodes: {}".format(invalid))
invalid_nodes.add(select_node)
if invalid_nodes:
self.log.info("Selecting invalid nodes: {}".format(invalid_nodes))
reset_selection()
select_nodes(invalid)
select_nodes(list(invalid_nodes))
else:
self.log.info("No invalid nodes found.")

View file

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<root>
<error id="main">
<title>Shot/Asset name</title>
<description>
## Publishing to a different asset context
There are publish instances present which are publishing into a different asset than your current context.
Usually this is not what you want but there can be cases where you might want to publish into another asset/shot or task.
If that's the case you can disable the validation on the instance to ignore it.
The wrong node's name is: \`{node_name}\`
### Correct context keys and values:
\`{correct_values}\`
### Wrong keys and values:
\`{wrong_values}\`.
## How to repair?
1. Use \"Repair\" button.
2. Hit Reload button on the publisher.
</description>
</error>
</root>

View file

@ -1,18 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<root>
<error id="main">
<title>Shot/Asset name</title>
<description>
## Invalid Shot/Asset name in subset
Following Node with name `{node_name}`:
Is in context of `{correct_name}` but Node _asset_ knob is set as `{wrong_name}`.
### How to repair?
1. Either use Repair or Select button.
2. If you chose Select then rename asset knob to correct name.
3. Hit Reload button on the publisher.
</description>
</error>
</root>

View file

@ -0,0 +1,112 @@
# -*- coding: utf-8 -*-
"""Validate if instance asset is the same as context asset."""
from __future__ import absolute_import
import pyblish.api
from openpype.pipeline.publish import (
RepairAction,
ValidateContentsOrder,
PublishXmlValidationError,
OptionalPyblishPluginMixin
)
from openpype.hosts.nuke.api import SelectInvalidAction
class ValidateCorrectAssetContext(
pyblish.api.InstancePlugin,
OptionalPyblishPluginMixin
):
"""Validator to check if instance asset context match context asset.
When working in per-shot style you always publish data in context of
current asset (shot). This validator checks if this is so. It is optional
so it can be disabled when needed.
Checking `asset` and `task` keys.
"""
order = ValidateContentsOrder
label = "Validate asset context"
hosts = ["nuke"]
actions = [
RepairAction,
SelectInvalidAction
]
optional = True
@classmethod
def apply_settings(cls, project_settings):
"""Apply deprecated settings from project settings.
"""
nuke_publish = project_settings["nuke"]["publish"]
if "ValidateCorrectAssetName" in nuke_publish:
settings = nuke_publish["ValidateCorrectAssetName"]
else:
settings = nuke_publish["ValidateCorrectAssetContext"]
cls.enabled = settings["enabled"]
cls.optional = settings["optional"]
cls.active = settings["active"]
def process(self, instance):
if not self.is_active(instance.data):
return
invalid_keys = self.get_invalid(instance)
if not invalid_keys:
return
message_values = {
"node_name": instance.data["transientData"]["node"].name(),
"correct_values": ", ".join([
"{} > {}".format(_key, instance.context.data[_key])
for _key in invalid_keys
]),
"wrong_values": ", ".join([
"{} > {}".format(_key, instance.data.get(_key))
for _key in invalid_keys
])
}
msg = (
"Instance `{node_name}` has wrong context keys:\n"
"Correct: `{correct_values}` | Wrong: `{wrong_values}`").format(
**message_values)
self.log.debug(msg)
raise PublishXmlValidationError(
self, msg, formatting_data=message_values
)
@classmethod
def get_invalid(cls, instance):
"""Get invalid keys from instance data and context data."""
invalid_keys = []
testing_keys = ["asset", "task"]
for _key in testing_keys:
if _key not in instance.data:
invalid_keys.append(_key)
continue
if instance.data[_key] != instance.context.data[_key]:
invalid_keys.append(_key)
return invalid_keys
@classmethod
def repair(cls, instance):
"""Repair instance data with context data."""
invalid_keys = cls.get_invalid(instance)
create_context = instance.context.data["create_context"]
instance_id = instance.data.get("instance_id")
created_instance = create_context.get_instance_by_id(
instance_id
)
for _key in invalid_keys:
created_instance[_key] = instance.context.data[_key]
create_context.save_changes()

View file

@ -1,138 +0,0 @@
# -*- coding: utf-8 -*-
"""Validate if instance asset is the same as context asset."""
from __future__ import absolute_import
import pyblish.api
import openpype.hosts.nuke.api.lib as nlib
from openpype.pipeline.publish import (
ValidateContentsOrder,
PublishXmlValidationError,
OptionalPyblishPluginMixin
)
class SelectInvalidInstances(pyblish.api.Action):
"""Select invalid instances in Outliner."""
label = "Select"
icon = "briefcase"
on = "failed"
def process(self, context, plugin):
"""Process invalid validators and select invalid instances."""
# Get the errored instances
failed = []
for result in context.data["results"]:
if (
result["error"] is None
or result["instance"] is None
or result["instance"] in failed
or result["plugin"] != plugin
):
continue
failed.append(result["instance"])
# Apply pyblish.logic to get the instances for the plug-in
instances = pyblish.api.instances_by_plugin(failed, plugin)
if instances:
self.deselect()
self.log.info(
"Selecting invalid nodes: %s" % ", ".join(
[str(x) for x in instances]
)
)
self.select(instances)
else:
self.log.info("No invalid nodes found.")
self.deselect()
def select(self, instances):
for inst in instances:
if inst.data.get("transientData", {}).get("node"):
select_node = inst.data["transientData"]["node"]
select_node["selected"].setValue(True)
def deselect(self):
nlib.reset_selection()
class RepairSelectInvalidInstances(pyblish.api.Action):
"""Repair the instance asset."""
label = "Repair"
icon = "wrench"
on = "failed"
def process(self, context, plugin):
# Get the errored instances
failed = []
for result in context.data["results"]:
if (
result["error"] is None
or result["instance"] is None
or result["instance"] in failed
or result["plugin"] != plugin
):
continue
failed.append(result["instance"])
# Apply pyblish.logic to get the instances for the plug-in
instances = pyblish.api.instances_by_plugin(failed, plugin)
self.log.debug(instances)
context_asset = context.data["assetEntity"]["name"]
for instance in instances:
node = instance.data["transientData"]["node"]
node_data = nlib.get_node_data(node, nlib.INSTANCE_DATA_KNOB)
node_data["asset"] = context_asset
nlib.set_node_data(node, nlib.INSTANCE_DATA_KNOB, node_data)
class ValidateCorrectAssetName(
pyblish.api.InstancePlugin,
OptionalPyblishPluginMixin
):
"""Validator to check if instance asset match context asset.
When working in per-shot style you always publish data in context of
current asset (shot). This validator checks if this is so. It is optional
so it can be disabled when needed.
Action on this validator will select invalid instances in Outliner.
"""
order = ValidateContentsOrder
label = "Validate correct asset name"
hosts = ["nuke"]
actions = [
SelectInvalidInstances,
RepairSelectInvalidInstances
]
optional = True
def process(self, instance):
if not self.is_active(instance.data):
return
asset = instance.data.get("asset")
context_asset = instance.context.data["assetEntity"]["name"]
node = instance.data["transientData"]["node"]
msg = (
"Instance `{}` has wrong shot/asset name:\n"
"Correct: `{}` | Wrong: `{}`").format(
instance.name, asset, context_asset)
self.log.debug(msg)
if asset != context_asset:
raise PublishXmlValidationError(
self, msg, formatting_data={
"node_name": node.name(),
"wrong_name": asset,
"correct_name": context_asset
}
)

View file

@ -23,7 +23,7 @@ class ValidateOutputResolution(
order = pyblish.api.ValidatorOrder
optional = True
families = ["render"]
label = "Write resolution"
label = "Validate Write resolution"
hosts = ["nuke"]
actions = [RepairAction]

View file

@ -82,12 +82,6 @@ class ValidateNukeWriteNode(
correct_data = get_write_node_template_attr(write_group_node)
check = []
self.log.debug("__ write_node: {}".format(
write_node
))
self.log.debug("__ correct_data: {}".format(
correct_data
))
# Collect key values of same type in a list.
values_by_name = defaultdict(list)
@ -96,9 +90,6 @@ class ValidateNukeWriteNode(
for knob_data in correct_data["knobs"]:
knob_type = knob_data["type"]
self.log.debug("__ knob_type: {}".format(
knob_type
))
if (
knob_type == "__legacy__"
@ -134,9 +125,6 @@ class ValidateNukeWriteNode(
fixed_values.append(value)
self.log.debug("__ key: {} | values: {}".format(
key, fixed_values
))
if (
node_value not in fixed_values
and key != "file"
@ -144,8 +132,6 @@ class ValidateNukeWriteNode(
):
check.append([key, value, write_node[key].value()])
self.log.info(check)
if check:
self._make_error(check)

View file

@ -341,7 +341,7 @@
"write"
]
},
"ValidateCorrectAssetName": {
"ValidateCorrectAssetContext": {
"enabled": true,
"optional": true,
"active": true

View file

@ -61,7 +61,7 @@
"name": "template_publish_plugin",
"template_data": [
{
"key": "ValidateCorrectAssetName",
"key": "ValidateCorrectAssetContext",
"label": "Validate Correct Asset Name"
}
]

View file

@ -0,0 +1,6 @@
from .control import SceneInventoryController
__all__ = (
"SceneInventoryController",
)

View file

@ -0,0 +1,134 @@
import ayon_api
from openpype.lib.events import QueuedEventSystem
from openpype.host import ILoadHost
from openpype.pipeline import (
registered_host,
get_current_context,
)
from openpype.tools.ayon_utils.models import HierarchyModel
from .models import SiteSyncModel
class SceneInventoryController:
"""This is a temporary controller for AYON.
Goal of this temporary controller is to provide a way to get current
context instead of using 'AvalonMongoDB' object (or 'legacy_io').
Also provides (hopefully) cleaner api for site sync.
"""
def __init__(self, host=None):
if host is None:
host = registered_host()
self._host = host
self._current_context = None
self._current_project = None
self._current_folder_id = None
self._current_folder_set = False
self._site_sync_model = SiteSyncModel(self)
# Switch dialog requirements
self._hierarchy_model = HierarchyModel(self)
self._event_system = self._create_event_system()
def emit_event(self, topic, data=None, source=None):
if data is None:
data = {}
self._event_system.emit(topic, data, source)
def register_event_callback(self, topic, callback):
self._event_system.add_callback(topic, callback)
def reset(self):
self._current_context = None
self._current_project = None
self._current_folder_id = None
self._current_folder_set = False
self._site_sync_model.reset()
self._hierarchy_model.reset()
def get_current_context(self):
if self._current_context is None:
if hasattr(self._host, "get_current_context"):
self._current_context = self._host.get_current_context()
else:
self._current_context = get_current_context()
return self._current_context
def get_current_project_name(self):
if self._current_project is None:
self._current_project = self.get_current_context()["project_name"]
return self._current_project
def get_current_folder_id(self):
if self._current_folder_set:
return self._current_folder_id
context = self.get_current_context()
project_name = context["project_name"]
folder_path = context.get("folder_path")
folder_name = context.get("asset_name")
folder_id = None
if folder_path:
folder = ayon_api.get_folder_by_path(project_name, folder_path)
if folder:
folder_id = folder["id"]
elif folder_name:
for folder in ayon_api.get_folders(
project_name, folder_names=[folder_name]
):
folder_id = folder["id"]
break
self._current_folder_id = folder_id
self._current_folder_set = True
return self._current_folder_id
def get_containers(self):
host = self._host
if isinstance(host, ILoadHost):
return host.get_containers()
elif hasattr(host, "ls"):
return host.ls()
return []
# Site Sync methods
def is_sync_server_enabled(self):
return self._site_sync_model.is_sync_server_enabled()
def get_sites_information(self):
return self._site_sync_model.get_sites_information()
def get_site_provider_icons(self):
return self._site_sync_model.get_site_provider_icons()
def get_representations_site_progress(self, representation_ids):
return self._site_sync_model.get_representations_site_progress(
representation_ids
)
def resync_representations(self, representation_ids, site_type):
return self._site_sync_model.resync_representations(
representation_ids, site_type
)
# Switch dialog methods
def get_folder_items(self, project_name, sender=None):
return self._hierarchy_model.get_folder_items(project_name, sender)
def get_folder_label(self, folder_id):
if not folder_id:
return None
project_name = self.get_current_project_name()
folder_item = self._hierarchy_model.get_folder_item(
project_name, folder_id)
if folder_item is None:
return None
return folder_item.label
def _create_event_system(self):
return QueuedEventSystem()

View file

@ -0,0 +1,622 @@
import collections
import re
import logging
import uuid
import copy
from collections import defaultdict
from qtpy import QtCore, QtGui
import qtawesome
from openpype.client import (
get_assets,
get_subsets,
get_versions,
get_last_version_by_subset_id,
get_representations,
)
from openpype.pipeline import (
get_current_project_name,
schema,
HeroVersionType,
)
from openpype.style import get_default_entity_icon_color
from openpype.tools.utils.models import TreeModel, Item
def walk_hierarchy(node):
"""Recursively yield group node."""
for child in node.children():
if child.get("isGroupNode"):
yield child
for _child in walk_hierarchy(child):
yield _child
class InventoryModel(TreeModel):
"""The model for the inventory"""
Columns = [
"Name",
"version",
"count",
"family",
"group",
"loader",
"objectName",
"active_site",
"remote_site",
]
active_site_col = Columns.index("active_site")
remote_site_col = Columns.index("remote_site")
OUTDATED_COLOR = QtGui.QColor(235, 30, 30)
CHILD_OUTDATED_COLOR = QtGui.QColor(200, 160, 30)
GRAYOUT_COLOR = QtGui.QColor(160, 160, 160)
UniqueRole = QtCore.Qt.UserRole + 2 # unique label role
def __init__(self, controller, parent=None):
super(InventoryModel, self).__init__(parent)
self.log = logging.getLogger(self.__class__.__name__)
self._controller = controller
self._hierarchy_view = False
self._default_icon_color = get_default_entity_icon_color()
site_icons = self._controller.get_site_provider_icons()
self._site_icons = {
provider: QtGui.QIcon(icon_path)
for provider, icon_path in site_icons.items()
}
def outdated(self, item):
value = item.get("version")
if isinstance(value, HeroVersionType):
return False
if item.get("version") == item.get("highest_version"):
return False
return True
def data(self, index, role):
if not index.isValid():
return
item = index.internalPointer()
if role == QtCore.Qt.FontRole:
# Make top-level entries bold
if item.get("isGroupNode") or item.get("isNotSet"): # group-item
font = QtGui.QFont()
font.setBold(True)
return font
if role == QtCore.Qt.ForegroundRole:
# Set the text color to the OUTDATED_COLOR when the
# collected version is not the same as the highest version
key = self.Columns[index.column()]
if key == "version": # version
if item.get("isGroupNode"): # group-item
if self.outdated(item):
return self.OUTDATED_COLOR
if self._hierarchy_view:
# If current group is not outdated, check if any
# outdated children.
for _node in walk_hierarchy(item):
if self.outdated(_node):
return self.CHILD_OUTDATED_COLOR
else:
if self._hierarchy_view:
# Although this is not a group item, we still need
# to distinguish which one contain outdated child.
for _node in walk_hierarchy(item):
if self.outdated(_node):
return self.CHILD_OUTDATED_COLOR.darker(150)
return self.GRAYOUT_COLOR
if key == "Name" and not item.get("isGroupNode"):
return self.GRAYOUT_COLOR
# Add icons
if role == QtCore.Qt.DecorationRole:
if index.column() == 0:
# Override color
color = item.get("color", self._default_icon_color)
if item.get("isGroupNode"): # group-item
return qtawesome.icon("fa.folder", color=color)
if item.get("isNotSet"):
return qtawesome.icon("fa.exclamation-circle", color=color)
return qtawesome.icon("fa.file-o", color=color)
if index.column() == 3:
# Family icon
return item.get("familyIcon", None)
column_name = self.Columns[index.column()]
if column_name == "group" and item.get("group"):
return qtawesome.icon("fa.object-group",
color=get_default_entity_icon_color())
if item.get("isGroupNode"):
if column_name == "active_site":
provider = item.get("active_site_provider")
return self._site_icons.get(provider)
if column_name == "remote_site":
provider = item.get("remote_site_provider")
return self._site_icons.get(provider)
if role == QtCore.Qt.DisplayRole and item.get("isGroupNode"):
column_name = self.Columns[index.column()]
progress = None
if column_name == "active_site":
progress = item.get("active_site_progress", 0)
elif column_name == "remote_site":
progress = item.get("remote_site_progress", 0)
if progress is not None:
return "{}%".format(max(progress, 0) * 100)
if role == self.UniqueRole:
return item["representation"] + item.get("objectName", "<none>")
return super(InventoryModel, self).data(index, role)
def set_hierarchy_view(self, state):
"""Set whether to display subsets in hierarchy view."""
state = bool(state)
if state != self._hierarchy_view:
self._hierarchy_view = state
def refresh(self, selected=None, containers=None):
"""Refresh the model"""
# for debugging or testing, injecting items from outside
if containers is None:
containers = self._controller.get_containers()
self.clear()
if not selected or not self._hierarchy_view:
self._add_containers(containers)
return
# Filter by cherry-picked items
self._add_containers((
container
for container in containers
if container["objectName"] in selected
))
def _add_containers(self, containers, parent=None):
"""Add the items to the model.
The items should be formatted similar to `api.ls()` returns, an item
is then represented as:
{"filename_v001.ma": [full/filename/of/loaded/filename_v001.ma,
full/filename/of/loaded/filename_v001.ma],
"nodetype" : "reference",
"node": "referenceNode1"}
Note: When performing an additional call to `add_items` it will *not*
group the new items with previously existing item groups of the
same type.
Args:
containers (generator): Container items.
parent (Item, optional): Set this item as parent for the added
items when provided. Defaults to the root of the model.
Returns:
node.Item: root node which has children added based on the data
"""
project_name = get_current_project_name()
self.beginResetModel()
# Group by representation
grouped = defaultdict(lambda: {"containers": list()})
for container in containers:
repre_id = container["representation"]
grouped[repre_id]["containers"].append(container)
(
repres_by_id,
versions_by_id,
products_by_id,
folders_by_id,
) = self._query_entities(project_name, set(grouped.keys()))
# Add to model
not_found = defaultdict(list)
not_found_ids = []
for repre_id, group_dict in sorted(grouped.items()):
group_containers = group_dict["containers"]
representation = repres_by_id.get(repre_id)
if not representation:
not_found["representation"].extend(group_containers)
not_found_ids.append(repre_id)
continue
version = versions_by_id.get(representation["parent"])
if not version:
not_found["version"].extend(group_containers)
not_found_ids.append(repre_id)
continue
product = products_by_id.get(version["parent"])
if not product:
not_found["product"].extend(group_containers)
not_found_ids.append(repre_id)
continue
folder = folders_by_id.get(product["parent"])
if not folder:
not_found["folder"].extend(group_containers)
not_found_ids.append(repre_id)
continue
group_dict.update({
"representation": representation,
"version": version,
"subset": product,
"asset": folder
})
for _repre_id in not_found_ids:
grouped.pop(_repre_id)
for where, group_containers in not_found.items():
# create the group header
group_node = Item()
name = "< NOT FOUND - {} >".format(where)
group_node["Name"] = name
group_node["representation"] = name
group_node["count"] = len(group_containers)
group_node["isGroupNode"] = False
group_node["isNotSet"] = True
self.add_child(group_node, parent=parent)
for container in group_containers:
item_node = Item()
item_node.update(container)
item_node["Name"] = container.get("objectName", "NO NAME")
item_node["isNotFound"] = True
self.add_child(item_node, parent=group_node)
# TODO Use product icons
family_icon = qtawesome.icon(
"fa.folder", color="#0091B2"
)
# Prepare site sync specific data
progress_by_id = self._controller.get_representations_site_progress(
set(grouped.keys())
)
sites_info = self._controller.get_sites_information()
for repre_id, group_dict in sorted(grouped.items()):
group_containers = group_dict["containers"]
representation = group_dict["representation"]
version = group_dict["version"]
subset = group_dict["subset"]
asset = group_dict["asset"]
# Get the primary family
maj_version, _ = schema.get_schema_version(subset["schema"])
if maj_version < 3:
src_doc = version
else:
src_doc = subset
prim_family = src_doc["data"].get("family")
if not prim_family:
families = src_doc["data"].get("families")
if families:
prim_family = families[0]
# Store the highest available version so the model can know
# whether current version is currently up-to-date.
highest_version = get_last_version_by_subset_id(
project_name, version["parent"]
)
# create the group header
group_node = Item()
group_node["Name"] = "{}_{}: ({})".format(
asset["name"], subset["name"], representation["name"]
)
group_node["representation"] = repre_id
group_node["version"] = version["name"]
group_node["highest_version"] = highest_version["name"]
group_node["family"] = prim_family or ""
group_node["familyIcon"] = family_icon
group_node["count"] = len(group_containers)
group_node["isGroupNode"] = True
group_node["group"] = subset["data"].get("subsetGroup")
# Site sync specific data
progress = progress_by_id[repre_id]
group_node.update(sites_info)
group_node["active_site_progress"] = progress["active_site"]
group_node["remote_site_progress"] = progress["remote_site"]
self.add_child(group_node, parent=parent)
for container in group_containers:
item_node = Item()
item_node.update(container)
# store the current version on the item
item_node["version"] = version["name"]
# Remapping namespace to item name.
# Noted that the name key is capital "N", by doing this, we
# can view namespace in GUI without changing container data.
item_node["Name"] = container["namespace"]
self.add_child(item_node, parent=group_node)
self.endResetModel()
return self._root_item
def _query_entities(self, project_name, repre_ids):
"""Query entities for representations from containers.
Returns:
tuple[dict, dict, dict, dict]: Representation, version, product
and folder documents by id.
"""
repres_by_id = {}
versions_by_id = {}
products_by_id = {}
folders_by_id = {}
output = (
repres_by_id,
versions_by_id,
products_by_id,
folders_by_id,
)
filtered_repre_ids = set()
for repre_id in repre_ids:
# Filter out invalid representation ids
# NOTE: This is added because scenes from OpenPype did contain
# ObjectId from mongo.
try:
uuid.UUID(repre_id)
filtered_repre_ids.add(repre_id)
except ValueError:
continue
if not filtered_repre_ids:
return output
repre_docs = get_representations(project_name, repre_ids)
repres_by_id.update({
repre_doc["_id"]: repre_doc
for repre_doc in repre_docs
})
version_ids = {
repre_doc["parent"] for repre_doc in repres_by_id.values()
}
if not version_ids:
return output
version_docs = get_versions(project_name, version_ids, hero=True)
versions_by_id.update({
version_doc["_id"]: version_doc
for version_doc in version_docs
})
hero_versions_by_subversion_id = collections.defaultdict(list)
for version_doc in versions_by_id.values():
if version_doc["type"] != "hero_version":
continue
subversion = version_doc["version_id"]
hero_versions_by_subversion_id[subversion].append(version_doc)
if hero_versions_by_subversion_id:
subversion_ids = set(
hero_versions_by_subversion_id.keys()
)
subversion_docs = get_versions(project_name, subversion_ids)
for subversion_doc in subversion_docs:
subversion_id = subversion_doc["_id"]
subversion_ids.discard(subversion_id)
h_version_docs = hero_versions_by_subversion_id[subversion_id]
for version_doc in h_version_docs:
version_doc["name"] = HeroVersionType(
subversion_doc["name"]
)
version_doc["data"] = copy.deepcopy(
subversion_doc["data"]
)
for subversion_id in subversion_ids:
h_version_docs = hero_versions_by_subversion_id[subversion_id]
for version_doc in h_version_docs:
versions_by_id.pop(version_doc["_id"])
product_ids = {
version_doc["parent"]
for version_doc in versions_by_id.values()
}
if not product_ids:
return output
product_docs = get_subsets(project_name, product_ids)
products_by_id.update({
product_doc["_id"]: product_doc
for product_doc in product_docs
})
folder_ids = {
product_doc["parent"]
for product_doc in products_by_id.values()
}
if not folder_ids:
return output
folder_docs = get_assets(project_name, folder_ids)
folders_by_id.update({
folder_doc["_id"]: folder_doc
for folder_doc in folder_docs
})
return output
class FilterProxyModel(QtCore.QSortFilterProxyModel):
"""Filter model to where key column's value is in the filtered tags"""
def __init__(self, *args, **kwargs):
super(FilterProxyModel, self).__init__(*args, **kwargs)
self._filter_outdated = False
self._hierarchy_view = False
def filterAcceptsRow(self, row, parent):
model = self.sourceModel()
source_index = model.index(row, self.filterKeyColumn(), parent)
# Always allow bottom entries (individual containers), since their
# parent group hidden if it wouldn't have been validated.
rows = model.rowCount(source_index)
if not rows:
return True
# Filter by regex
if hasattr(self, "filterRegExp"):
regex = self.filterRegExp()
else:
regex = self.filterRegularExpression()
pattern = regex.pattern()
if pattern:
pattern = re.escape(pattern)
if not self._matches(row, parent, pattern):
return False
if self._filter_outdated:
# When filtering to outdated we filter the up to date entries
# thus we "allow" them when they are outdated
if not self._is_outdated(row, parent):
return False
return True
def set_filter_outdated(self, state):
"""Set whether to show the outdated entries only."""
state = bool(state)
if state != self._filter_outdated:
self._filter_outdated = bool(state)
self.invalidateFilter()
def set_hierarchy_view(self, state):
state = bool(state)
if state != self._hierarchy_view:
self._hierarchy_view = state
def _is_outdated(self, row, parent):
"""Return whether row is outdated.
A row is considered outdated if it has "version" and "highest_version"
data and in the internal data structure, and they are not of an
equal value.
"""
def outdated(node):
version = node.get("version", None)
highest = node.get("highest_version", None)
# Always allow indices that have no version data at all
if version is None and highest is None:
return True
# If either a version or highest is present but not the other
# consider the item invalid.
if not self._hierarchy_view:
# Skip this check if in hierarchy view, or the child item
# node will be hidden even it's actually outdated.
if version is None or highest is None:
return False
return version != highest
index = self.sourceModel().index(row, self.filterKeyColumn(), parent)
# The scene contents are grouped by "representation", e.g. the same
# "representation" loaded twice is grouped under the same header.
# Since the version check filters these parent groups we skip that
# check for the individual children.
has_parent = index.parent().isValid()
if has_parent and not self._hierarchy_view:
return True
# Filter to those that have the different version numbers
node = index.internalPointer()
if outdated(node):
return True
if self._hierarchy_view:
for _node in walk_hierarchy(node):
if outdated(_node):
return True
return False
def _matches(self, row, parent, pattern):
"""Return whether row matches regex pattern.
Args:
row (int): row number in model
parent (QtCore.QModelIndex): parent index
pattern (regex.pattern): pattern to check for in key
Returns:
bool
"""
model = self.sourceModel()
column = self.filterKeyColumn()
role = self.filterRole()
def matches(row, parent, pattern):
index = model.index(row, column, parent)
key = model.data(index, role)
if re.search(pattern, key, re.IGNORECASE):
return True
if matches(row, parent, pattern):
return True
# Also allow if any of the children matches
source_index = model.index(row, column, parent)
rows = model.rowCount(source_index)
if any(
matches(idx, source_index, pattern)
for idx in range(rows)
):
return True
if not self._hierarchy_view:
return False
for idx in range(rows):
child_index = model.index(idx, column, source_index)
child_rows = model.rowCount(child_index)
return any(
self._matches(child_idx, child_index, pattern)
for child_idx in range(child_rows)
)
return True

View file

@ -0,0 +1,6 @@
from .site_sync import SiteSyncModel
__all__ = (
"SiteSyncModel",
)

View file

@ -0,0 +1,176 @@
from openpype.client import get_representations
from openpype.modules import ModulesManager
NOT_SET = object()
class SiteSyncModel:
def __init__(self, controller):
self._controller = controller
self._sync_server_module = NOT_SET
self._sync_server_enabled = None
self._active_site = NOT_SET
self._remote_site = NOT_SET
self._active_site_provider = NOT_SET
self._remote_site_provider = NOT_SET
def reset(self):
self._sync_server_module = NOT_SET
self._sync_server_enabled = None
self._active_site = NOT_SET
self._remote_site = NOT_SET
self._active_site_provider = NOT_SET
self._remote_site_provider = NOT_SET
def is_sync_server_enabled(self):
"""Site sync is enabled.
Returns:
bool: Is enabled or not.
"""
self._cache_sync_server_module()
return self._sync_server_enabled
def get_site_provider_icons(self):
"""Icon paths per provider.
Returns:
dict[str, str]: Path by provider name.
"""
site_sync = self._get_sync_server_module()
if site_sync is None:
return {}
return site_sync.get_site_icons()
def get_sites_information(self):
return {
"active_site": self._get_active_site(),
"active_site_provider": self._get_active_site_provider(),
"remote_site": self._get_remote_site(),
"remote_site_provider": self._get_remote_site_provider()
}
def get_representations_site_progress(self, representation_ids):
"""Get progress of representations sync."""
representation_ids = set(representation_ids)
output = {
repre_id: {
"active_site": 0,
"remote_site": 0,
}
for repre_id in representation_ids
}
if not self.is_sync_server_enabled():
return output
project_name = self._controller.get_current_project_name()
site_sync = self._get_sync_server_module()
repre_docs = get_representations(project_name, representation_ids)
active_site = self._get_active_site()
remote_site = self._get_remote_site()
for repre_doc in repre_docs:
repre_output = output[repre_doc["_id"]]
result = site_sync.get_progress_for_repre(
repre_doc, active_site, remote_site
)
repre_output["active_site"] = result[active_site]
repre_output["remote_site"] = result[remote_site]
return output
def resync_representations(self, representation_ids, site_type):
"""
Args:
representation_ids (Iterable[str]): Representation ids.
site_type (Literal[active_site, remote_site]): Site type.
"""
project_name = self._controller.get_current_project_name()
site_sync = self._get_sync_server_module()
active_site = self._get_active_site()
remote_site = self._get_remote_site()
progress = self.get_representations_site_progress(
representation_ids
)
for repre_id in representation_ids:
repre_progress = progress.get(repre_id)
if not repre_progress:
continue
if site_type == "active_site":
# check opposite from added site, must be 1 or unable to sync
check_progress = repre_progress["remote_site"]
site = active_site
else:
check_progress = repre_progress["active_site"]
site = remote_site
if check_progress == 1:
site_sync.add_site(
project_name, repre_id, site, force=True
)
def _get_sync_server_module(self):
self._cache_sync_server_module()
return self._sync_server_module
def _cache_sync_server_module(self):
if self._sync_server_module is not NOT_SET:
return self._sync_server_module
manager = ModulesManager()
site_sync = manager.modules_by_name.get("sync_server")
sync_enabled = site_sync is not None and site_sync.enabled
self._sync_server_module = site_sync
self._sync_server_enabled = sync_enabled
def _get_active_site(self):
if self._active_site is NOT_SET:
self._cache_sites()
return self._active_site
def _get_remote_site(self):
if self._remote_site is NOT_SET:
self._cache_sites()
return self._remote_site
def _get_active_site_provider(self):
if self._active_site_provider is NOT_SET:
self._cache_sites()
return self._active_site_provider
def _get_remote_site_provider(self):
if self._remote_site_provider is NOT_SET:
self._cache_sites()
return self._remote_site_provider
def _cache_sites(self):
site_sync = self._get_sync_server_module()
active_site = None
remote_site = None
active_site_provider = None
remote_site_provider = None
if site_sync is not None:
project_name = self._controller.get_current_project_name()
active_site = site_sync.get_active_site(project_name)
remote_site = site_sync.get_remote_site(project_name)
active_site_provider = "studio"
remote_site_provider = "studio"
if active_site != "studio":
active_site_provider = site_sync.get_active_provider(
project_name, active_site
)
if remote_site != "studio":
remote_site_provider = site_sync.get_active_provider(
project_name, remote_site
)
self._active_site = active_site
self._remote_site = remote_site
self._active_site_provider = active_site_provider
self._remote_site_provider = remote_site_provider

View file

@ -0,0 +1,6 @@
from .dialog import SwitchAssetDialog
__all__ = (
"SwitchAssetDialog",
)

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,307 @@
from qtpy import QtWidgets, QtCore
import qtawesome
from openpype.tools.utils import (
PlaceholderLineEdit,
BaseClickableFrame,
set_style_property,
)
from openpype.tools.ayon_utils.widgets import FoldersWidget
NOT_SET = object()
class ClickableLineEdit(QtWidgets.QLineEdit):
"""QLineEdit capturing left mouse click.
Triggers `clicked` signal on mouse click.
"""
clicked = QtCore.Signal()
def __init__(self, *args, **kwargs):
super(ClickableLineEdit, self).__init__(*args, **kwargs)
self.setReadOnly(True)
self._mouse_pressed = False
def mousePressEvent(self, event):
if event.button() == QtCore.Qt.LeftButton:
self._mouse_pressed = True
event.accept()
def mouseMoveEvent(self, event):
event.accept()
def mouseReleaseEvent(self, event):
if self._mouse_pressed:
self._mouse_pressed = False
if self.rect().contains(event.pos()):
self.clicked.emit()
event.accept()
def mouseDoubleClickEvent(self, event):
event.accept()
class ControllerWrap:
def __init__(self, controller):
self._controller = controller
self._selected_folder_id = None
def emit_event(self, *args, **kwargs):
self._controller.emit_event(*args, **kwargs)
def register_event_callback(self, *args, **kwargs):
self._controller.register_event_callback(*args, **kwargs)
def get_current_project_name(self):
return self._controller.get_current_project_name()
def get_folder_items(self, *args, **kwargs):
return self._controller.get_folder_items(*args, **kwargs)
def set_selected_folder(self, folder_id):
self._selected_folder_id = folder_id
def get_selected_folder_id(self):
return self._selected_folder_id
class FoldersDialog(QtWidgets.QDialog):
"""Dialog to select asset for a context of instance."""
def __init__(self, controller, parent):
super(FoldersDialog, self).__init__(parent)
self.setWindowTitle("Select folder")
filter_input = PlaceholderLineEdit(self)
filter_input.setPlaceholderText("Filter folders..")
controller_wrap = ControllerWrap(controller)
folders_widget = FoldersWidget(controller_wrap, self)
folders_widget.set_deselectable(True)
ok_btn = QtWidgets.QPushButton("OK", self)
cancel_btn = QtWidgets.QPushButton("Cancel", self)
btns_layout = QtWidgets.QHBoxLayout()
btns_layout.addStretch(1)
btns_layout.addWidget(ok_btn)
btns_layout.addWidget(cancel_btn)
layout = QtWidgets.QVBoxLayout(self)
layout.addWidget(filter_input, 0)
layout.addWidget(folders_widget, 1)
layout.addLayout(btns_layout, 0)
folders_widget.double_clicked.connect(self._on_ok_clicked)
folders_widget.refreshed.connect(self._on_folders_refresh)
filter_input.textChanged.connect(self._on_filter_change)
ok_btn.clicked.connect(self._on_ok_clicked)
cancel_btn.clicked.connect(self._on_cancel_clicked)
self._filter_input = filter_input
self._ok_btn = ok_btn
self._cancel_btn = cancel_btn
self._folders_widget = folders_widget
self._controller_wrap = controller_wrap
# Set selected folder only when user confirms the dialog
self._selected_folder_id = None
self._selected_folder_label = None
self._folder_id_to_select = NOT_SET
self._first_show = True
self._default_height = 500
def showEvent(self, event):
"""Refresh asset model on show."""
super(FoldersDialog, self).showEvent(event)
if self._first_show:
self._first_show = False
self._on_first_show()
def refresh(self):
project_name = self._controller_wrap.get_current_project_name()
self._folders_widget.set_project_name(project_name)
def _on_first_show(self):
center = self.rect().center()
size = self.size()
size.setHeight(self._default_height)
self.resize(size)
new_pos = self.mapToGlobal(center)
new_pos.setX(new_pos.x() - int(self.width() / 2))
new_pos.setY(new_pos.y() - int(self.height() / 2))
self.move(new_pos)
def _on_folders_refresh(self):
if self._folder_id_to_select is NOT_SET:
return
self._folders_widget.set_selected_folder(self._folder_id_to_select)
self._folder_id_to_select = NOT_SET
def _on_filter_change(self, text):
"""Trigger change of filter of folders."""
self._folders_widget.set_name_filter(text)
def _on_cancel_clicked(self):
self.done(0)
def _on_ok_clicked(self):
self._selected_folder_id = (
self._folders_widget.get_selected_folder_id()
)
self._selected_folder_label = (
self._folders_widget.get_selected_folder_label()
)
self.done(1)
def set_selected_folder(self, folder_id):
"""Change preselected folder before showing the dialog.
This also resets model and clean filter.
"""
if (
self._folders_widget.is_refreshing
or self._folders_widget.get_project_name() is None
):
self._folder_id_to_select = folder_id
else:
self._folders_widget.set_selected_folder(folder_id)
def get_selected_folder_id(self):
"""Get selected folder id.
Returns:
Union[str, None]: Selected folder id or None if nothing
is selected.
"""
return self._selected_folder_id
def get_selected_folder_label(self):
return self._selected_folder_label
class FoldersField(BaseClickableFrame):
"""Field where asset name of selected instance/s is showed.
Click on the field will trigger `FoldersDialog`.
"""
value_changed = QtCore.Signal()
def __init__(self, controller, parent):
super(FoldersField, self).__init__(parent)
self.setObjectName("AssetNameInputWidget")
# Don't use 'self' for parent!
# - this widget has specific styles
dialog = FoldersDialog(controller, parent)
name_input = ClickableLineEdit(self)
name_input.setObjectName("AssetNameInput")
icon = qtawesome.icon("fa.window-maximize", color="white")
icon_btn = QtWidgets.QPushButton(self)
icon_btn.setIcon(icon)
icon_btn.setObjectName("AssetNameInputButton")
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
):
w_size_policy = widget.sizePolicy()
w_size_policy.setVerticalPolicy(
QtWidgets.QSizePolicy.MinimumExpanding)
widget.setSizePolicy(w_size_policy)
size_policy = self.sizePolicy()
size_policy.setVerticalPolicy(QtWidgets.QSizePolicy.Maximum)
self.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 = controller
self._dialog = dialog
self._name_input = name_input
self._icon_btn = icon_btn
self._selected_folder_id = None
self._selected_folder_label = None
self._selected_items = []
self._is_valid = True
def refresh(self):
self._dialog.refresh()
def is_valid(self):
"""Is asset valid."""
return self._is_valid
def get_selected_folder_id(self):
"""Selected asset names."""
return self._selected_folder_id
def get_selected_folder_label(self):
return self._selected_folder_label
def set_text(self, text):
"""Set text in text field.
Does not change selected items (assets).
"""
self._name_input.setText(text)
def set_valid(self, is_valid):
state = ""
if not is_valid:
state = "invalid"
self._set_state_property(state)
def set_selected_item(self, folder_id=None, folder_label=None):
"""Set folder for selection.
Args:
folder_id (Optional[str]): Folder id to select.
folder_label (Optional[str]): Folder label.
"""
self._selected_folder_id = folder_id
if not folder_id:
folder_label = None
elif folder_id and not folder_label:
folder_label = self._controller.get_folder_label(folder_id)
self._selected_folder_label = folder_label
self.set_text(folder_label if folder_label else "<folder>")
def _on_dialog_finish(self, result):
if not result:
return
folder_id = self._dialog.get_selected_folder_id()
folder_label = self._dialog.get_selected_folder_label()
self.set_selected_item(folder_id, folder_label)
self.value_changed.emit()
def _mouse_release_callback(self):
self._dialog.set_selected_folder(self._selected_folder_id)
self._dialog.open()
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)

View file

@ -0,0 +1,94 @@
from qtpy import QtWidgets, QtCore
from openpype import style
class ButtonWithMenu(QtWidgets.QToolButton):
def __init__(self, parent=None):
super(ButtonWithMenu, self).__init__(parent)
self.setObjectName("ButtonWithMenu")
self.setPopupMode(QtWidgets.QToolButton.MenuButtonPopup)
menu = QtWidgets.QMenu(self)
self.setMenu(menu)
self._menu = menu
self._actions = []
def menu(self):
return self._menu
def clear_actions(self):
if self._menu is not None:
self._menu.clear()
self._actions = []
def add_action(self, action):
self._actions.append(action)
self._menu.addAction(action)
def _on_action_trigger(self):
action = self.sender()
if action not in self._actions:
return
action.trigger()
class SearchComboBox(QtWidgets.QComboBox):
"""Searchable ComboBox with empty placeholder value as first value"""
def __init__(self, parent):
super(SearchComboBox, self).__init__(parent)
self.setEditable(True)
self.setInsertPolicy(QtWidgets.QComboBox.NoInsert)
combobox_delegate = QtWidgets.QStyledItemDelegate(self)
self.setItemDelegate(combobox_delegate)
completer = self.completer()
completer.setCompletionMode(
QtWidgets.QCompleter.PopupCompletion
)
completer.setCaseSensitivity(QtCore.Qt.CaseInsensitive)
completer_view = completer.popup()
completer_view.setObjectName("CompleterView")
completer_delegate = QtWidgets.QStyledItemDelegate(completer_view)
completer_view.setItemDelegate(completer_delegate)
completer_view.setStyleSheet(style.load_stylesheet())
self._combobox_delegate = combobox_delegate
self._completer_delegate = completer_delegate
self._completer = completer
def set_placeholder(self, placeholder):
self.lineEdit().setPlaceholderText(placeholder)
def populate(self, items):
self.clear()
self.addItems([""]) # ensure first item is placeholder
self.addItems(items)
def get_valid_value(self):
"""Return the current text if it's a valid value else None
Note: The empty placeholder value is valid and returns as ""
"""
text = self.currentText()
lookup = set(self.itemText(i) for i in range(self.count()))
if text not in lookup:
return None
return text or None
def set_valid_value(self, value):
"""Try to locate 'value' and pre-select it in dropdown."""
index = self.findText(value)
if index > -1:
self.setCurrentIndex(index)

View file

@ -0,0 +1,825 @@
import uuid
import collections
import logging
import itertools
from functools import partial
from qtpy import QtWidgets, QtCore
import qtawesome
from openpype.client import (
get_version_by_id,
get_versions,
get_hero_versions,
get_representation_by_id,
get_representations,
)
from openpype import style
from openpype.pipeline import (
HeroVersionType,
update_container,
remove_container,
discover_inventory_actions,
)
from openpype.tools.utils.lib import (
iter_model_rows,
format_version
)
from .switch_dialog import SwitchAssetDialog
from .model import InventoryModel
DEFAULT_COLOR = "#fb9c15"
log = logging.getLogger("SceneInventory")
class SceneInventoryView(QtWidgets.QTreeView):
data_changed = QtCore.Signal()
hierarchy_view_changed = QtCore.Signal(bool)
def __init__(self, controller, parent):
super(SceneInventoryView, self).__init__(parent=parent)
# view settings
self.setIndentation(12)
self.setAlternatingRowColors(True)
self.setSortingEnabled(True)
self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
self.customContextMenuRequested.connect(self._show_right_mouse_menu)
self._hierarchy_view = False
self._selected = None
self._controller = controller
def _set_hierarchy_view(self, enabled):
if enabled == self._hierarchy_view:
return
self._hierarchy_view = enabled
self.hierarchy_view_changed.emit(enabled)
def _enter_hierarchy(self, items):
self._selected = set(i["objectName"] for i in items)
self._set_hierarchy_view(True)
self.data_changed.emit()
self.expandToDepth(1)
self.setStyleSheet("""
QTreeView {
border-color: #fb9c15;
}
""")
def _leave_hierarchy(self):
self._set_hierarchy_view(False)
self.data_changed.emit()
self.setStyleSheet("QTreeView {}")
def _build_item_menu_for_selection(self, items, menu):
# Exclude items that are "NOT FOUND" since setting versions, updating
# and removal won't work for those items.
items = [item for item in items if not item.get("isNotFound")]
if not items:
return
# An item might not have a representation, for example when an item
# is listed as "NOT FOUND"
repre_ids = set()
for item in items:
repre_id = item["representation"]
try:
uuid.UUID(repre_id)
repre_ids.add(repre_id)
except ValueError:
pass
project_name = self._controller.get_current_project_name()
repre_docs = get_representations(
project_name, representation_ids=repre_ids, fields=["parent"]
)
version_ids = {
repre_doc["parent"]
for repre_doc in repre_docs
}
loaded_versions = get_versions(
project_name, version_ids=version_ids, hero=True
)
loaded_hero_versions = []
versions_by_parent_id = collections.defaultdict(list)
subset_ids = set()
for version in loaded_versions:
if version["type"] == "hero_version":
loaded_hero_versions.append(version)
else:
parent_id = version["parent"]
versions_by_parent_id[parent_id].append(version)
subset_ids.add(parent_id)
all_versions = get_versions(
project_name, subset_ids=subset_ids, hero=True
)
hero_versions = []
versions = []
for version in all_versions:
if version["type"] == "hero_version":
hero_versions.append(version)
else:
versions.append(version)
has_loaded_hero_versions = len(loaded_hero_versions) > 0
has_available_hero_version = len(hero_versions) > 0
has_outdated = False
for version in versions:
parent_id = version["parent"]
current_versions = versions_by_parent_id[parent_id]
for current_version in current_versions:
if current_version["name"] < version["name"]:
has_outdated = True
break
if has_outdated:
break
switch_to_versioned = None
if has_loaded_hero_versions:
def _on_switch_to_versioned(items):
repre_ids = {
item["representation"]
for item in items
}
repre_docs = get_representations(
project_name,
representation_ids=repre_ids,
fields=["parent"]
)
version_ids = set()
version_id_by_repre_id = {}
for repre_doc in repre_docs:
version_id = repre_doc["parent"]
repre_id = str(repre_doc["_id"])
version_id_by_repre_id[repre_id] = version_id
version_ids.add(version_id)
hero_versions = get_hero_versions(
project_name,
version_ids=version_ids,
fields=["version_id"]
)
hero_src_version_ids = set()
for hero_version in hero_versions:
version_id = hero_version["version_id"]
hero_src_version_ids.add(version_id)
hero_version_id = hero_version["_id"]
for _repre_id, current_version_id in (
version_id_by_repre_id.items()
):
if current_version_id == hero_version_id:
version_id_by_repre_id[_repre_id] = version_id
version_docs = get_versions(
project_name,
version_ids=hero_src_version_ids,
fields=["name"]
)
version_name_by_id = {}
for version_doc in version_docs:
version_name_by_id[version_doc["_id"]] = \
version_doc["name"]
# Specify version per item to update to
update_items = []
update_versions = []
for item in items:
repre_id = item["representation"]
version_id = version_id_by_repre_id.get(repre_id)
version_name = version_name_by_id.get(version_id)
if version_name is not None:
update_items.append(item)
update_versions.append(version_name)
self._update_containers(update_items, update_versions)
update_icon = qtawesome.icon(
"fa.asterisk",
color=DEFAULT_COLOR
)
switch_to_versioned = QtWidgets.QAction(
update_icon,
"Switch to versioned",
menu
)
switch_to_versioned.triggered.connect(
lambda: _on_switch_to_versioned(items)
)
update_to_latest_action = None
if has_outdated or has_loaded_hero_versions:
update_icon = qtawesome.icon(
"fa.angle-double-up",
color=DEFAULT_COLOR
)
update_to_latest_action = QtWidgets.QAction(
update_icon,
"Update to latest",
menu
)
update_to_latest_action.triggered.connect(
lambda: self._update_containers(items, version=-1)
)
change_to_hero = None
if has_available_hero_version:
# TODO change icon
change_icon = qtawesome.icon(
"fa.asterisk",
color="#00b359"
)
change_to_hero = QtWidgets.QAction(
change_icon,
"Change to hero",
menu
)
change_to_hero.triggered.connect(
lambda: self._update_containers(items,
version=HeroVersionType(-1))
)
# set version
set_version_icon = qtawesome.icon("fa.hashtag", color=DEFAULT_COLOR)
set_version_action = QtWidgets.QAction(
set_version_icon,
"Set version",
menu
)
set_version_action.triggered.connect(
lambda: self._show_version_dialog(items))
# switch folder
switch_folder_icon = qtawesome.icon("fa.sitemap", color=DEFAULT_COLOR)
switch_folder_action = QtWidgets.QAction(
switch_folder_icon,
"Switch Folder",
menu
)
switch_folder_action.triggered.connect(
lambda: self._show_switch_dialog(items))
# remove
remove_icon = qtawesome.icon("fa.remove", color=DEFAULT_COLOR)
remove_action = QtWidgets.QAction(remove_icon, "Remove items", menu)
remove_action.triggered.connect(
lambda: self._show_remove_warning_dialog(items))
# add the actions
if switch_to_versioned:
menu.addAction(switch_to_versioned)
if update_to_latest_action:
menu.addAction(update_to_latest_action)
if change_to_hero:
menu.addAction(change_to_hero)
menu.addAction(set_version_action)
menu.addAction(switch_folder_action)
menu.addSeparator()
menu.addAction(remove_action)
self._handle_sync_server(menu, repre_ids)
def _handle_sync_server(self, menu, repre_ids):
"""Adds actions for download/upload when SyncServer is enabled
Args:
menu (OptionMenu)
repre_ids (list) of object_ids
Returns:
(OptionMenu)
"""
if not self._controller.is_sync_server_enabled():
return
menu.addSeparator()
download_icon = qtawesome.icon("fa.download", color=DEFAULT_COLOR)
download_active_action = QtWidgets.QAction(
download_icon,
"Download",
menu
)
download_active_action.triggered.connect(
lambda: self._add_sites(repre_ids, "active_site"))
upload_icon = qtawesome.icon("fa.upload", color=DEFAULT_COLOR)
upload_remote_action = QtWidgets.QAction(
upload_icon,
"Upload",
menu
)
upload_remote_action.triggered.connect(
lambda: self._add_sites(repre_ids, "remote_site"))
menu.addAction(download_active_action)
menu.addAction(upload_remote_action)
def _add_sites(self, repre_ids, site_type):
"""(Re)sync all 'repre_ids' to specific site.
It checks if opposite site has fully available content to limit
accidents. (ReSync active when no remote >> losing active content)
Args:
repre_ids (list)
site_type (Literal[active_site, remote_site]): Site type.
"""
self._controller.resync_representations(repre_ids, site_type)
self.data_changed.emit()
def _build_item_menu(self, items=None):
"""Create menu for the selected items"""
if not items:
items = []
menu = QtWidgets.QMenu(self)
# add the actions
self._build_item_menu_for_selection(items, menu)
# These two actions should be able to work without selection
# expand all items
expandall_action = QtWidgets.QAction(menu, text="Expand all items")
expandall_action.triggered.connect(self.expandAll)
# collapse all items
collapse_action = QtWidgets.QAction(menu, text="Collapse all items")
collapse_action.triggered.connect(self.collapseAll)
menu.addAction(expandall_action)
menu.addAction(collapse_action)
custom_actions = self._get_custom_actions(containers=items)
if custom_actions:
submenu = QtWidgets.QMenu("Actions", self)
for action in custom_actions:
color = action.color or DEFAULT_COLOR
icon = qtawesome.icon("fa.%s" % action.icon, color=color)
action_item = QtWidgets.QAction(icon, action.label, submenu)
action_item.triggered.connect(
partial(self._process_custom_action, action, items))
submenu.addAction(action_item)
menu.addMenu(submenu)
# go back to flat view
back_to_flat_action = None
if self._hierarchy_view:
back_to_flat_icon = qtawesome.icon("fa.list", color=DEFAULT_COLOR)
back_to_flat_action = QtWidgets.QAction(
back_to_flat_icon,
"Back to Full-View",
menu
)
back_to_flat_action.triggered.connect(self._leave_hierarchy)
# send items to hierarchy view
enter_hierarchy_icon = qtawesome.icon("fa.indent", color="#d8d8d8")
enter_hierarchy_action = QtWidgets.QAction(
enter_hierarchy_icon,
"Cherry-Pick (Hierarchy)",
menu
)
enter_hierarchy_action.triggered.connect(
lambda: self._enter_hierarchy(items))
if items:
menu.addAction(enter_hierarchy_action)
if back_to_flat_action is not None:
menu.addAction(back_to_flat_action)
return menu
def _get_custom_actions(self, containers):
"""Get the registered Inventory Actions
Args:
containers(list): collection of containers
Returns:
list: collection of filter and initialized actions
"""
def sorter(Plugin):
"""Sort based on order attribute of the plugin"""
return Plugin.order
# Fedd an empty dict if no selection, this will ensure the compat
# lookup always work, so plugin can interact with Scene Inventory
# reversely.
containers = containers or [dict()]
# Check which action will be available in the menu
Plugins = discover_inventory_actions()
compatible = [p() for p in Plugins if
any(p.is_compatible(c) for c in containers)]
return sorted(compatible, key=sorter)
def _process_custom_action(self, action, containers):
"""Run action and if results are returned positive update the view
If the result is list or dict, will select view items by the result.
Args:
action (InventoryAction): Inventory Action instance
containers (list): Data of currently selected items
Returns:
None
"""
result = action.process(containers)
if result:
self.data_changed.emit()
if isinstance(result, (list, set)):
self._select_items_by_action(result)
if isinstance(result, dict):
self._select_items_by_action(
result["objectNames"], result["options"]
)
def _select_items_by_action(self, object_names, options=None):
"""Select view items by the result of action
Args:
object_names (list or set): A list/set of container object name
options (dict): GUI operation options.
Returns:
None
"""
options = options or dict()
if options.get("clear", True):
self.clearSelection()
object_names = set(object_names)
if (
self._hierarchy_view
and not self._selected.issuperset(object_names)
):
# If any container not in current cherry-picked view, update
# view before selecting them.
self._selected.update(object_names)
self.data_changed.emit()
model = self.model()
selection_model = self.selectionModel()
select_mode = {
"select": QtCore.QItemSelectionModel.Select,
"deselect": QtCore.QItemSelectionModel.Deselect,
"toggle": QtCore.QItemSelectionModel.Toggle,
}[options.get("mode", "select")]
for index in iter_model_rows(model, 0):
item = index.data(InventoryModel.ItemRole)
if item.get("isGroupNode"):
continue
name = item.get("objectName")
if name in object_names:
self.scrollTo(index) # Ensure item is visible
flags = select_mode | QtCore.QItemSelectionModel.Rows
selection_model.select(index, flags)
object_names.remove(name)
if len(object_names) == 0:
break
def _show_right_mouse_menu(self, pos):
"""Display the menu when at the position of the item clicked"""
globalpos = self.viewport().mapToGlobal(pos)
if not self.selectionModel().hasSelection():
print("No selection")
# Build menu without selection, feed an empty list
menu = self._build_item_menu()
menu.exec_(globalpos)
return
active = self.currentIndex() # index under mouse
active = active.sibling(active.row(), 0) # get first column
# move index under mouse
indices = self.get_indices()
if active in indices:
indices.remove(active)
indices.append(active)
# Extend to the sub-items
all_indices = self._extend_to_children(indices)
items = [dict(i.data(InventoryModel.ItemRole)) for i in all_indices
if i.parent().isValid()]
if self._hierarchy_view:
# Ensure no group item
items = [n for n in items if not n.get("isGroupNode")]
menu = self._build_item_menu(items)
menu.exec_(globalpos)
def get_indices(self):
"""Get the selected rows"""
selection_model = self.selectionModel()
return selection_model.selectedRows()
def _extend_to_children(self, indices):
"""Extend the indices to the children indices.
Top-level indices are extended to its children indices. Sub-items
are kept as is.
Args:
indices (list): The indices to extend.
Returns:
list: The children indices
"""
def get_children(i):
model = i.model()
rows = model.rowCount(parent=i)
for row in range(rows):
child = model.index(row, 0, parent=i)
yield child
subitems = set()
for i in indices:
valid_parent = i.parent().isValid()
if valid_parent and i not in subitems:
subitems.add(i)
if self._hierarchy_view:
# Assume this is a group item
for child in get_children(i):
subitems.add(child)
else:
# is top level item
for child in get_children(i):
subitems.add(child)
return list(subitems)
def _show_version_dialog(self, items):
"""Create a dialog with the available versions for the selected file
Args:
items (list): list of items to run the "set_version" for
Returns:
None
"""
active = items[-1]
project_name = self._controller.get_current_project_name()
# Get available versions for active representation
repre_doc = get_representation_by_id(
project_name,
active["representation"],
fields=["parent"]
)
repre_version_doc = get_version_by_id(
project_name,
repre_doc["parent"],
fields=["parent"]
)
version_docs = list(get_versions(
project_name,
subset_ids=[repre_version_doc["parent"]],
hero=True
))
hero_version = None
standard_versions = []
for version_doc in version_docs:
if version_doc["type"] == "hero_version":
hero_version = version_doc
else:
standard_versions.append(version_doc)
versions = list(reversed(
sorted(standard_versions, key=lambda item: item["name"])
))
if hero_version:
_version_id = hero_version["version_id"]
for _version in versions:
if _version["_id"] != _version_id:
continue
hero_version["name"] = HeroVersionType(
_version["name"]
)
hero_version["data"] = _version["data"]
break
# Get index among the listed versions
current_item = None
current_version = active["version"]
if isinstance(current_version, HeroVersionType):
current_item = hero_version
else:
for version in versions:
if version["name"] == current_version:
current_item = version
break
all_versions = []
if hero_version:
all_versions.append(hero_version)
all_versions.extend(versions)
if current_item:
index = all_versions.index(current_item)
else:
index = 0
versions_by_label = dict()
labels = []
for version in all_versions:
is_hero = version["type"] == "hero_version"
label = format_version(version["name"], is_hero)
labels.append(label)
versions_by_label[label] = version["name"]
label, state = QtWidgets.QInputDialog.getItem(
self,
"Set version..",
"Set version number to",
labels,
current=index,
editable=False
)
if not state:
return
if label:
version = versions_by_label[label]
self._update_containers(items, version)
def _show_switch_dialog(self, items):
"""Display Switch dialog"""
dialog = SwitchAssetDialog(self._controller, self, items)
dialog.switched.connect(self.data_changed.emit)
dialog.show()
def _show_remove_warning_dialog(self, items):
"""Prompt a dialog to inform the user the action will remove items"""
accept = QtWidgets.QMessageBox.Ok
buttons = accept | QtWidgets.QMessageBox.Cancel
state = QtWidgets.QMessageBox.question(
self,
"Are you sure?",
"Are you sure you want to remove {} item(s)".format(len(items)),
buttons=buttons,
defaultButton=accept
)
if state != accept:
return
for item in items:
remove_container(item)
self.data_changed.emit()
def _show_version_error_dialog(self, version, items):
"""Shows QMessageBox when version switch doesn't work
Args:
version: str or int or None
"""
if version == -1:
version_str = "latest"
elif isinstance(version, HeroVersionType):
version_str = "hero"
elif isinstance(version, int):
version_str = "v{:03d}".format(version)
else:
version_str = version
dialog = QtWidgets.QMessageBox(self)
dialog.setIcon(QtWidgets.QMessageBox.Warning)
dialog.setStyleSheet(style.load_stylesheet())
dialog.setWindowTitle("Update failed")
switch_btn = dialog.addButton(
"Switch Folder",
QtWidgets.QMessageBox.ActionRole
)
switch_btn.clicked.connect(lambda: self._show_switch_dialog(items))
dialog.addButton(QtWidgets.QMessageBox.Cancel)
msg = (
"Version update to '{}' failed as representation doesn't exist."
"\n\nPlease update to version with a valid representation"
" OR \n use 'Switch Folder' button to change folder."
).format(version_str)
dialog.setText(msg)
dialog.exec_()
def update_all(self):
"""Update all items that are currently 'outdated' in the view"""
# Get the source model through the proxy model
model = self.model().sourceModel()
# Get all items from outdated groups
outdated_items = []
for index in iter_model_rows(model,
column=0,
include_root=False):
item = index.data(model.ItemRole)
if not item.get("isGroupNode"):
continue
# Only the group nodes contain the "highest_version" data and as
# such we find only the groups and take its children.
if not model.outdated(item):
continue
# Collect all children which we want to update
children = item.children()
outdated_items.extend(children)
if not outdated_items:
log.info("Nothing to update.")
return
# Trigger update to latest
self._update_containers(outdated_items, version=-1)
def _update_containers(self, items, version):
"""Helper to update items to given version (or version per item)
If at least one item is specified this will always try to refresh
the inventory even if errors occurred on any of the items.
Arguments:
items (list): Items to update
version (int or list): Version to set to.
This can be a list specifying a version for each item.
Like `update_container` version -1 sets the latest version
and HeroTypeVersion instances set the hero version.
"""
if isinstance(version, (list, tuple)):
# We allow a unique version to be specified per item. In that case
# the length must match with the items
assert len(items) == len(version), (
"Number of items mismatches number of versions: "
"{} items - {} versions".format(len(items), len(version))
)
versions = version
else:
# Repeat the same version infinitely
versions = itertools.repeat(version)
# Trigger update to latest
try:
for item, item_version in zip(items, versions):
try:
update_container(item, item_version)
except AssertionError:
self._show_version_error_dialog(item_version, [item])
log.warning("Update failed", exc_info=True)
finally:
# Always update the scene inventory view, even if errors occurred
self.data_changed.emit()

View file

@ -0,0 +1,200 @@
from qtpy import QtWidgets, QtCore, QtGui
import qtawesome
from openpype import style, resources
from openpype.tools.utils.delegates import VersionDelegate
from openpype.tools.utils.lib import (
preserve_expanded_rows,
preserve_selection,
)
from openpype.tools.ayon_sceneinventory import SceneInventoryController
from .model import (
InventoryModel,
FilterProxyModel
)
from .view import SceneInventoryView
class ControllerVersionDelegate(VersionDelegate):
"""Version delegate that uses controller to get project.
Original VersionDelegate is using 'AvalonMongoDB' object instead. Don't
worry about the variable name, object is stored to '_dbcon' attribute.
"""
def get_project_name(self):
self._dbcon.get_current_project_name()
class SceneInventoryWindow(QtWidgets.QDialog):
"""Scene Inventory window"""
def __init__(self, controller=None, parent=None):
super(SceneInventoryWindow, self).__init__(parent)
if controller is None:
controller = SceneInventoryController()
project_name = controller.get_current_project_name()
icon = QtGui.QIcon(resources.get_openpype_icon_filepath())
self.setWindowIcon(icon)
self.setWindowTitle("Scene Inventory - {}".format(project_name))
self.setObjectName("SceneInventory")
self.resize(1100, 480)
# region control
filter_label = QtWidgets.QLabel("Search", self)
text_filter = QtWidgets.QLineEdit(self)
outdated_only_checkbox = QtWidgets.QCheckBox(
"Filter to outdated", self
)
outdated_only_checkbox.setToolTip("Show outdated files only")
outdated_only_checkbox.setChecked(False)
icon = qtawesome.icon("fa.arrow-up", color="white")
update_all_button = QtWidgets.QPushButton(self)
update_all_button.setToolTip("Update all outdated to latest version")
update_all_button.setIcon(icon)
icon = qtawesome.icon("fa.refresh", color="white")
refresh_button = QtWidgets.QPushButton(self)
refresh_button.setToolTip("Refresh")
refresh_button.setIcon(icon)
control_layout = QtWidgets.QHBoxLayout()
control_layout.addWidget(filter_label)
control_layout.addWidget(text_filter)
control_layout.addWidget(outdated_only_checkbox)
control_layout.addWidget(update_all_button)
control_layout.addWidget(refresh_button)
model = InventoryModel(controller)
proxy = FilterProxyModel()
proxy.setSourceModel(model)
proxy.setDynamicSortFilter(True)
proxy.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive)
view = SceneInventoryView(controller, self)
view.setModel(proxy)
sync_enabled = controller.is_sync_server_enabled()
view.setColumnHidden(model.active_site_col, not sync_enabled)
view.setColumnHidden(model.remote_site_col, not sync_enabled)
# set some nice default widths for the view
view.setColumnWidth(0, 250) # name
view.setColumnWidth(1, 55) # version
view.setColumnWidth(2, 55) # count
view.setColumnWidth(3, 150) # family
view.setColumnWidth(4, 120) # group
view.setColumnWidth(5, 150) # loader
# apply delegates
version_delegate = ControllerVersionDelegate(controller, self)
column = model.Columns.index("version")
view.setItemDelegateForColumn(column, version_delegate)
layout = QtWidgets.QVBoxLayout(self)
layout.addLayout(control_layout)
layout.addWidget(view)
show_timer = QtCore.QTimer()
show_timer.setInterval(0)
show_timer.setSingleShot(False)
# signals
show_timer.timeout.connect(self._on_show_timer)
text_filter.textChanged.connect(self._on_text_filter_change)
outdated_only_checkbox.stateChanged.connect(
self._on_outdated_state_change
)
view.hierarchy_view_changed.connect(
self._on_hierarchy_view_change
)
view.data_changed.connect(self._on_refresh_request)
refresh_button.clicked.connect(self._on_refresh_request)
update_all_button.clicked.connect(self._on_update_all)
self._show_timer = show_timer
self._show_counter = 0
self._controller = controller
self._update_all_button = update_all_button
self._outdated_only_checkbox = outdated_only_checkbox
self._view = view
self._model = model
self._proxy = proxy
self._version_delegate = version_delegate
self._first_show = True
self._first_refresh = True
def showEvent(self, event):
super(SceneInventoryWindow, self).showEvent(event)
if self._first_show:
self._first_show = False
self.setStyleSheet(style.load_stylesheet())
self._show_counter = 0
self._show_timer.start()
def keyPressEvent(self, event):
"""Custom keyPressEvent.
Override keyPressEvent to do nothing so that Maya's panels won't
take focus when pressing "SHIFT" whilst mouse is over viewport or
outliner. This way users don't accidentally perform Maya commands
whilst trying to name an instance.
"""
def _on_refresh_request(self):
"""Signal callback to trigger 'refresh' without any arguments."""
self.refresh()
def refresh(self, containers=None):
self._first_refresh = False
self._controller.reset()
with preserve_expanded_rows(
tree_view=self._view,
role=self._model.UniqueRole
):
with preserve_selection(
tree_view=self._view,
role=self._model.UniqueRole,
current_index=False
):
kwargs = {"containers": containers}
# TODO do not touch view's inner attribute
if self._view._hierarchy_view:
kwargs["selected"] = self._view._selected
self._model.refresh(**kwargs)
def _on_show_timer(self):
if self._show_counter < 3:
self._show_counter += 1
return
self._show_timer.stop()
self.refresh()
def _on_hierarchy_view_change(self, enabled):
self._proxy.set_hierarchy_view(enabled)
self._model.set_hierarchy_view(enabled)
def _on_text_filter_change(self, text_filter):
if hasattr(self._proxy, "setFilterRegExp"):
self._proxy.setFilterRegExp(text_filter)
else:
self._proxy.setFilterRegularExpression(text_filter)
def _on_outdated_state_change(self):
self._proxy.set_filter_outdated(
self._outdated_only_checkbox.isChecked()
)
def _on_update_all(self):
self._view.update_all()

View file

@ -24,9 +24,12 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate):
lock = False
def __init__(self, dbcon, *args, **kwargs):
self.dbcon = dbcon
self._dbcon = dbcon
super(VersionDelegate, self).__init__(*args, **kwargs)
def get_project_name(self):
return self._dbcon.active_project()
def displayText(self, value, locale):
if isinstance(value, HeroVersionType):
return lib.format_version(value, True)
@ -120,7 +123,7 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate):
"Version is not integer"
)
project_name = self.dbcon.active_project()
project_name = self.get_project_name()
# Add all available versions to the editor
parent_id = item["version_document"]["parent"]
version_docs = [

View file

@ -171,14 +171,23 @@ class HostToolsHelper:
def get_scene_inventory_tool(self, parent):
"""Create, cache and return scene inventory tool window."""
if self._scene_inventory_tool is None:
from openpype.tools.sceneinventory import SceneInventoryWindow
host = registered_host()
ILoadHost.validate_load_methods(host)
scene_inventory_window = SceneInventoryWindow(
parent=parent or self._parent
)
if AYON_SERVER_ENABLED:
from openpype.tools.ayon_sceneinventory.window import (
SceneInventoryWindow)
scene_inventory_window = SceneInventoryWindow(
parent=parent or self._parent
)
else:
from openpype.tools.sceneinventory import SceneInventoryWindow
scene_inventory_window = SceneInventoryWindow(
parent=parent or self._parent
)
self._scene_inventory_tool = scene_inventory_window
return self._scene_inventory_tool

View file

@ -236,7 +236,7 @@ class PublishPuginsModel(BaseSettingsModel):
default_factory=CollectInstanceDataModel,
section="Collectors"
)
ValidateCorrectAssetName: OptionalPluginModel = Field(
ValidateCorrectAssetContext: OptionalPluginModel = Field(
title="Validate Correct Folder Name",
default_factory=OptionalPluginModel,
section="Validators"
@ -308,7 +308,7 @@ DEFAULT_PUBLISH_PLUGIN_SETTINGS = {
"write"
]
},
"ValidateCorrectAssetName": {
"ValidateCorrectAssetContext": {
"enabled": True,
"optional": True,
"active": True