mirror of
https://github.com/ynput/ayon-core.git
synced 2025-12-24 21:04:40 +01:00
[Automated] Merged develop into main
This commit is contained in:
commit
e54a4e11d7
66 changed files with 4366 additions and 432 deletions
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]:
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
112
openpype/hosts/nuke/plugins/publish/validate_asset_context.py
Normal file
112
openpype/hosts/nuke/plugins/publish/validate_asset_context.py
Normal 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()
|
||||
|
|
@ -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
|
||||
}
|
||||
)
|
||||
|
|
@ -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)])
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -341,7 +341,7 @@
|
|||
"write"
|
||||
]
|
||||
},
|
||||
"ValidateCorrectAssetName": {
|
||||
"ValidateCorrectAssetContext": {
|
||||
"enabled": true,
|
||||
"optional": true,
|
||||
"active": true
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@
|
|||
"name": "template_publish_plugin",
|
||||
"template_data": [
|
||||
{
|
||||
"key": "ValidateCorrectAssetName",
|
||||
"key": "ValidateCorrectAssetContext",
|
||||
"label": "Validate Correct Asset Name"
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
|
|
|||
6
openpype/tools/ayon_sceneinventory/__init__.py
Normal file
6
openpype/tools/ayon_sceneinventory/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from .control import SceneInventoryController
|
||||
|
||||
|
||||
__all__ = (
|
||||
"SceneInventoryController",
|
||||
)
|
||||
134
openpype/tools/ayon_sceneinventory/control.py
Normal file
134
openpype/tools/ayon_sceneinventory/control.py
Normal 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()
|
||||
622
openpype/tools/ayon_sceneinventory/model.py
Normal file
622
openpype/tools/ayon_sceneinventory/model.py
Normal 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
|
||||
6
openpype/tools/ayon_sceneinventory/models/__init__.py
Normal file
6
openpype/tools/ayon_sceneinventory/models/__init__.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from .site_sync import SiteSyncModel
|
||||
|
||||
|
||||
__all__ = (
|
||||
"SiteSyncModel",
|
||||
)
|
||||
176
openpype/tools/ayon_sceneinventory/models/site_sync.py
Normal file
176
openpype/tools/ayon_sceneinventory/models/site_sync.py
Normal 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
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
from .dialog import SwitchAssetDialog
|
||||
|
||||
|
||||
__all__ = (
|
||||
"SwitchAssetDialog",
|
||||
)
|
||||
1333
openpype/tools/ayon_sceneinventory/switch_dialog/dialog.py
Normal file
1333
openpype/tools/ayon_sceneinventory/switch_dialog/dialog.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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)
|
||||
94
openpype/tools/ayon_sceneinventory/switch_dialog/widgets.py
Normal file
94
openpype/tools/ayon_sceneinventory/switch_dialog/widgets.py
Normal 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)
|
||||
825
openpype/tools/ayon_sceneinventory/view.py
Normal file
825
openpype/tools/ayon_sceneinventory/view.py
Normal 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()
|
||||
200
openpype/tools/ayon_sceneinventory/window.py
Normal file
200
openpype/tools/ayon_sceneinventory/window.py
Normal 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()
|
||||
|
|
@ -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 []
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue