Merge branch 'develop' into bugfix/AY-5716_Hiero-loader-with-old-expected-data

This commit is contained in:
Jakub Jezek 2024-06-05 16:53:28 +02:00
commit e10df3725c
No known key found for this signature in database
GPG key ID: 06DBD609ADF27FD9
1489 changed files with 10109 additions and 5756 deletions

View file

@ -8,7 +8,6 @@ import inspect
import logging import logging
import threading import threading
import collections import collections
from uuid import uuid4 from uuid import uuid4
from abc import ABCMeta, abstractmethod from abc import ABCMeta, abstractmethod
@ -51,8 +50,25 @@ IGNORED_MODULES_IN_AYON = set()
# - this is used to log the missing addon # - this is used to log the missing addon
MOVED_ADDON_MILESTONE_VERSIONS = { MOVED_ADDON_MILESTONE_VERSIONS = {
"applications": VersionInfo(0, 2, 0), "applications": VersionInfo(0, 2, 0),
"blender": VersionInfo(0, 2, 0),
"celaction": VersionInfo(0, 2, 0),
"clockify": VersionInfo(0, 2, 0),
"flame": VersionInfo(0, 2, 0),
"fusion": VersionInfo(0, 2, 0),
"harmony": VersionInfo(0, 2, 0),
"hiero": VersionInfo(0, 2, 0),
"max": VersionInfo(0, 2, 0),
"photoshop": VersionInfo(0, 2, 0),
"traypublisher": VersionInfo(0, 2, 0),
"tvpaint": VersionInfo(0, 2, 0),
"maya": VersionInfo(0, 2, 0),
"nuke": VersionInfo(0, 2, 0),
"resolve": VersionInfo(0, 2, 0),
"substancepainter": VersionInfo(0, 2, 0),
"houdini": VersionInfo(0, 3, 0),
} }
# Inherit from `object` for Python 2 hosts # Inherit from `object` for Python 2 hosts
class _ModuleClass(object): class _ModuleClass(object):
"""Fake module class for storing AYON addons. """Fake module class for storing AYON addons.
@ -538,6 +554,9 @@ class AYONAddon(object):
enabled = True enabled = True
_id = None _id = None
# Temporary variable for 'version' property
_missing_version_warned = False
def __init__(self, manager, settings): def __init__(self, manager, settings):
self.manager = manager self.manager = manager
@ -568,6 +587,26 @@ class AYONAddon(object):
pass pass
@property
def version(self):
"""Addon version.
Todo:
Should be abstract property (required). Introduced in
ayon-core 0.3.3 .
Returns:
str: Addon version as semver compatible string.
"""
if not self.__class__._missing_version_warned:
self.__class__._missing_version_warned = True
print(
f"DEV WARNING: Addon '{self.name}' does not have"
f" defined version."
)
return "0.0.0"
def initialize(self, settings): def initialize(self, settings):
"""Initialization of addon attributes. """Initialization of addon attributes.
@ -683,6 +722,30 @@ class OpenPypeAddOn(OpenPypeModule):
enabled = True enabled = True
class _AddonReportInfo:
def __init__(
self, class_name, name, version, report_value_by_label
):
self.class_name = class_name
self.name = name
self.version = version
self.report_value_by_label = report_value_by_label
@classmethod
def from_addon(cls, addon, report):
class_name = addon.__class__.__name__
report_value_by_label = {
label: reported.get(class_name)
for label, reported in report.items()
}
return cls(
addon.__class__.__name__,
addon.name,
addon.version,
report_value_by_label
)
class AddonsManager: class AddonsManager:
"""Manager of addons that helps to load and prepare them to work. """Manager of addons that helps to load and prepare them to work.
@ -859,10 +922,6 @@ class AddonsManager:
name_alias = getattr(addon, "openpype_alias", None) name_alias = getattr(addon, "openpype_alias", None)
if name_alias: if name_alias:
aliased_names.append((name_alias, addon)) aliased_names.append((name_alias, addon))
enabled_str = "X"
if not addon.enabled:
enabled_str = " "
self.log.debug("[{}] {}".format(enabled_str, name))
now = time.time() now = time.time()
report[addon.__class__.__name__] = now - prev_start_time report[addon.__class__.__name__] = now - prev_start_time
@ -874,6 +933,13 @@ class AddonsManager:
exc_info=True exc_info=True
) )
for addon_name in sorted(self._addons_by_name.keys()):
addon = self._addons_by_name[addon_name]
enabled_str = "X" if addon.enabled else " "
self.log.debug(
f"[{enabled_str}] {addon.name} ({addon.version})"
)
for item in aliased_names: for item in aliased_names:
name_alias, addon = item name_alias, addon = item
if name_alias not in self._addons_by_name: if name_alias not in self._addons_by_name:
@ -1162,39 +1228,55 @@ class AddonsManager:
available_col_names |= set(addon_names.keys()) available_col_names |= set(addon_names.keys())
# Prepare ordered dictionary for columns # Prepare ordered dictionary for columns
cols = collections.OrderedDict() addons_info = [
# Add addon names to first columnt _AddonReportInfo.from_addon(addon, self._report)
cols["Addon name"] = list(sorted(
addon.__class__.__name__
for addon in self.addons for addon in self.addons
if addon.__class__.__name__ in available_col_names if addon.__class__.__name__ in available_col_names
)) ]
addons_info.sort(key=lambda x: x.name)
addon_name_rows = [
addon_info.name
for addon_info in addons_info
]
addon_version_rows = [
addon_info.version
for addon_info in addons_info
]
# Add total key (as last addon) # Add total key (as last addon)
cols["Addon name"].append(self._report_total_key) addon_name_rows.append(self._report_total_key)
addon_version_rows.append(f"({len(addons_info)})")
cols = collections.OrderedDict()
# Add addon names to first columnt
cols["Addon name"] = addon_name_rows
cols["Version"] = addon_version_rows
# Add columns from report # Add columns from report
total_by_addon = {
row: 0
for row in addon_name_rows
}
for label in self._report.keys(): for label in self._report.keys():
cols[label] = [] rows = []
col_total = 0
total_addon_times = {} for addon_info in addons_info:
for addon_name in cols["Addon name"]: value = addon_info.report_value_by_label.get(label)
total_addon_times[addon_name] = 0 if value is None:
rows.append("N/A")
for label, reported in self._report.items():
for addon_name in cols["Addon name"]:
col_time = reported.get(addon_name)
if col_time is None:
cols[label].append("N/A")
continue continue
cols[label].append("{:.3f}".format(col_time)) rows.append("{:.3f}".format(value))
total_addon_times[addon_name] += col_time total_by_addon[addon_info.name] += value
col_total += value
total_by_addon[self._report_total_key] += col_total
rows.append("{:.3f}".format(col_total))
cols[label] = rows
# Add to also total column that should sum the row # Add to also total column that should sum the row
cols[self._report_total_key] = [] cols[self._report_total_key] = [
for addon_name in cols["Addon name"]: "{:.3f}".format(total_by_addon[addon_name])
cols[self._report_total_key].append( for addon_name in cols["Addon name"]
"{:.3f}".format(total_addon_times[addon_name]) ]
)
# Prepare column widths and total row count # Prepare column widths and total row count
# - column width is by # - column width is by
@ -1321,7 +1403,7 @@ class TrayAddonsManager(AddonsManager):
self.doubleclick_callback = None self.doubleclick_callback = None
def add_doubleclick_callback(self, addon, callback): def add_doubleclick_callback(self, addon, callback):
"""Register doubleclick callbacks on tray icon. """Register double-click callbacks on tray icon.
Currently, there is no way how to determine which is launched. Name of Currently, there is no way how to determine which is launched. Name of
callback can be defined with `doubleclick_callback` attribute. callback can be defined with `doubleclick_callback` attribute.

View file

@ -1,7 +1,7 @@
from ayon_applications import PreLaunchHook from ayon_applications import PreLaunchHook
from ayon_core.pipeline.colorspace import get_imageio_config from ayon_core.pipeline.colorspace import get_imageio_config_preset
from ayon_core.pipeline.template_data import get_template_data_with_names from ayon_core.pipeline.template_data import get_template_data
class OCIOEnvHook(PreLaunchHook): class OCIOEnvHook(PreLaunchHook):
@ -26,32 +26,38 @@ class OCIOEnvHook(PreLaunchHook):
def execute(self): def execute(self):
"""Hook entry method.""" """Hook entry method."""
template_data = get_template_data_with_names( folder_entity = self.data["folder_entity"]
project_name=self.data["project_name"],
folder_path=self.data["folder_path"], template_data = get_template_data(
task_name=self.data["task_name"], self.data["project_entity"],
folder_entity=folder_entity,
task_entity=self.data["task_entity"],
host_name=self.host_name, host_name=self.host_name,
settings=self.data["project_settings"] settings=self.data["project_settings"],
) )
config_data = get_imageio_config( config_data = get_imageio_config_preset(
project_name=self.data["project_name"], self.data["project_name"],
host_name=self.host_name, self.data["folder_path"],
project_settings=self.data["project_settings"], self.data["task_name"],
anatomy_data=template_data, self.host_name,
anatomy=self.data["anatomy"], anatomy=self.data["anatomy"],
project_settings=self.data["project_settings"],
template_data=template_data,
env=self.launch_context.env, env=self.launch_context.env,
folder_id=folder_entity["id"],
) )
if config_data: if not config_data:
ocio_path = config_data["path"]
if self.host_name in ["nuke", "hiero"]:
ocio_path = ocio_path.replace("\\", "/")
self.log.info(
f"Setting OCIO environment to config path: {ocio_path}")
self.launch_context.env["OCIO"] = ocio_path
else:
self.log.debug("OCIO not set or enabled") self.log.debug("OCIO not set or enabled")
return
ocio_path = config_data["path"]
if self.host_name in ["nuke", "hiero"]:
ocio_path = ocio_path.replace("\\", "/")
self.log.info(
f"Setting OCIO environment to config path: {ocio_path}")
self.launch_context.env["OCIO"] = ocio_path

View file

@ -1,6 +0,0 @@
from .addon import BlenderAddon
__all__ = (
"BlenderAddon",
)

View file

@ -1,10 +0,0 @@
from .addon import (
HOST_DIR,
FlameAddon,
)
__all__ = (
"HOST_DIR",
"FlameAddon",
)

View file

@ -1,10 +0,0 @@
from .addon import (
HIERO_ROOT_DIR,
HieroAddon,
)
__all__ = (
"HIERO_ROOT_DIR",
"HieroAddon",
)

View file

@ -1,21 +0,0 @@
"""Collector for pointcache types.
This will add additional family to pointcache instance based on
the creator_identifier parameter.
"""
import pyblish.api
class CollectPointcacheType(pyblish.api.InstancePlugin):
"""Collect data type for pointcache instance."""
order = pyblish.api.CollectorOrder
hosts = ["houdini"]
families = ["pointcache"]
label = "Collect type of pointcache"
def process(self, instance):
if instance.data["creator_identifier"] == "io.openpype.creators.houdini.bgeo": # noqa: E501
instance.data["families"] += ["bgeo"]
elif instance.data["creator_identifier"] == "io.openpype.creators.houdini.pointcache": # noqa: E501
instance.data["families"] += ["abc"]

View file

@ -1,71 +0,0 @@
import pyblish.api
import ayon_core.hosts.maya.api.action
from ayon_core.pipeline.publish import (
PublishValidationError,
ValidateContentsOrder,
OptionalPyblishPluginMixin
)
from maya import cmds
class ValidateAnimatedReferenceRig(pyblish.api.InstancePlugin,
OptionalPyblishPluginMixin):
"""Validate all nodes in skeletonAnim_SET are referenced"""
order = ValidateContentsOrder
hosts = ["maya"]
families = ["animation.fbx"]
label = "Animated Reference Rig"
accepted_controllers = ["transform", "locator"]
actions = [ayon_core.hosts.maya.api.action.SelectInvalidAction]
optional = False
def process(self, instance):
if not self.is_active(instance.data):
return
animated_sets = instance.data.get("animated_skeleton", [])
if not animated_sets:
self.log.debug(
"No nodes found in skeletonAnim_SET. "
"Skipping validation of animated reference rig..."
)
return
for animated_reference in animated_sets:
is_referenced = cmds.referenceQuery(
animated_reference, isNodeReferenced=True)
if not bool(is_referenced):
raise PublishValidationError(
"All the content in skeletonAnim_SET"
" should be referenced nodes"
)
invalid_controls = self.validate_controls(animated_sets)
if invalid_controls:
raise PublishValidationError(
"All the content in skeletonAnim_SET"
" should be transforms"
)
@classmethod
def validate_controls(self, set_members):
"""Check if the controller set contains only accepted node types.
Checks if all its set members are within the hierarchy of the root
Checks if the node types of the set members valid
Args:
set_members: list of nodes of the skeleton_anim_set
hierarchy: list of nodes which reside under the root node
Returns:
errors (list)
"""
# Validate control types
invalid = []
set_members = cmds.ls(set_members, long=True)
for node in set_members:
if cmds.nodeType(node) not in self.accepted_controllers:
invalid.append(node)
return invalid

View file

@ -1,6 +0,0 @@
from .addon import ResolveAddon
__all__ = (
"ResolveAddon",
)

View file

@ -80,17 +80,21 @@ def get_engine_versions(env=None):
def get_editor_exe_path(engine_path: Path, engine_version: str) -> Path: def get_editor_exe_path(engine_path: Path, engine_version: str) -> Path:
"""Get UE Editor executable path.""" """Get UE Editor executable path."""
ue_path = engine_path / "Engine/Binaries" ue_path = engine_path / "Engine/Binaries"
ue_name = "UnrealEditor"
# handle older versions of Unreal Engine
if engine_version.split(".")[0] == "4":
ue_name = "UE4Editor"
if platform.system().lower() == "windows": if platform.system().lower() == "windows":
if engine_version.split(".")[0] == "4": ue_path /= f"Win64/{ue_name}.exe"
ue_path /= "Win64/UE4Editor.exe"
elif engine_version.split(".")[0] == "5":
ue_path /= "Win64/UnrealEditor.exe"
elif platform.system().lower() == "linux": elif platform.system().lower() == "linux":
ue_path /= "Linux/UE4Editor" ue_path /= f"Linux/{ue_name}"
elif platform.system().lower() == "darwin": elif platform.system().lower() == "darwin":
ue_path /= "Mac/UE4Editor" ue_path /= f"Mac/{ue_name}"
return ue_path return ue_path

View file

@ -1,5 +0,0 @@
from .clockify_module import ClockifyModule
__all__ = (
"ClockifyModule",
)

View file

