[Automated] Merged develop into main

This commit is contained in:
ynbot 2023-10-14 05:23:51 +02:00 committed by GitHub
commit e54a4e11d7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
66 changed files with 4366 additions and 432 deletions

View file

@ -35,6 +35,7 @@ body:
label: Version
description: What version are you running? Look to OpenPype Tray
options:
- 3.17.2
- 3.17.2-nightly.4
- 3.17.2-nightly.3
- 3.17.2-nightly.2
@ -134,7 +135,6 @@ body:
- 3.14.11-nightly.2
- 3.14.11-nightly.1
- 3.14.10
- 3.14.10-nightly.9
validations:
required: true
- type: dropdown

View file

@ -460,36 +460,6 @@ def ls() -> Iterator:
yield parse_container(container)
def update_hierarchy(containers):
"""Hierarchical container support
This is the function to support Scene Inventory to draw hierarchical
view for containers.
We need both parent and children to visualize the graph.
"""
all_containers = set(ls()) # lookup set
for container in containers:
# Find parent
# FIXME (jasperge): re-evaluate this. How would it be possible
# to 'nest' assets? Collections can have several parents, for
# now assume it has only 1 parent
parent = [
coll for coll in bpy.data.collections if container in coll.children
]
for node in parent:
if node in all_containers:
container["parent"] = node
break
log.debug("Container: %s", container)
yield container
def publish():
"""Shorthand to publish from within host."""

View file

@ -165,7 +165,8 @@ class CreateSaver(NewCreator):
filepath = self.temp_rendering_path_template.format(
**formatting_data)
tool["Clip"] = os.path.normpath(filepath)
comp = get_current_comp()
tool["Clip"] = comp.ReverseMapPath(os.path.normpath(filepath))
# Rename tool
if tool.Name != subset:

View file

@ -161,7 +161,7 @@ class FusionLoadSequence(load.LoaderPlugin):
with comp_lock_and_undo_chunk(comp, "Create Loader"):
args = (-32768, -32768)
tool = comp.AddTool("Loader", *args)
tool["Clip"] = path
tool["Clip"] = comp.ReverseMapPath(path)
# Set global in point to start frame (if in version.data)
start = self._get_start(context["version"], tool)
@ -244,7 +244,7 @@ class FusionLoadSequence(load.LoaderPlugin):
"TimeCodeOffset",
),
):
tool["Clip"] = path
tool["Clip"] = comp.ReverseMapPath(path)
# Set the global in to the start frame of the sequence
global_in_changed = loader_shift(tool, start, relative=False)

View file