@ -29,15 +29,11 @@ from ayon_core.pipeline.publish.lib import (
JSONDecodeError = getattr(json.decoder, "JSONDecodeError", ValueError) JSONDecodeError = getattr(json.decoder, "JSONDecodeError", ValueError)
# TODO both 'requests_post' and 'requests_get' should not set 'verify' based
# on environment variable. This should be done in a more controlled way,
# e.g. each deadline url could have checkbox to enabled/disable
# ssl verification.
def requests_post(*args, **kwargs): def requests_post(*args, **kwargs):
"""Wrap request post method. """Wrap request post method.
Disabling SSL certificate validation if ``DONT_VERIFY_SSL`` environment Disabling SSL certificate validation if ``verify`` kwarg is set to False.
variable is found. This is useful when Deadline server is This is useful when Deadline server is
running with self-signed certificates and its certificate is not running with self-signed certificates and its certificate is not
added to trusted certificates on client machines. added to trusted certificates on client machines.
@ -46,10 +42,6 @@ def requests_post(*args, **kwargs):
of defense SSL is providing, and it is not recommended. of defense SSL is providing, and it is not recommended.
""" """
if 'verify' not in kwargs:
kwargs['verify'] = False if os.getenv("OPENPYPE_DONT_VERIFY_SSL",
True) else True # noqa
auth = kwargs.get("auth") auth = kwargs.get("auth")
if auth: if auth:
kwargs["auth"] = tuple(auth) # explicit cast to tuple kwargs["auth"] = tuple(auth) # explicit cast to tuple
@ -61,8 +53,8 @@ def requests_post(*args, **kwargs):
def requests_get(*args, **kwargs): def requests_get(*args, **kwargs):
"""Wrap request get method. """Wrap request get method.
Disabling SSL certificate validation if ``DONT_VERIFY_SSL`` environment Disabling SSL certificate validation if ``verify`` kwarg is set to False.
variable is found. This is useful when Deadline server is This is useful when Deadline server is
running with self-signed certificates and its certificate is not running with self-signed certificates and its certificate is not
added to trusted certificates on client machines. added to trusted certificates on client machines.
@ -71,9 +63,6 @@ def requests_get(*args, **kwargs):
of defense SSL is providing, and it is not recommended. of defense SSL is providing, and it is not recommended.
""" """
if 'verify' not in kwargs:
kwargs['verify'] = False if os.getenv("OPENPYPE_DONT_VERIFY_SSL",
True) else True # noqa
auth = kwargs.get("auth") auth = kwargs.get("auth")
if auth: if auth:
kwargs["auth"] = tuple(auth) kwargs["auth"] = tuple(auth)
@ -466,7 +455,8 @@ class AbstractSubmitDeadline(pyblish.api.InstancePlugin,
self.aux_files = self.get_aux_files() self.aux_files = self.get_aux_files()
auth = instance.data["deadline"]["auth"] auth = instance.data["deadline"]["auth"]
job_id = self.process_submission(auth) verify = instance.data["deadline"]["verify"]
job_id = self.process_submission(auth, verify)
self.log.info("Submitted job to Deadline: {}.".format(job_id)) self.log.info("Submitted job to Deadline: {}.".format(job_id))
# TODO: Find a way that's more generic and not render type specific # TODO: Find a way that's more generic and not render type specific
@ -479,10 +469,10 @@ class AbstractSubmitDeadline(pyblish.api.InstancePlugin,
job_info=render_job_info, job_info=render_job_info,
plugin_info=render_plugin_info plugin_info=render_plugin_info
) )
render_job_id = self.submit(payload, auth) render_job_id = self.submit(payload, auth, verify)
self.log.info("Render job id: %s", render_job_id) self.log.info("Render job id: %s", render_job_id)
def process_submission(self, auth=None): def process_submission(self, auth=None, verify=True):
"""Process data for submission. """Process data for submission.
This takes Deadline JobInfo, PluginInfo, AuxFile, creates payload This takes Deadline JobInfo, PluginInfo, AuxFile, creates payload
@ -493,7 +483,7 @@ class AbstractSubmitDeadline(pyblish.api.InstancePlugin,
""" """
payload = self.assemble_payload() payload = self.assemble_payload()
return self.submit(payload, auth) return self.submit(payload, auth, verify)
@abstractmethod @abstractmethod
def get_job_info(self): def get_job_info(self):
@ -583,7 +573,7 @@ class AbstractSubmitDeadline(pyblish.api.InstancePlugin,
"AuxFiles": aux_files or self.aux_files "AuxFiles": aux_files or self.aux_files
} }
def submit(self, payload, auth): def submit(self, payload, auth, verify):
"""Submit payload to Deadline API end-point. """Submit payload to Deadline API end-point.
This takes payload in the form of JSON file and POST it to This takes payload in the form of JSON file and POST it to
@ -592,6 +582,7 @@ class AbstractSubmitDeadline(pyblish.api.InstancePlugin,
Args: Args:
payload (dict): dict to become json in deadline submission. payload (dict): dict to become json in deadline submission.
auth (tuple): (username, password) auth (tuple): (username, password)
verify (bool): verify SSL certificate if present
Returns: Returns:
str: resulting Deadline job id. str: resulting Deadline job id.
@ -601,8 +592,8 @@ class AbstractSubmitDeadline(pyblish.api.InstancePlugin,
""" """
url = "{}/api/jobs".format(self._deadline_url) url = "{}/api/jobs".format(self._deadline_url)
response = requests_post(url, json=payload, response = requests_post(
auth=auth) url, json=payload, auth=auth, verify=verify)
if not response.ok: if not response.ok:
self.log.error("Submission failed!") self.log.error("Submission failed!")
self.log.error(response.status_code) self.log.error(response.status_code)

View file

@ -7,6 +7,8 @@ import six
from ayon_core.lib import Logger from ayon_core.lib import Logger
from ayon_core.modules import AYONAddon, IPluginPaths from ayon_core.modules import AYONAddon, IPluginPaths
from .version import __version__
class DeadlineWebserviceError(Exception): class DeadlineWebserviceError(Exception):
""" """
@ -16,6 +18,7 @@ class DeadlineWebserviceError(Exception):
class DeadlineModule(AYONAddon, IPluginPaths): class DeadlineModule(AYONAddon, IPluginPaths):
name = "deadline" name = "deadline"
version = __version__
def initialize(self, studio_settings): def initialize(self, studio_settings):
# This module is always enabled # This module is always enabled

View file

@ -26,27 +26,32 @@ class CollectDeadlinePools(pyblish.api.InstancePlugin,
order = pyblish.api.CollectorOrder + 0.420 order = pyblish.api.CollectorOrder + 0.420
label = "Collect Deadline Pools" label = "Collect Deadline Pools"
hosts = ["aftereffects", hosts = [
"fusion", "aftereffects",
"harmony" "fusion",
"nuke", "harmony",
"maya", "maya",
"max", "max",
"houdini"] "houdini",
"nuke",
]
families = ["render", families = [
"rendering", "render",
"render.farm", "prerender",
"renderFarm", "rendering",
"renderlayer", "render.farm",
"maxrender", "renderFarm",
"usdrender", "renderlayer",
"redshift_rop", "maxrender",
"arnold_rop", "usdrender",
"mantra_rop", "redshift_rop",
"karma_rop", "arnold_rop",
"vray_rop", "mantra_rop",
"publish.hou"] "karma_rop",
"vray_rop",
"publish.hou",
]
primary_pool = None primary_pool = None
secondary_pool = None secondary_pool = None

View file

@ -76,6 +76,9 @@ class CollectDeadlineUserCredentials(pyblish.api.InstancePlugin):
) )
instance.data["deadline"]["auth"] = None instance.data["deadline"]["auth"] = None
instance.data["deadline"]["verify"] = (
not deadline_info["not_verify_ssl"])
if not deadline_info["require_authentication"]: if not deadline_info["require_authentication"]:
return return
# TODO import 'get_addon_site_settings' when available # TODO import 'get_addon_site_settings' when available

View file

@ -174,8 +174,9 @@ class BlenderSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline,
instance.data["toBeRenderedOn"] = "deadline" instance.data["toBeRenderedOn"] = "deadline"
payload = self.assemble_payload() payload = self.assemble_payload()
return self.submit(payload, auth = instance.data["deadline"]["auth"]
auth=instance.data["deadline"]["auth"]) verify = instance.data["deadline"]["verify"]
return self.submit(payload, auth=auth, verify=verify)
def from_published_scene(self): def from_published_scene(self):
""" """

View file

@ -193,9 +193,11 @@ class CelactionSubmitDeadline(pyblish.api.InstancePlugin):
self.expected_files(instance, render_path) self.expected_files(instance, render_path)
self.log.debug("__ expectedFiles: `{}`".format( self.log.debug("__ expectedFiles: `{}`".format(
instance.data["expectedFiles"])) instance.data["expectedFiles"]))
auth = instance.data["deadline"]["auth"]
verify = instance.data["deadline"]["verify"]
response = requests_post(self.deadline_url, json=payload, response = requests_post(self.deadline_url, json=payload,
auth=instance.data["deadline"]["require_authentication"]) auth=auth,
verify=verify)
if not response.ok: if not response.ok:
self.log.error( self.log.error(

View file

@ -79,7 +79,7 @@ class FusionSubmitDeadline(
else: else:
context.data[key] = True context.data[key] = True
from ayon_core.hosts.fusion.api.lib import get_frame_path from ayon_fusion.api.lib import get_frame_path
deadline_url = instance.data["deadline"]["url"] deadline_url = instance.data["deadline"]["url"]
assert deadline_url, "Requires Deadline Webservice URL" assert deadline_url, "Requires Deadline Webservice URL"
@ -242,7 +242,8 @@ class FusionSubmitDeadline(
# E.g. http://192.168.0.1:8082/api/jobs # E.g. http://192.168.0.1:8082/api/jobs
url = "{}/api/jobs".format(deadline_url) url = "{}/api/jobs".format(deadline_url)
auth = instance.data["deadline"]["auth"] auth = instance.data["deadline"]["auth"]
response = requests_post(url, json=payload, auth=auth) verify = instance.data["deadline"]["verify"]
response = requests_post(url, json=payload, auth=auth, verify=verify)
if not response.ok: if not response.ok:
raise Exception(response.text) raise Exception(response.text)

View file

@ -85,7 +85,7 @@ class HoudiniSubmitDeadline(
priority = 50 priority = 50
chunk_size = 1 chunk_size = 1
group = "" group = ""
@classmethod @classmethod
def get_attribute_defs(cls): def get_attribute_defs(cls):
return [ return [
@ -188,7 +188,7 @@ class HoudiniSubmitDeadline(
job_info.Pool = instance.data.get("primaryPool") job_info.Pool = instance.data.get("primaryPool")
job_info.SecondaryPool = instance.data.get("secondaryPool") job_info.SecondaryPool = instance.data.get("secondaryPool")
if split_render_job and is_export_job: if split_render_job and is_export_job:
job_info.Priority = attribute_values.get( job_info.Priority = attribute_values.get(
"export_priority", self.export_priority "export_priority", self.export_priority
@ -309,6 +309,11 @@ class HoudiniSubmitDeadline(
return attr.asdict(plugin_info) return attr.asdict(plugin_info)
def process(self, instance): def process(self, instance):
if not instance.data["farm"]:
self.log.debug("Render on farm is disabled. "
"Skipping deadline submission.")
return
super(HoudiniSubmitDeadline, self).process(instance) super(HoudiniSubmitDeadline, self).process(instance)
# TODO: Avoid the need for this logic here, needed for submit publish # TODO: Avoid the need for this logic here, needed for submit publish

View file

@ -15,11 +15,11 @@ from ayon_core.pipeline.publish.lib import (
replace_with_published_scene_path replace_with_published_scene_path
) )
from ayon_core.pipeline.publish import KnownPublishError from ayon_core.pipeline.publish import KnownPublishError
from ayon_core.hosts.max.api.lib import ( from ayon_max.api.lib import (
get_current_renderer, get_current_renderer,
get_multipass_setting get_multipass_setting
) )
from ayon_core.hosts.max.api.lib_rendersettings import RenderSettings from ayon_max.api.lib_rendersettings import RenderSettings
from openpype_modules.deadline import abstract_submit_deadline from openpype_modules.deadline import abstract_submit_deadline
from openpype_modules.deadline.abstract_submit_deadline import DeadlineJobInfo from openpype_modules.deadline.abstract_submit_deadline import DeadlineJobInfo
@ -181,27 +181,35 @@ class MaxSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline,
self.log.debug("Submitting 3dsMax render..") self.log.debug("Submitting 3dsMax render..")
project_settings = instance.context.data["project_settings"] project_settings = instance.context.data["project_settings"]
auth = instance.data["deadline"]["auth"]
verify = instance.data["deadline"]["verify"]
if instance.data.get("multiCamera"): if instance.data.get("multiCamera"):
self.log.debug("Submitting jobs for multiple cameras..") self.log.debug("Submitting jobs for multiple cameras..")
payload = self._use_published_name_for_multiples( payload = self._use_published_name_for_multiples(
payload_data, project_settings) payload_data, project_settings)
job_infos, plugin_infos = payload job_infos, plugin_infos = payload
for job_info, plugin_info in zip(job_infos, plugin_infos): for job_info, plugin_info in zip(job_infos, plugin_infos):
self.submit(self.assemble_payload(job_info, plugin_info), self.submit(
instance.data["deadline"]["auth"]) self.assemble_payload(job_info, plugin_info),
auth=auth,
verify=verify
)
else: else:
payload = self._use_published_name(payload_data, project_settings) payload = self._use_published_name(payload_data, project_settings)
job_info, plugin_info = payload job_info, plugin_info = payload
self.submit(self.assemble_payload(job_info, plugin_info), self.submit(
instance.data["deadline"]["auth"]) self.assemble_payload(job_info, plugin_info),
auth=auth,
verify=verify
)
def _use_published_name(self, data, project_settings): def _use_published_name(self, data, project_settings):
# Not all hosts can import these modules. # Not all hosts can import these modules.
from ayon_core.hosts.max.api.lib import ( from ayon_max.api.lib import (
get_current_renderer, get_current_renderer,
get_multipass_setting get_multipass_setting
) )
from ayon_core.hosts.max.api.lib_rendersettings import RenderSettings from ayon_max.api.lib_rendersettings import RenderSettings
instance = self._instance instance = self._instance
job_info = copy.deepcopy(self.job_info) job_info = copy.deepcopy(self.job_info)

View file

@ -39,8 +39,8 @@ from ayon_core.lib import (
EnumDef, EnumDef,
is_in_tests, is_in_tests,
) )
from ayon_core.hosts.maya.api.lib_rendersettings import RenderSettings from ayon_maya.api.lib_rendersettings import RenderSettings
from ayon_core.hosts.maya.api.lib import get_attr_in_layer from ayon_maya.api.lib import get_attr_in_layer
from openpype_modules.deadline import abstract_submit_deadline from openpype_modules.deadline import abstract_submit_deadline
from openpype_modules.deadline.abstract_submit_deadline import DeadlineJobInfo from openpype_modules.deadline.abstract_submit_deadline import DeadlineJobInfo
@ -292,7 +292,7 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline,
return plugin_payload return plugin_payload
def process_submission(self, auth=None): def process_submission(self, auth=None, verify=True):
from maya import cmds from maya import cmds
instance = self._instance instance = self._instance
@ -332,8 +332,10 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline,
if "vrayscene" in instance.data["families"]: if "vrayscene" in instance.data["families"]:
self.log.debug("Submitting V-Ray scene render..") self.log.debug("Submitting V-Ray scene render..")
vray_export_payload = self._get_vray_export_payload(payload_data) vray_export_payload = self._get_vray_export_payload(payload_data)
export_job = self.submit(vray_export_payload, export_job = self.submit(vray_export_payload,
instance.data["deadline"]["auth"]) auth=auth,
verify=verify)
payload = self._get_vray_render_payload(payload_data) payload = self._get_vray_render_payload(payload_data)
@ -353,7 +355,8 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline,
# Submit main render job # Submit main render job
job_info, plugin_info = payload job_info, plugin_info = payload
self.submit(self.assemble_payload(job_info, plugin_info), self.submit(self.assemble_payload(job_info, plugin_info),
instance.data["deadline"]["auth"]) auth=auth,
verify=verify)
def _tile_render(self, payload): def _tile_render(self, payload):
"""Submit as tile render per frame with dependent assembly jobs.""" """Submit as tile render per frame with dependent assembly jobs."""
@ -557,13 +560,18 @@ class MayaSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline,
# Submit assembly jobs # Submit assembly jobs
assembly_job_ids = [] assembly_job_ids = []
num_assemblies = len(assembly_payloads) num_assemblies = len(assembly_payloads)
auth = instance.data["deadline"]["auth"]
verify = instance.data["deadline"]["verify"]
for i, payload in enumerate(assembly_payloads): for i, payload in enumerate(assembly_payloads):
self.log.debug( self.log.debug(
"submitting assembly job {} of {}".format(i + 1, "submitting assembly job {} of {}".format(i + 1,
num_assemblies) num_assemblies)
) )
assembly_job_id = self.submit(payload, assembly_job_id = self.submit(
instance.data["deadline"]["auth"]) payload,
auth=auth,
verify=verify
)
assembly_job_ids.append(assembly_job_id) assembly_job_ids.append(assembly_job_id)
instance.data["assemblySubmissionJobs"] = assembly_job_ids instance.data["assemblySubmissionJobs"] = assembly_job_ids

View file

@ -424,8 +424,12 @@ class NukeSubmitDeadline(pyblish.api.InstancePlugin,
self.log.debug("__ expectedFiles: `{}`".format( self.log.debug("__ expectedFiles: `{}`".format(
instance.data["expectedFiles"])) instance.data["expectedFiles"]))
auth = instance.data["deadline"]["auth"] auth = instance.data["deadline"]["auth"]
response = requests_post(self.deadline_url, json=payload, timeout=10, verify = instance.data["deadline"]["verify"]
auth=auth) response = requests_post(self.deadline_url,
json=payload,
timeout=10,
auth=auth,
verify=verify)
if not response.ok: if not response.ok:
raise Exception(response.text) raise Exception(response.text)

View file

@ -210,8 +210,9 @@ class ProcessSubmittedCacheJobOnFarm(pyblish.api.InstancePlugin,
url = "{}/api/jobs".format(self.deadline_url) url = "{}/api/jobs".format(self.deadline_url)
auth = instance.data["deadline"]["auth"] auth = instance.data["deadline"]["auth"]
response = requests_post(url, json=payload, timeout=10, verify = instance.data["deadline"]["verify"]
auth=auth) response = requests_post(
url, json=payload, timeout=10, auth=auth, verify=verify)
if not response.ok: if not response.ok:
raise Exception(response.text) raise Exception(response.text)

View file

@ -304,8 +304,9 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin,
url = "{}/api/jobs".format(self.deadline_url) url = "{}/api/jobs".format(self.deadline_url)
auth = instance.data["deadline"]["auth"] auth = instance.data["deadline"]["auth"]
response = requests_post(url, json=payload, timeout=10, verify = instance.data["deadline"]["verify"]
auth=auth) response = requests_post(
url, json=payload, timeout=10, auth=auth, verify=verify)
if not response.ok: if not response.ok:
raise Exception(response.text) raise Exception(response.text)

View file

@ -1 +1 @@
__version__ = "0.1.10" __version__ = "0.1.12"

View file

@ -1,6 +1,9 @@
from .version import __version__
from .addon import JobQueueAddon from .addon import JobQueueAddon
__all__ = ( __all__ = (
"__version__",
"JobQueueAddon", "JobQueueAddon",
) )

View file

@ -44,9 +44,12 @@ import platform
from ayon_core.addon import AYONAddon, click_wrap from ayon_core.addon import AYONAddon, click_wrap
from ayon_core.settings import get_studio_settings from ayon_core.settings import get_studio_settings
from .version import __version__
class JobQueueAddon(AYONAddon): class JobQueueAddon(AYONAddon):
name = "job_queue" name = "job_queue"
version = __version__
def initialize(self, studio_settings): def initialize(self, studio_settings):
addon_settings = studio_settings.get(self.name) or {} addon_settings = studio_settings.get(self.name) or {}

View file

@ -0,0 +1 @@
__version__ = "1.0.0"

View file

@ -7,6 +7,7 @@ from ayon_core.addon import AYONAddon, ITrayAction
class LauncherAction(AYONAddon, ITrayAction): class LauncherAction(AYONAddon, ITrayAction):
label = "Launcher" label = "Launcher"
name = "launcher_tool" name = "launcher_tool"
version = "1.0.0"
def initialize(self, settings): def initialize(self, settings):

View file

@ -3,6 +3,7 @@ from ayon_core.addon import AYONAddon, ITrayAddon
class LoaderAddon(AYONAddon, ITrayAddon): class LoaderAddon(AYONAddon, ITrayAddon):
name = "loader_tool" name = "loader_tool"
version = "1.0.0"
def initialize(self, settings): def initialize(self, settings):
# Tray attributes # Tray attributes

View file

@ -4,6 +4,7 @@ from ayon_core.addon import AYONAddon, ITrayAction
class PythonInterpreterAction(AYONAddon, ITrayAction): class PythonInterpreterAction(AYONAddon, ITrayAction):
label = "Console" label = "Console"
name = "python_interpreter" name = "python_interpreter"
version = "1.0.0"
admin_action = True admin_action = True
def initialize(self, settings): def initialize(self, settings):

View file

@ -1,6 +1,9 @@
from .version import __version__
from .addon import RoyalRenderAddon from .addon import RoyalRenderAddon
__all__ = ( __all__ = (
"__version__",
"RoyalRenderAddon", "RoyalRenderAddon",
) )

View file

@ -4,10 +4,13 @@ import os
from ayon_core.addon import AYONAddon, IPluginPaths from ayon_core.addon import AYONAddon, IPluginPaths
from .version import __version__
class RoyalRenderAddon(AYONAddon, IPluginPaths): class RoyalRenderAddon(AYONAddon, IPluginPaths):
"""Class providing basic Royal Render implementation logic.""" """Class providing basic Royal Render implementation logic."""
name = "royalrender" name = "royalrender"
version = __version__
# _rr_api = None # _rr_api = None
# @property # @property

View file

@ -7,7 +7,7 @@ from ayon_core.lib import Logger, run_subprocess, AYONSettingsRegistry
from ayon_core.lib.vendor_bin_utils import find_tool_in_custom_paths from ayon_core.lib.vendor_bin_utils import find_tool_in_custom_paths
from .rr_job import SubmitFile from .rr_job import SubmitFile
from .rr_job import RRjob, SubmitterParameter # noqa F401 from .rr_job import RRJob, SubmitterParameter # noqa F401
class Api: class Api:

View file

@ -0,0 +1 @@
__version__ = "0.1.1"

View file

@ -1,7 +1,10 @@
from .version import __version__
from .timers_manager import ( from .timers_manager import (
TimersManager TimersManager
) )
__all__ = ( __all__ = (
"__version__",
"TimersManager", "TimersManager",
) )

View file

@ -10,6 +10,7 @@ from ayon_core.addon import (
) )
from ayon_core.lib.events import register_event_callback from ayon_core.lib.events import register_event_callback
from .version import __version__
from .exceptions import InvalidContextError from .exceptions import InvalidContextError
TIMER_MODULE_DIR = os.path.dirname(os.path.abspath(__file__)) TIMER_MODULE_DIR = os.path.dirname(os.path.abspath(__file__))
@ -96,6 +97,7 @@ class TimersManager(
See `ExampleTimersManagerConnector`. See `ExampleTimersManagerConnector`.
""" """
name = "timers_manager" name = "timers_manager"
version = __version__
label = "Timers Service" label = "Timers Service"
_required_methods = ( _required_methods = (

View file

@ -0,0 +1 @@
__version__ = "0.1.1"

View file

@ -1,8 +1,13 @@
from .version import __version__
from .structures import HostMsgAction
from .webserver_module import ( from .webserver_module import (
WebServerAddon WebServerAddon
) )
__all__ = ( __all__ = (
"__version__",
"HostMsgAction",
"WebServerAddon", "WebServerAddon",
) )

View file

@ -9,22 +9,18 @@ from qtpy import QtWidgets
from ayon_core.addon import ITrayService from ayon_core.addon import ITrayService
from ayon_core.tools.stdout_broker.window import ConsoleDialog from ayon_core.tools.stdout_broker.window import ConsoleDialog
from .structures import HostMsgAction
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# Host listener icon type
class IconType: class IconType:
IDLE = "idle" IDLE = "idle"
RUNNING = "running" RUNNING = "running"
FAILED = "failed" FAILED = "failed"
class MsgAction:
CONNECTING = "connecting"
INITIALIZED = "initialized"
ADD = "add"
CLOSE = "close"
class HostListener: class HostListener:
def __init__(self, webserver, module): def __init__(self, webserver, module):
self._window_per_id = {} self._window_per_id = {}
@ -96,22 +92,22 @@ class HostListener:
if msg.type == aiohttp.WSMsgType.TEXT: if msg.type == aiohttp.WSMsgType.TEXT:
host_name, action, text = self._parse_message(msg) host_name, action, text = self._parse_message(msg)
if action == MsgAction.CONNECTING: if action == HostMsgAction.CONNECTING:
self._action_per_id[host_name] = None self._action_per_id[host_name] = None
# must be sent to main thread, or action wont trigger # must be sent to main thread, or action wont trigger
self.module.execute_in_main_thread( self.module.execute_in_main_thread(
lambda: self._host_is_connecting(host_name, text)) lambda: self._host_is_connecting(host_name, text))
elif action == MsgAction.CLOSE: elif action == HostMsgAction.CLOSE:
# clean close # clean close
self._close(host_name) self._close(host_name)
await ws.close() await ws.close()
elif action == MsgAction.INITIALIZED: elif action == HostMsgAction.INITIALIZED:
self.module.execute_in_main_thread( self.module.execute_in_main_thread(
# must be queued as _host_is_connecting might not # must be queued as _host_is_connecting might not
# be triggered/finished yet # be triggered/finished yet
lambda: self._set_host_icon(host_name, lambda: self._set_host_icon(host_name,
IconType.RUNNING)) IconType.RUNNING))
elif action == MsgAction.ADD: elif action == HostMsgAction.ADD:
self.module.execute_in_main_thread( self.module.execute_in_main_thread(
lambda: self._add_text(host_name, text)) lambda: self._add_text(host_name, text))
elif msg.type == aiohttp.WSMsgType.ERROR: elif msg.type == aiohttp.WSMsgType.ERROR:

View file

@ -0,0 +1,6 @@
# Host listener message actions
class HostMsgAction:
CONNECTING = "connecting"
INITIALIZED = "initialized"
ADD = "add"
CLOSE = "close"

View file

@ -0,0 +1 @@
__version__ = "1.0.0"

View file

@ -26,9 +26,12 @@ import socket
from ayon_core import resources from ayon_core import resources
from ayon_core.addon import AYONAddon, ITrayService from ayon_core.addon import AYONAddon, ITrayService
from .version import __version__
class WebServerAddon(AYONAddon, ITrayService): class WebServerAddon(AYONAddon, ITrayService):
name = "webserver" name = "webserver"
version = __version__
label = "WebServer" label = "WebServer"
webserver_url_env = "AYON_WEBSERVER_URL" webserver_url_env = "AYON_WEBSERVER_URL"

File diff suppressed because it is too large Load diff

View file

@ -459,36 +459,6 @@ def is_representation_from_latest(representation):
) )
def get_template_data_from_session(session=None, settings=None):
"""Template data for template fill from session keys.
Args:
session (Union[Dict[str, str], None]): The Session to use. If not
provided use the currently active global Session.
settings (Optional[Dict[str, Any]]): Prepared studio or project
settings.
Returns:
Dict[str, Any]: All available data from session.
"""
if session is not None:
project_name = session["AYON_PROJECT_NAME"]
folder_path = session["AYON_FOLDER_PATH"]
task_name = session["AYON_TASK_NAME"]
host_name = session["AYON_HOST_NAME"]
else:
context = get_current_context()
project_name = context["project_name"]
folder_path = context["folder_path"]
task_name = context["task_name"]
host_name = get_current_host_name()
return get_template_data_with_names(
project_name, folder_path, task_name, host_name, settings
)
def get_current_context_template_data(settings=None): def get_current_context_template_data(settings=None):
"""Prepare template data for current context. """Prepare template data for current context.

View file

@ -681,7 +681,7 @@ class PublishAttributeValues(AttributeValues):
@property @property
def parent(self): def parent(self):
self.publish_attributes.parent return self.publish_attributes.parent
class PublishAttributes: class PublishAttributes:

View file

@ -336,17 +336,16 @@ def get_plugin_settings(plugin, project_settings, log, category=None):
settings_category = getattr(plugin, "settings_category", None) settings_category = getattr(plugin, "settings_category", None)
if settings_category: if settings_category:
try: try:
return ( category_settings = project_settings[settings_category]
project_settings
[settings_category]
["publish"]
[plugin.__name__]
)
except KeyError: except KeyError:
log.warning(( log.warning((
"Couldn't find plugin '{}' settings" "Couldn't find settings category '{}' in project settings"
" under settings category '{}'" ).format(settings_category))
).format(plugin.__name__, settings_category)) return {}
try:
return category_settings["publish"][plugin.__name__]
except KeyError:
return {} return {}
# Use project settings based on a category name # Use project settings based on a category name

View file

@ -108,69 +108,6 @@ class IntegrateAsset(pyblish.api.InstancePlugin):
label = "Integrate Asset" label = "Integrate Asset"
order = pyblish.api.IntegratorOrder order = pyblish.api.IntegratorOrder
families = ["workfile",
"pointcache",
"pointcloud",
"proxyAbc",
"camera",
"animation",
"model",
"maxScene",
"mayaAscii",
"mayaScene",
"setdress",
"layout",
"ass",
"assProxy",
"vdbcache",
"scene",
"vrayproxy",
"vrayscene_layer",
"render",
"prerender",
"imagesequence",
"review",
"rendersetup",
"rig",
"plate",
"look",
"ociolook",
"audio",
"yetiRig",
"yeticache",
"nukenodes",
"gizmo",
"source",
"matchmove",
"image",
"assembly",
"fbx",
"gltf",
"textures",
"action",
"harmony.template",
"harmony.palette",
"editorial",
"background",
"camerarig",
"redshiftproxy",
"effect",
"xgen",
"hda",
"usd",
"staticMesh",
"skeletalMesh",
"mvLook",
"mvUsd",
"mvUsdComposition",
"mvUsdOverride",
"online",
"uasset",
"blendScene",
"yeticacheUE",
"tycache",
"csv_ingest_file",
]
default_template_name = "publish" default_template_name = "publish"
@ -360,7 +297,7 @@ class IntegrateAsset(pyblish.api.InstancePlugin):
# Compute the resource file infos once (files belonging to the # Compute the resource file infos once (files belonging to the
# version instance instead of an individual representation) so # version instance instead of an individual representation) so
# we can re-use those file infos per representation # we can reuse those file infos per representation
resource_file_infos = self.get_files_info( resource_file_infos = self.get_files_info(
resource_destinations, anatomy resource_destinations, anatomy
) )

View file

@ -1,6 +1,11 @@
import pyblish.api import pyblish.api
from ayon_core.lib import filter_profiles
from ayon_core.host import ILoadHost
from ayon_core.pipeline.load import any_outdated_containers from ayon_core.pipeline.load import any_outdated_containers
from ayon_core.pipeline import ( from ayon_core.pipeline import (
get_current_host_name,
registered_host,
PublishXmlValidationError, PublishXmlValidationError,
OptionalPyblishPluginMixin OptionalPyblishPluginMixin
) )
@ -18,17 +23,50 @@ class ShowInventory(pyblish.api.Action):
host_tools.show_scene_inventory() host_tools.show_scene_inventory()
class ValidateContainers(OptionalPyblishPluginMixin, class ValidateOutdatedContainers(
pyblish.api.ContextPlugin): OptionalPyblishPluginMixin,
pyblish.api.ContextPlugin
):
"""Containers are must be updated to latest version on publish.""" """Containers are must be updated to latest version on publish."""
label = "Validate Outdated Containers" label = "Validate Outdated Containers"
order = pyblish.api.ValidatorOrder order = pyblish.api.ValidatorOrder
hosts = ["maya", "houdini", "nuke", "harmony", "photoshop", "aftereffects"]
optional = True optional = True
actions = [ShowInventory] actions = [ShowInventory]
@classmethod
def apply_settings(cls, settings):
# Disable plugin if host does not inherit from 'ILoadHost'
# - not a host that can load containers
host = registered_host()
if not isinstance(host, ILoadHost):
cls.enabled = False
return
# Disable if no profile is found for the current host
profiles = (
settings
["core"]
["publish"]
["ValidateOutdatedContainers"]
["plugin_state_profiles"]
)
profile = filter_profiles(
profiles, {"host_names": get_current_host_name()}
)
if not profile:
cls.enabled = False
return
# Apply settings from profile
for attr_name in {
"enabled",
"optional",
"active",
}:
setattr(cls, attr_name, profile[attr_name])
def process(self, context): def process(self, context):
if not self.is_active(context.data): if not self.is_active(context.data):
return return

View file

@ -1,8 +1,14 @@
import pyblish.api import pyblish.api
from ayon_core.pipeline.publish import PublishValidationError
from ayon_core.lib import filter_profiles
from ayon_core.pipeline.publish import (
PublishValidationError,
OptionalPyblishPluginMixin
)
from ayon_core.pipeline import get_current_host_name
class ValidateVersion(pyblish.api.InstancePlugin): class ValidateVersion(pyblish.api.InstancePlugin, OptionalPyblishPluginMixin):
"""Validate instance version. """Validate instance version.
AYON does not allow overwriting previously published versions. AYON does not allow overwriting previously published versions.
@ -11,13 +17,39 @@ class ValidateVersion(pyblish.api.InstancePlugin):
order = pyblish.api.ValidatorOrder order = pyblish.api.ValidatorOrder
label = "Validate Version" label = "Validate Version"
hosts = ["nuke", "maya", "houdini", "blender",
"photoshop", "aftereffects"]
optional = False optional = False
active = True active = True
@classmethod
def apply_settings(cls, settings):
# Disable if no profile is found for the current host
profiles = (
settings
["core"]
["publish"]
["ValidateVersion"]
["plugin_state_profiles"]
)
profile = filter_profiles(
profiles, {"host_names": get_current_host_name()}
)
if not profile:
cls.enabled = False
return
# Apply settings from profile
for attr_name in {
"enabled",
"optional",
"active",
}:
setattr(cls, attr_name, profile[attr_name])
def process(self, instance): def process(self, instance):
if not self.is_active(instance.data):
return
version = instance.data.get("version") version = instance.data.get("version")
latest_version = instance.data.get("latestVersion") latest_version = instance.data.get("latestVersion")

View file

@ -1,28 +1,31 @@
"""OpenColorIO Wrapper. """OpenColorIO Wrapper.
Only to be interpreted by Python 3. It is run in subprocess in case Receive OpenColorIO information and store it in JSON format for processed
Python 2 hosts needs to use it. Or it is used as module for Python 3 that don't have access to OpenColorIO or their version of OpenColorIO is
processing. not compatible.
Providing functionality:
- get_colorspace - console command - python 2
- returning all available color spaces
found in input config path.
- _get_colorspace_data - python 3 - module function
- returning all available colorspaces
found in input config path.
- get_views - console command - python 2
- returning all available viewers
found in input config path.
- _get_views_data - python 3 - module function
- returning all available viewers
found in input config path.
""" """
import click
import json import json
from pathlib import Path from pathlib import Path
import PyOpenColorIO as ocio
import click
from ayon_core.pipeline.colorspace import (
has_compatible_ocio_package,
get_display_view_colorspace_name,
get_config_file_rules_colorspace_from_filepath,
get_config_version_data,
get_ocio_config_views,
get_ocio_config_colorspaces,
)
def _save_output_to_json_file(output, output_path):
json_path = Path(output_path)
with open(json_path, "w") as stream:
json.dump(output, stream)
print(f"Data are saved to '{json_path}'")
@click.group() @click.group()
@ -30,404 +33,185 @@ def main():
pass # noqa: WPS100 pass # noqa: WPS100
@main.group() @main.command(
def config(): name="get_ocio_config_colorspaces",
"""Config related commands group help="return all colorspaces from config file")
@click.option(
Example of use: "--config_path",
> pyton.exe ./ocio_wrapper.py config <command> *args required=True,
""" help="OCIO config path to read ocio config file.",
pass # noqa: WPS100 type=click.Path(exists=True))
@click.option(
"--output_path",
@main.group() required=True,
def colorspace(): help="path where to write output json file",
"""Colorspace related commands group type=click.Path())
def _get_ocio_config_colorspaces(config_path, output_path):
Example of use:
> pyton.exe ./ocio_wrapper.py config <command> *args
"""
pass # noqa: WPS100
@config.command(
name="get_colorspace",
help=(
"return all colorspaces from config file "
"--path input arg is required"
)
)
@click.option("--in_path", required=True,
help="path where to read ocio config file",
type=click.Path(exists=True))
@click.option("--out_path", required=True,
help="path where to write output json file",
type=click.Path())
def get_colorspace(in_path, out_path):
"""Aggregate all colorspace to file. """Aggregate all colorspace to file.
Python 2 wrapped console command
Args: Args:
in_path (str): config file path string config_path (str): config file path string
out_path (str): temp json file path string output_path (str): temp json file path string
Example of use: Example of use:
> pyton.exe ./ocio_wrapper.py config get_colorspace > pyton.exe ./ocio_wrapper.py config get_colorspace
--in_path=<path> --out_path=<path> --config_path <path> --output_path <path>
""" """
json_path = Path(out_path) _save_output_to_json_file(
get_ocio_config_colorspaces(config_path),
out_data = _get_colorspace_data(in_path) output_path
with open(json_path, "w") as f_:
json.dump(out_data, f_)
print(f"Colorspace data are saved to '{json_path}'")
def _get_colorspace_data(config_path):
"""Return all found colorspace data.
Args:
config_path (str): path string leading to config.ocio
Raises:
IOError: Input config does not exist.
Returns:
dict: aggregated available colorspaces
"""
config_path = Path(config_path)
if not config_path.is_file():
raise IOError(
f"Input path `{config_path}` should be `config.ocio` file")
config = ocio.Config().CreateFromFile(str(config_path))
colorspace_data = {
"roles": {},
"colorspaces": {
color.getName(): {
"family": color.getFamily(),
"categories": list(color.getCategories()),
"aliases": list(color.getAliases()),
"equalitygroup": color.getEqualityGroup(),
}
for color in config.getColorSpaces()
},
"displays_views": {
f"{view} ({display})": {
"display": display,
"view": view
}
for display in config.getDisplays()
for view in config.getViews(display)
},
"looks": {}
}
# add looks
looks = config.getLooks()
if looks:
colorspace_data["looks"] = {
look.getName(): {"process_space": look.getProcessSpace()}
for look in looks
}
# add roles
roles = config.getRoles()
if roles:
colorspace_data["roles"] = {
role: {"colorspace": colorspace}
for (role, colorspace) in roles
}
return colorspace_data
@config.command(
name="get_views",
help=(
"return all viewers from config file "
"--path input arg is required"
) )
)
@click.option("--in_path", required=True,
help="path where to read ocio config file", @main.command(
type=click.Path(exists=True)) name="get_ocio_config_views",
@click.option("--out_path", required=True, help="All viewers from config file")
help="path where to write output json file", @click.option(
type=click.Path()) "--config_path",
def get_views(in_path, out_path): required=True,
help="OCIO config path to read ocio config file.",
type=click.Path(exists=True))
@click.option(
"--output_path",
required=True,
help="path where to write output json file",
type=click.Path())
def _get_ocio_config_views(config_path, output_path):
"""Aggregate all viewers to file. """Aggregate all viewers to file.
Python 2 wrapped console command
Args: Args:
in_path (str): config file path string config_path (str): config file path string
out_path (str): temp json file path string output_path (str): temp json file path string
Example of use: Example of use:
> pyton.exe ./ocio_wrapper.py config get_views \ > pyton.exe ./ocio_wrapper.py config get_views \
--in_path=<path> --out_path=<path> --config_path <path> --output <path>
""" """
json_path = Path(out_path) _save_output_to_json_file(
get_ocio_config_views(config_path),
out_data = _get_views_data(in_path) output_path
with open(json_path, "w") as f_:
json.dump(out_data, f_)
print(f"Viewer data are saved to '{json_path}'")
def _get_views_data(config_path):
"""Return all found viewer data.
Args:
config_path (str): path string leading to config.ocio
Raises:
IOError: Input config does not exist.
Returns:
dict: aggregated available viewers
"""
config_path = Path(config_path)
if not config_path.is_file():
raise IOError("Input path should be `config.ocio` file")
config = ocio.Config().CreateFromFile(str(config_path))
data_ = {}
for display in config.getDisplays():
for view in config.getViews(display):
colorspace = config.getDisplayViewColorSpaceName(display, view)
# Special token. See https://opencolorio.readthedocs.io/en/latest/guides/authoring/authoring.html#shared-views # noqa
if colorspace == "<USE_DISPLAY_NAME>":
colorspace = display
data_[f"{display}/{view}"] = {
"display": display,
"view": view,
"colorspace": colorspace
}
return data_
@config.command(
name="get_version",
help=(
"return major and minor version from config file "
"--config_path input arg is required"
"--out_path input arg is required"
) )
)
@click.option("--config_path", required=True,
help="path where to read ocio config file",
type=click.Path(exists=True))
@click.option("--out_path", required=True,
help="path where to write output json file",
type=click.Path())
def get_version(config_path, out_path):
"""Get version of config.
Python 2 wrapped console command
@main.command(
name="get_config_version_data",
help="Get major and minor version from config file")
@click.option(
"--config_path",
required=True,
help="OCIO config path to read ocio config file.",
type=click.Path(exists=True))
@click.option(
"--output_path",
required=True,
help="path where to write output json file",
type=click.Path())
def _get_config_version_data(config_path, output_path):
"""Get version of config.
Args: Args:
config_path (str): ocio config file path string config_path (str): ocio config file path string
out_path (str): temp json file path string output_path (str): temp json file path string
Example of use: Example of use:
> pyton.exe ./ocio_wrapper.py config get_version \ > pyton.exe ./ocio_wrapper.py config get_version \
--config_path=<path> --out_path=<path> --config_path <path> --output_path <path>
""" """
json_path = Path(out_path) _save_output_to_json_file(
get_config_version_data(config_path),
out_data = _get_version_data(config_path) output_path
with open(json_path, "w") as f_:
json.dump(out_data, f_)
print(f"Config version data are saved to '{json_path}'")
def _get_version_data(config_path):
"""Return major and minor version info.
Args:
config_path (str): path string leading to config.ocio
Raises:
IOError: Input config does not exist.
Returns:
dict: minor and major keys with values
"""
config_path = Path(config_path)
if not config_path.is_file():
raise IOError("Input path should be `config.ocio` file")
config = ocio.Config().CreateFromFile(str(config_path))
return {
"major": config.getMajorVersion(),
"minor": config.getMinorVersion()
}
@colorspace.command(
name="get_config_file_rules_colorspace_from_filepath",
help=(
"return colorspace from filepath "
"--config_path - ocio config file path (input arg is required) "
"--filepath - any file path (input arg is required) "
"--out_path - temp json file path (input arg is required)"
) )
)
@click.option("--config_path", required=True,
help="path where to read ocio config file", @main.command(
type=click.Path(exists=True)) name="get_config_file_rules_colorspace_from_filepath",
@click.option("--filepath", required=True, help="Colorspace file rules from filepath")
help="path to file to get colorspace from", @click.option(
type=click.Path()) "--config_path",
@click.option("--out_path", required=True, required=True,
help="path where to write output json file", help="OCIO config path to read ocio config file.",
type=click.Path()) type=click.Path(exists=True))
def get_config_file_rules_colorspace_from_filepath( @click.option(
config_path, filepath, out_path "--filepath",
required=True,
help="Path to file to get colorspace from.",
type=click.Path())
@click.option(
"--output_path",
required=True,
help="Path where to write output json file.",
type=click.Path())
def _get_config_file_rules_colorspace_from_filepath(
config_path, filepath, output_path
): ):
"""Get colorspace from file path wrapper. """Get colorspace from file path wrapper.
Python 2 wrapped console command
Args: Args:
config_path (str): config file path string config_path (str): config file path string
filepath (str): path string leading to file filepath (str): path string leading to file
out_path (str): temp json file path string output_path (str): temp json file path string
Example of use: Example of use:
> pyton.exe ./ocio_wrapper.py \ > python.exe ./ocio_wrapper.py \
colorspace get_config_file_rules_colorspace_from_filepath \ colorspace get_config_file_rules_colorspace_from_filepath \
--config_path=<path> --filepath=<path> --out_path=<path> --config_path <path> --filepath <path> --output_path <path>
""" """
json_path = Path(out_path) _save_output_to_json_file(
get_config_file_rules_colorspace_from_filepath(config_path, filepath),
colorspace = _get_config_file_rules_colorspace_from_filepath( output_path
config_path, filepath) )
with open(json_path, "w") as f_:
json.dump(colorspace, f_)
print(f"Colorspace name is saved to '{json_path}'")
def _get_config_file_rules_colorspace_from_filepath(config_path, filepath): @main.command(
"""Return found colorspace data found in v2 file rules.
Args:
config_path (str): path string leading to config.ocio
filepath (str): path string leading to v2 file rules
Raises:
IOError: Input config does not exist.
Returns:
dict: aggregated available colorspaces
"""
config_path = Path(config_path)
if not config_path.is_file():
raise IOError(
f"Input path `{config_path}` should be `config.ocio` file")
config = ocio.Config().CreateFromFile(str(config_path))
# TODO: use `parseColorSpaceFromString` instead if ocio v1
colorspace = config.getColorSpaceFromFilepath(str(filepath))
return colorspace
def _get_display_view_colorspace_name(config_path, display, view):
"""Returns the colorspace attribute of the (display, view) pair.
Args:
config_path (str): path string leading to config.ocio
display (str): display name e.g. "ACES"
view (str): view name e.g. "sRGB"
Raises:
IOError: Input config does not exist.
Returns:
view color space name (str) e.g. "Output - sRGB"
"""
config_path = Path(config_path)
if not config_path.is_file():
raise IOError("Input path should be `config.ocio` file")
config = ocio.Config.CreateFromFile(str(config_path))
colorspace = config.getDisplayViewColorSpaceName(display, view)
return colorspace
@config.command(
name="get_display_view_colorspace_name", name="get_display_view_colorspace_name",
help=( help=(
"return default view colorspace name " "Default view colorspace name for the given display and view"
"for the given display and view " ))
"--path input arg is required" @click.option(
) "--config_path",
) required=True,
@click.option("--in_path", required=True, help="path where to read ocio config file",
help="path where to read ocio config file", type=click.Path(exists=True))
type=click.Path(exists=True)) @click.option(
@click.option("--out_path", required=True, "--display",
help="path where to write output json file", required=True,
type=click.Path()) help="Display name",
@click.option("--display", required=True, type=click.STRING)
help="display name", @click.option(
type=click.STRING) "--view",
@click.option("--view", required=True, required=True,
help="view name", help="view name",
type=click.STRING) type=click.STRING)
def get_display_view_colorspace_name(in_path, out_path, @click.option(
display, view): "--output_path",
required=True,
help="path where to write output json file",
type=click.Path())
def _get_display_view_colorspace_name(
config_path, display, view, output_path
):
"""Aggregate view colorspace name to file. """Aggregate view colorspace name to file.
Wrapper command for processes without access to OpenColorIO Wrapper command for processes without access to OpenColorIO
Args: Args:
in_path (str): config file path string config_path (str): config file path string
out_path (str): temp json file path string output_path (str): temp json file path string
display (str): display name e.g. "ACES" display (str): display name e.g. "ACES"
view (str): view name e.g. "sRGB" view (str): view name e.g. "sRGB"
Example of use: Example of use:
> pyton.exe ./ocio_wrapper.py config \ > pyton.exe ./ocio_wrapper.py config \
get_display_view_colorspace_name --in_path=<path> \ get_display_view_colorspace_name --config_path <path> \
--out_path=<path> --display=<display> --view=<view> --output_path <path> --display <display> --view <view>
""" """
_save_output_to_json_file(
get_display_view_colorspace_name(config_path, display, view),
output_path
)
out_data = _get_display_view_colorspace_name(in_path,
display,
view)
with open(out_path, "w") as f: if __name__ == "__main__":
json.dump(out_data, f) if not has_compatible_ocio_package():
raise RuntimeError("OpenColorIO is not available.")
print(f"Display view colorspace saved to '{out_path}'")
if __name__ == '__main__':
main() main()

View file

@ -14,6 +14,7 @@ from .hierarchy import (
) )
from .thumbnails import ThumbnailsModel from .thumbnails import ThumbnailsModel
from .selection import HierarchyExpectedSelection from .selection import HierarchyExpectedSelection
from .users import UsersModel
__all__ = ( __all__ = (
@ -32,4 +33,6 @@ __all__ = (
"ThumbnailsModel", "ThumbnailsModel",
"HierarchyExpectedSelection", "HierarchyExpectedSelection",
"UsersModel",
) )

View file

@ -5,7 +5,7 @@ import ayon_api
import six import six
from ayon_core.style import get_default_entity_icon_color from ayon_core.style import get_default_entity_icon_color
from ayon_core.lib import CacheItem from ayon_core.lib import CacheItem, NestedCacheItem
PROJECTS_MODEL_SENDER = "projects.model" PROJECTS_MODEL_SENDER = "projects.model"
@ -17,6 +17,49 @@ class AbstractHierarchyController:
pass pass
class StatusItem:
"""Item representing status of project.
Args:
name (str): Status name ("Not ready").
color (str): Status color in hex ("#434a56").
short (str): Short status name ("NRD").
icon (str): Icon name in MaterialIcons ("fiber_new").
state (Literal["not_started", "in_progress", "done", "blocked"]):
Status state.
"""
def __init__(self, name, color, short, icon, state):
self.name = name
self.color = color
self.short = short
self.icon = icon
self.state = state
def to_data(self):
return {
"name": self.name,
"color": self.color,
"short": self.short,
"icon": self.icon,
"state": self.state,
}
@classmethod
def from_data(cls, data):
return cls(**data)
@classmethod
def from_project_item(cls, status_data):
return cls(
name=status_data["name"],
color=status_data["color"],
short=status_data["shortName"],
icon=status_data["icon"],
state=status_data["state"],
)
class ProjectItem: class ProjectItem:
"""Item representing folder entity on a server. """Item representing folder entity on a server.
@ -40,6 +83,23 @@ class ProjectItem:
} }
self.icon = icon self.icon = icon
@classmethod
def from_entity(cls, project_entity):
"""Creates folder item from entity.
Args:
project_entity (dict[str, Any]): Project entity.
Returns:
ProjectItem: Project item.
"""
return cls(
project_entity["name"],
project_entity["active"],
project_entity["library"],
)
def to_data(self): def to_data(self):
"""Converts folder item to data. """Converts folder item to data.
@ -79,7 +139,7 @@ def _get_project_items_from_entitiy(projects):
""" """
return [ return [
ProjectItem(project["name"], project["active"], project["library"]) ProjectItem.from_entity(project)
for project in projects for project in projects
] ]
@ -87,18 +147,29 @@ def _get_project_items_from_entitiy(projects):
class ProjectsModel(object): class ProjectsModel(object):
def __init__(self, controller): def __init__(self, controller):
self._projects_cache = CacheItem(default_factory=list) self._projects_cache = CacheItem(default_factory=list)
self._project_items_by_name = {} self._project_statuses_cache = NestedCacheItem(
self._projects_by_name = {} levels=1, default_factory=list
)
self._projects_by_name = NestedCacheItem(
levels=1, default_factory=list
)
self._is_refreshing = False self._is_refreshing = False
self._controller = controller self._controller = controller
def reset(self): def reset(self):
self._projects_cache.reset() self._projects_cache.reset()
self._project_items_by_name = {} self._project_statuses_cache.reset()
self._projects_by_name = {} self._projects_by_name.reset()
def refresh(self): def refresh(self):
"""Refresh project items.
This method will requery list of ProjectItem returned by
'get_project_items'.
To reset all cached items use 'reset' method.
"""
self._refresh_projects_cache() self._refresh_projects_cache()
def get_project_items(self, sender): def get_project_items(self, sender):
@ -117,12 +188,51 @@ class ProjectsModel(object):
return self._projects_cache.get_data() return self._projects_cache.get_data()
def get_project_entity(self, project_name): def get_project_entity(self, project_name):
if project_name not in self._projects_by_name: """Get project entity.
Args:
project_name (str): Project name.
Returns:
Union[dict[str, Any], None]: Project entity or None if project
was not found by name.
"""
project_cache = self._projects_by_name[project_name]
if not project_cache.is_valid:
entity = None entity = None
if project_name: if project_name:
entity = ayon_api.get_project(project_name) entity = ayon_api.get_project(project_name)
self._projects_by_name[project_name] = entity project_cache.update_data(entity)
return self._projects_by_name[project_name] return project_cache.get_data()
def get_project_status_items(self, project_name, sender):
"""Get project status items.
Args:
project_name (str): Project name.
sender (Union[str, None]): Name of sender who asked for items.
Returns:
list[StatusItem]: Status items for project.
"""
statuses_cache = self._project_statuses_cache[project_name]
if not statuses_cache.is_valid:
with self._project_statuses_refresh_event_manager(
sender, project_name
):
project_entity = None
if project_name:
project_entity = self.get_project_entity(project_name)
statuses = []
if project_entity:
statuses = [
StatusItem.from_project_item(status)
for status in project_entity["statuses"]
]
statuses_cache.update_data(statuses)
return statuses_cache.get_data()
@contextlib.contextmanager @contextlib.contextmanager
def _project_refresh_event_manager(self, sender): def _project_refresh_event_manager(self, sender):
@ -143,6 +253,23 @@ class ProjectsModel(object):
) )
self._is_refreshing = False self._is_refreshing = False
@contextlib.contextmanager
def _project_statuses_refresh_event_manager(self, sender, project_name):
self._controller.emit_event(
"projects.statuses.refresh.started",
{"sender": sender, "project_name": project_name},
PROJECTS_MODEL_SENDER
)
try:
yield
finally:
self._controller.emit_event(
"projects.statuses.refresh.finished",
{"sender": sender, "project_name": project_name},
PROJECTS_MODEL_SENDER
)
def _refresh_projects_cache(self, sender=None): def _refresh_projects_cache(self, sender=None):
if self._is_refreshing: if self._is_refreshing:
return None return None

View file

@ -0,0 +1,84 @@
import ayon_api
from ayon_core.lib import CacheItem
class UserItem:
def __init__(
self,
username,
full_name,
email,
avatar_url,
active,
):
self.username = username
self.full_name = full_name
self.email = email
self.avatar_url = avatar_url
self.active = active
@classmethod
def from_entity_data(cls, user_data):
return cls(
user_data["name"],
user_data["attrib"]["fullName"],
user_data["attrib"]["email"],
user_data["attrib"]["avatarUrl"],
user_data["active"],
)
class UsersModel:
def __init__(self, controller):
self._controller = controller
self._users_cache = CacheItem(default_factory=list)
def get_user_items(self):
"""Get user items.
Returns:
List[UserItem]: List of user items.
"""
self._invalidate_cache()
return self._users_cache.get_data()
def get_user_items_by_name(self):
"""Get user items by name.
Implemented as most of cases using this model will need to find
user information by username.
Returns:
Dict[str, UserItem]: Dictionary of user items by name.
"""
return {
user_item.username: user_item
for user_item in self.get_user_items()
}
def get_user_item_by_username(self, username):
"""Get user item by username.
Args:
username (str): Username.
Returns:
Union[UserItem, None]: User item or None if not found.
"""
self._invalidate_cache()
for user_item in self.get_user_items():
if user_item.username == username:
return user_item
return None
def _invalidate_cache(self):
if self._users_cache.is_valid:
return
self._users_cache.update_data([
UserItem.from_entity_data(user)
for user in ayon_api.get_users()
])

View file

@ -290,6 +290,34 @@ class ActionDelegate(QtWidgets.QStyledItemDelegate):
painter.drawPixmap(extender_x, extender_y, pix) painter.drawPixmap(extender_x, extender_y, pix)
class ActionsProxyModel(QtCore.QSortFilterProxyModel):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive)
def lessThan(self, left, right):
# Sort by action order and then by label
left_value = left.data(ACTION_SORT_ROLE)
right_value = right.data(ACTION_SORT_ROLE)
# Values are same -> use super sorting
if left_value == right_value:
# Default behavior is using DisplayRole
return super().lessThan(left, right)
# Validate 'None' values
if right_value is None:
return True
if left_value is None:
return False
# Sort values and handle incompatible types
try:
return left_value < right_value
except TypeError:
return True
class ActionsWidget(QtWidgets.QWidget): class ActionsWidget(QtWidgets.QWidget):
def __init__(self, controller, parent): def __init__(self, controller, parent):
super(ActionsWidget, self).__init__(parent) super(ActionsWidget, self).__init__(parent)
@ -316,10 +344,7 @@ class ActionsWidget(QtWidgets.QWidget):
model = ActionsQtModel(controller) model = ActionsQtModel(controller)
proxy_model = QtCore.QSortFilterProxyModel() proxy_model = ActionsProxyModel()
proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive)
proxy_model.setSortRole(ACTION_SORT_ROLE)
proxy_model.setSourceModel(model) proxy_model.setSourceModel(model)
view.setModel(proxy_model) view.setModel(proxy_model)
@ -359,7 +384,8 @@ class ActionsWidget(QtWidgets.QWidget):
def _on_model_refresh(self): def _on_model_refresh(self):
self._proxy_model.sort(0) self._proxy_model.sort(0)
# Force repaint all items # Force repaint all items
self._view.update() viewport = self._view.viewport()
viewport.update()
def _on_animation(self): def _on_animation(self):
time_now = time.time() time_now = time.time()

View file

@ -114,6 +114,7 @@ class VersionItem:
thumbnail_id (Union[str, None]): Thumbnail id. thumbnail_id (Union[str, None]): Thumbnail id.
published_time (Union[str, None]): Published time in format published_time (Union[str, None]): Published time in format
'%Y%m%dT%H%M%SZ'. '%Y%m%dT%H%M%SZ'.
status (Union[str, None]): Status name.
author (Union[str, None]): Author. author (Union[str, None]): Author.
frame_range (Union[str, None]): Frame range. frame_range (Union[str, None]): Frame range.
duration (Union[int, None]): Duration. duration (Union[int, None]): Duration.
@ -132,6 +133,7 @@ class VersionItem:
thumbnail_id, thumbnail_id,
published_time, published_time,
author, author,
status,
frame_range, frame_range,
duration, duration,
handles, handles,
@ -146,6 +148,7 @@ class VersionItem:
self.is_hero = is_hero self.is_hero = is_hero
self.published_time = published_time self.published_time = published_time
self.author = author self.author = author
self.status = status
self.frame_range = frame_range self.frame_range = frame_range
self.duration = duration self.duration = duration
self.handles = handles self.handles = handles
@ -169,12 +172,30 @@ class VersionItem:
def __gt__(self, other): def __gt__(self, other):
if not isinstance(other, VersionItem): if not isinstance(other, VersionItem):
return False return False
if ( # Make sure hero versions are positive
other.version == self.version version = abs(self.version)
and self.is_hero other_version = abs(other.version)
): # Hero version is greater than non-hero
if version == other_version:
return self.is_hero
return version > other_version
def __lt__(self, other):
if not isinstance(other, VersionItem):
return True return True
return other.version < self.version # Make sure hero versions are positive
version = abs(self.version)
other_version = abs(other.version)
# Non-hero version is lesser than hero
if version == other_version:
return not self.is_hero
return version < other_version
def __ge__(self, other):
return self.__eq__(other) or self.__gt__(other)
def __le__(self, other):
return self.__eq__(other) or self.__lt__(other)
def to_data(self): def to_data(self):
return { return {
@ -185,6 +206,7 @@ class VersionItem:
"is_hero": self.is_hero, "is_hero": self.is_hero,
"published_time": self.published_time, "published_time": self.published_time,
"author": self.author, "author": self.author,
"status": self.status,
"frame_range": self.frame_range, "frame_range": self.frame_range,
"duration": self.duration, "duration": self.duration,
"handles": self.handles, "handles": self.handles,
@ -488,6 +510,27 @@ class FrontendLoaderController(_BaseLoaderController):
pass pass
@abstractmethod
def get_project_status_items(self, project_name, sender=None):
"""Items for all projects available on server.
Triggers event topics "projects.statuses.refresh.started" and
"projects.statuses.refresh.finished" with data:
{
"sender": sender,
"project_name": project_name
}
Args:
project_name (Union[str, None]): Project name.
sender (Optional[str]): Sender who requested the items.
Returns:
list[StatusItem]: List of status items.
"""
pass
@abstractmethod @abstractmethod
def get_product_items(self, project_name, folder_ids, sender=None): def get_product_items(self, project_name, folder_ids, sender=None):
"""Product items for folder ids. """Product items for folder ids.

View file

@ -180,6 +180,11 @@ class LoaderController(BackendLoaderController, FrontendLoaderController):
def get_project_items(self, sender=None): def get_project_items(self, sender=None):
return self._projects_model.get_project_items(sender) return self._projects_model.get_project_items(sender)
def get_project_status_items(self, project_name, sender=None):
return self._projects_model.get_project_status_items(
project_name, sender
)
def get_folder_items(self, project_name, sender=None): def get_folder_items(self, project_name, sender=None):
return self._hierarchy_model.get_folder_items(project_name, sender) return self._hierarchy_model.get_folder_items(project_name, sender)

View file

@ -58,6 +58,7 @@ def version_item_from_entity(version):
thumbnail_id=version["thumbnailId"], thumbnail_id=version["thumbnailId"],
published_time=published_time, published_time=published_time,
author=author, author=author,
status=version["status"],
frame_range=frame_range, frame_range=frame_range,
duration=duration, duration=duration,
handles=handles, handles=handles,
@ -526,8 +527,11 @@ class ProductsModel:
products = list(ayon_api.get_products(project_name, **kwargs)) products = list(ayon_api.get_products(project_name, **kwargs))
product_ids = {product["id"] for product in products} product_ids = {product["id"] for product in products}
# Add 'status' to fields -> fixed in ayon-python-api 1.0.4
fields = ayon_api.get_default_fields_for_type("version")
fields.add("status")
versions = ayon_api.get_versions( versions = ayon_api.get_versions(
project_name, product_ids=product_ids project_name, product_ids=product_ids, fields=fields
) )
return self._create_product_items( return self._create_product_items(

View file

@ -104,7 +104,10 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate):
style = QtWidgets.QApplication.style() style = QtWidgets.QApplication.style()
style.drawControl( style.drawControl(
style.CE_ItemViewItem, option, painter, option.widget QtWidgets.QCommonStyle.CE_ItemViewItem,
option,
painter,
option.widget
) )
painter.save() painter.save()
@ -116,9 +119,14 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate):
pen.setColor(fg_color) pen.setColor(fg_color)
painter.setPen(pen) painter.setPen(pen)
text_rect = style.subElementRect(style.SE_ItemViewItemText, option) text_rect = style.subElementRect(
QtWidgets.QCommonStyle.SE_ItemViewItemText,
option
)
text_margin = style.proxy().pixelMetric( text_margin = style.proxy().pixelMetric(
style.PM_FocusFrameHMargin, option, option.widget QtWidgets.QCommonStyle.PM_FocusFrameHMargin,
option,
option.widget
) + 1 ) + 1
painter.drawText( painter.drawText(

View file

@ -22,18 +22,22 @@ VERSION_HERO_ROLE = QtCore.Qt.UserRole + 11
VERSION_NAME_ROLE = QtCore.Qt.UserRole + 12 VERSION_NAME_ROLE = QtCore.Qt.UserRole + 12
VERSION_NAME_EDIT_ROLE = QtCore.Qt.UserRole + 13 VERSION_NAME_EDIT_ROLE = QtCore.Qt.UserRole + 13
VERSION_PUBLISH_TIME_ROLE = QtCore.Qt.UserRole + 14 VERSION_PUBLISH_TIME_ROLE = QtCore.Qt.UserRole + 14
VERSION_AUTHOR_ROLE = QtCore.Qt.UserRole + 15 VERSION_STATUS_NAME_ROLE = QtCore.Qt.UserRole + 15
VERSION_FRAME_RANGE_ROLE = QtCore.Qt.UserRole + 16 VERSION_STATUS_SHORT_ROLE = QtCore.Qt.UserRole + 16
VERSION_DURATION_ROLE = QtCore.Qt.UserRole + 17 VERSION_STATUS_COLOR_ROLE = QtCore.Qt.UserRole + 17
VERSION_HANDLES_ROLE = QtCore.Qt.UserRole + 18 VERSION_STATUS_ICON_ROLE = QtCore.Qt.UserRole + 18
VERSION_STEP_ROLE = QtCore.Qt.UserRole + 19 VERSION_AUTHOR_ROLE = QtCore.Qt.UserRole + 19
VERSION_AVAILABLE_ROLE = QtCore.Qt.UserRole + 20 VERSION_FRAME_RANGE_ROLE = QtCore.Qt.UserRole + 20
VERSION_THUMBNAIL_ID_ROLE = QtCore.Qt.UserRole + 21 VERSION_DURATION_ROLE = QtCore.Qt.UserRole + 21
ACTIVE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 22 VERSION_HANDLES_ROLE = QtCore.Qt.UserRole + 22
REMOTE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 23 VERSION_STEP_ROLE = QtCore.Qt.UserRole + 23
REPRESENTATIONS_COUNT_ROLE = QtCore.Qt.UserRole + 24 VERSION_AVAILABLE_ROLE = QtCore.Qt.UserRole + 24
SYNC_ACTIVE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 25 VERSION_THUMBNAIL_ID_ROLE = QtCore.Qt.UserRole + 25
SYNC_REMOTE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 26 ACTIVE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 26
REMOTE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 27
REPRESENTATIONS_COUNT_ROLE = QtCore.Qt.UserRole + 28
SYNC_ACTIVE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 29
SYNC_REMOTE_SITE_AVAILABILITY = QtCore.Qt.UserRole + 30
class ProductsModel(QtGui.QStandardItemModel): class ProductsModel(QtGui.QStandardItemModel):
@ -44,6 +48,7 @@ class ProductsModel(QtGui.QStandardItemModel):
"Product type", "Product type",
"Folder", "Folder",
"Version", "Version",
"Status",
"Time", "Time",
"Author", "Author",
"Frames", "Frames",
@ -69,11 +74,35 @@ class ProductsModel(QtGui.QStandardItemModel):
] ]
] ]
version_col = column_labels.index("Version") product_name_col = column_labels.index("Product name")
published_time_col = column_labels.index("Time") product_type_col = column_labels.index("Product type")
folders_label_col = column_labels.index("Folder") folders_label_col = column_labels.index("Folder")
version_col = column_labels.index("Version")
status_col = column_labels.index("Status")
published_time_col = column_labels.index("Time")
author_col = column_labels.index("Author")
frame_range_col = column_labels.index("Frames")
duration_col = column_labels.index("Duration")
handles_col = column_labels.index("Handles")
step_col = column_labels.index("Step")
in_scene_col = column_labels.index("In scene") in_scene_col = column_labels.index("In scene")
sitesync_avail_col = column_labels.index("Availability") sitesync_avail_col = column_labels.index("Availability")
_display_role_mapping = {
product_name_col: QtCore.Qt.DisplayRole,
product_type_col: PRODUCT_TYPE_ROLE,
folders_label_col: FOLDER_LABEL_ROLE,
version_col: VERSION_NAME_ROLE,
status_col: VERSION_STATUS_NAME_ROLE,
published_time_col: VERSION_PUBLISH_TIME_ROLE,
author_col: VERSION_AUTHOR_ROLE,
frame_range_col: VERSION_FRAME_RANGE_ROLE,
duration_col: VERSION_DURATION_ROLE,
handles_col: VERSION_HANDLES_ROLE,
step_col: VERSION_STEP_ROLE,
in_scene_col: PRODUCT_IN_SCENE_ROLE,
sitesync_avail_col: VERSION_AVAILABLE_ROLE,
}
def __init__(self, controller): def __init__(self, controller):
super(ProductsModel, self).__init__() super(ProductsModel, self).__init__()
@ -96,6 +125,7 @@ class ProductsModel(QtGui.QStandardItemModel):
self._last_project_name = None self._last_project_name = None
self._last_folder_ids = [] self._last_folder_ids = []
self._last_project_statuses = {}
def get_product_item_indexes(self): def get_product_item_indexes(self):
return [ return [
@ -141,6 +171,15 @@ class ProductsModel(QtGui.QStandardItemModel):
if not index.isValid(): if not index.isValid():
return None return None
if role in (VERSION_STATUS_SHORT_ROLE, VERSION_STATUS_COLOR_ROLE):
status_name = self.data(index, VERSION_STATUS_NAME_ROLE)
status_item = self._last_project_statuses.get(status_name)
if status_item is None:
return ""
if role == VERSION_STATUS_SHORT_ROLE:
return status_item.short
return status_item.color
col = index.column() col = index.column()
if col == 0: if col == 0:
return super(ProductsModel, self).data(index, role) return super(ProductsModel, self).data(index, role)
@ -160,7 +199,9 @@ class ProductsModel(QtGui.QStandardItemModel):
product_item = self._product_items_by_id.get(product_id) product_item = self._product_items_by_id.get(product_id)
if product_item is None: if product_item is None:
return None return None
return list(product_item.version_items.values()) product_items = list(product_item.version_items.values())
product_items.sort(reverse=True)
return product_items
if role == QtCore.Qt.EditRole: if role == QtCore.Qt.EditRole:
return None return None
@ -168,29 +209,8 @@ class ProductsModel(QtGui.QStandardItemModel):
if role == QtCore.Qt.DisplayRole: if role == QtCore.Qt.DisplayRole:
if not index.data(PRODUCT_ID_ROLE): if not index.data(PRODUCT_ID_ROLE):
return None return None
if col == self.version_col: role = self._display_role_mapping.get(col)
role = VERSION_NAME_ROLE if role is None:
elif col == 1:
role = PRODUCT_TYPE_ROLE
elif col == 2:
role = FOLDER_LABEL_ROLE
elif col == 4:
role = VERSION_PUBLISH_TIME_ROLE
elif col == 5:
role = VERSION_AUTHOR_ROLE
elif col == 6:
role = VERSION_FRAME_RANGE_ROLE
elif col == 7:
role = VERSION_DURATION_ROLE
elif col == 8:
role = VERSION_HANDLES_ROLE
elif col == 9:
role = VERSION_STEP_ROLE
elif col == 10:
role = PRODUCT_IN_SCENE_ROLE
elif col == 11:
role = VERSION_AVAILABLE_ROLE
else:
return None return None
index = self.index(index.row(), 0, index.parent()) index = self.index(index.row(), 0, index.parent())
@ -312,6 +332,7 @@ class ProductsModel(QtGui.QStandardItemModel):
version_item.published_time, VERSION_PUBLISH_TIME_ROLE version_item.published_time, VERSION_PUBLISH_TIME_ROLE
) )
model_item.setData(version_item.author, VERSION_AUTHOR_ROLE) model_item.setData(version_item.author, VERSION_AUTHOR_ROLE)
model_item.setData(version_item.status, VERSION_STATUS_NAME_ROLE)
model_item.setData(version_item.frame_range, VERSION_FRAME_RANGE_ROLE) model_item.setData(version_item.frame_range, VERSION_FRAME_RANGE_ROLE)
model_item.setData(version_item.duration, VERSION_DURATION_ROLE) model_item.setData(version_item.duration, VERSION_DURATION_ROLE)
model_item.setData(version_item.handles, VERSION_HANDLES_ROLE) model_item.setData(version_item.handles, VERSION_HANDLES_ROLE)
@ -393,6 +414,11 @@ class ProductsModel(QtGui.QStandardItemModel):
self._last_project_name = project_name self._last_project_name = project_name
self._last_folder_ids = folder_ids self._last_folder_ids = folder_ids
status_items = self._controller.get_project_status_items(project_name)
self._last_project_statuses = {
status_item.name: status_item
for status_item in status_items
}
active_site_icon_def = self._controller.get_active_site_icon_def( active_site_icon_def = self._controller.get_active_site_icon_def(
project_name project_name

View file

@ -6,7 +6,7 @@ from ayon_core.tools.utils import (
RecursiveSortFilterProxyModel, RecursiveSortFilterProxyModel,
DeselectableTreeView, DeselectableTreeView,
) )
from ayon_core.tools.utils.delegates import PrettyTimeDelegate from ayon_core.tools.utils.delegates import PrettyTimeDelegate, StatusDelegate
from .products_model import ( from .products_model import (
ProductsModel, ProductsModel,
@ -17,12 +17,16 @@ from .products_model import (
FOLDER_ID_ROLE, FOLDER_ID_ROLE,
PRODUCT_ID_ROLE, PRODUCT_ID_ROLE,
VERSION_ID_ROLE, VERSION_ID_ROLE,
VERSION_STATUS_NAME_ROLE,
VERSION_STATUS_SHORT_ROLE,
VERSION_STATUS_COLOR_ROLE,
VERSION_STATUS_ICON_ROLE,
VERSION_THUMBNAIL_ID_ROLE, VERSION_THUMBNAIL_ID_ROLE,
) )
from .products_delegates import ( from .products_delegates import (
VersionDelegate, VersionDelegate,
LoadedInSceneDelegate, LoadedInSceneDelegate,
SiteSyncDelegate SiteSyncDelegate,
) )
from .actions_utils import show_actions_menu from .actions_utils import show_actions_menu
@ -89,6 +93,7 @@ class ProductsWidget(QtWidgets.QWidget):
90, # Product type 90, # Product type
130, # Folder label 130, # Folder label
60, # Version 60, # Version
100, # Status
125, # Time 125, # Time
75, # Author 75, # Author
75, # Frames 75, # Frames
@ -128,20 +133,24 @@ class ProductsWidget(QtWidgets.QWidget):
products_view.setColumnWidth(idx, width) products_view.setColumnWidth(idx, width)
version_delegate = VersionDelegate() version_delegate = VersionDelegate()
products_view.setItemDelegateForColumn(
products_model.version_col, version_delegate)
time_delegate = PrettyTimeDelegate() time_delegate = PrettyTimeDelegate()
products_view.setItemDelegateForColumn( status_delegate = StatusDelegate(
products_model.published_time_col, time_delegate) VERSION_STATUS_NAME_ROLE,
VERSION_STATUS_SHORT_ROLE,
VERSION_STATUS_COLOR_ROLE,
VERSION_STATUS_ICON_ROLE,
)
in_scene_delegate = LoadedInSceneDelegate() in_scene_delegate = LoadedInSceneDelegate()
products_view.setItemDelegateForColumn(
products_model.in_scene_col, in_scene_delegate)
sitesync_delegate = SiteSyncDelegate() sitesync_delegate = SiteSyncDelegate()
products_view.setItemDelegateForColumn(
products_model.sitesync_avail_col, sitesync_delegate) for col, delegate in (
(products_model.version_col, version_delegate),
(products_model.published_time_col, time_delegate),
(products_model.status_col, status_delegate),
(products_model.in_scene_col, in_scene_delegate),
(products_model.sitesync_avail_col, sitesync_delegate),
):
products_view.setItemDelegateForColumn(col, delegate)
main_layout = QtWidgets.QHBoxLayout(self) main_layout = QtWidgets.QHBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0) main_layout.setContentsMargins(0, 0, 0, 0)
@ -175,6 +184,7 @@ class ProductsWidget(QtWidgets.QWidget):
self._version_delegate = version_delegate self._version_delegate = version_delegate
self._time_delegate = time_delegate self._time_delegate = time_delegate
self._status_delegate = status_delegate
self._in_scene_delegate = in_scene_delegate self._in_scene_delegate = in_scene_delegate
self._sitesync_delegate = sitesync_delegate self._sitesync_delegate = sitesync_delegate

View file

@ -335,9 +335,7 @@ class LoaderWindow(QtWidgets.QWidget):
def closeEvent(self, event): def closeEvent(self, event):
super(LoaderWindow, self).closeEvent(event) super(LoaderWindow, self).closeEvent(event)
# Deselect project so current context will be selected
# on next 'showEvent'
self._controller.set_selected_project(None)
self._reset_on_show = True self._reset_on_show = True
def keyPressEvent(self, event): def keyPressEvent(self, event):

View file

@ -723,7 +723,6 @@ class ProjectPushItemProcess:
dst_project_name = self._item.dst_project_name dst_project_name = self._item.dst_project_name
dst_folder_id = self._item.dst_folder_id dst_folder_id = self._item.dst_folder_id
dst_task_name = self._item.dst_task_name dst_task_name = self._item.dst_task_name
dst_task_name_low = dst_task_name.lower()
new_folder_name = self._item.new_folder_name new_folder_name = self._item.new_folder_name
if not dst_folder_id and not new_folder_name: if not dst_folder_id and not new_folder_name:
self._status.set_failed( self._status.set_failed(
@ -765,7 +764,7 @@ class ProjectPushItemProcess:
dst_project_name, folder_ids=[folder_entity["id"]] dst_project_name, folder_ids=[folder_entity["id"]]
) )
} }
task_info = folder_tasks.get(dst_task_name_low) task_info = folder_tasks.get(dst_task_name.lower())
if not task_info: if not task_info:
self._status.set_failed( self._status.set_failed(
f"Could find task with name \"{dst_task_name}\"" f"Could find task with name \"{dst_task_name}\""

View file

@ -1,14 +1,14 @@
import ayon_api import ayon_api
from ayon_core.lib.events import QueuedEventSystem from ayon_core.lib.events import QueuedEventSystem
from ayon_core.host import ILoadHost from ayon_core.host import HostBase
from ayon_core.pipeline import ( from ayon_core.pipeline import (
registered_host, registered_host,
get_current_context, get_current_context,
) )
from ayon_core.tools.common_models import HierarchyModel from ayon_core.tools.common_models import HierarchyModel, ProjectsModel
from .models import SiteSyncModel from .models import SiteSyncModel, ContainersModel
class SceneInventoryController: class SceneInventoryController:
@ -28,11 +28,16 @@ class SceneInventoryController:
self._current_folder_id = None self._current_folder_id = None
self._current_folder_set = False self._current_folder_set = False
self._containers_model = ContainersModel(self)
self._sitesync_model = SiteSyncModel(self) self._sitesync_model = SiteSyncModel(self)
# Switch dialog requirements # Switch dialog requirements
self._hierarchy_model = HierarchyModel(self) self._hierarchy_model = HierarchyModel(self)
self._projects_model = ProjectsModel(self)
self._event_system = self._create_event_system() self._event_system = self._create_event_system()
def get_host(self) -> HostBase:
return self._host
def emit_event(self, topic, data=None, source=None): def emit_event(self, topic, data=None, source=None):
if data is None: if data is None:
data = {} data = {}
@ -47,6 +52,7 @@ class SceneInventoryController:
self._current_folder_id = None self._current_folder_id = None
self._current_folder_set = False self._current_folder_set = False
self._containers_model.reset()
self._sitesync_model.reset() self._sitesync_model.reset()
self._hierarchy_model.reset() self._hierarchy_model.reset()
@ -80,13 +86,32 @@ class SceneInventoryController:
self._current_folder_set = True self._current_folder_set = True
return self._current_folder_id return self._current_folder_id
def get_project_status_items(self):
project_name = self.get_current_project_name()
return self._projects_model.get_project_status_items(
project_name, None
)
# Containers methods
def get_containers(self): def get_containers(self):
host = self._host return self._containers_model.get_containers()
if isinstance(host, ILoadHost):
return list(host.get_containers()) def get_containers_by_item_ids(self, item_ids):
elif hasattr(host, "ls"): return self._containers_model.get_containers_by_item_ids(item_ids)
return list(host.ls())
return [] def get_container_items(self):
return self._containers_model.get_container_items()
def get_container_items_by_id(self, item_ids):
return self._containers_model.get_container_items_by_id(item_ids)
def get_representation_info_items(self, representation_ids):
return self._containers_model.get_representation_info_items(
representation_ids
)
def get_version_items(self, product_ids):
return self._containers_model.get_version_items(product_ids)
# Site Sync methods # Site Sync methods
def is_sitesync_enabled(self): def is_sitesync_enabled(self):

View file

@ -1,38 +1,10 @@
import numbers
import ayon_api
from ayon_core.pipeline import HeroVersionType
from ayon_core.tools.utils.models import TreeModel
from ayon_core.tools.utils.lib import format_version
from qtpy import QtWidgets, QtCore, QtGui from qtpy import QtWidgets, QtCore, QtGui
from .model import VERSION_LABEL_ROLE
class VersionDelegate(QtWidgets.QStyledItemDelegate): class VersionDelegate(QtWidgets.QStyledItemDelegate):
"""A delegate that display version integer formatted as version string.""" """A delegate that display version integer formatted as version string."""
version_changed = QtCore.Signal()
first_run = False
lock = False
def __init__(self, controller, *args, **kwargs):
self._controller = controller
super(VersionDelegate, self).__init__(*args, **kwargs)
def get_project_name(self):
return self._controller.get_current_project_name()
def displayText(self, value, locale):
if isinstance(value, HeroVersionType):
return format_version(value)
if not isinstance(value, numbers.Integral):
# For cases where no version is resolved like NOT FOUND cases
# where a representation might not exist in current database
return
return format_version(value)
def paint(self, painter, option, index): def paint(self, painter, option, index):
fg_color = index.data(QtCore.Qt.ForegroundRole) fg_color = index.data(QtCore.Qt.ForegroundRole)
if fg_color: if fg_color:
@ -44,7 +16,7 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate):
fg_color = None fg_color = None
if not fg_color: if not fg_color:
return super(VersionDelegate, self).paint(painter, option, index) return super().paint(painter, option, index)
if option.widget: if option.widget:
style = option.widget.style() style = option.widget.style()
@ -60,9 +32,7 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate):
painter.save() painter.save()
text = self.displayText( text = index.data(VERSION_LABEL_ROLE)
index.data(QtCore.Qt.DisplayRole), option.locale
)
pen = painter.pen() pen = painter.pen()
pen.setColor(fg_color) pen.setColor(fg_color)
painter.setPen(pen) painter.setPen(pen)
@ -82,77 +52,3 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate):
) )
painter.restore() painter.restore()
def createEditor(self, parent, option, index):
item = index.data(TreeModel.ItemRole)
if item.get("isGroup") or item.get("isMerged"):
return
editor = QtWidgets.QComboBox(parent)
def commit_data():
if not self.first_run:
self.commitData.emit(editor) # Update model data
self.version_changed.emit() # Display model data
editor.currentIndexChanged.connect(commit_data)
self.first_run = True
self.lock = False
return editor
def setEditorData(self, editor, index):
if self.lock:
# Only set editor data once per delegation
return
editor.clear()
# Current value of the index
item = index.data(TreeModel.ItemRole)
value = index.data(QtCore.Qt.DisplayRole)
project_name = self.get_project_name()
# Add all available versions to the editor
product_id = item["version_entity"]["productId"]
version_entities = list(sorted(
ayon_api.get_versions(
project_name, product_ids={product_id}, active=True
),
key=lambda item: abs(item["version"])
))
selected = None
items = []
is_hero_version = value < 0
for version_entity in version_entities:
version = version_entity["version"]
label = format_version(version)
item = QtGui.QStandardItem(label)
item.setData(version_entity, QtCore.Qt.UserRole)
items.append(item)
if (
version == value
or is_hero_version and version < 0
):
selected = item
# Reverse items so latest versions be upper
items.reverse()
for item in items:
editor.model().appendRow(item)
index = 0
if selected:
index = selected.row()
# Will trigger index-change signal
editor.setCurrentIndex(index)
self.first_run = False
self.lock = True
def setModelData(self, editor, model, index):
"""Apply the integer version back in the model"""
version = editor.itemData(editor.currentIndex())
model.setData(index, version["name"])

View file

@ -1,57 +1,113 @@
import re import re
import logging import logging
import uuid
from collections import defaultdict import collections
import ayon_api
from qtpy import QtCore, QtGui from qtpy import QtCore, QtGui
import qtawesome import qtawesome
from ayon_core.pipeline import (
get_current_project_name,
HeroVersionType,
)
from ayon_core.style import get_default_entity_icon_color from ayon_core.style import get_default_entity_icon_color
from ayon_core.tools.utils import get_qt_icon from ayon_core.tools.utils import get_qt_icon
from ayon_core.tools.utils.models import TreeModel, Item from ayon_core.tools.utils.lib import format_version
ITEM_ID_ROLE = QtCore.Qt.UserRole + 1
NAME_COLOR_ROLE = QtCore.Qt.UserRole + 2
COUNT_ROLE = QtCore.Qt.UserRole + 3
IS_CONTAINER_ITEM_ROLE = QtCore.Qt.UserRole + 4
VERSION_IS_LATEST_ROLE = QtCore.Qt.UserRole + 5
VERSION_IS_HERO_ROLE = QtCore.Qt.UserRole + 6
VERSION_LABEL_ROLE = QtCore.Qt.UserRole + 7
VERSION_COLOR_ROLE = QtCore.Qt.UserRole + 8
STATUS_NAME_ROLE = QtCore.Qt.UserRole + 9
STATUS_COLOR_ROLE = QtCore.Qt.UserRole + 10
STATUS_SHORT_ROLE = QtCore.Qt.UserRole + 11
STATUS_ICON_ROLE = QtCore.Qt.UserRole + 12
PRODUCT_ID_ROLE = QtCore.Qt.UserRole + 13
PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 14
PRODUCT_TYPE_ICON_ROLE = QtCore.Qt.UserRole + 15
PRODUCT_GROUP_NAME_ROLE = QtCore.Qt.UserRole + 16
PRODUCT_GROUP_ICON_ROLE = QtCore.Qt.UserRole + 17
LOADER_NAME_ROLE = QtCore.Qt.UserRole + 18
OBJECT_NAME_ROLE = QtCore.Qt.UserRole + 19
ACTIVE_SITE_PROGRESS_ROLE = QtCore.Qt.UserRole + 20
REMOTE_SITE_PROGRESS_ROLE = QtCore.Qt.UserRole + 21
ACTIVE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 22
REMOTE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 23
# This value hold unique value of container that should be used to identify
# containers inbetween refresh.
ITEM_UNIQUE_NAME_ROLE = QtCore.Qt.UserRole + 24
def walk_hierarchy(node): class InventoryModel(QtGui.QStandardItemModel):
"""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""" """The model for the inventory"""
Columns = [ column_labels = [
"Name", "Name",
"version", "Version",
"count", "Status",
"productType", "Count",
"group", "Product type",
"loader", "Group",
"objectName", "Loader",
"active_site", "Object name",
"remote_site", "Active site",
"Remote site",
] ]
active_site_col = Columns.index("active_site") name_col = column_labels.index("Name")
remote_site_col = Columns.index("remote_site") version_col = column_labels.index("Version")
status_col = column_labels.index("Status")
count_col = column_labels.index("Count")
product_type_col = column_labels.index("Product type")
product_group_col = column_labels.index("Group")
loader_col = column_labels.index("Loader")
object_name_col = column_labels.index("Object name")
active_site_col = column_labels.index("Active site")
remote_site_col = column_labels.index("Remote site")
display_role_by_column = {
name_col: QtCore.Qt.DisplayRole,
version_col: VERSION_LABEL_ROLE,
status_col: STATUS_NAME_ROLE,
count_col: COUNT_ROLE,
product_type_col: PRODUCT_TYPE_ROLE,
product_group_col: PRODUCT_GROUP_NAME_ROLE,
loader_col: LOADER_NAME_ROLE,
object_name_col: OBJECT_NAME_ROLE,
active_site_col: ACTIVE_SITE_PROGRESS_ROLE,
remote_site_col: REMOTE_SITE_PROGRESS_ROLE,
}
decoration_role_by_column = {
name_col: QtCore.Qt.DecorationRole,
product_type_col: PRODUCT_TYPE_ICON_ROLE,
product_group_col: PRODUCT_GROUP_ICON_ROLE,
active_site_col: ACTIVE_SITE_ICON_ROLE,
remote_site_col: REMOTE_SITE_ICON_ROLE,
}
foreground_role_by_column = {
name_col: NAME_COLOR_ROLE,
version_col: VERSION_COLOR_ROLE,
status_col: STATUS_COLOR_ROLE
}
width_by_column = {
name_col: 250,
version_col: 55,
status_col: 100,
count_col: 55,
product_type_col: 150,
product_group_col: 120,
loader_col: 150,
}
OUTDATED_COLOR = QtGui.QColor(235, 30, 30) OUTDATED_COLOR = QtGui.QColor(235, 30, 30)
CHILD_OUTDATED_COLOR = QtGui.QColor(200, 160, 30) CHILD_OUTDATED_COLOR = QtGui.QColor(200, 160, 30)
GRAYOUT_COLOR = QtGui.QColor(160, 160, 160) GRAYOUT_COLOR = QtGui.QColor(160, 160, 160)
UniqueRole = QtCore.Qt.UserRole + 2 # unique label role
def __init__(self, controller, parent=None): def __init__(self, controller, parent=None):
super(InventoryModel, self).__init__(parent) super().__init__(parent)
self.setColumnCount(len(self.column_labels))
for idx, label in enumerate(self.column_labels):
self.setHeaderData(idx, QtCore.Qt.Horizontal, label)
self.log = logging.getLogger(self.__class__.__name__) self.log = logging.getLogger(self.__class__.__name__)
self._controller = controller self._controller = controller
@ -60,103 +116,217 @@ class InventoryModel(TreeModel):
self._default_icon_color = get_default_entity_icon_color() self._default_icon_color = get_default_entity_icon_color()
site_icons = self._controller.get_site_provider_icons()
self._site_icons = {
provider: get_qt_icon(icon_def)
for provider, icon_def in site_icons.items()
}
def outdated(self, item): def outdated(self, item):
return item.get("isOutdated", True) return item.get("isOutdated", True)
def refresh(self, selected=None):
"""Refresh the model"""
# for debugging or testing, injecting items from outside
container_items = self._controller.get_container_items()
self._clear_items()
items_by_repre_id = {}
for container_item in container_items:
# if (
# selected is not None
# and container_item.item_id not in selected
# ):
# continue
repre_id = container_item.representation_id
items = items_by_repre_id.setdefault(repre_id, [])
items.append(container_item)
repre_id = set(items_by_repre_id.keys())
repre_info_by_id = self._controller.get_representation_info_items(
repre_id
)
product_ids = {
repre_info.product_id
for repre_info in repre_info_by_id.values()
}
version_items_by_product_id = self._controller.get_version_items(
product_ids
)
# SiteSync addon information
progress_by_id = self._controller.get_representations_site_progress(
repre_id
)
sites_info = self._controller.get_sites_information()
site_icons = {
provider: get_qt_icon(icon_def)
for provider, icon_def in (
self._controller.get_site_provider_icons().items()
)
}
status_items_by_name = {
status_item.name: status_item
for status_item in self._controller.get_project_status_items()
}
group_item_icon = qtawesome.icon(
"fa.folder", color=self._default_icon_color
)
valid_item_icon = qtawesome.icon(
"fa.file-o", color=self._default_icon_color
)
invalid_item_icon = qtawesome.icon(
"fa.exclamation-circle", color=self._default_icon_color
)
group_icon = qtawesome.icon(
"fa.object-group", color=self._default_icon_color
)
product_type_icon = qtawesome.icon(
"fa.folder", color="#0091B2"
)
group_item_font = QtGui.QFont()
group_item_font.setBold(True)
active_site_icon = site_icons.get(sites_info["active_site_provider"])
remote_site_icon = site_icons.get(sites_info["remote_site_provider"])
root_item = self.invisibleRootItem()
group_items = []
for repre_id, container_items in items_by_repre_id.items():
repre_info = repre_info_by_id[repre_id]
version_label = "N/A"
version_color = None
is_latest = False
is_hero = False
status_name = None
status_color = None
status_short = None
if not repre_info.is_valid:
group_name = "< Entity N/A >"
item_icon = invalid_item_icon
else:
group_name = "{}_{}: ({})".format(
repre_info.folder_path.rsplit("/")[-1],
repre_info.product_name,
repre_info.representation_name
)
item_icon = valid_item_icon
version_items = (
version_items_by_product_id[repre_info.product_id]
)
version_item = version_items[repre_info.version_id]
version_label = format_version(version_item.version)
is_hero = version_item.version < 0
is_latest = version_item.is_latest
if not is_latest:
version_color = self.OUTDATED_COLOR
status_name = version_item.status
status_item = status_items_by_name.get(status_name)
if status_item:
status_short = status_item.short
status_color = status_item.color
container_model_items = []
for container_item in container_items:
unique_name = (
repre_info.representation_name
+ container_item.object_name or "<none>"
)
item = QtGui.QStandardItem()
item.setColumnCount(root_item.columnCount())
item.setData(container_item.namespace, QtCore.Qt.DisplayRole)
item.setData(self.GRAYOUT_COLOR, NAME_COLOR_ROLE)
item.setData(self.GRAYOUT_COLOR, VERSION_COLOR_ROLE)
item.setData(item_icon, QtCore.Qt.DecorationRole)
item.setData(repre_info.product_id, PRODUCT_ID_ROLE)
item.setData(container_item.item_id, ITEM_ID_ROLE)
item.setData(version_label, VERSION_LABEL_ROLE)
item.setData(container_item.loader_name, LOADER_NAME_ROLE)
item.setData(container_item.object_name, OBJECT_NAME_ROLE)
item.setData(True, IS_CONTAINER_ITEM_ROLE)
item.setData(unique_name, ITEM_UNIQUE_NAME_ROLE)
container_model_items.append(item)
if not container_model_items:
continue
progress = progress_by_id[repre_id]
active_site_progress = "{}%".format(
max(progress["active_site"], 0) * 100
)
remote_site_progress = "{}%".format(
max(progress["remote_site"], 0) * 100
)
group_item = QtGui.QStandardItem()
group_item.setColumnCount(root_item.columnCount())
group_item.setData(group_name, QtCore.Qt.DisplayRole)
group_item.setData(group_name, ITEM_UNIQUE_NAME_ROLE)
group_item.setData(group_item_icon, QtCore.Qt.DecorationRole)
group_item.setData(group_item_font, QtCore.Qt.FontRole)
group_item.setData(repre_info.product_id, PRODUCT_ID_ROLE)
group_item.setData(repre_info.product_type, PRODUCT_TYPE_ROLE)
group_item.setData(product_type_icon, PRODUCT_TYPE_ICON_ROLE)
group_item.setData(is_latest, VERSION_IS_LATEST_ROLE)
group_item.setData(is_hero, VERSION_IS_HERO_ROLE)
group_item.setData(version_label, VERSION_LABEL_ROLE)
group_item.setData(len(container_items), COUNT_ROLE)
group_item.setData(status_name, STATUS_NAME_ROLE)
group_item.setData(status_short, STATUS_SHORT_ROLE)
group_item.setData(status_color, STATUS_COLOR_ROLE)
group_item.setData(
active_site_progress, ACTIVE_SITE_PROGRESS_ROLE
)
group_item.setData(
remote_site_progress, REMOTE_SITE_PROGRESS_ROLE
)
group_item.setData(active_site_icon, ACTIVE_SITE_ICON_ROLE)
group_item.setData(remote_site_icon, REMOTE_SITE_ICON_ROLE)
group_item.setData(False, IS_CONTAINER_ITEM_ROLE)
if version_color is not None:
group_item.setData(version_color, VERSION_COLOR_ROLE)
if repre_info.product_group:
group_item.setData(
repre_info.product_group, PRODUCT_GROUP_NAME_ROLE
)
group_item.setData(group_icon, PRODUCT_GROUP_ICON_ROLE)
group_item.appendRows(container_model_items)
group_items.append(group_item)
if group_items:
root_item.appendRows(group_items)
def flags(self, index):
return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable
def data(self, index, role): def data(self, index, role):
if not index.isValid(): if not index.isValid():
return return
item = index.internalPointer() col = index.column()
if role == QtCore.Qt.DisplayRole:
role = self.display_role_by_column.get(col)
if role is None:
print(col, role)
return None
if role == QtCore.Qt.FontRole: elif role == QtCore.Qt.DecorationRole:
# Make top-level entries bold role = self.decoration_role_by_column.get(col)
if item.get("isGroupNode") or item.get("isNotSet"): # group-item if role is None:
font = QtGui.QFont() return None
font.setBold(True)
return font
if role == QtCore.Qt.ForegroundRole: elif role == QtCore.Qt.ForegroundRole:
# Set the text color to the OUTDATED_COLOR when the role = self.foreground_role_by_column.get(col)
# collected version is not the same as the highest version if role is None:
key = self.Columns[index.column()] return None
if key == "version": # version
if item.get("isGroupNode"): # group-item
if self.outdated(item):
return self.OUTDATED_COLOR
if self._hierarchy_view: if col != 0:
# If current group is not outdated, check if any index = self.index(index.row(), 0, index.parent())
# outdated children.
for _node in walk_hierarchy(item):
if self.outdated(_node):
return self.CHILD_OUTDATED_COLOR
else:
if self._hierarchy_view: return super().data(index, role)
# 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:
# Product type icon
return item.get("productTypeIcon", 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): def set_hierarchy_view(self, state):
"""Set whether to display products in hierarchy view.""" """Set whether to display products in hierarchy view."""
@ -165,299 +335,34 @@ class InventoryModel(TreeModel):
if state != self._hierarchy_view: if state != self._hierarchy_view:
self._hierarchy_view = state self._hierarchy_view = state
def refresh(self, selected=None, containers=None): def get_outdated_item_ids(self, ignore_hero=True):
"""Refresh the model""" outdated_item_ids = []
root_item = self.invisibleRootItem()
# for debugging or testing, injecting items from outside for row in range(root_item.rowCount()):
if containers is None: group_item = root_item.child(row)
containers = self._controller.get_containers() if group_item.data(VERSION_IS_LATEST_ROLE):
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 continue
version_entity = versions_by_id.get(representation["versionId"]) if ignore_hero and group_item.data(VERSION_IS_HERO_ROLE):
if not version_entity:
not_found["version"].extend(group_containers)
not_found_ids.append(repre_id)
continue continue
product_entity = products_by_id.get(version_entity["productId"]) for idx in range(group_item.rowCount()):
if not product_entity: item = group_item.child(idx)
not_found["product"].extend(group_containers) outdated_item_ids.append(item.data(ITEM_ID_ROLE))
not_found_ids.append(repre_id) return outdated_item_ids
continue
folder_entity = folders_by_id.get(product_entity["folderId"]) def _clear_items(self):
if not folder_entity: root_item = self.invisibleRootItem()
not_found["folder"].extend(group_containers) root_item.removeRows(0, root_item.rowCount())
not_found_ids.append(repre_id)
continue
group_dict.update({
"representation": representation,
"version": version_entity,
"product": product_entity,
"folder": folder_entity
})
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
product_type_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()
# Query the highest available version so the model can know
# whether current version is currently up-to-date.
highest_version_by_product_id = ayon_api.get_last_versions(
project_name,
product_ids={
group["version"]["productId"] for group in grouped.values()
},
fields={"productId", "version"}
)
# Map value to `version` key
highest_version_by_product_id = {
product_id: version["version"]
for product_id, version in highest_version_by_product_id.items()
}
for repre_id, group_dict in sorted(grouped.items()):
group_containers = group_dict["containers"]
repre_entity = group_dict["representation"]
version_entity = group_dict["version"]
folder_entity = group_dict["folder"]
product_entity = group_dict["product"]
product_type = product_entity["productType"]
# create the group header
group_node = Item()
group_node["Name"] = "{}_{}: ({})".format(
folder_entity["name"],
product_entity["name"],
repre_entity["name"]
)
group_node["representation"] = repre_id
# Detect hero version type
version = version_entity["version"]
if version < 0:
version = HeroVersionType(version)
group_node["version"] = version
# Check if the version is outdated.
# Hero versions are never considered to be outdated.
is_outdated = False
if not isinstance(version, HeroVersionType):
last_version = highest_version_by_product_id.get(
version_entity["productId"])
if last_version is not None:
is_outdated = version_entity["version"] != last_version
group_node["isOutdated"] = is_outdated
group_node["productType"] = product_type or ""
group_node["productTypeIcon"] = product_type_icon
group_node["count"] = len(group_containers)
group_node["isGroupNode"] = True
group_node["group"] = product_entity["attrib"].get("productGroup")
# 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_entity["version"]
item_node["version_entity"] = version_entity
# 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_entities = ayon_api.get_representations(project_name, repre_ids)
repres_by_id.update({
repre_entity["id"]: repre_entity
for repre_entity in repre_entities
})
version_ids = {
repre_entity["versionId"]
for repre_entity in repres_by_id.values()
}
if not version_ids:
return output
versions_by_id.update({
version_entity["id"]: version_entity
for version_entity in ayon_api.get_versions(
project_name, version_ids=version_ids
)
})
product_ids = {
version_entity["productId"]
for version_entity in versions_by_id.values()
}
if not product_ids:
return output
products_by_id.update({
product_entity["id"]: product_entity
for product_entity in ayon_api.get_products(
project_name, product_ids=product_ids
)
})
folder_ids = {
product_entity["folderId"]
for product_entity in products_by_id.values()
}
if not folder_ids:
return output
folders_by_id.update({
folder_entity["id"]: folder_entity
for folder_entity in ayon_api.get_folders(
project_name, folder_ids=folder_ids
)
})
return output
class FilterProxyModel(QtCore.QSortFilterProxyModel): class FilterProxyModel(QtCore.QSortFilterProxyModel):
"""Filter model to where key column's value is in the filtered tags""" """Filter model to where key column's value is in the filtered tags"""
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super(FilterProxyModel, self).__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.setDynamicSortFilter(True)
self.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive)
self._filter_outdated = False self._filter_outdated = False
self._hierarchy_view = False self._hierarchy_view = False
@ -467,28 +372,23 @@ class FilterProxyModel(QtCore.QSortFilterProxyModel):
# Always allow bottom entries (individual containers), since their # Always allow bottom entries (individual containers), since their
# parent group hidden if it wouldn't have been validated. # parent group hidden if it wouldn't have been validated.
rows = model.rowCount(source_index) if source_index.data(IS_CONTAINER_ITEM_ROLE):
if not rows:
return True 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: if self._filter_outdated:
# When filtering to outdated we filter the up to date entries # When filtering to outdated we filter the up to date entries
# thus we "allow" them when they are outdated # thus we "allow" them when they are outdated
if not self._is_outdated(row, parent): if source_index.data(VERSION_IS_LATEST_ROLE):
return False return False
# Filter by regex
if hasattr(self, "filterRegularExpression"):
regex = self.filterRegularExpression()
else:
regex = self.filterRegExp()
if not self._matches(row, parent, regex.pattern()):
return False
return True return True
def set_filter_outdated(self, state): def set_filter_outdated(self, state):
@ -505,37 +405,6 @@ class FilterProxyModel(QtCore.QSortFilterProxyModel):
if state != self._hierarchy_view: if state != self._hierarchy_view:
self._hierarchy_view = state self._hierarchy_view = state
def _is_outdated(self, row, parent):
"""Return whether row is outdated.
A row is considered outdated if `isOutdated` data is true or not set.
"""
def outdated(node):
return node.get("isOutdated", True)
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): def _matches(self, row, parent, pattern):
"""Return whether row matches regex pattern. """Return whether row matches regex pattern.
@ -548,38 +417,31 @@ class FilterProxyModel(QtCore.QSortFilterProxyModel):
bool bool
""" """
if not pattern:
return True
flags = 0
if self.sortCaseSensitivity() == QtCore.Qt.CaseInsensitive:
flags = re.IGNORECASE
regex = re.compile(re.escape(pattern), flags=flags)
model = self.sourceModel() model = self.sourceModel()
column = self.filterKeyColumn() column = self.filterKeyColumn()
role = self.filterRole() role = self.filterRole()
def matches(row, parent, pattern): matches_queue = collections.deque()
matches_queue.append((row, parent))
while matches_queue:
queue_item = matches_queue.popleft()
row, parent = queue_item
index = model.index(row, column, parent) index = model.index(row, column, parent)
key = model.data(index, role) value = model.data(index, role)
if re.search(pattern, key, re.IGNORECASE): if regex.search(value):
return True return True
if matches(row, parent, pattern): for idx in range(model.rowCount(index)):
return True matches_queue.append((idx, index))
# Also allow if any of the children matches return False
source_index = model.index(row, column, parent)
rows = model.rowCount(source_index)
if any(
matches(idx, source_index, pattern)
for idx in range(rows)
):
return True
if not self._hierarchy_view:
return False
for idx in range(rows):
child_index = model.index(idx, column, source_index)
child_rows = model.rowCount(child_index)
return any(
self._matches(child_idx, child_index, pattern)
for child_idx in range(child_rows)
)
return True

View file

@ -1,6 +1,8 @@
from .containers import ContainersModel
from .sitesync import SiteSyncModel from .sitesync import SiteSyncModel
__all__ = ( __all__ = (
"ContainersModel",
"SiteSyncModel", "SiteSyncModel",
) )

View file

@ -0,0 +1,343 @@
import uuid
import collections
import ayon_api
from ayon_api.graphql import GraphQlQuery
from ayon_core.host import ILoadHost
# --- Implementation that should be in ayon-python-api ---
# The implementation is not available in all versions of ayon-python-api.
RepresentationHierarchy = collections.namedtuple(
"RepresentationHierarchy",
("folder", "product", "version", "representation")
)
def representations_parent_ids_qraphql_query():
query = GraphQlQuery("RepresentationsHierarchyQuery")
project_name_var = query.add_variable("projectName", "String!")
repre_ids_var = query.add_variable("representationIds", "[String!]")
project_field = query.add_field("project")
project_field.set_filter("name", project_name_var)
repres_field = project_field.add_field_with_edges("representations")
repres_field.add_field("id")
repres_field.add_field("name")
repres_field.set_filter("ids", repre_ids_var)
version_field = repres_field.add_field("version")
version_field.add_field("id")
product_field = version_field.add_field("product")
product_field.add_field("id")
product_field.add_field("name")
product_field.add_field("productType")
product_attrib_field = product_field.add_field("attrib")
product_attrib_field.add_field("productGroup")
folder_field = product_field.add_field("folder")
folder_field.add_field("id")
folder_field.add_field("path")
return query
def get_representations_hierarchy(project_name, representation_ids):
"""Find representations parents by representation id.
Representation parent entities up to project.
Args:
project_name (str): Project where to look for entities.
representation_ids (Iterable[str]): Representation ids.
Returns:
dict[str, RepresentationParents]: Parent entities by
representation id.
"""
if not representation_ids:
return {}
repre_ids = set(representation_ids)
output = {
repre_id: RepresentationHierarchy(None, None, None, None)
for repre_id in representation_ids
}
query = representations_parent_ids_qraphql_query()
query.set_variable_value("projectName", project_name)
query.set_variable_value("representationIds", list(repre_ids))
con = ayon_api.get_server_api_connection()
parsed_data = query.query(con)
for repre in parsed_data["project"]["representations"]:
repre_id = repre["id"]
version = repre.pop("version")
product = version.pop("product")
folder = product.pop("folder")
output[repre_id] = RepresentationHierarchy(
folder, product, version, repre
)
return output
# --- END of ayon-python-api implementation ---
class ContainerItem:
def __init__(
self,
representation_id,
loader_name,
namespace,
name,
object_name,
item_id
):
self.representation_id = representation_id
self.loader_name = loader_name
self.object_name = object_name
self.namespace = namespace
self.name = name
self.item_id = item_id
@classmethod
def from_container_data(cls, container):
return cls(
representation_id=container["representation"],
loader_name=container["loader"],
namespace=container["namespace"],
name=container["name"],
object_name=container["objectName"],
item_id=uuid.uuid4().hex,
)
class RepresentationInfo:
def __init__(
self,
folder_id,
folder_path,
product_id,
product_name,
product_type,
product_group,
version_id,
representation_name,
):
self.folder_id = folder_id
self.folder_path = folder_path
self.product_id = product_id
self.product_name = product_name
self.product_type = product_type
self.product_group = product_group
self.version_id = version_id
self.representation_name = representation_name
self._is_valid = None
@property
def is_valid(self):
if self._is_valid is None:
self._is_valid = (
self.folder_id is not None
and self.product_id is not None
and self.version_id is not None
and self.representation_name is not None
)
return self._is_valid
@classmethod
def new_invalid(cls):
return cls(None, None, None, None, None, None, None, None)
class VersionItem:
def __init__(self, version_id, product_id, version, status, is_latest):
self.version = version
self.version_id = version_id
self.product_id = product_id
self.version = version
self.status = status
self.is_latest = is_latest
@property
def is_hero(self):
return self.version < 0
@classmethod
def from_entity(cls, version_entity, is_latest):
return cls(
version_id=version_entity["id"],
product_id=version_entity["productId"],
version=version_entity["version"],
status=version_entity["status"],
is_latest=is_latest,
)
class ContainersModel:
def __init__(self, controller):
self._controller = controller
self._items_cache = None
self._containers_by_id = {}
self._container_items_by_id = {}
self._version_items_by_product_id = {}
self._repre_info_by_id = {}
def reset(self):
self._items_cache = None
self._containers_by_id = {}
self._container_items_by_id = {}
self._version_items_by_product_id = {}
self._repre_info_by_id = {}
def get_containers(self):
self._update_cache()
return list(self._containers_by_id.values())
def get_containers_by_item_ids(self, item_ids):
return {
item_id: self._containers_by_id.get(item_id)
for item_id in item_ids
}
def get_container_items(self):
self._update_cache()
return list(self._items_cache)
def get_container_items_by_id(self, item_ids):
return {
item_id: self._container_items_by_id.get(item_id)
for item_id in item_ids
}
def get_representation_info_items(self, representation_ids):
output = {}
missing_repre_ids = set()
for repre_id in representation_ids:
try:
uuid.UUID(repre_id)
except ValueError:
output[repre_id] = RepresentationInfo.new_invalid()
continue
repre_info = self._repre_info_by_id.get(repre_id)
if repre_info is None:
missing_repre_ids.add(repre_id)
else:
output[repre_id] = repre_info
if not missing_repre_ids:
return output
project_name = self._controller.get_current_project_name()
repre_hierarchy_by_id = get_representations_hierarchy(
project_name, missing_repre_ids
)
for repre_id, repre_hierarchy in repre_hierarchy_by_id.items():
kwargs = {
"folder_id": None,
"folder_path": None,
"product_id": None,
"product_name": None,
"product_type": None,
"product_group": None,
"version_id": None,
"representation_name": None,
}
folder = repre_hierarchy.folder
product = repre_hierarchy.product
version = repre_hierarchy.version
repre = repre_hierarchy.representation
if folder:
kwargs["folder_id"] = folder["id"]
kwargs["folder_path"] = folder["path"]
if product:
group = product["attrib"]["productGroup"]
kwargs["product_id"] = product["id"]
kwargs["product_name"] = product["name"]
kwargs["product_type"] = product["productType"]
kwargs["product_group"] = group
if version:
kwargs["version_id"] = version["id"]
if repre:
kwargs["representation_name"] = repre["name"]
repre_info = RepresentationInfo(**kwargs)
self._repre_info_by_id[repre_id] = repre_info
output[repre_id] = repre_info
return output
def get_version_items(self, product_ids):
if not product_ids:
return {}
missing_ids = {
product_id
for product_id in product_ids
if product_id not in self._version_items_by_product_id
}
if missing_ids:
def version_sorted(entity):
return entity["version"]
project_name = self._controller.get_current_project_name()
version_entities_by_product_id = {
product_id: []
for product_id in missing_ids
}
version_entities = list(ayon_api.get_versions(
project_name,
product_ids=missing_ids,
fields={"id", "version", "productId", "status"}
))
version_entities.sort(key=version_sorted)
for version_entity in version_entities:
product_id = version_entity["productId"]
version_entities_by_product_id[product_id].append(
version_entity
)
for product_id, version_entities in (
version_entities_by_product_id.items()
):
last_version = abs(version_entities[-1]["version"])
version_items_by_id = {
entity["id"]: VersionItem.from_entity(
entity, abs(entity["version"]) == last_version
)
for entity in version_entities
}
self._version_items_by_product_id[product_id] = (
version_items_by_id
)
return {
product_id: dict(self._version_items_by_product_id[product_id])
for product_id in product_ids
}
def _update_cache(self):
if self._items_cache is not None:
return
host = self._controller.get_host()
if isinstance(host, ILoadHost):
containers = list(host.get_containers())
elif hasattr(host, "ls"):
containers = list(host.ls())
else:
containers = []
container_items = []
containers_by_id = {}
container_items_by_id = {}
for container in containers:
item = ContainerItem.from_container_data(container)
containers_by_id[item.item_id] = container
container_items_by_id[item.item_id] = item
container_items.append(item)
self._containers_by_id = containers_by_id
self._container_items_by_id = container_items_by_id
self._items_cache = container_items

View file

@ -0,0 +1,216 @@
import uuid
from qtpy import QtWidgets, QtCore, QtGui
from ayon_core.tools.utils.delegates import StatusDelegate
from .model import (
ITEM_ID_ROLE,
STATUS_NAME_ROLE,
STATUS_SHORT_ROLE,
STATUS_COLOR_ROLE,
STATUS_ICON_ROLE,
)
class VersionOption:
def __init__(
self,
version,
label,
status_name,
status_short,
status_color
):
self.version = version
self.label = label
self.status_name = status_name
self.status_short = status_short
self.status_color = status_color
class SelectVersionModel(QtGui.QStandardItemModel):
def data(self, index, role=None):
if role is None:
role = QtCore.Qt.DisplayRole
index = self.index(index.row(), 0, index.parent())
return super().data(index, role)
class SelectVersionComboBox(QtWidgets.QComboBox):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
combo_model = SelectVersionModel(0, 2)
self.setModel(combo_model)
combo_view = QtWidgets.QTreeView(self)
combo_view.setHeaderHidden(True)
combo_view.setIndentation(0)
self.setView(combo_view)
header = combo_view.header()
header.setSectionResizeMode(0, QtWidgets.QHeaderView.ResizeToContents)
header.setSectionResizeMode(1, QtWidgets.QHeaderView.Stretch)
status_delegate = StatusDelegate(
STATUS_NAME_ROLE,
STATUS_SHORT_ROLE,
STATUS_COLOR_ROLE,
STATUS_ICON_ROLE,
)
combo_view.setItemDelegateForColumn(1, status_delegate)
self._combo_model = combo_model
self._combo_view = combo_view
self._status_delegate = status_delegate
self._items_by_id = {}
def paintEvent(self, event):
painter = QtWidgets.QStylePainter(self)
option = QtWidgets.QStyleOptionComboBox()
self.initStyleOption(option)
painter.drawComplexControl(QtWidgets.QStyle.CC_ComboBox, option)
idx = self.currentIndex()
status_name = self.itemData(idx, STATUS_NAME_ROLE)
if status_name is None:
painter.drawControl(QtWidgets.QStyle.CE_ComboBoxLabel, option)
return
painter.save()
text_field_rect = self.style().subControlRect(
QtWidgets.QStyle.CC_ComboBox,
option,
QtWidgets.QStyle.SC_ComboBoxEditField
)
adj_rect = text_field_rect.adjusted(1, 0, -1, 0)
painter.drawText(
adj_rect,
QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter,
option.currentText
)
metrics = QtGui.QFontMetrics(self.font())
text_width = metrics.width(option.currentText)
x_offset = text_width + 2
diff_width = adj_rect.width() - x_offset
if diff_width <= 0:
return
status_rect = adj_rect.adjusted(x_offset + 2, 0, 0, 0)
if diff_width < metrics.width(status_name):
status_name = self.itemData(idx, STATUS_SHORT_ROLE)
color = QtGui.QColor(self.itemData(idx, STATUS_COLOR_ROLE))
pen = painter.pen()
pen.setColor(color)
painter.setPen(pen)
painter.drawText(
status_rect,
QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter,
status_name
)
def set_current_index(self, index):
model = self._combo_view.model()
if index > model.rowCount():
return
self.setCurrentIndex(index)
def get_item_by_id(self, item_id):
return self._items_by_id[item_id]
def set_versions(self, version_options):
self._items_by_id = {}
model = self._combo_model
root_item = model.invisibleRootItem()
root_item.removeRows(0, root_item.rowCount())
new_items = []
for version_option in version_options:
item_id = uuid.uuid4().hex
item = QtGui.QStandardItem(version_option.label)
item.setColumnCount(root_item.columnCount())
item.setData(
version_option.status_name, STATUS_NAME_ROLE
)
item.setData(
version_option.status_short, STATUS_SHORT_ROLE
)
item.setData(
version_option.status_color, STATUS_COLOR_ROLE
)
item.setData(item_id, ITEM_ID_ROLE)
new_items.append(item)
self._items_by_id[item_id] = version_option
if new_items:
root_item.appendRows(new_items)
class SelectVersionDialog(QtWidgets.QDialog):
def __init__(self, parent=None):
super().__init__(parent=parent)
self.setWindowTitle("Select version")
label_widget = QtWidgets.QLabel("Set version number to", self)
versions_combobox = SelectVersionComboBox(self)
btns_widget = QtWidgets.QWidget(self)
confirm_btn = QtWidgets.QPushButton("OK", btns_widget)
cancel_btn = QtWidgets.QPushButton("Cancel", btns_widget)
btns_layout = QtWidgets.QHBoxLayout(btns_widget)
btns_layout.setContentsMargins(0, 0, 0, 0)
btns_layout.addStretch(1)
btns_layout.addWidget(confirm_btn, 0)
btns_layout.addWidget(cancel_btn, 0)
main_layout = QtWidgets.QVBoxLayout(self)
main_layout.addWidget(label_widget, 0)
main_layout.addWidget(versions_combobox, 0)
main_layout.addWidget(btns_widget, 0)
confirm_btn.clicked.connect(self._on_confirm)
cancel_btn.clicked.connect(self._on_cancel)
self._selected_item = None
self._cancelled = False
self._versions_combobox = versions_combobox
def get_selected_item(self):
if self._cancelled:
return None
return self._selected_item
def set_versions(self, version_options):
self._versions_combobox.set_versions(version_options)
def select_index(self, index):
self._versions_combobox.set_current_index(index)
@classmethod
def ask_for_version(cls, version_options, index=None, parent=None):
dialog = cls(parent)
dialog.set_versions(version_options)
if index is not None:
dialog.select_index(index)
dialog.exec_()
return dialog.get_selected_item()
def _on_confirm(self):
self._cancelled = False
index = self._versions_combobox.currentIndex()
item_id = self._versions_combobox.itemData(index, ITEM_ID_ROLE)
self._selected_item = self._versions_combobox.get_item_by_id(item_id)
self.accept()
def _on_cancel(self):
self._cancelled = True
self.reject()

File diff suppressed because it is too large Load diff

View file

@ -2,17 +2,10 @@ from qtpy import QtWidgets, QtCore, QtGui
import qtawesome import qtawesome
from ayon_core import style, resources from ayon_core import style, resources
from ayon_core.tools.utils.lib import ( from ayon_core.tools.utils import PlaceholderLineEdit
preserve_expanded_rows,
preserve_selection,
)
from ayon_core.tools.sceneinventory import SceneInventoryController from ayon_core.tools.sceneinventory import SceneInventoryController
from .delegates import VersionDelegate
from .model import (
InventoryModel,
FilterProxyModel
)
from .view import SceneInventoryView from .view import SceneInventoryView
@ -20,7 +13,7 @@ class SceneInventoryWindow(QtWidgets.QDialog):
"""Scene Inventory window""" """Scene Inventory window"""
def __init__(self, controller=None, parent=None): def __init__(self, controller=None, parent=None):
super(SceneInventoryWindow, self).__init__(parent) super().__init__(parent)
if controller is None: if controller is None:
controller = SceneInventoryController() controller = SceneInventoryController()
@ -33,10 +26,9 @@ class SceneInventoryWindow(QtWidgets.QDialog):
self.resize(1100, 480) self.resize(1100, 480)
# region control
filter_label = QtWidgets.QLabel("Search", self) filter_label = QtWidgets.QLabel("Search", self)
text_filter = QtWidgets.QLineEdit(self) text_filter = PlaceholderLineEdit(self)
text_filter.setPlaceholderText("Filter by name...")
outdated_only_checkbox = QtWidgets.QCheckBox( outdated_only_checkbox = QtWidgets.QCheckBox(
"Filter to outdated", self "Filter to outdated", self
@ -44,52 +36,30 @@ class SceneInventoryWindow(QtWidgets.QDialog):
outdated_only_checkbox.setToolTip("Show outdated files only") outdated_only_checkbox.setToolTip("Show outdated files only")
outdated_only_checkbox.setChecked(False) outdated_only_checkbox.setChecked(False)
icon = qtawesome.icon("fa.arrow-up", color="white") update_all_icon = qtawesome.icon("fa.arrow-up", color="white")
update_all_button = QtWidgets.QPushButton(self) update_all_button = QtWidgets.QPushButton(self)
update_all_button.setToolTip("Update all outdated to latest version") update_all_button.setToolTip("Update all outdated to latest version")
update_all_button.setIcon(icon) update_all_button.setIcon(update_all_icon)
icon = qtawesome.icon("fa.refresh", color="white") refresh_icon = qtawesome.icon("fa.refresh", color="white")
refresh_button = QtWidgets.QPushButton(self) refresh_button = QtWidgets.QPushButton(self)
refresh_button.setToolTip("Refresh") refresh_button.setToolTip("Refresh")
refresh_button.setIcon(icon) refresh_button.setIcon(refresh_icon)
control_layout = QtWidgets.QHBoxLayout() headers_widget = QtWidgets.QWidget(self)
control_layout.addWidget(filter_label) headers_layout = QtWidgets.QHBoxLayout(headers_widget)
control_layout.addWidget(text_filter) headers_layout.setContentsMargins(0, 0, 0, 0)
control_layout.addWidget(outdated_only_checkbox) headers_layout.addWidget(filter_label, 0)
control_layout.addWidget(update_all_button) headers_layout.addWidget(text_filter, 1)
control_layout.addWidget(refresh_button) headers_layout.addWidget(outdated_only_checkbox, 0)
headers_layout.addWidget(update_all_button, 0)
model = InventoryModel(controller) headers_layout.addWidget(refresh_button, 0)
proxy = FilterProxyModel()
proxy.setSourceModel(model)
proxy.setDynamicSortFilter(True)
proxy.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive)
view = SceneInventoryView(controller, self) view = SceneInventoryView(controller, self)
view.setModel(proxy)
sync_enabled = controller.is_sitesync_enabled() main_layout = QtWidgets.QVBoxLayout(self)
view.setColumnHidden(model.active_site_col, not sync_enabled) main_layout.addWidget(headers_widget, 0)
view.setColumnHidden(model.remote_site_col, not sync_enabled) main_layout.addWidget(view, 1)
# 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) # product type
view.setColumnWidth(4, 120) # group
view.setColumnWidth(5, 150) # loader
# apply delegates
version_delegate = VersionDelegate(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 = QtCore.QTimer()
show_timer.setInterval(0) show_timer.setInterval(0)
@ -114,12 +84,8 @@ class SceneInventoryWindow(QtWidgets.QDialog):
self._update_all_button = update_all_button self._update_all_button = update_all_button
self._outdated_only_checkbox = outdated_only_checkbox self._outdated_only_checkbox = outdated_only_checkbox
self._view = view self._view = view
self._model = model
self._proxy = proxy
self._version_delegate = version_delegate
self._first_show = True self._first_show = True
self._first_refresh = True
def showEvent(self, event): def showEvent(self, event):
super(SceneInventoryWindow, self).showEvent(event) super(SceneInventoryWindow, self).showEvent(event)
@ -139,29 +105,16 @@ class SceneInventoryWindow(QtWidgets.QDialog):
whilst trying to name an instance. whilst trying to name an instance.
""" """
pass
def _on_refresh_request(self): def _on_refresh_request(self):
"""Signal callback to trigger 'refresh' without any arguments.""" """Signal callback to trigger 'refresh' without any arguments."""
self.refresh() self.refresh()
def refresh(self, containers=None): def refresh(self):
self._first_refresh = False
self._controller.reset() self._controller.reset()
with preserve_expanded_rows( self._view.refresh()
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): def _on_show_timer(self):
if self._show_counter < 3: if self._show_counter < 3:
@ -171,17 +124,13 @@ class SceneInventoryWindow(QtWidgets.QDialog):
self.refresh() self.refresh()
def _on_hierarchy_view_change(self, enabled): def _on_hierarchy_view_change(self, enabled):
self._proxy.set_hierarchy_view(enabled) self._view.set_hierarchy_view(enabled)
self._model.set_hierarchy_view(enabled)
def _on_text_filter_change(self, text_filter): def _on_text_filter_change(self, text_filter):
if hasattr(self._proxy, "setFilterRegExp"): self._view.set_text_filter(text_filter)
self._proxy.setFilterRegExp(text_filter)
else:
self._proxy.setFilterRegularExpression(text_filter)
def _on_outdated_state_change(self): def _on_outdated_state_change(self):
self._proxy.set_filter_outdated( self._view.set_filter_outdated(
self._outdated_only_checkbox.isChecked() self._outdated_only_checkbox.isChecked()
) )

View file

@ -0,0 +1,5 @@
from .broker import StdOutBroker
__all__ = (
"StdOutBroker",
)

View file

@ -1,173 +1,12 @@
import os import warnings
import sys from .broker import StdOutBroker
import threading
import collections
import websocket
import json
from datetime import datetime
from ayon_core.lib import Logger warnings.warn(
from openpype_modules.webserver.host_console_listener import MsgAction (
"Import of 'StdOutBroker' from 'ayon_core.tools.stdout_broker.app'"
" is deprecated. Please use 'ayon_core.tools.stdout_broker' instead."
),
DeprecationWarning
)
log = Logger.get_logger(__name__) __all__ = ("StdOutBroker", )
class StdOutBroker:
"""
Application showing console in Services tray for non python hosts
instead of cmd window.
"""
MAX_LINES = 10000
TIMER_TIMEOUT = 0.200
def __init__(self, host_name):
self.host_name = host_name
self.webserver_client = None
self.original_stdout_write = None
self.original_stderr_write = None
self.log_queue = collections.deque()
date_str = datetime.now().strftime("%d%m%Y%H%M%S")
self.host_id = "{}_{}".format(self.host_name, date_str)
self._std_available = False
self._is_running = False
self._catch_std_outputs()
self._timer = None
@property
def send_to_tray(self):
"""Checks if connected to tray and have access to logs."""
return self.webserver_client and self._std_available
def start(self):
"""Start app, create and start timer"""
if not self._std_available or self._is_running:
return
self._is_running = True
self._create_timer()
self._connect_to_tray()
def stop(self):
"""Disconnect from Tray, process last logs"""
if not self._is_running:
return
self._is_running = False
self._process_queue()
self._disconnect_from_tray()
def host_connected(self):
"""Send to Tray console that host is ready - icon change. """
log.info("Host {} connected".format(self.host_id))
payload = {
"host": self.host_id,
"action": MsgAction.INITIALIZED,
"text": "Integration with {}".format(
str.capitalize(self.host_name))
}
self._send(payload)
def _create_timer(self):
timer = threading.Timer(self.TIMER_TIMEOUT, self._timer_callback)
timer.start()
self._timer = timer
def _timer_callback(self):
if not self._is_running:
return
self._process_queue()
self._create_timer()
def _connect_to_tray(self):
"""Connect to Tray webserver to pass console output. """
if not self._std_available: # not content to log
return
ws = websocket.WebSocket()
webserver_url = os.environ.get("AYON_WEBSERVER_URL")
if not webserver_url:
print("Unknown webserver url, cannot connect to pass log")
return
webserver_url = webserver_url.replace("http", "ws")
ws.connect("{}/ws/host_listener".format(webserver_url))
self.webserver_client = ws
payload = {
"host": self.host_id,
"action": MsgAction.CONNECTING,
"text": "Integration with {}".format(
str.capitalize(self.host_name))
}
self._send(payload)
def _disconnect_from_tray(self):
"""Send to Tray that host is closing - remove from Services. """
print("Host {} closing".format(self.host_name))
if not self.webserver_client:
return
payload = {
"host": self.host_id,
"action": MsgAction.CLOSE,
"text": "Integration with {}".format(
str.capitalize(self.host_name))
}
self._send(payload)
self.webserver_client.close()
def _catch_std_outputs(self):
"""Redirects standard out and error to own functions"""
if sys.stdout:
self.original_stdout_write = sys.stdout.write
sys.stdout.write = self._my_stdout_write
self._std_available = True
if sys.stderr:
self.original_stderr_write = sys.stderr.write
sys.stderr.write = self._my_stderr_write
self._std_available = True
def _my_stdout_write(self, text):
"""Appends outputted text to queue, keep writing to original stdout"""
if self.original_stdout_write is not None:
self.original_stdout_write(text)
if self.send_to_tray:
self.log_queue.append(text)
def _my_stderr_write(self, text):
"""Appends outputted text to queue, keep writing to original stderr"""
if self.original_stderr_write is not None:
self.original_stderr_write(text)
if self.send_to_tray:
self.log_queue.append(text)
def _process_queue(self):
"""Sends lines and purges queue"""
if not self.send_to_tray:
return
lines = tuple(self.log_queue)
self.log_queue.clear()
if lines:
payload = {
"host": self.host_id,
"action": MsgAction.ADD,
"text": "\n".join(lines)
}
self._send(payload)
def _send(self, payload):
"""Worker method to send to existing websocket connection."""
if not self.send_to_tray:
return
try:
self.webserver_client.send(json.dumps(payload))
except ConnectionResetError: # Tray closed
self._connect_to_tray()

View file

@ -0,0 +1,174 @@
import os
import sys
import threading
import collections
import json
from datetime import datetime
import websocket
from ayon_core.lib import Logger
from ayon_core.modules.webserver import HostMsgAction
log = Logger.get_logger(__name__)
class StdOutBroker:
"""
Application showing console in Services tray for non python hosts
instead of cmd window.
"""
MAX_LINES = 10000
TIMER_TIMEOUT = 0.200
def __init__(self, host_name):
self.host_name = host_name
self.webserver_client = None
self.original_stdout_write = None
self.original_stderr_write = None
self.log_queue = collections.deque()
date_str = datetime.now().strftime("%d%m%Y%H%M%S")
self.host_id = "{}_{}".format(self.host_name, date_str)
self._std_available = False
self._is_running = False
self._catch_std_outputs()
self._timer = None
@property
def send_to_tray(self):
"""Checks if connected to tray and have access to logs."""
return self.webserver_client and self._std_available
def start(self):
"""Start app, create and start timer"""
if not self._std_available or self._is_running:
return
self._is_running = True
self._create_timer()
self._connect_to_tray()
def stop(self):
"""Disconnect from Tray, process last logs"""
if not self._is_running:
return
self._is_running = False
self._process_queue()
self._disconnect_from_tray()
def host_connected(self):
"""Send to Tray console that host is ready - icon change. """
log.info("Host {} connected".format(self.host_id))
payload = {
"host": self.host_id,
"action": HostMsgAction.INITIALIZED,
"text": "Integration with {}".format(
str.capitalize(self.host_name))
}
self._send(payload)
def _create_timer(self):
timer = threading.Timer(self.TIMER_TIMEOUT, self._timer_callback)
timer.start()
self._timer = timer
def _timer_callback(self):
if not self._is_running:
return
self._process_queue()
self._create_timer()
def _connect_to_tray(self):
"""Connect to Tray webserver to pass console output. """
if not self._std_available: # not content to log
return
ws = websocket.WebSocket()
webserver_url = os.environ.get("AYON_WEBSERVER_URL")
if not webserver_url:
print("Unknown webserver url, cannot connect to pass log")
return
webserver_url = webserver_url.replace("http", "ws")
ws.connect("{}/ws/host_listener".format(webserver_url))
self.webserver_client = ws
payload = {
"host": self.host_id,
"action": HostMsgAction.CONNECTING,
"text": "Integration with {}".format(
str.capitalize(self.host_name))
}
self._send(payload)
def _disconnect_from_tray(self):
"""Send to Tray that host is closing - remove from Services. """
print("Host {} closing".format(self.host_name))
if not self.webserver_client:
return
payload = {
"host": self.host_id,
"action": HostMsgAction.CLOSE,
"text": "Integration with {}".format(
str.capitalize(self.host_name))
}
self._send(payload)
self.webserver_client.close()
def _catch_std_outputs(self):
"""Redirects standard out and error to own functions"""
if sys.stdout:
self.original_stdout_write = sys.stdout.write
sys.stdout.write = self._my_stdout_write
self._std_available = True
if sys.stderr:
self.original_stderr_write = sys.stderr.write
sys.stderr.write = self._my_stderr_write
self._std_available = True
def _my_stdout_write(self, text):
"""Appends outputted text to queue, keep writing to original stdout"""
if self.original_stdout_write is not None:
self.original_stdout_write(text)
if self.send_to_tray:
self.log_queue.append(text)
def _my_stderr_write(self, text):
"""Appends outputted text to queue, keep writing to original stderr"""
if self.original_stderr_write is not None:
self.original_stderr_write(text)
if self.send_to_tray:
self.log_queue.append(text)
def _process_queue(self):
"""Sends lines and purges queue"""
if not self.send_to_tray:
return
lines = tuple(self.log_queue)
self.log_queue.clear()
if lines:
payload = {
"host": self.host_id,
"action": HostMsgAction.ADD,
"text": "\n".join(lines)
}
self._send(payload)
def _send(self, payload):
"""Worker method to send to existing websocket connection."""
if not self.send_to_tray:
return
try:
self.webserver_client.send(json.dumps(payload))
except ConnectionResetError: # Tray closed
self._connect_to_tray()

View file

@ -447,8 +447,10 @@ class SystemTrayIcon(QtWidgets.QSystemTrayIcon):
def initialize_addons(self): def initialize_addons(self):
self._initializing_addons = True self._initializing_addons = True
self.tray_man.initialize_addons() try:
self._initializing_addons = False self.tray_man.initialize_addons()
finally:
self._initializing_addons = False
def _click_timer_timeout(self): def _click_timer_timeout(self):
self._click_timer.stop() self._click_timer.stop()

View file

@ -2,7 +2,7 @@ import time
from datetime import datetime from datetime import datetime
import logging import logging
from qtpy import QtWidgets from qtpy import QtWidgets, QtGui
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -106,3 +106,80 @@ class PrettyTimeDelegate(QtWidgets.QStyledItemDelegate):
def displayText(self, value, locale): def displayText(self, value, locale):
if value is not None: if value is not None:
return pretty_timestamp(value) return pretty_timestamp(value)
class StatusDelegate(QtWidgets.QStyledItemDelegate):
"""Delegate showing status name and short name."""
def __init__(
self,
status_name_role,
status_short_name_role,
status_color_role,
status_icon_role,
*args, **kwargs
):
super().__init__(*args, **kwargs)
self.status_name_role = status_name_role
self.status_short_name_role = status_short_name_role
self.status_color_role = status_color_role
self.status_icon_role = status_icon_role
def paint(self, painter, option, index):
if option.widget:
style = option.widget.style()
else:
style = QtWidgets.QApplication.style()
style.drawControl(
QtWidgets.QCommonStyle.CE_ItemViewItem,
option,
painter,
option.widget
)
painter.save()
text_rect = style.subElementRect(
QtWidgets.QCommonStyle.SE_ItemViewItemText,
option
)
text_margin = style.proxy().pixelMetric(
QtWidgets.QCommonStyle.PM_FocusFrameHMargin,
option,
option.widget
) + 1
padded_text_rect = text_rect.adjusted(
text_margin, 0, - text_margin, 0
)
fm = QtGui.QFontMetrics(option.font)
text = self._get_status_name(index)
if padded_text_rect.width() < fm.width(text):
text = self._get_status_short_name(index)
fg_color = self._get_status_color(index)
pen = painter.pen()
pen.setColor(fg_color)
painter.setPen(pen)
painter.drawText(
padded_text_rect,
option.displayAlignment,
text
)
painter.restore()
def _get_status_name(self, index):
return index.data(self.status_name_role)
def _get_status_short_name(self, index):
return index.data(self.status_short_name_role)
def _get_status_color(self, index):
return QtGui.QColor(index.data(self.status_color_role))
def _get_status_icon(self, index):
if self.status_icon_role is not None:
return index.data(self.status_icon_role)
return None

View file

@ -1,6 +1,7 @@
import os import os
import sys import sys
import contextlib import contextlib
import collections
from functools import partial from functools import partial
from qtpy import QtWidgets, QtCore, QtGui from qtpy import QtWidgets, QtCore, QtGui
@ -196,16 +197,16 @@ def get_openpype_qt_app():
return get_ayon_qt_app() return get_ayon_qt_app()
def iter_model_rows(model, column, include_root=False): def iter_model_rows(model, column=0, include_root=False):
"""Iterate over all row indices in a model""" """Iterate over all row indices in a model"""
indices = [QtCore.QModelIndex()] # start iteration at root indexes_queue = collections.deque()
# start iteration at root
for index in indices: indexes_queue.append(QtCore.QModelIndex())
while indexes_queue:
index = indexes_queue.popleft()
# Add children to the iterations # Add children to the iterations
child_rows = model.rowCount(index) for child_row in range(model.rowCount(index)):
for child_row in range(child_rows): indexes_queue.append(model.index(child_row, column, index))
child_index = model.index(child_row, column, index)
indices.append(child_index)
if not include_root and not index.isValid(): if not include_root and not index.isValid():
continue continue

View file

@ -13,8 +13,10 @@ class WorkfileInfo:
task_id (str): Task id. task_id (str): Task id.
filepath (str): Filepath. filepath (str): Filepath.
filesize (int): File size. filesize (int): File size.
creation_time (int): Creation time (timestamp). creation_time (float): Creation time (timestamp).
modification_time (int): Modification time (timestamp). modification_time (float): Modification time (timestamp).
created_by (Union[str, none]): User who created the file.
updated_by (Union[str, none]): User who last updated the file.
note (str): Note. note (str): Note.
""" """
@ -26,6 +28,8 @@ class WorkfileInfo:
filesize, filesize,
creation_time, creation_time,
modification_time, modification_time,
created_by,
updated_by,
note, note,
): ):
self.folder_id = folder_id self.folder_id = folder_id
@ -34,6 +38,8 @@ class WorkfileInfo:
self.filesize = filesize self.filesize = filesize
self.creation_time = creation_time self.creation_time = creation_time
self.modification_time = modification_time self.modification_time = modification_time
self.created_by = created_by
self.updated_by = updated_by
self.note = note self.note = note
def to_data(self): def to_data(self):
@ -50,6 +56,8 @@ class WorkfileInfo:
"filesize": self.filesize, "filesize": self.filesize,
"creation_time": self.creation_time, "creation_time": self.creation_time,
"modification_time": self.modification_time, "modification_time": self.modification_time,
"created_by": self.created_by,
"updated_by": self.updated_by,
"note": self.note, "note": self.note,
} }
@ -212,6 +220,7 @@ class FileItem:
dirpath (str): Directory path of file. dirpath (str): Directory path of file.
filename (str): Filename. filename (str): Filename.
modified (float): Modified timestamp. modified (float): Modified timestamp.
created_by (Optional[str]): Username.
representation_id (Optional[str]): Representation id of published representation_id (Optional[str]): Representation id of published
workfile. workfile.
filepath (Optional[str]): Prepared filepath. filepath (Optional[str]): Prepared filepath.
@ -223,6 +232,8 @@ class FileItem:
dirpath, dirpath,
filename, filename,
modified, modified,
created_by=None,
updated_by=None,
representation_id=None, representation_id=None,
filepath=None, filepath=None,
exists=None exists=None
@ -230,6 +241,8 @@ class FileItem:
self.filename = filename self.filename = filename
self.dirpath = dirpath self.dirpath = dirpath
self.modified = modified self.modified = modified
self.created_by = created_by
self.updated_by = updated_by
self.representation_id = representation_id self.representation_id = representation_id
self._filepath = filepath self._filepath = filepath
self._exists = exists self._exists = exists
@ -269,6 +282,7 @@ class FileItem:
"filename": self.filename, "filename": self.filename,
"dirpath": self.dirpath, "dirpath": self.dirpath,
"modified": self.modified, "modified": self.modified,
"created_by": self.created_by,
"representation_id": self.representation_id, "representation_id": self.representation_id,
"filepath": self.filepath, "filepath": self.filepath,
"exists": self.exists, "exists": self.exists,
@ -522,6 +536,16 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon):
pass pass
@abstractmethod
def get_user_items_by_name(self):
"""Get user items available on AYON server.
Returns:
Dict[str, UserItem]: User items by username.
"""
pass
# Host information # Host information
@abstractmethod @abstractmethod
def get_workfile_extensions(self): def get_workfile_extensions(self):

View file

@ -19,6 +19,7 @@ from ayon_core.tools.common_models import (
HierarchyModel, HierarchyModel,
HierarchyExpectedSelection, HierarchyExpectedSelection,
ProjectsModel, ProjectsModel,
UsersModel,
) )
from .abstract import ( from .abstract import (
@ -161,6 +162,7 @@ class BaseWorkfileController(
self._save_is_enabled = True self._save_is_enabled = True
# Expected selected folder and task # Expected selected folder and task
self._users_model = self._create_users_model()
self._expected_selection = self._create_expected_selection_obj() self._expected_selection = self._create_expected_selection_obj()
self._selection_model = self._create_selection_model() self._selection_model = self._create_selection_model()
self._projects_model = self._create_projects_model() self._projects_model = self._create_projects_model()
@ -176,6 +178,12 @@ class BaseWorkfileController(
def is_host_valid(self): def is_host_valid(self):
return self._host_is_valid return self._host_is_valid
def _create_users_model(self):
return UsersModel(self)
def _create_workfiles_model(self):
return WorkfilesModel(self)
def _create_expected_selection_obj(self): def _create_expected_selection_obj(self):
return WorkfilesToolExpectedSelection(self) return WorkfilesToolExpectedSelection(self)
@ -188,9 +196,6 @@ class BaseWorkfileController(
def _create_hierarchy_model(self): def _create_hierarchy_model(self):
return HierarchyModel(self) return HierarchyModel(self)
def _create_workfiles_model(self):
return WorkfilesModel(self)
@property @property
def event_system(self): def event_system(self):
"""Inner event system for workfiles tool controller. """Inner event system for workfiles tool controller.
@ -272,6 +277,9 @@ class BaseWorkfileController(
{"enabled": enabled} {"enabled": enabled}
) )
def get_user_items_by_name(self):
return self._users_model.get_user_items_by_name()
# Host information # Host information
def get_workfile_extensions(self): def get_workfile_extensions(self):
host = self._host host = self._host

View file

@ -6,6 +6,7 @@ import arrow
import ayon_api import ayon_api
from ayon_api.operations import OperationsSession from ayon_api.operations import OperationsSession
from ayon_core.lib import get_ayon_username
from ayon_core.pipeline.template_data import ( from ayon_core.pipeline.template_data import (
get_template_data, get_template_data,
get_task_template_data, get_task_template_data,
@ -23,6 +24,8 @@ from ayon_core.tools.workfiles.abstract import (
WorkfileInfo, WorkfileInfo,
) )
_NOT_SET = object()
class CommentMatcher(object): class CommentMatcher(object):
"""Use anatomy and work file data to parse comments from filenames. """Use anatomy and work file data to parse comments from filenames.
@ -188,10 +191,17 @@ class WorkareaModel:
if ext not in self._extensions: if ext not in self._extensions:
continue continue
modified = os.path.getmtime(filepath) workfile_info = self._controller.get_workfile_info(
items.append( folder_id, task_id, filepath
FileItem(workdir, filename, modified)
) )
modified = os.path.getmtime(filepath)
items.append(FileItem(
workdir,
filename,
modified,
workfile_info.created_by,
workfile_info.updated_by,
))
return items return items
def _get_template_key(self, fill_data): def _get_template_key(self, fill_data):
@ -439,6 +449,7 @@ class WorkfileEntitiesModel:
self._controller = controller self._controller = controller
self._cache = {} self._cache = {}
self._items = {} self._items = {}
self._current_username = _NOT_SET
def _get_workfile_info_identifier( def _get_workfile_info_identifier(
self, folder_id, task_id, rootless_path self, folder_id, task_id, rootless_path
@ -459,8 +470,12 @@ class WorkfileEntitiesModel:
self, folder_id, task_id, workfile_info, filepath self, folder_id, task_id, workfile_info, filepath
): ):
note = "" note = ""
created_by = None
updated_by = None
if workfile_info: if workfile_info:
note = workfile_info["attrib"].get("description") or "" note = workfile_info["attrib"].get("description") or ""
created_by = workfile_info.get("createdBy")
updated_by = workfile_info.get("updatedBy")
filestat = os.stat(filepath) filestat = os.stat(filepath)
return WorkfileInfo( return WorkfileInfo(
@ -470,6 +485,8 @@ class WorkfileEntitiesModel:
filesize=filestat.st_size, filesize=filestat.st_size,
creation_time=filestat.st_ctime, creation_time=filestat.st_ctime,
modification_time=filestat.st_mtime, modification_time=filestat.st_mtime,
created_by=created_by,
updated_by=updated_by,
note=note note=note
) )
@ -481,7 +498,7 @@ class WorkfileEntitiesModel:
for workfile_info in ayon_api.get_workfiles_info( for workfile_info in ayon_api.get_workfiles_info(
self._controller.get_current_project_name(), self._controller.get_current_project_name(),
task_ids=[task_id], task_ids=[task_id],
fields=["id", "path", "attrib"], fields=["id", "path", "attrib", "createdBy", "updatedBy"],
): ):
workfile_identifier = self._get_workfile_info_identifier( workfile_identifier = self._get_workfile_info_identifier(
folder_id, task_id, workfile_info["path"] folder_id, task_id, workfile_info["path"]
@ -525,18 +542,32 @@ class WorkfileEntitiesModel:
self._items.pop(identifier, None) self._items.pop(identifier, None)
return return
if note is None:
return
old_note = workfile_info.get("attrib", {}).get("note") old_note = workfile_info.get("attrib", {}).get("note")
new_workfile_info = copy.deepcopy(workfile_info) new_workfile_info = copy.deepcopy(workfile_info)
attrib = new_workfile_info.setdefault("attrib", {}) update_data = {}
attrib["description"] = note if note is not None and old_note != note:
update_data["attrib"] = {"description": note}
attrib = new_workfile_info.setdefault("attrib", {})
attrib["description"] = note
username = self._get_current_username()
# Automatically fix 'createdBy' and 'updatedBy' fields
# NOTE both fields were not automatically filled by server
# until 1.1.3 release.
if workfile_info.get("createdBy") is None:
update_data["createdBy"] = username
new_workfile_info["createdBy"] = username
if workfile_info.get("updatedBy") != username:
update_data["updatedBy"] = username
new_workfile_info["updatedBy"] = username
if not update_data:
return
self._cache[identifier] = new_workfile_info self._cache[identifier] = new_workfile_info
self._items.pop(identifier, None) self._items.pop(identifier, None)
if old_note == note:
return
project_name = self._controller.get_current_project_name() project_name = self._controller.get_current_project_name()
@ -545,7 +576,7 @@ class WorkfileEntitiesModel:
project_name, project_name,
"workfile", "workfile",
workfile_info["id"], workfile_info["id"],
{"attrib": {"description": note}}, update_data,
) )
session.commit() session.commit()
@ -554,13 +585,18 @@ class WorkfileEntitiesModel:
project_name = self._controller.get_current_project_name() project_name = self._controller.get_current_project_name()
username = self._get_current_username()
workfile_info = { workfile_info = {
"path": rootless_path, "path": rootless_path,
"taskId": task_id, "taskId": task_id,
"attrib": { "attrib": {
"extension": extension, "extension": extension,
"description": note "description": note
} },
# TODO remove 'createdBy' and 'updatedBy' fields when server is
# or above 1.1.3 .
"createdBy": username,
"updatedBy": username,
} }
session = OperationsSession() session = OperationsSession()
@ -568,6 +604,11 @@ class WorkfileEntitiesModel:
session.commit() session.commit()
return workfile_info return workfile_info
def _get_current_username(self):
if self._current_username is _NOT_SET:
self._current_username = get_ayon_username()
return self._current_username
class PublishWorkfilesModel: class PublishWorkfilesModel:
"""Model for handling of published workfiles. """Model for handling of published workfiles.
@ -599,7 +640,7 @@ class PublishWorkfilesModel:
return self._cached_repre_extensions return self._cached_repre_extensions
def _file_item_from_representation( def _file_item_from_representation(
self, repre_entity, project_anatomy, task_name=None self, repre_entity, project_anatomy, author, task_name=None
): ):
if task_name is not None: if task_name is not None:
task_info = repre_entity["context"].get("task") task_info = repre_entity["context"].get("task")
@ -634,6 +675,8 @@ class PublishWorkfilesModel:
dirpath, dirpath,
filename, filename,
created_at.float_timestamp, created_at.float_timestamp,
author,
None,
repre_entity["id"] repre_entity["id"]
) )
@ -643,9 +686,9 @@ class PublishWorkfilesModel:
# Get subset docs of folder # Get subset docs of folder
product_entities = ayon_api.get_products( product_entities = ayon_api.get_products(
project_name, project_name,
folder_ids=[folder_id], folder_ids={folder_id},
product_types=["workfile"], product_types={"workfile"},
fields=["id", "name"] fields={"id", "name"}
) )
output = [] output = []
@ -657,25 +700,33 @@ class PublishWorkfilesModel:
version_entities = ayon_api.get_versions( version_entities = ayon_api.get_versions(
project_name, project_name,
product_ids=product_ids, product_ids=product_ids,
fields=["id", "productId"] fields={"id", "author"}
) )
version_ids = {version["id"] for version in version_entities} versions_by_id = {
if not version_ids: version["id"]: version
for version in version_entities
}
if not versions_by_id:
return output return output
# Query representations of filtered versions and add filter for # Query representations of filtered versions and add filter for
# extension # extension
repre_entities = ayon_api.get_representations( repre_entities = ayon_api.get_representations(
project_name, project_name,
version_ids=version_ids version_ids=set(versions_by_id)
) )
project_anatomy = self._controller.project_anatomy project_anatomy = self._controller.project_anatomy
# Filter queried representations by task name if task is set # Filter queried representations by task name if task is set
file_items = [] file_items = []
for repre_entity in repre_entities: for repre_entity in repre_entities:
version_id = repre_entity["versionId"]
version_entity = versions_by_id[version_id]
file_item = self._file_item_from_representation( file_item = self._file_item_from_representation(
repre_entity, project_anatomy, task_name repre_entity,
project_anatomy,
version_entity["author"],
task_name,
) )
if file_item is not None: if file_item is not None:
file_items.append(file_item) file_items.append(file_item)

View file

@ -13,7 +13,8 @@ from .utils import BaseOverlayFrame
REPRE_ID_ROLE = QtCore.Qt.UserRole + 1 REPRE_ID_ROLE = QtCore.Qt.UserRole + 1
FILEPATH_ROLE = QtCore.Qt.UserRole + 2 FILEPATH_ROLE = QtCore.Qt.UserRole + 2
DATE_MODIFIED_ROLE = QtCore.Qt.UserRole + 3 AUTHOR_ROLE = QtCore.Qt.UserRole + 3
DATE_MODIFIED_ROLE = QtCore.Qt.UserRole + 4
class PublishedFilesModel(QtGui.QStandardItemModel): class PublishedFilesModel(QtGui.QStandardItemModel):
@ -23,13 +24,19 @@ class PublishedFilesModel(QtGui.QStandardItemModel):
controller (AbstractWorkfilesFrontend): The control object. controller (AbstractWorkfilesFrontend): The control object.
""" """
columns = [
"Name",
"Author",
"Date Modified",
]
date_modified_col = columns.index("Date Modified")
def __init__(self, controller): def __init__(self, controller):
super(PublishedFilesModel, self).__init__() super(PublishedFilesModel, self).__init__()
self.setColumnCount(2) self.setColumnCount(len(self.columns))
for idx, label in enumerate(self.columns):
self.setHeaderData(0, QtCore.Qt.Horizontal, "Name") self.setHeaderData(idx, QtCore.Qt.Horizontal, label)
self.setHeaderData(1, QtCore.Qt.Horizontal, "Date Modified")
controller.register_event_callback( controller.register_event_callback(
"selection.task.changed", "selection.task.changed",
@ -185,6 +192,8 @@ class PublishedFilesModel(QtGui.QStandardItemModel):
self._remove_empty_item() self._remove_empty_item()
self._remove_missing_context_item() self._remove_missing_context_item()
user_items_by_name = self._controller.get_user_items_by_name()
items_to_remove = set(self._items_by_id.keys()) items_to_remove = set(self._items_by_id.keys())
new_items = [] new_items = []
for file_item in file_items: for file_item in file_items:
@ -205,8 +214,15 @@ class PublishedFilesModel(QtGui.QStandardItemModel):
else: else:
flags = QtCore.Qt.NoItemFlags flags = QtCore.Qt.NoItemFlags
author = file_item.created_by
user_item = user_items_by_name.get(author)
if user_item is not None and user_item.full_name:
author = user_item.full_name
item.setFlags(flags) item.setFlags(flags)
item.setData(file_item.filepath, FILEPATH_ROLE) item.setData(file_item.filepath, FILEPATH_ROLE)
item.setData(author, AUTHOR_ROLE)
item.setData(file_item.modified, DATE_MODIFIED_ROLE) item.setData(file_item.modified, DATE_MODIFIED_ROLE)
self._items_by_id[repre_id] = item self._items_by_id[repre_id] = item
@ -225,22 +241,30 @@ class PublishedFilesModel(QtGui.QStandardItemModel):
# Use flags of first column for all columns # Use flags of first column for all columns
if index.column() != 0: if index.column() != 0:
index = self.index(index.row(), 0, index.parent()) index = self.index(index.row(), 0, index.parent())
return super(PublishedFilesModel, self).flags(index) return super().flags(index)
def data(self, index, role=None): def data(self, index, role=None):
if role is None: if role is None:
role = QtCore.Qt.DisplayRole role = QtCore.Qt.DisplayRole
# Handle roles for first column # Handle roles for first column
if index.column() == 1: col = index.column()
if role == QtCore.Qt.DecorationRole: if col != 1:
return None return super().data(index, role)
if role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole): if role == QtCore.Qt.DecorationRole:
return None
if role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole):
if col == 1:
role = AUTHOR_ROLE
elif col == 2:
role = DATE_MODIFIED_ROLE role = DATE_MODIFIED_ROLE
index = self.index(index.row(), 0, index.parent()) else:
return None
index = self.index(index.row(), 0, index.parent())
return super(PublishedFilesModel, self).data(index, role) return super().data(index, role)
class SelectContextOverlay(BaseOverlayFrame): class SelectContextOverlay(BaseOverlayFrame):
@ -295,7 +319,7 @@ class PublishedFilesWidget(QtWidgets.QWidget):
view.setModel(proxy_model) view.setModel(proxy_model)
time_delegate = PrettyTimeDelegate() time_delegate = PrettyTimeDelegate()
view.setItemDelegateForColumn(1, time_delegate) view.setItemDelegateForColumn(model.date_modified_col, time_delegate)
# Default to a wider first filename column it is what we mostly care # Default to a wider first filename column it is what we mostly care
# about and the date modified is relatively small anyway. # about and the date modified is relatively small anyway.

View file

@ -10,7 +10,8 @@ from ayon_core.tools.utils.delegates import PrettyTimeDelegate
FILENAME_ROLE = QtCore.Qt.UserRole + 1 FILENAME_ROLE = QtCore.Qt.UserRole + 1
FILEPATH_ROLE = QtCore.Qt.UserRole + 2 FILEPATH_ROLE = QtCore.Qt.UserRole + 2
DATE_MODIFIED_ROLE = QtCore.Qt.UserRole + 3 AUTHOR_ROLE = QtCore.Qt.UserRole + 3
DATE_MODIFIED_ROLE = QtCore.Qt.UserRole + 4
class WorkAreaFilesModel(QtGui.QStandardItemModel): class WorkAreaFilesModel(QtGui.QStandardItemModel):
@ -21,14 +22,20 @@ class WorkAreaFilesModel(QtGui.QStandardItemModel):
""" """
refreshed = QtCore.Signal() refreshed = QtCore.Signal()
columns = [
"Name",
"Author",
"Date Modified",
]
date_modified_col = columns.index("Date Modified")
def __init__(self, controller): def __init__(self, controller):
super(WorkAreaFilesModel, self).__init__() super(WorkAreaFilesModel, self).__init__()
self.setColumnCount(2) self.setColumnCount(len(self.columns))
self.setHeaderData(0, QtCore.Qt.Horizontal, "Name") for idx, label in enumerate(self.columns):
self.setHeaderData(1, QtCore.Qt.Horizontal, "Date Modified") self.setHeaderData(idx, QtCore.Qt.Horizontal, label)
controller.register_event_callback( controller.register_event_callback(
"selection.folder.changed", "selection.folder.changed",
@ -186,6 +193,7 @@ class WorkAreaFilesModel(QtGui.QStandardItemModel):
return return
self._remove_empty_item() self._remove_empty_item()
self._remove_missing_context_item() self._remove_missing_context_item()
user_items_by_name = self._controller.get_user_items_by_name()
items_to_remove = set(self._items_by_filename.keys()) items_to_remove = set(self._items_by_filename.keys())
new_items = [] new_items = []
@ -205,7 +213,13 @@ class WorkAreaFilesModel(QtGui.QStandardItemModel):
item.setData(file_item.filename, QtCore.Qt.DisplayRole) item.setData(file_item.filename, QtCore.Qt.DisplayRole)
item.setData(file_item.filename, FILENAME_ROLE) item.setData(file_item.filename, FILENAME_ROLE)
updated_by = file_item.updated_by
user_item = user_items_by_name.get(updated_by)
if user_item is not None and user_item.full_name:
updated_by = user_item.full_name
item.setData(file_item.filepath, FILEPATH_ROLE) item.setData(file_item.filepath, FILEPATH_ROLE)
item.setData(updated_by, AUTHOR_ROLE)
item.setData(file_item.modified, DATE_MODIFIED_ROLE) item.setData(file_item.modified, DATE_MODIFIED_ROLE)
self._items_by_filename[file_item.filename] = item self._items_by_filename[file_item.filename] = item
@ -224,22 +238,30 @@ class WorkAreaFilesModel(QtGui.QStandardItemModel):
# Use flags of first column for all columns # Use flags of first column for all columns
if index.column() != 0: if index.column() != 0:
index = self.index(index.row(), 0, index.parent()) index = self.index(index.row(), 0, index.parent())
return super(WorkAreaFilesModel, self).flags(index) return super().flags(index)
def data(self, index, role=None): def data(self, index, role=None):
if role is None: if role is None:
role = QtCore.Qt.DisplayRole role = QtCore.Qt.DisplayRole
# Handle roles for first column # Handle roles for first column
if index.column() == 1: col = index.column()
if role == QtCore.Qt.DecorationRole: if col == 0:
return None return super().data(index, role)
if role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole): if role == QtCore.Qt.DecorationRole:
return None
if role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole):
if col == 1:
role = AUTHOR_ROLE
elif col == 2:
role = DATE_MODIFIED_ROLE role = DATE_MODIFIED_ROLE
index = self.index(index.row(), 0, index.parent()) else:
return None
index = self.index(index.row(), 0, index.parent())
return super(WorkAreaFilesModel, self).data(index, role) return super().data(index, role)
def set_published_mode(self, published_mode): def set_published_mode(self, published_mode):
if self._published_mode == published_mode: if self._published_mode == published_mode:
@ -279,7 +301,7 @@ class WorkAreaFilesWidget(QtWidgets.QWidget):
view.setModel(proxy_model) view.setModel(proxy_model)
time_delegate = PrettyTimeDelegate() time_delegate = PrettyTimeDelegate()
view.setItemDelegateForColumn(1, time_delegate) view.setItemDelegateForColumn(model.date_modified_col, time_delegate)
# Default to a wider first filename column it is what we mostly care # Default to a wider first filename column it is what we mostly care
# about and the date modified is relatively small anyway. # about and the date modified is relatively small anyway.

View file

@ -147,13 +147,38 @@ class SidePanelWidget(QtWidgets.QWidget):
workfile_info.creation_time) workfile_info.creation_time)
modification_time = datetime.datetime.fromtimestamp( modification_time = datetime.datetime.fromtimestamp(
workfile_info.modification_time) workfile_info.modification_time)
user_items_by_name = self._controller.get_user_items_by_name()
def convert_username(username):
user_item = user_items_by_name.get(username)
if user_item is not None and user_item.full_name:
return user_item.full_name
return username
created_lines = [
creation_time.strftime(datetime_format)
]
if workfile_info.created_by:
created_lines.insert(
0, convert_username(workfile_info.created_by)
)
modified_lines = [
modification_time.strftime(datetime_format)
]
if workfile_info.updated_by:
modified_lines.insert(
0, convert_username(workfile_info.updated_by)
)
lines = ( lines = (
"<b>Size:</b>", "<b>Size:</b>",
size_value, size_value,
"<b>Created:</b>", "<b>Created:</b>",
creation_time.strftime(datetime_format), "<br/>".join(created_lines),
"<b>Modified:</b>", "<b>Modified:</b>",
modification_time.strftime(datetime_format) "<br/>".join(modified_lines),
) )
self._orig_note = note self._orig_note = note
self._note_input.setPlainText(note) self._note_input.setPlainText(note)

View file

@ -107,7 +107,7 @@ class WorkfilesToolWindow(QtWidgets.QWidget):
split_widget.addWidget(tasks_widget) split_widget.addWidget(tasks_widget)
split_widget.addWidget(col_3_widget) split_widget.addWidget(col_3_widget)
split_widget.addWidget(side_panel) split_widget.addWidget(side_panel)
split_widget.setSizes([255, 160, 455, 175]) split_widget.setSizes([255, 175, 550, 190])
body_layout.addWidget(split_widget) body_layout.addWidget(split_widget)
@ -169,7 +169,7 @@ class WorkfilesToolWindow(QtWidgets.QWidget):
# Force focus on the open button by default, required for Houdini. # Force focus on the open button by default, required for Houdini.
self._files_widget.setFocus() self._files_widget.setFocus()
self.resize(1200, 600) self.resize(1260, 600)
def _create_col_1_widget(self, controller, parent): def _create_col_1_widget(self, controller, parent):
col_widget = QtWidgets.QWidget(parent) col_widget = QtWidgets.QWidget(parent)

View file

@ -1,3 +1,3 @@
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""Package declaring AYON core addon version.""" """Package declaring AYON core addon version."""
__version__ = "0.3.1" __version__ = "0.3.3-dev.1"

View file

@ -16,7 +16,7 @@ aiohttp_json_rpc = "*" # TVPaint server
aiohttp-middlewares = "^2.0.0" aiohttp-middlewares = "^2.0.0"
wsrpc_aiohttp = "^3.1.1" # websocket server wsrpc_aiohttp = "^3.1.1" # websocket server
Click = "^8" Click = "^8"
OpenTimelineIO = "0.14.1" OpenTimelineIO = "0.16.0"
opencolorio = "2.2.1" opencolorio = "2.2.1"
Pillow = "9.5.0" Pillow = "9.5.0"
pynput = "^1.7.2" # Timers manager - TODO remove pynput = "^1.7.2" # Timers manager - TODO remove

View file

@ -1,6 +1,6 @@
name = "core" name = "core"
title = "Core" title = "Core"
version = "0.3.1" version = "0.3.3-dev.1"
client_dir = "ayon_core" client_dir = "ayon_core"

View file

@ -80,11 +80,11 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
exclude = [ exclude = [
"client/ayon_core/hosts/unreal/integration/*", "client/ayon_core/hosts/unreal/integration/*",
"client/ayon_core/hosts/aftereffects/api/extension/js/libs/*", "client/ayon_core/hosts/aftereffects/api/extension/js/libs/*",
"client/ayon_core/hosts/hiero/api/startup/*",
"client/ayon_core/modules/deadline/repository/custom/plugins/CelAction/*", "client/ayon_core/modules/deadline/repository/custom/plugins/CelAction/*",
"client/ayon_core/modules/deadline/repository/custom/plugins/HarmonyAYON/*", "client/ayon_core/modules/deadline/repository/custom/plugins/HarmonyAYON/*",
"client/ayon_core/modules/click_wrap.py", "client/ayon_core/modules/click_wrap.py",
"client/ayon_core/scripts/slates/__init__.py" "client/ayon_core/scripts/slates/__init__.py",
"server_addon/hiero/client/ayon_hiero/api/startup/*"
] ]
[tool.ruff.lint.per-file-ignores] [tool.ruff.lint.per-file-ignores]
@ -108,6 +108,10 @@ line-ending = "auto"
# Ignore words that are not in the dictionary. # Ignore words that are not in the dictionary.
ignore-words-list = "ayon,ynput,parms,parm,hda,developpement,ue" ignore-words-list = "ayon,ynput,parms,parm,hda,developpement,ue"
# Ignore lines that contain this regex. This is hack for missing inline ignore.
# Remove with next codespell release (>2.2.6)
ignore-regex = ".*codespell:ignore.*"
skip = "./.*,./package/*,*/vendor/*,*/unreal/integration/*,*/aftereffects/api/extension/js/libs/*" skip = "./.*,./package/*,*/vendor/*,*/unreal/integration/*,*/aftereffects/api/extension/js/libs/*"
count = true count = true
quiet-level = 3 quiet-level = 3

View file

@ -1,6 +1,12 @@
from typing import Any
from ayon_server.addons import BaseServerAddon from ayon_server.addons import BaseServerAddon
from .settings import CoreSettings, DEFAULT_VALUES from .settings import (
CoreSettings,
DEFAULT_VALUES,
convert_settings_overrides,
)
class CoreAddon(BaseServerAddon): class CoreAddon(BaseServerAddon):
@ -9,3 +15,14 @@ class CoreAddon(BaseServerAddon):
async def get_default_settings(self): async def get_default_settings(self):
settings_model_cls = self.get_settings_model() settings_model_cls = self.get_settings_model()
return settings_model_cls(**DEFAULT_VALUES) return settings_model_cls(**DEFAULT_VALUES)
async def convert_settings_overrides(
self,
source_version: str,
overrides: dict[str, Any],
) -> dict[str, Any]:
convert_settings_overrides(source_version, overrides)
# Use super conversion
return await super().convert_settings_overrides(
source_version, overrides
)

View file

@ -1,7 +1,10 @@
from .main import CoreSettings, DEFAULT_VALUES from .main import CoreSettings, DEFAULT_VALUES
from .conversion import convert_settings_overrides
__all__ = ( __all__ = (
"CoreSettings", "CoreSettings",
"DEFAULT_VALUES", "DEFAULT_VALUES",
"convert_settings_overrides",
) )

View file

@ -0,0 +1,86 @@
import copy
from typing import Any
from .publish_plugins import DEFAULT_PUBLISH_VALUES
def _convert_imageio_configs_0_3_1(overrides):
"""Imageio config settings did change to profiles since 0.3.1. ."""
imageio_overrides = overrides.get("imageio") or {}
if (
"ocio_config" not in imageio_overrides
or "filepath" not in imageio_overrides["ocio_config"]
):
return
ocio_config = imageio_overrides.pop("ocio_config")
filepath = ocio_config["filepath"]
if not filepath:
return
first_filepath = filepath[0]
ocio_config_profiles = imageio_overrides.setdefault(
"ocio_config_profiles", []
)
base_value = {
"type": "builtin_path",
"product_name": "",
"host_names": [],
"task_names": [],
"task_types": [],
"custom_path": "",
"builtin_path": "{BUILTIN_OCIO_ROOT}/aces_1.2/config.ocio"
}
if first_filepath in (
"{BUILTIN_OCIO_ROOT}/aces_1.2/config.ocio",
"{BUILTIN_OCIO_ROOT}/nuke-default/config.ocio",
):
base_value["type"] = "builtin_path"
base_value["builtin_path"] = first_filepath
else:
base_value["type"] = "custom_path"
base_value["custom_path"] = first_filepath
ocio_config_profiles.append(base_value)
def _convert_validate_version_0_3_3(publish_overrides):
"""ValidateVersion plugin changed in 0.3.3."""
if "ValidateVersion" not in publish_overrides:
return
validate_version = publish_overrides["ValidateVersion"]
# Already new settings
if "plugin_state_profiles" in validate_version:
return
# Use new default profile as base
profile = copy.deepcopy(
DEFAULT_PUBLISH_VALUES["ValidateVersion"]["plugin_state_profiles"][0]
)
# Copy values from old overrides to new overrides
for key in {
"enabled",
"optional",
"active",
}:
if key not in validate_version:
continue
profile[key] = validate_version.pop(key)
validate_version["plugin_state_profiles"] = [profile]
def _conver_publish_plugins(overrides):
if "publish" not in overrides:
return
_convert_validate_version_0_3_3(overrides["publish"])
def convert_settings_overrides(
source_version: str,
overrides: dict[str, Any],
) -> dict[str, Any]:
_convert_imageio_configs_0_3_1(overrides)
_conver_publish_plugins(overrides)
return overrides

View file

@ -54,9 +54,67 @@ class CoreImageIOFileRulesModel(BaseSettingsModel):
return value return value
class CoreImageIOConfigModel(BaseSettingsModel): def _ocio_config_profile_types():
filepath: list[str] = SettingsField( return [
default_factory=list, title="Config path" {"value": "builtin_path", "label": "AYON built-in OCIO config"},
{"value": "custom_path", "label": "Path to OCIO config"},
{"value": "product_name", "label": "Published product"},
]
def _ocio_built_in_paths():
return [
{
"value": "{BUILTIN_OCIO_ROOT}/aces_1.2/config.ocio",
"label": "ACES 1.2",
"description": "Aces 1.2 OCIO config file."
},
{
"value": "{BUILTIN_OCIO_ROOT}/nuke-default/config.ocio",
"label": "Nuke default",
},
]
class CoreImageIOConfigProfilesModel(BaseSettingsModel):
_layout = "expanded"
host_names: list[str] = SettingsField(
default_factory=list,
title="Host names"
)
task_types: list[str] = SettingsField(
default_factory=list,
title="Task types",
enum_resolver=task_types_enum
)
task_names: list[str] = SettingsField(
default_factory=list,
title="Task names"
)
type: str = SettingsField(
title="Profile type",
enum_resolver=_ocio_config_profile_types,
conditionalEnum=True,
default="builtin_path",
section="---",
)
builtin_path: str = SettingsField(
"ACES 1.2",
title="Built-in OCIO config",
enum_resolver=_ocio_built_in_paths,
)
custom_path: str = SettingsField(
"",
title="OCIO config path",
description="Path to OCIO config. Anatomy formatting is supported.",
)
product_name: str = SettingsField(
"",
title="Product name",
description=(
"Published product name to get OCIO config from. "
"Partial match is supported."
),
) )
@ -65,9 +123,8 @@ class CoreImageIOBaseModel(BaseSettingsModel):
False, False,
title="Enable Color Management" title="Enable Color Management"
) )
ocio_config: CoreImageIOConfigModel = SettingsField( ocio_config_profiles: list[CoreImageIOConfigProfilesModel] = SettingsField(
default_factory=CoreImageIOConfigModel, default_factory=list, title="OCIO config profiles"
title="OCIO config"
) )
file_rules: CoreImageIOFileRulesModel = SettingsField( file_rules: CoreImageIOFileRulesModel = SettingsField(
default_factory=CoreImageIOFileRulesModel, default_factory=CoreImageIOFileRulesModel,
@ -186,12 +243,17 @@ class CoreSettings(BaseSettingsModel):
DEFAULT_VALUES = { DEFAULT_VALUES = {
"imageio": { "imageio": {
"activate_global_color_management": False, "activate_global_color_management": False,
"ocio_config": { "ocio_config_profiles": [
"filepath": [ {
"{BUILTIN_OCIO_ROOT}/aces_1.2/config.ocio", "host_names": [],
"{BUILTIN_OCIO_ROOT}/nuke-default/config.ocio" "task_types": [],
] "task_names": [],
}, "type": "builtin_path",
"builtin_path": "{BUILTIN_OCIO_ROOT}/aces_1.2/config.ocio",
"custom_path": "",
"product_name": "",
}
],
"file_rules": { "file_rules": {
"activate_global_file_rules": False, "activate_global_file_rules": False,
"rules": [ "rules": [
@ -199,42 +261,57 @@ DEFAULT_VALUES = {
"name": "example", "name": "example",
"pattern": ".*(beauty).*", "pattern": ".*(beauty).*",
"colorspace": "ACES - ACEScg", "colorspace": "ACES - ACEScg",
"ext": "exr" "ext": "exr",
} }
] ],
} },
}, },
"studio_name": "", "studio_name": "",
"studio_code": "", "studio_code": "",
"environments": "{\n\"STUDIO_SW\": {\n \"darwin\": \"/mnt/REPO_SW\",\n \"linux\": \"/mnt/REPO_SW\",\n \"windows\": \"P:/REPO_SW\"\n }\n}", "environments": json.dumps(
{
"STUDIO_SW": {
"darwin": "/mnt/REPO_SW",
"linux": "/mnt/REPO_SW",
"windows": "P:/REPO_SW"
}
},
indent=4
),
"tools": DEFAULT_TOOLS_VALUES, "tools": DEFAULT_TOOLS_VALUES,
"version_start_category": { "version_start_category": {
"profiles": [] "profiles": []
}, },
"publish": DEFAULT_PUBLISH_VALUES, "publish": DEFAULT_PUBLISH_VALUES,
"project_folder_structure": json.dumps({ "project_folder_structure": json.dumps(
"__project_root__": { {
"prod": {}, "__project_root__": {
"resources": { "prod": {},
"footage": { "resources": {
"plates": {}, "footage": {
"offline": {} "plates": {},
"offline": {}
},
"audio": {},
"art_dept": {}
}, },
"audio": {}, "editorial": {},
"art_dept": {} "assets": {
}, "characters": {},
"editorial": {}, "locations": {}
"assets": { },
"characters": {}, "shots": {}
"locations": {} }
}, },
"shots": {} indent=4
} ),
}, indent=4),
"project_plugins": { "project_plugins": {
"windows": [], "windows": [],
"darwin": [], "darwin": [],
"linux": [] "linux": []
}, },
"project_environments": "{}" "project_environments": json.dumps(
{},
indent=4
)
} }

View file

@ -59,6 +59,28 @@ class CollectFramesFixDefModel(BaseSettingsModel):
) )
class PluginStateByHostModelProfile(BaseSettingsModel):
_layout = "expanded"
# Filtering
host_names: list[str] = SettingsField(
default_factory=list,
title="Host names"
)
# Profile values
enabled: bool = SettingsField(True, title="Enabled")
optional: bool = SettingsField(True, title="Optional")
active: bool = SettingsField(True, title="Active")
class PluginStateByHostModel(BaseSettingsModel):
_isGroup = True
plugin_state_profiles: list[PluginStateByHostModelProfile] = SettingsField(
default_factory=list,
title="Plugin enable state profiles",
description="Change plugin state based on host name."
)
class ValidateIntentProfile(BaseSettingsModel): class ValidateIntentProfile(BaseSettingsModel):
_layout = "expanded" _layout = "expanded"
hosts: list[str] = SettingsField(default_factory=list, title="Host names") hosts: list[str] = SettingsField(default_factory=list, title="Host names")
@ -536,7 +558,7 @@ class ExtractBurninProfile(BaseSettingsModel):
_layout = "expanded" _layout = "expanded"
product_types: list[str] = SettingsField( product_types: list[str] = SettingsField(
default_factory=list, default_factory=list,
title="Produt types" title="Product types"
) )
hosts: list[str] = SettingsField( hosts: list[str] = SettingsField(
default_factory=list, default_factory=list,
@ -766,9 +788,17 @@ class PublishPuginsModel(BaseSettingsModel):
default_factory=ValidateBaseModel, default_factory=ValidateBaseModel,
title="Validate Editorial Asset Name" title="Validate Editorial Asset Name"
) )
ValidateVersion: ValidateBaseModel = SettingsField( ValidateVersion: PluginStateByHostModel = SettingsField(
default_factory=ValidateBaseModel, default_factory=PluginStateByHostModel,
title="Validate Version" title="Validate Version",
description=(
"Validate that product version to integrate"
" is newer than latest version in AYON."
)
)
ValidateOutdatedContainers: PluginStateByHostModel = SettingsField(
default_factory=PluginStateByHostModel,
title="Validate Containers"
) )
ValidateIntent: ValidateIntentModel = SettingsField( ValidateIntent: ValidateIntentModel = SettingsField(
default_factory=ValidateIntentModel, default_factory=ValidateIntentModel,
@ -851,9 +881,40 @@ DEFAULT_PUBLISH_VALUES = {
"active": True "active": True
}, },
"ValidateVersion": { "ValidateVersion": {
"enabled": True, "plugin_state_profiles": [
"optional": False, {
"active": True "host_names": [
"aftereffects",
"blender",
"houdini",
"maya",
"nuke",
"photoshop",
],
"enabled": True,
"optional": False,
"active": True
}
]
},
"ValidateOutdatedContainers": {
"plugin_state_profiles": [
{
# Default host names are based on original
# filter of ValidateContainer pyblish plugin
"host_names": [
"maya",
"houdini",
"nuke",
"harmony",
"photoshop",
"aftereffects"
],
"enabled": True,
"optional": True,
"active": True
}
]
}, },
"ValidateIntent": { "ValidateIntent": {
"enabled": False, "enabled": False,

View file

@ -1,3 +1,3 @@
name = "aftereffects" name = "aftereffects"
title = "AfterEffects" title = "AfterEffects"
version = "0.1.3" version = "0.1.4"

View file

@ -22,12 +22,6 @@ class ValidateSceneSettingsModel(BaseSettingsModel):
) )
class ValidateContainersModel(BaseSettingsModel):
enabled: bool = SettingsField(True, title="Enabled")
optional: bool = SettingsField(True, title="Optional")
active: bool = SettingsField(True, title="Active")
class AfterEffectsPublishPlugins(BaseSettingsModel): class AfterEffectsPublishPlugins(BaseSettingsModel):
CollectReview: CollectReviewPluginModel = SettingsField( CollectReview: CollectReviewPluginModel = SettingsField(
default_factory=CollectReviewPluginModel, default_factory=CollectReviewPluginModel,
@ -37,10 +31,6 @@ class AfterEffectsPublishPlugins(BaseSettingsModel):
default_factory=ValidateSceneSettingsModel, default_factory=ValidateSceneSettingsModel,
title="Validate Scene Settings", title="Validate Scene Settings",
) )
ValidateContainers: ValidateContainersModel = SettingsField(
default_factory=ValidateContainersModel,
title="Validate Containers",
)
AE_PUBLISH_PLUGINS_DEFAULTS = { AE_PUBLISH_PLUGINS_DEFAULTS = {
@ -58,9 +48,4 @@ AE_PUBLISH_PLUGINS_DEFAULTS = {
".*" ".*"
] ]
}, },
"ValidateContainers": {
"enabled": True,
"optional": True,
"active": True,
}
} }

View file

@ -212,7 +212,13 @@ class ApplicationsAddonSettings(BaseSettingsModel):
scope=["studio"] scope=["studio"]
) )
only_available: bool = SettingsField( only_available: bool = SettingsField(
True, title="Show only available applications") True,
title="Show only available applications",
description="Enable to show only applications in AYON Launcher"
" for which the executable paths are found on the running machine."
" This applies as an additional filter to the applications defined in a "
" project's anatomy settings to ignore unavailable applications."
)
@validator("tool_groups") @validator("tool_groups")
def validate_unique_name(cls, value): def validate_unique_name(cls, value):

View file

@ -0,0 +1,13 @@
from .version import __version__
from .addon import (
BlenderAddon,
BLENDER_ADDON_ROOT,
)
__all__ = (
"__version__",
"BlenderAddon",
"BLENDER_ADDON_ROOT",
)

View file

@ -1,18 +1,21 @@
import os import os
from ayon_core.addon import AYONAddon, IHostAddon from ayon_core.addon import AYONAddon, IHostAddon
BLENDER_ROOT_DIR = os.path.dirname(os.path.abspath(__file__)) from .version import __version__
BLENDER_ADDON_ROOT = os.path.dirname(os.path.abspath(__file__))
class BlenderAddon(AYONAddon, IHostAddon): class BlenderAddon(AYONAddon, IHostAddon):
name = "blender" name = "blender"
version = __version__
host_name = "blender" host_name = "blender"
def add_implementation_envs(self, env, _app): def add_implementation_envs(self, env, _app):
"""Modify environments to contain all required for implementation.""" """Modify environments to contain all required for implementation."""
# Prepare path to implementation script # Prepare path to implementation script
implementation_user_script_path = os.path.join( implementation_user_script_path = os.path.join(
BLENDER_ROOT_DIR, BLENDER_ADDON_ROOT,
"blender_addon" "blender_addon"
) )
@ -61,7 +64,7 @@ class BlenderAddon(AYONAddon, IHostAddon):
if app.host_name != self.host_name: if app.host_name != self.host_name:
return [] return []
return [ return [
os.path.join(BLENDER_ROOT_DIR, "hooks") os.path.join(BLENDER_ADDON_ROOT, "hooks")
] ]
def get_workfile_extensions(self): def get_workfile_extensions(self):

View file

@ -15,7 +15,6 @@ from .pipeline import (
from .plugin import ( from .plugin import (
Creator, Creator,
Loader,
) )
from .workio import ( from .workio import (
@ -51,7 +50,6 @@ __all__ = [
"BlenderHost", "BlenderHost",
"Creator", "Creator",
"Loader",
# Workfiles API # Workfiles API
"open_file", "open_file",

Some files were not shown because too many files have changed in this diff Show more