@ -145,9 +145,11 @@ class CollectFusionRender(
start = render_instance.frameStart - render_instance.handleStart
end = render_instance.frameEnd + render_instance.handleEnd
path = (
render_instance.tool["Clip"]
[render_instance.workfileComp.TIME_UNDEFINED]
comp = render_instance.workfileComp
path = comp.MapPath(
render_instance.tool["Clip"][
render_instance.workfileComp.TIME_UNDEFINED
]
)
output_dir = os.path.dirname(path)
render_instance.outputDir = output_dir

View file

@ -1,21 +1,24 @@
# -*- coding: utf-8 -*-
import pyblish.api
from openpype.pipeline import PublishValidationError
from pymxs import runtime as rt
class ValidateMaxContents(pyblish.api.InstancePlugin):
"""Validates Max contents.
class ValidateInstanceHasMembers(pyblish.api.InstancePlugin):
"""Validates Instance has members.
Check if MaxScene container includes any contents underneath.
Check if MaxScene containers includes any contents underneath.
"""
order = pyblish.api.ValidatorOrder
families = ["camera",
"model",
"maxScene",
"review"]
"review",
"pointcache",
"pointcloud",
"redshiftproxy"]
hosts = ["max"]
label = "Max Scene Contents"
label = "Container Contents"
def process(self, instance):
if not instance.data["members"]:

View file

@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
"""Maya look extractor."""
import sys
from abc import ABCMeta, abstractmethod
from collections import OrderedDict
import contextlib
@ -176,6 +177,24 @@ class MakeRSTexBin(TextureProcessor):
source
]
# if color management is enabled we pass color space information
if color_management["enabled"]:
config_path = color_management["config"]
if not os.path.exists(config_path):
raise RuntimeError("OCIO config not found at: "
"{}".format(config_path))
if not os.getenv("OCIO"):
self.log.debug(
"OCIO environment variable not set."
"Setting it with OCIO config from Maya."
)
os.environ["OCIO"] = config_path
self.log.debug("converting colorspace {0} to redshift render "
"colorspace".format(colorspace))
subprocess_args.extend(["-cs", colorspace])
hash_args = ["rstex"]
texture_hash = source_hash(source, *hash_args)
@ -186,11 +205,11 @@ class MakeRSTexBin(TextureProcessor):
self.log.debug(" ".join(subprocess_args))
try:
run_subprocess(subprocess_args)
run_subprocess(subprocess_args, logger=self.log)
except Exception:
self.log.error("Texture .rstexbin conversion failed",
exc_info=True)
raise
six.reraise(*sys.exc_info())
return TextureResult(
path=destination,
@ -472,7 +491,7 @@ class ExtractLook(publish.Extractor):
"rstex": MakeRSTexBin
}.items():
if instance.data.get(key, False):
processor = Processor()
processor = Processor(log=self.log)
processor.apply_settings(context.data["system_settings"],
context.data["project_settings"])
processors.append(processor)

View file

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

View file

@ -20,33 +20,58 @@ 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 invalid nodes for the plug-ins
self.log.info("Finding invalid nodes..")
invalid = list()
invalid = set()
for instance in errored_instances:
invalid_nodes = plugin.get_invalid(instance)
if invalid_nodes:
if isinstance(invalid_nodes, (list, tuple)):
invalid.append(invalid_nodes[0])
invalid.update(invalid_nodes)
else:
self.log.warning("Plug-in returned to be invalid, "
"but has no selectable nodes.")
# Ensure unique (process each node only once)
invalid = list(set(invalid))
if invalid:
self.log.info("Selecting invalid nodes: {}".format(invalid))
reset_selection()
select_nodes(invalid)
else:
self.log.info("No invalid nodes found.")
class SelectInstanceNodeAction(pyblish.api.Action):
"""Select instance node for failed plugin."""
label = "Select instance node"
on = "failed" # This action is only available on a failed plug-in
icon = "mdi.cursor-default-click"
def process(self, context, 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 instance nodes..")
nodes = set()
for instance in errored_instances:
instance_node = instance.data.get("transientData", {}).get("node")
if not instance_node:
raise RuntimeError(
"No transientData['node'] found on instance: {}".format(
instance
)
)
nodes.add(instance_node)
if nodes:
self.log.info("Selecting instance nodes: {}".format(nodes))
reset_selection()
select_nodes(nodes)
else:
self.log.info("No instance nodes found.")

View file

@ -2833,9 +2833,10 @@ def select_nodes(nodes):
"""Selects all inputted nodes
Arguments:
nodes (list): nuke nodes to be selected
nodes (Union[list, tuple, set]): nuke nodes to be selected
"""
assert isinstance(nodes, (list, tuple)), "nodes has to be list or tuple"
assert isinstance(nodes, (list, tuple, set)), \
"nodes has to be list, tuple or set"
for node in nodes:
node["selected"].setValue(True)

View file

@ -57,4 +57,4 @@ class CollectBackdrops(pyblish.api.InstancePlugin):
if version:
instance.data['version'] = version
self.log.info("Backdrop instance collected: `{}`".format(instance))
self.log.debug("Backdrop instance collected: `{}`".format(instance))

View file

@ -64,4 +64,4 @@ class CollectContextData(pyblish.api.ContextPlugin):
context.data["scriptData"] = script_data
context.data.update(script_data)
self.log.info('Context from Nuke script collected')
self.log.debug('Context from Nuke script collected')

View file

@ -43,4 +43,4 @@ class CollectGizmo(pyblish.api.InstancePlugin):
"frameStart": first_frame,
"frameEnd": last_frame
})
self.log.info("Gizmo instance collected: `{}`".format(instance))
self.log.debug("Gizmo instance collected: `{}`".format(instance))

View file

@ -43,4 +43,4 @@ class CollectModel(pyblish.api.InstancePlugin):
"frameStart": first_frame,
"frameEnd": last_frame
})
self.log.info("Model instance collected: `{}`".format(instance))
self.log.debug("Model instance collected: `{}`".format(instance))

View file

@ -39,7 +39,7 @@ class CollectSlate(pyblish.api.InstancePlugin):
instance.data["slateNode"] = slate_node
instance.data["slate"] = True
instance.data["families"].append("slate")
self.log.info(
self.log.debug(
"Slate node is in node graph: `{}`".format(slate.name()))
self.log.debug(
"__ instance.data: `{}`".format(instance.data))

View file

@ -37,4 +37,6 @@ class CollectWorkfile(pyblish.api.InstancePlugin):
# adding basic script data
instance.data.update(script_data)
self.log.info("Collect script version")
self.log.debug(
"Collected current script version: {}".format(current_file)
)

View file

@ -56,8 +56,6 @@ class ExtractBackdropNode(publish.Extractor):
# connect output node
for n, output in connections_out.items():
opn = nuke.createNode("Output")
self.log.info(n.name())
self.log.info(output.name())
output.setInput(
next((i for i, d in enumerate(output.dependencies())
if d.name() in n.name()), 0), opn)
@ -102,5 +100,5 @@ class ExtractBackdropNode(publish.Extractor):
}
instance.data["representations"].append(representation)
self.log.info("Extracted instance '{}' to: {}".format(
self.log.debug("Extracted instance '{}' to: {}".format(
instance.name, path))

View file

@ -36,11 +36,8 @@ class ExtractCamera(publish.Extractor):
step = 1
output_range = str(nuke.FrameRange(first_frame, last_frame, step))
self.log.info("instance.data: `{}`".format(
pformat(instance.data)))
rm_nodes = []
self.log.info("Crating additional nodes")
self.log.debug("Creating additional nodes for 3D Camera Extractor")
subset = instance.data["subset"]
staging_dir = self.staging_dir(instance)
@ -84,8 +81,6 @@ class ExtractCamera(publish.Extractor):
for n in rm_nodes:
nuke.delete(n)
self.log.info(file_path)
# create representation data
if "representations" not in instance.data:
instance.data["representations"] = []
@ -112,7 +107,7 @@ class ExtractCamera(publish.Extractor):
"frameEndHandle": last_frame,
})
self.log.info("Extracted instance '{0}' to: {1}".format(
self.log.debug("Extracted instance '{0}' to: {1}".format(
instance.name, file_path))

View file

@ -85,8 +85,5 @@ class ExtractGizmo(publish.Extractor):
}
instance.data["representations"].append(representation)
self.log.info("Extracted instance '{}' to: {}".format(
self.log.debug("Extracted instance '{}' to: {}".format(
instance.name, path))
self.log.info("Data {}".format(
instance.data))

View file

@ -33,13 +33,13 @@ class ExtractModel(publish.Extractor):
first_frame = int(nuke.root()["first_frame"].getValue())
last_frame = int(nuke.root()["last_frame"].getValue())
self.log.info("instance.data: `{}`".format(
self.log.debug("instance.data: `{}`".format(
pformat(instance.data)))
rm_nodes = []
model_node = instance.data["transientData"]["node"]
self.log.info("Crating additional nodes")
self.log.debug("Creating additional nodes for Extract Model")
subset = instance.data["subset"]
staging_dir = self.staging_dir(instance)
@ -76,7 +76,7 @@ class ExtractModel(publish.Extractor):
for n in rm_nodes:
nuke.delete(n)
self.log.info(file_path)
self.log.debug("Filepath: {}".format(file_path))
# create representation data
if "representations" not in instance.data:
@ -104,5 +104,5 @@ class ExtractModel(publish.Extractor):
"frameEndHandle": last_frame,
})
self.log.info("Extracted instance '{0}' to: {1}".format(
self.log.debug("Extracted instance '{0}' to: {1}".format(
instance.name, file_path))

View file

@ -27,7 +27,7 @@ class CreateOutputNode(pyblish.api.ContextPlugin):
if active_node:
active_node = active_node.pop()
self.log.info(active_node)
self.log.debug("Active node: {}".format(active_node))
active_node['selected'].setValue(True)
# select only instance render node

View file

@ -119,7 +119,7 @@ class NukeRenderLocal(publish.Extractor,
instance.data["representations"].append(repre)
self.log.info("Extracted instance '{0}' to: {1}".format(
self.log.debug("Extracted instance '{0}' to: {1}".format(
instance.name,
out_dir
))
@ -143,7 +143,7 @@ class NukeRenderLocal(publish.Extractor,
instance.data["families"] = families
collections, remainder = clique.assemble(filenames)
self.log.info('collections: {}'.format(str(collections)))
self.log.debug('collections: {}'.format(str(collections)))
if collections:
collection = collections[0]

View file

@ -20,7 +20,7 @@ class ExtractReviewDataLut(publish.Extractor):
hosts = ["nuke"]
def process(self, instance):
self.log.info("Creating staging dir...")
self.log.debug("Creating staging dir...")
if "representations" in instance.data:
staging_dir = instance.data[
"representations"][0]["stagingDir"].replace("\\", "/")
@ -33,7 +33,7 @@ class ExtractReviewDataLut(publish.Extractor):
staging_dir = os.path.normpath(os.path.dirname(render_path))
instance.data["stagingDir"] = staging_dir
self.log.info(
self.log.debug(
"StagingDir `{0}`...".format(instance.data["stagingDir"]))
# generate data

View file

@ -52,7 +52,7 @@ class ExtractReviewIntermediates(publish.Extractor):
task_type = instance.context.data["taskType"]
subset = instance.data["subset"]
self.log.info("Creating staging dir...")
self.log.debug("Creating staging dir...")
if "representations" not in instance.data:
instance.data["representations"] = []
@ -62,10 +62,10 @@ class ExtractReviewIntermediates(publish.Extractor):
instance.data["stagingDir"] = staging_dir
self.log.info(
self.log.debug(
"StagingDir `{0}`...".format(instance.data["stagingDir"]))
self.log.info(self.outputs)
self.log.debug("Outputs: {}".format(self.outputs))
# generate data
with maintained_selection():
@ -104,9 +104,10 @@ class ExtractReviewIntermediates(publish.Extractor):
re.search(s, subset) for s in f_subsets):
continue
self.log.info(
self.log.debug(
"Baking output `{}` with settings: {}".format(
o_name, o_data))
o_name, o_data)
)
# check if settings have more then one preset
# so we dont need to add outputName to representation
@ -155,10 +156,10 @@ class ExtractReviewIntermediates(publish.Extractor):
instance.data["useSequenceForReview"] = False
else:
instance.data["families"].remove("review")
self.log.info((
self.log.debug(
"Removing `review` from families. "
"Not available baking profile."
))
)
self.log.debug(instance.data["families"])
self.log.debug(

View file

@ -3,13 +3,12 @@ import pyblish.api
class ExtractScriptSave(pyblish.api.Extractor):
"""
"""
"""Save current Nuke workfile script"""
label = 'Script Save'
order = pyblish.api.Extractor.order - 0.1
hosts = ['nuke']
def process(self, instance):
self.log.info('saving script')
self.log.debug('Saving current script')
nuke.scriptSave()

View file

@ -48,7 +48,7 @@ class ExtractSlateFrame(publish.Extractor):
if instance.data.get("bakePresets"):
for o_name, o_data in instance.data["bakePresets"].items():
self.log.info("_ o_name: {}, o_data: {}".format(
self.log.debug("_ o_name: {}, o_data: {}".format(
o_name, pformat(o_data)))
self.render_slate(
instance,
@ -65,14 +65,14 @@ class ExtractSlateFrame(publish.Extractor):
def _create_staging_dir(self, instance):
self.log.info("Creating staging dir...")
self.log.debug("Creating staging dir...")
staging_dir = os.path.normpath(
os.path.dirname(instance.data["path"]))
instance.data["stagingDir"] = staging_dir
self.log.info(
self.log.debug(
"StagingDir `{0}`...".format(instance.data["stagingDir"]))
def _check_frames_exists(self, instance):
@ -275,10 +275,10 @@ class ExtractSlateFrame(publish.Extractor):
break
if not matching_repre:
self.log.info((
"Matching reresentaion was not found."
self.log.info(
"Matching reresentation was not found."
" Representation files were not filled with slate."
))
)
return
# Add frame to matching representation files
@ -345,7 +345,7 @@ class ExtractSlateFrame(publish.Extractor):
try:
node[key].setValue(value)
self.log.info("Change key \"{}\" to value \"{}\"".format(
self.log.debug("Change key \"{}\" to value \"{}\"".format(
key, value
))
except NameError:

View file

@ -69,7 +69,7 @@ class ExtractThumbnail(publish.Extractor):
"bake_viewer_input_process"]
node = instance.data["transientData"]["node"] # group node
self.log.info("Creating staging dir...")
self.log.debug("Creating staging dir...")
if "representations" not in instance.data:
instance.data["representations"] = []
@ -79,7 +79,7 @@ class ExtractThumbnail(publish.Extractor):
instance.data["stagingDir"] = staging_dir
self.log.info(
self.log.debug(
"StagingDir `{0}`...".format(instance.data["stagingDir"]))
temporary_nodes = []

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 SelectInstanceNodeAction
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,
SelectInstanceNodeAction
]
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

@ -43,8 +43,8 @@ class SelectCenterInNodeGraph(pyblish.api.Action):
all_xC.append(xC)
all_yC.append(yC)
self.log.info("all_xC: `{}`".format(all_xC))
self.log.info("all_yC: `{}`".format(all_yC))
self.log.debug("all_xC: `{}`".format(all_xC))
self.log.debug("all_yC: `{}`".format(all_yC))
# zoom to nodes in node graph
nuke.zoom(2, [min(all_xC), min(all_yC)])

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]
@ -104,9 +104,9 @@ class ValidateOutputResolution(
_rfn["resize"].setValue(0)
_rfn["black_outside"].setValue(1)
cls.log.info("I am adding reformat node")
cls.log.info("Adding reformat node")
if cls.resolution_msg == invalid:
reformat = cls.get_reformat(instance)
reformat["format"].setValue(nuke.root()["format"].value())
cls.log.info("I am fixing reformat to root.format")
cls.log.info("Fixing reformat to root.format")

View file

@ -76,8 +76,8 @@ class ValidateRenderedFrames(pyblish.api.InstancePlugin):
return
collections, remainder = clique.assemble(repre["files"])
self.log.info("collections: {}".format(str(collections)))
self.log.info("remainder: {}".format(str(remainder)))
self.log.debug("collections: {}".format(str(collections)))
self.log.debug("remainder: {}".format(str(remainder)))
collection = collections[0]
@ -103,15 +103,15 @@ class ValidateRenderedFrames(pyblish.api.InstancePlugin):
coll_start = min(collection.indexes)
coll_end = max(collection.indexes)
self.log.info("frame_length: {}".format(frame_length))
self.log.info("collected_frames_len: {}".format(
self.log.debug("frame_length: {}".format(frame_length))
self.log.debug("collected_frames_len: {}".format(
collected_frames_len))
self.log.info("f_start_h-f_end_h: {}-{}".format(
self.log.debug("f_start_h-f_end_h: {}-{}".format(
f_start_h, f_end_h))
self.log.info(
self.log.debug(
"coll_start-coll_end: {}-{}".format(coll_start, coll_end))
self.log.info(
self.log.debug(
"len(collection.indexes): {}".format(collected_frames_len)
)

View file

@ -39,7 +39,7 @@ class RepairNukeWriteNodeAction(pyblish.api.Action):
set_node_knobs_from_settings(write_node, correct_data["knobs"])
self.log.info("Node attributes were fixed")
self.log.debug("Node attributes were fixed")
class ValidateNukeWriteNode(
@ -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

@ -103,4 +103,4 @@ class HierarchyPage(QtWidgets.QWidget):
self._controller.refresh()
def _on_filter_text_changed(self, text):
self._folders_widget.set_name_filer(text)
self._folders_widget.set_name_filter(text)

View file

@ -11,14 +11,14 @@ from openpype.tools.ayon_utils.widgets import (
FoldersModel,
FOLDERS_MODEL_SENDER_NAME,
)
from openpype.tools.ayon_utils.widgets.folders_widget import ITEM_ID_ROLE
from openpype.tools.ayon_utils.widgets.folders_widget import FOLDER_ID_ROLE
if qtpy.API == "pyside":
from PySide.QtGui import QStyleOptionViewItemV4
elif qtpy.API == "pyqt4":
from PyQt4.QtGui import QStyleOptionViewItemV4
UNDERLINE_COLORS_ROLE = QtCore.Qt.UserRole + 4
UNDERLINE_COLORS_ROLE = QtCore.Qt.UserRole + 50
class UnderlinesFolderDelegate(QtWidgets.QItemDelegate):
@ -257,13 +257,11 @@ class LoaderFoldersWidget(QtWidgets.QWidget):
Args:
controller (AbstractWorkfilesFrontend): The control object.
parent (QtWidgets.QWidget): The parent widget.
handle_expected_selection (bool): If True, the widget will handle
the expected selection. Defaults to False.
"""
refreshed = QtCore.Signal()
def __init__(self, controller, parent, handle_expected_selection=False):
def __init__(self, controller, parent):
super(LoaderFoldersWidget, self).__init__(parent)
folders_view = DeselectableTreeView(self)
@ -313,10 +311,9 @@ class LoaderFoldersWidget(QtWidgets.QWidget):
self._folders_proxy_model = folders_proxy_model
self._folders_label_delegate = folders_label_delegate
self._handle_expected_selection = handle_expected_selection
self._expected_selection = None
def set_name_filer(self, name):
def set_name_filter(self, name):
"""Set filter of folder name.
Args:
@ -365,7 +362,7 @@ class LoaderFoldersWidget(QtWidgets.QWidget):
selection_model = self._folders_view.selectionModel()
item_ids = []
for index in selection_model.selectedIndexes():
item_id = index.data(ITEM_ID_ROLE)
item_id = index.data(FOLDER_ID_ROLE)
if item_id is not None:
item_ids.append(item_id)
return item_ids
@ -379,9 +376,6 @@ class LoaderFoldersWidget(QtWidgets.QWidget):
self._update_expected_selection(event.data)
def _update_expected_selection(self, expected_data=None):
if not self._handle_expected_selection:
return
if expected_data is None:
expected_data = self._controller.get_expected_selection_data()
@ -395,9 +389,6 @@ class LoaderFoldersWidget(QtWidgets.QWidget):
self._set_expected_selection()
def _set_expected_selection(self):
if not self._handle_expected_selection:
return
folder_id = self._expected_selection
selected_ids = self._get_selected_item_ids()
self._expected_selection = None

View file

@ -183,7 +183,7 @@ class ProductsWidget(QtWidgets.QWidget):
not controller.is_loaded_products_supported()
)
def set_name_filer(self, name):
def set_name_filter(self, name):
"""Set filter of product name.
Args:

View file

@ -382,7 +382,7 @@ class LoaderWindow(QtWidgets.QWidget):
self._controller.reset()
def _show_group_dialog(self):
project_name = self._projects_combobox.get_current_project_name()
project_name = self._projects_combobox.get_selected_project_name()
if not project_name:
return
@ -397,7 +397,7 @@ class LoaderWindow(QtWidgets.QWidget):
self._group_dialog.show()
def _on_folder_filter_change(self, text):
self._folders_widget.set_name_filer(text)
self._folders_widget.set_name_filter(text)
def _on_product_group_change(self):
self._products_widget.set_enable_grouping(
@ -405,7 +405,7 @@ class LoaderWindow(QtWidgets.QWidget):
)
def _on_product_filter_change(self, text):
self._products_widget.set_name_filer(text)
self._products_widget.set_name_filter(text)
def _on_product_type_filter_change(self):
self._products_widget.set_product_type_filter(
@ -419,7 +419,7 @@ class LoaderWindow(QtWidgets.QWidget):
def _on_products_selection_change(self):
items = self._products_widget.get_selected_version_info()
self._info_widget.set_selected_version_info(
self._projects_combobox.get_current_project_name(),
self._projects_combobox.get_selected_project_name(),
items
)

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

@ -29,16 +29,21 @@ class FolderItem:
parent_id (Union[str, None]): Parent folder id. If 'None' then project
is parent.
name (str): Name of folder.
path (str): Folder path.
folder_type (str): Type of folder.
label (Union[str, None]): Folder label.
icon (Union[dict[str, Any], None]): Icon definition.
"""
def __init__(
self, entity_id, parent_id, name, label, icon
self, entity_id, parent_id, name, path, folder_type, label, icon
):
self.entity_id = entity_id
self.parent_id = parent_id
self.name = name
self.path = path
self.folder_type = folder_type
self.label = label or name
if not icon:
icon = {
"type": "awesome-font",
@ -46,7 +51,6 @@ class FolderItem:
"color": get_default_entity_icon_color()
}
self.icon = icon
self.label = label or name
def to_data(self):
"""Converts folder item to data.
@ -59,6 +63,8 @@ class FolderItem:
"entity_id": self.entity_id,
"parent_id": self.parent_id,
"name": self.name,
"path": self.path,
"folder_type": self.folder_type,
"label": self.label,
"icon": self.icon,
}
@ -90,8 +96,7 @@ class TaskItem:
name (str): Name of task.
task_type (str): Type of task.
parent_id (str): Parent folder id.
icon_name (str): Name of icon from font awesome.
icon_color (str): Hex color string that will be used for icon.
icon (Union[dict[str, Any], None]): Icon definitions.
"""
def __init__(
@ -183,12 +188,31 @@ def _get_task_items_from_tasks(tasks):
def _get_folder_item_from_hierarchy_item(item):
name = item["name"]
path_parts = list(item["parents"])
path_parts.append(name)
return FolderItem(
item["id"],
item["parentId"],
item["name"],
name,
"/".join(path_parts),
item["folderType"],
item["label"],
None
None,
)
def _get_folder_item_from_entity(entity):
name = entity["name"]
return FolderItem(
entity["id"],
entity["parentId"],
name,
entity["path"],
entity["folderType"],
entity["label"] or name,
None,
)
@ -223,13 +247,84 @@ class HierarchyModel(object):
self._tasks_by_id.reset()
def refresh_project(self, project_name):
"""Force to refresh folder items for a project.
Args:
project_name (str): Name of project to refresh.
"""
self._refresh_folders_cache(project_name)
def get_folder_items(self, project_name, sender):
"""Get folder items by project name.
The folders are cached per project name. If the cache is not valid
then the folders are queried from server.
Args:
project_name (str): Name of project where to look for folders.
sender (Union[str, None]): Who requested the folder ids.
Returns:
dict[str, FolderItem]: Folder items by id.
"""
if not self._folders_items[project_name].is_valid:
self._refresh_folders_cache(project_name, sender)
return self._folders_items[project_name].get_data()
def get_folder_items_by_id(self, project_name, folder_ids):
"""Get folder items by ids.
This function will query folders if they are not in cache. But the
queried items are not added to cache back.
Args:
project_name (str): Name of project where to look for folders.
folder_ids (Iterable[str]): Folder ids.
Returns:
dict[str, Union[FolderItem, None]]: Folder items by id.
"""
folder_ids = set(folder_ids)
if self._folders_items[project_name].is_valid:
cache_data = self._folders_items[project_name].get_data()
return {
folder_id: cache_data.get(folder_id)
for folder_id in folder_ids
}
folders = ayon_api.get_folders(
project_name,
folder_ids=folder_ids,
fields=["id", "name", "label", "parentId", "path", "folderType"]
)
# Make sure all folder ids are in output
output = {folder_id: None for folder_id in folder_ids}
output.update({
folder["id"]: _get_folder_item_from_entity(folder)
for folder in folders
})
return output
def get_folder_item(self, project_name, folder_id):
"""Get folder items by id.
This function will query folder if they is not in cache. But the
queried items are not added to cache back.
Args:
project_name (str): Name of project where to look for folders.
folder_id (str): Folder id.
Returns:
Union[FolderItem, None]: Folder item.
"""
items = self.get_folder_items_by_id(
project_name, [folder_id]
)
return items.get(folder_id)
def get_task_items(self, project_name, folder_id, sender):
if not project_name or not folder_id:
return []

View file

@ -4,14 +4,16 @@ from qtpy import QtWidgets, QtGui, QtCore
from openpype.tools.utils import (
RecursiveSortFilterProxyModel,
DeselectableTreeView,
TreeView,
)
from .utils import RefreshThread, get_qt_icon
FOLDERS_MODEL_SENDER_NAME = "qt_folders_model"
ITEM_ID_ROLE = QtCore.Qt.UserRole + 1
ITEM_NAME_ROLE = QtCore.Qt.UserRole + 2
FOLDER_ID_ROLE = QtCore.Qt.UserRole + 1
FOLDER_NAME_ROLE = QtCore.Qt.UserRole + 2
FOLDER_PATH_ROLE = QtCore.Qt.UserRole + 3
FOLDER_TYPE_ROLE = QtCore.Qt.UserRole + 4
class FoldersModel(QtGui.QStandardItemModel):
@ -84,6 +86,15 @@ class FoldersModel(QtGui.QStandardItemModel):
return QtCore.QModelIndex()
return self.indexFromItem(item)
def get_project_name(self):
"""Project name which model currently use.
Returns:
Union[str, None]: Currently used project name.
"""
return self._last_project_name
def set_project_name(self, project_name):
"""Refresh folders items.
@ -151,12 +162,13 @@ class FoldersModel(QtGui.QStandardItemModel):
"""
icon = get_qt_icon(folder_item.icon)
item.setData(folder_item.entity_id, ITEM_ID_ROLE)
item.setData(folder_item.name, ITEM_NAME_ROLE)
item.setData(folder_item.entity_id, FOLDER_ID_ROLE)
item.setData(folder_item.name, FOLDER_NAME_ROLE)
item.setData(folder_item.path, FOLDER_PATH_ROLE)
item.setData(folder_item.folder_type, FOLDER_TYPE_ROLE)
item.setData(folder_item.label, QtCore.Qt.DisplayRole)
item.setData(icon, QtCore.Qt.DecorationRole)
def _fill_items(self, folder_items_by_id):
if not folder_items_by_id:
if folder_items_by_id is not None:
@ -193,7 +205,7 @@ class FoldersModel(QtGui.QStandardItemModel):
folder_ids_to_add = set(folder_items)
for row_idx in reversed(range(parent_item.rowCount())):
child_item = parent_item.child(row_idx)
child_id = child_item.data(ITEM_ID_ROLE)
child_id = child_item.data(FOLDER_ID_ROLE)
if child_id in ids_to_remove:
removed_items.append(parent_item.takeRow(row_idx))
else:
@ -259,10 +271,14 @@ class FoldersWidget(QtWidgets.QWidget):
the expected selection. Defaults to False.
"""
double_clicked = QtCore.Signal(QtGui.QMouseEvent)
selection_changed = QtCore.Signal()
refreshed = QtCore.Signal()
def __init__(self, controller, parent, handle_expected_selection=False):
super(FoldersWidget, self).__init__(parent)
folders_view = DeselectableTreeView(self)
folders_view = TreeView(self)
folders_view.setHeaderHidden(True)
folders_model = FoldersModel(controller)
@ -295,7 +311,7 @@ class FoldersWidget(QtWidgets.QWidget):
selection_model = folders_view.selectionModel()
selection_model.selectionChanged.connect(self._on_selection_change)
folders_view.double_clicked.connect(self.double_clicked)
folders_model.refreshed.connect(self._on_model_refresh)
self._controller = controller
@ -306,7 +322,27 @@ class FoldersWidget(QtWidgets.QWidget):
self._handle_expected_selection = handle_expected_selection
self._expected_selection = None
def set_name_filer(self, name):
@property
def is_refreshing(self):
"""Model is refreshing.
Returns:
bool: True if model is refreshing.
"""
return self._folders_model.is_refreshing
@property
def has_content(self):
"""Has at least one folder.
Returns:
bool: True if model has at least one folder.
"""
return self._folders_model.has_content
def set_name_filter(self, name):
"""Set filter of folder name.
Args:
@ -323,16 +359,108 @@ class FoldersWidget(QtWidgets.QWidget):
self._folders_model.refresh()
def get_project_name(self):
"""Project name in which folders widget currently is.
Returns:
Union[str, None]: Currently used project name.
"""
return self._folders_model.get_project_name()
def set_project_name(self, project_name):
"""Set project name.
Do not use this method when controller is handling selection of
project using 'selection.project.changed' event.
Args:
project_name (str): Project name.
"""
self._folders_model.set_project_name(project_name)
def get_selected_folder_id(self):
"""Get selected folder id.
Returns:
Union[str, None]: Folder id which is selected.
"""
return self._get_selected_item_id()
def get_selected_folder_label(self):
"""Selected folder label.
Returns:
Union[str, None]: Selected folder label.
"""
item_id = self._get_selected_item_id()
return self.get_folder_label(item_id)
def get_folder_label(self, folder_id):
"""Folder label for a given folder id.
Returns:
Union[str, None]: Folder label.
"""
index = self._folders_model.get_index_by_id(folder_id)
if index.isValid():
return index.data(QtCore.Qt.DisplayRole)
return None
def set_selected_folder(self, folder_id):
"""Change selection.
Args:
folder_id (Union[str, None]): Folder id or None to deselect.
"""
if folder_id is None:
self._folders_view.clearSelection()
return True
if folder_id == self._get_selected_item_id():
return True
index = self._folders_model.get_index_by_id(folder_id)
if not index.isValid():
return False
proxy_index = self._folders_proxy_model.mapFromSource(index)
if not proxy_index.isValid():
return False
selection_model = self._folders_view.selectionModel()
selection_model.setCurrentIndex(
proxy_index, QtCore.QItemSelectionModel.SelectCurrent
)
return True
def set_deselectable(self, enabled):
"""Set deselectable mode.
Items in view can be deselected.
Args:
enabled (bool): Enable deselectable mode.
"""
self._folders_view.set_deselectable(enabled)
def _get_selected_index(self):
return self._folders_model.get_index_by_id(
self.get_selected_folder_id()
)
def _on_project_selection_change(self, event):
project_name = event["project_name"]
self._set_project_name(project_name)
def _set_project_name(self, project_name):
self._folders_model.set_project_name(project_name)
self.set_project_name(project_name)
def _on_folders_refresh_finished(self, event):
if event["sender"] != FOLDERS_MODEL_SENDER_NAME:
self._set_project_name(event["project_name"])
self.set_project_name(event["project_name"])
def _on_controller_refresh(self):
self._update_expected_selection()
@ -341,11 +469,12 @@ class FoldersWidget(QtWidgets.QWidget):
if self._expected_selection:
self._set_expected_selection()
self._folders_proxy_model.sort(0)
self.refreshed.emit()
def _get_selected_item_id(self):
selection_model = self._folders_view.selectionModel()
for index in selection_model.selectedIndexes():
item_id = index.data(ITEM_ID_ROLE)
item_id = index.data(FOLDER_ID_ROLE)
if item_id is not None:
return item_id
return None
@ -353,6 +482,7 @@ class FoldersWidget(QtWidgets.QWidget):
def _on_selection_change(self):
item_id = self._get_selected_item_id()
self._controller.set_selected_folder(item_id)
self.selection_changed.emit()
# Expected selection handling
def _on_expected_selection_change(self, event):
@ -380,12 +510,6 @@ class FoldersWidget(QtWidgets.QWidget):
folder_id = self._expected_selection
self._expected_selection = None
if (
folder_id is not None
and folder_id != self._get_selected_item_id()
):
index = self._folders_model.get_index_by_id(folder_id)
if index.isValid():
proxy_index = self._folders_proxy_model.mapFromSource(index)
self._folders_view.setCurrentIndex(proxy_index)
if folder_id is not None:
self.set_selected_folder(folder_id)
self._controller.expected_folder_selected(folder_id)

View file

@ -395,6 +395,7 @@ class ProjectSortFilterProxy(QtCore.QSortFilterProxyModel):
class ProjectsCombobox(QtWidgets.QWidget):
refreshed = QtCore.Signal()
selection_changed = QtCore.Signal()
def __init__(self, controller, parent, handle_expected_selection=False):
super(ProjectsCombobox, self).__init__(parent)
@ -482,7 +483,7 @@ class ProjectsCombobox(QtWidgets.QWidget):
self._listen_selection_change = listen
def get_current_project_name(self):
def get_selected_project_name(self):
"""Name of selected project.
Returns:
@ -502,7 +503,7 @@ class ProjectsCombobox(QtWidgets.QWidget):
if not self._select_item_visible:
return
if "project_name" not in kwargs:
project_name = self.get_current_project_name()
project_name = self.get_selected_project_name()
else:
project_name = kwargs.get("project_name")
@ -536,6 +537,7 @@ class ProjectsCombobox(QtWidgets.QWidget):
idx, PROJECT_NAME_ROLE)
self._update_select_item_visiblity(project_name=project_name)
self._controller.set_selected_project(project_name)
self.selection_changed.emit()
def _on_model_refresh(self):
self._projects_proxy_model.sort(0)
@ -561,7 +563,7 @@ class ProjectsCombobox(QtWidgets.QWidget):
return
project_name = self._expected_selection
if project_name is not None:
if project_name != self.get_current_project_name():
if project_name != self.get_selected_project_name():
self.set_selection(project_name)
else:
# Fake project change

View file

@ -296,6 +296,9 @@ class TasksWidget(QtWidgets.QWidget):
handle_expected_selection (Optional[bool]): Handle expected selection.
"""
refreshed = QtCore.Signal()
selection_changed = QtCore.Signal()
def __init__(self, controller, parent, handle_expected_selection=False):
super(TasksWidget, self).__init__(parent)
@ -380,6 +383,7 @@ class TasksWidget(QtWidgets.QWidget):
if not self._set_expected_selection():
self._on_selection_change()
self._tasks_proxy_model.sort(0)
self.refreshed.emit()
def _get_selected_item_ids(self):
selection_model = self._tasks_view.selectionModel()
@ -400,6 +404,7 @@ class TasksWidget(QtWidgets.QWidget):
parent_id, task_id, task_name = self._get_selected_item_ids()
self._controller.set_selected_task(task_id, task_name)
self.selection_changed.emit()
# Expected selection handling
def _on_expected_selection_change(self, event):

View file

@ -5,9 +5,10 @@ from openpype.style import (
get_default_entity_icon_color,
get_disabled_entity_icon_color,
)
from openpype.tools.utils import TreeView
from openpype.tools.utils.delegates import PrettyTimeDelegate
from .utils import TreeView, BaseOverlayFrame
from .utils import BaseOverlayFrame
REPRE_ID_ROLE = QtCore.Qt.UserRole + 1
@ -306,7 +307,7 @@ class PublishedFilesWidget(QtWidgets.QWidget):
selection_model = view.selectionModel()
selection_model.selectionChanged.connect(self._on_selection_change)
view.double_clicked_left.connect(self._on_left_double_click)
view.double_clicked.connect(self._on_mouse_double_click)
controller.register_event_callback(
"expected_selection_changed",
@ -350,8 +351,9 @@ class PublishedFilesWidget(QtWidgets.QWidget):
repre_id = self.get_selected_repre_id()
self._controller.set_selected_representation_id(repre_id)
def _on_left_double_click(self):
self.save_as_requested.emit()
def _on_mouse_double_click(self, event):
if event.button() == QtCore.Qt.LeftButton:
self.save_as_requested.emit()
def _on_expected_selection_change(self, event):
if (

View file

@ -5,10 +5,9 @@ from openpype.style import (
get_default_entity_icon_color,
get_disabled_entity_icon_color,
)
from openpype.tools.utils import TreeView
from openpype.tools.utils.delegates import PrettyTimeDelegate
from .utils import TreeView
FILENAME_ROLE = QtCore.Qt.UserRole + 1
FILEPATH_ROLE = QtCore.Qt.UserRole + 2
DATE_MODIFIED_ROLE = QtCore.Qt.UserRole + 3
@ -271,7 +270,7 @@ class WorkAreaFilesWidget(QtWidgets.QWidget):
selection_model = view.selectionModel()
selection_model.selectionChanged.connect(self._on_selection_change)
view.double_clicked_left.connect(self._on_left_double_click)
view.double_clicked.connect(self._on_mouse_double_click)
view.customContextMenuRequested.connect(self._on_context_menu)
controller.register_event_callback(
@ -333,8 +332,9 @@ class WorkAreaFilesWidget(QtWidgets.QWidget):
filepath = self.get_selected_path()
self._controller.set_selected_workfile_path(filepath)
def _on_left_double_click(self):
self.open_current_requested.emit()
def _on_mouse_double_click(self, event):
if event.button() == QtCore.Qt.LeftButton:
self.save_as_requested.emit()
def _on_context_menu(self, point):
index = self._view.indexAt(point)

View file

@ -264,7 +264,7 @@ class FoldersWidget(QtWidgets.QWidget):
self._expected_selection = None
def set_name_filer(self, name):
def set_name_filter(self, name):
self._folders_proxy_model.setFilterFixedString(name)
def _clear(self):

View file

@ -1,70 +1,4 @@
from qtpy import QtWidgets, QtCore
from openpype.tools.flickcharm import FlickCharm
class TreeView(QtWidgets.QTreeView):
"""Ultimate TreeView with flick charm and double click signals.
Tree view have deselectable mode, which allows to deselect items by
clicking on item area without any items.
Todos:
Add to tools utils.
"""
double_clicked_left = QtCore.Signal()
double_clicked_right = QtCore.Signal()
def __init__(self, *args, **kwargs):
super(TreeView, self).__init__(*args, **kwargs)
self._deselectable = False
self._flick_charm_activated = False
self._flick_charm = FlickCharm(parent=self)
self._before_flick_scroll_mode = None
def is_deselectable(self):
return self._deselectable
def set_deselectable(self, deselectable):
self._deselectable = deselectable
deselectable = property(is_deselectable, set_deselectable)
def mousePressEvent(self, event):
if self._deselectable:
index = self.indexAt(event.pos())
if not index.isValid():
# clear the selection
self.clearSelection()
# clear the current index
self.setCurrentIndex(QtCore.QModelIndex())
super(TreeView, self).mousePressEvent(event)
def mouseDoubleClickEvent(self, event):
if event.button() == QtCore.Qt.LeftButton:
self.double_clicked_left.emit()
elif event.button() == QtCore.Qt.RightButton:
self.double_clicked_right.emit()
return super(TreeView, self).mouseDoubleClickEvent(event)
def activate_flick_charm(self):
if self._flick_charm_activated:
return
self._flick_charm_activated = True
self._before_flick_scroll_mode = self.verticalScrollMode()
self._flick_charm.activateOn(self)
self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel)
def deactivate_flick_charm(self):
if not self._flick_charm_activated:
return
self._flick_charm_activated = False
self._flick_charm.deactivateFrom(self)
if self._before_flick_scroll_mode is not None:
self.setVerticalScrollMode(self._before_flick_scroll_mode)
class BaseOverlayFrame(QtWidgets.QFrame):

View file

@ -338,7 +338,7 @@ class WorkfilesToolWindow(QtWidgets.QWidget):
self._side_panel.set_published_mode(published_mode)
def _on_folder_filter_change(self, text):
self._folder_widget.set_name_filer(text)
self._folder_widget.set_name_filter(text)
def _on_go_to_current_clicked(self):
self._controller.go_to_current_context()

View file

@ -20,7 +20,10 @@ from .widgets import (
RefreshButton,
GoToCurrentButton,
)
from .views import DeselectableTreeView
from .views import (
DeselectableTreeView,
TreeView,
)
from .error_dialog import ErrorMessageBox
from .lib import (
WrappedCallbackItem,
@ -71,6 +74,7 @@ __all__ = (
"GoToCurrentButton",
"DeselectableTreeView",
"TreeView",
"ErrorMessageBox",

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

@ -1,4 +1,6 @@
from openpype.resources import get_image_path
from openpype.tools.flickcharm import FlickCharm
from qtpy import QtWidgets, QtCore, QtGui, QtSvg
@ -57,3 +59,63 @@ class TreeViewSpinner(QtWidgets.QTreeView):
self.paint_empty(event)
else:
super(TreeViewSpinner, self).paintEvent(event)
class TreeView(QtWidgets.QTreeView):
"""Ultimate TreeView with flick charm and double click signals.
Tree view have deselectable mode, which allows to deselect items by
clicking on item area without any items.
Todos:
Add refresh animation.
"""
double_clicked = QtCore.Signal(QtGui.QMouseEvent)
def __init__(self, *args, **kwargs):
super(TreeView, self).__init__(*args, **kwargs)
self._deselectable = False
self._flick_charm_activated = False
self._flick_charm = FlickCharm(parent=self)
self._before_flick_scroll_mode = None
def is_deselectable(self):
return self._deselectable
def set_deselectable(self, deselectable):
self._deselectable = deselectable
deselectable = property(is_deselectable, set_deselectable)
def mousePressEvent(self, event):
if self._deselectable:
index = self.indexAt(event.pos())
if not index.isValid():
# clear the selection
self.clearSelection()
# clear the current index
self.setCurrentIndex(QtCore.QModelIndex())
super(TreeView, self).mousePressEvent(event)
def mouseDoubleClickEvent(self, event):
self.double_clicked.emit(event)
return super(TreeView, self).mouseDoubleClickEvent(event)
def activate_flick_charm(self):
if self._flick_charm_activated:
return
self._flick_charm_activated = True
self._before_flick_scroll_mode = self.verticalScrollMode()
self._flick_charm.activateOn(self)
self.setVerticalScrollMode(QtWidgets.QAbstractItemView.ScrollPerPixel)
def deactivate_flick_charm(self):
if not self._flick_charm_activated:
return
self._flick_charm_activated = False
self._flick_charm.deactivateFrom(self)
if self._before_flick_scroll_mode is not None:
self.setVerticalScrollMode(self._before_flick_scroll_mode)

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