diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index f74904f79d..25f36ebc9a 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -35,6 +35,7 @@ body:
label: Version
description: What version are you running? Look to OpenPype Tray
options:
+ - 3.17.2
- 3.17.2-nightly.4
- 3.17.2-nightly.3
- 3.17.2-nightly.2
@@ -134,7 +135,6 @@ body:
- 3.14.11-nightly.2
- 3.14.11-nightly.1
- 3.14.10
- - 3.14.10-nightly.9
validations:
required: true
- type: dropdown
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8f14340348..7d5cf2c4d2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,6 +1,477 @@
# Changelog
+## [3.17.2](https://github.com/ynput/OpenPype/tree/3.17.2)
+
+
+[Full Changelog](https://github.com/ynput/OpenPype/compare/3.17.1...3.17.2)
+
+### **🆕 New features**
+
+
+
+Maya: Add MayaPy application. #5705
+
+This adds mayapy to the application to be launched from a task.
+
+
+___
+
+
+
+
+
+Feature: Copy resources when downloading last workfile #4944
+
+When the last published workfile is downloaded as a prelaunch hook, all resource files referenced in the workfile representation are copied to the `resources` folder, which is inside the local workfile folder.
+
+
+___
+
+
+
+
+
+Blender: Deadline support #5438
+
+Add Deadline support for Blender.
+
+
+___
+
+
+
+
+
+Fusion: implement toggle to use Deadline plugin FusionCmd #5678
+
+Fusion 17 doesn't work in DL 10.3, but FusionCmd does. It might be probably better option as headless variant.Fusion plugin seems to be closing and reopening application when worker is running on artist machine, not so with FusionCmdAdded configuration to Project Settings for admin to select appropriate Deadline plugin:
+
+
+___
+
+
+
+
+
+Loader tool: Refactor loader tool (for AYON) #5729
+
+Refactored loader tool to new tool. Separated backend and frontend logic. Refactored logic is AYON-centric and is used only in AYON mode, so it does not affect OpenPype. The tool is also replacing library loader.
+
+
+___
+
+
+
+### **🚀 Enhancements**
+
+
+
+Maya: implement matchmove publishing #5445
+
+Add possibility to export multiple cameras in single `matchmove` family instance, both in `abc` and `ma`.Exposed flag 'Keep image planes' to control export of image planes.
+
+
+___
+
+
+
+
+
+Maya: Add optional Fbx extractors in Rig and Animation family #5589
+
+This PR allows user to export control rigs(optionally with mesh) and animated rig in fbx optionally by attaching the rig objects to the two newly introduced sets.
+
+
+___
+
+
+
+
+
+Maya: Optional Resolution Validator for Render #5693
+
+Adding optional resolution validator for maya in render family, similar to the one in Max.It checks if the resolution in render setting aligns with that in setting from the db.
+
+
+___
+
+
+
+
+
+Use host's node uniqueness for instance id in new publisher #5490
+
+Instead of writing `instance_id` as parm or attributes on the publish instances we can, for some hosts, just rely on a unique name or path within the scene to refer to that particular instance. By doing so we fix #4820 because upon duplicating such a publish instance using the host's (DCC) functionality the uniqueness for the duplicate is then already ensured instead of attributes remaining exact same value as where to were duplicated from, making `instance_id` a non-unique value.
+
+
+___
+
+
+
+
+
+Max: Implementation of OCIO configuration #5499
+
+Resolve #5473 Implementation of OCIO configuration for Max 2024 regarding to the update of Max 2024
+
+
+___
+
+
+
+
+
+Nuke: Multiple format supports for ExtractReviewDataMov #5623
+
+This PR would fix the bug of the plugin `ExtractReviewDataMov` not being able to support extensions other than `mov`. The plugin is also renamed to `ExtractReviewDataBakingStreams` as i provides multiple format supoort.
+
+
+___
+
+
+
+
+
+Bugfix: houdini switching context doesnt update variables #5651
+
+Allows admins to have a list of vars (e.g. JOB) with (dynamic) values that will be updated on context changes, e.g. when switching to another asset or task.Using template keys is supported but formatting keys capitalization variants is not, e.g. {Asset} and {ASSET} won't workDisabling Update Houdini vars on context change feature will leave all Houdini vars unmanaged and thus no context update changes will occur.Also, this PR adds a new button in menu to update vars on demand.
+
+
+___
+
+
+
+
+
+Publisher: Fix report maker memory leak + optimize lookups using set #5667
+
+Fixes a memory leak where resetting publisher does not clear the stored plugins for the Publish Report Maker.Also changes the stored plugins to a `set` to optimize the lookup speeds.
+
+
+___
+
+
+
+
+
+Add openpype_mongo command flag for testing. #5676
+
+Instead of changing the environment, this command flag allows for changing the database.
+
+
+___
+
+
+
+
+
+Nuke: minor docstring and code tweaks for ExtractReviewMov #5695
+
+Code and docstring tweaks on https://github.com/ynput/OpenPype/pull/5623
+
+
+___
+
+
+
+
+
+AYON: Small settings fixes #5699
+
+Small changes/fixes related to AYON settings. All foundry apps variant `13-0` has label `13.0`. Key `"ExtractReviewIntermediates"` is not mandatory in settings.
+
+
+___
+
+
+
+
+
+Blender: Alembic Animation loader #5711
+
+Implemented loading Alembic Animations in Blender.
+
+
+___
+
+
+
+### **🐛 Bug fixes**
+
+
+
+Maya: Missing "data" field and enabling of audio #5618
+
+When updating audio containers, the field "data" was missing and the audio node was not enabled on the timeline.
+
+
+___
+
+
+
+
+
+Maya: Bug in validate Plug-in Path Attribute #5687
+
+Overwriting list with string is causing `TypeError: string indices must be integers` in subsequent iterations, crashing the validator plugin.
+
+
+___
+
+
+
+
+
+General: Avoid fallback if value is 0 for handle start/end #5652
+
+There's a bug on the `pyblish_functions.get_time_data_from_instance_or_context` where if `handleStart` or `handleEnd` on the instance are set to value 0 it's falling back to grabbing the handles from the instance context. Instead, the logic should be that it only falls back to the `instance.context` if the key doesn't exist.This change was only affecting me on the `handleStart`/`handleEnd` and it's unlikely it could cause issues on `frameStart`, `frameEnd` or `fps` but regardless, the `get` logic is wrong.
+
+
+___
+
+
+
+
+
+Fusion: added missing env vars to Deadline submission #5659
+
+Environment variables discerning type of job was missing. Without this injection of environment variables won't start.
+
+
+___
+
+
+
+
+
+Nuke: workfile version synchronization settings fixed #5662
+
+Settings for synchronizing workfile version to published products is fixed.
+
+
+___
+
+
+
+
+
+AYON Workfiles Tool: Open workfile changes context #5671
+
+Change context when workfile is opened.
+
+
+___
+
+
+
+
+
+Blender: Fix remove/update in new layout instance #5679
+
+Fixes an error that occurs when removing or updating an asset in a new layout instance.
+
+
+___
+
+
+
+
+
+AYON Launcher tool: Fix refresh btn #5685
+
+Refresh button does propagate refreshed content properly. Folders and tasks are cached for 60 seconds instead of 10 seconds. Auto-refresh in launcher will refresh only actions and related data which is project and project settings.
+
+
+___
+
+
+
+
+
+Deadline: handle all valid paths in RenderExecutable #5694
+
+This commit enhances the path resolution mechanism in the RenderExecutable function of the Ayon plugin. Previously, the function only considered paths starting with a tilde (~), ignoring other valid paths listed in exe_list. This limitation led to an empty expanded_paths list when none of the paths in exe_list started with a tilde, causing the function to fail in finding the Ayon executable.With this fix, the RenderExecutable function now correctly processes and includes all valid paths from exe_list, improving its reliability and preventing unnecessary errors related to Ayon executable location.
+
+
+___
+
+
+
+
+
+AYON Launcher tool: Fix skip last workfile boolean #5700
+
+Skip last workfile boolean works as expected.
+
+
+___
+
+
+
+
+
+Chore: Explore here action can work without task #5703
+
+Explore here action does not crash when task is not selected, and change error message a little.
+
+
+___
+
+
+
+
+
+Testing: Inject mongo_url argument earlier #5706
+
+Fix for https://github.com/ynput/OpenPype/pull/5676The Mongo url is used earlier in the execution.
+
+
+___
+
+
+
+
+
+Blender: Add support to auto-install PySide2 in blender 4 #5723
+
+Change version regex to support blender 4 subfolder.
+
+
+___
+
+
+
+
+
+Fix: Hardcoded main site and wrongly copied workfile #5733
+
+Fixing these two issues:
+- Hardcoded main site -> Replaced by `anatomy.fill_root`.
+- Workfiles can sometimes be copied while they shouldn't.
+
+
+___
+
+
+
+
+
+Bugfix: ServerDeleteOperation asset -> folder conversion typo #5735
+
+Fix ServerDeleteOperation asset -> folder conversion typo
+
+
+___
+
+
+
+
+
+Nuke: loaders are filtering correctly #5739
+
+Variable name for filtering by extensions were not correct - it suppose to be plural. It is fixed now and filtering is working as suppose to.
+
+
+___
+
+
+
+
+
+Nuke: failing multiple thumbnails integration #5741
+
+This handles the situation when `ExtractReviewIntermediates` (previously `ExtractReviewDataMov`) has multiple outputs, including thumbnails that need to be integrated. Previously, integrating the thumbnail representation was causing an issue in the integration process. However, we have now resolved this issue by no longer integrating thumbnails as loadable representations.NOW default is that thumbnail representation are NOT integrated (eg. they will not show up in DB > couldn't be Loaded in Loader) and no `_thumb.jpg` will be left in `render` (most likely) publish folder.IF there would be need to override this behavior, please use `project_settings/global/publish/PreIntegrateThumbnails`
+
+
+___
+
+
+
+
+
+AYON Settings: Fix global overrides #5745
+
+The `output` dictionary that gets passed into `ayon_settings._convert_global_project_settings` gets replaced when converting the settings for `ExtractOIIOTranscode`. This results in `global` not being in the output dictionary and thus the defaults being used and not the project overrides.
+
+
+___
+
+
+
+
+
+Chore: AYON query functions arguments #5752
+
+Fixed how `archived` argument is handled in get subsets/assets function.
+
+
+___
+
+
+
+### **🔀 Refactored code**
+
+
+
+Publisher: Refactor Report Maker plugin data storage to be a dict by plugin.id #5668
+
+Refactor Report Maker plugin data storage to be a dict by `plugin.id`Also fixes `_current_plugin_data` type on `__init__`
+
+
+___
+
+
+
+
+
+Chore: Refactor Resolve into new style HostBase, IWorkfileHost, ILoadHost #5701
+
+Refactor Resolve into new style HostBase, IWorkfileHost, ILoadHost
+
+
+___
+
+
+
+### **Merged pull requests**
+
+
+
+Chore: Maya reduce get project settings calls #5669
+
+Re-use system settings / project settings where we can instead of requerying.
+
+
+___
+
+
+
+
+
+Extended error message when getting subset name #5649
+
+Each Creator is using `get_subset_name` functions which collects context data and fills configured template with placeholders.If any key is missing in the template, non descriptive error is thrown.This should provide more verbose message:
+
+
+___
+
+
+
+
+
+Tests: Remove checks for env var #5696
+
+Env var will be filled in `env_var` fixture, here it is too early to check
+
+
+___
+
+
+
+
+
+
## [3.17.1](https://github.com/ynput/OpenPype/tree/3.17.1)
diff --git a/openpype/client/server/entities.py b/openpype/client/server/entities.py
index 3ee62a3172..16223d3d91 100644
--- a/openpype/client/server/entities.py
+++ b/openpype/client/server/entities.py
@@ -75,9 +75,9 @@ def _get_subsets(
):
fields.add(key)
- active = None
+ active = True
if archived:
- active = False
+ active = None
for subset in con.get_products(
project_name,
@@ -196,7 +196,7 @@ def get_assets(
active = True
if archived:
- active = False
+ active = None
con = get_server_api_connection()
fields = folder_fields_v3_to_v4(fields, con)
diff --git a/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py b/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py
index b20df4ffe2..46288db743 100644
--- a/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py
+++ b/openpype/hosts/nuke/plugins/publish/extract_thumbnail.py
@@ -8,6 +8,7 @@ from openpype.hosts.nuke import api as napi
from openpype.hosts.nuke.api.lib import set_node_knobs_from_settings
+# Python 2/3 compatibility
if sys.version_info[0] >= 3:
unicode = str
@@ -45,11 +46,12 @@ class ExtractThumbnail(publish.Extractor):
for o_name, o_data in instance.data["bakePresets"].items():
self.render_thumbnail(instance, o_name, **o_data)
else:
- viewer_process_swithes = {
+ viewer_process_switches = {
"bake_viewer_process": True,
"bake_viewer_input_process": True
}
- self.render_thumbnail(instance, None, **viewer_process_swithes)
+ self.render_thumbnail(
+ instance, None, **viewer_process_switches)
def render_thumbnail(self, instance, output_name=None, **kwargs):
first_frame = instance.data["frameStartHandle"]
@@ -61,8 +63,6 @@ class ExtractThumbnail(publish.Extractor):
# solve output name if any is set
output_name = output_name or ""
- if output_name:
- output_name = "_" + output_name
bake_viewer_process = kwargs["bake_viewer_process"]
bake_viewer_input_process_node = kwargs[
@@ -166,26 +166,42 @@ class ExtractThumbnail(publish.Extractor):
previous_node = dag_node
temporary_nodes.append(dag_node)
+ thumb_name = "thumbnail"
+ # only add output name and
+ # if there are more than one bake preset
+ if (
+ output_name
+ and len(instance.data.get("bakePresets", {}).keys()) > 1
+ ):
+ thumb_name = "{}_{}".format(output_name, thumb_name)
+
# create write node
write_node = nuke.createNode("Write")
- file = fhead[:-1] + output_name + ".jpg"
- name = "thumbnail"
- path = os.path.join(staging_dir, file).replace("\\", "/")
- instance.data["thumbnail"] = path
- write_node["file"].setValue(path)
+ file = fhead[:-1] + thumb_name + ".jpg"
+ thumb_path = os.path.join(staging_dir, file).replace("\\", "/")
+
+ # add thumbnail to cleanup
+ instance.context.data["cleanupFullPaths"].append(thumb_path)
+
+ # make sure only one thumbnail path is set
+ # and it is existing file
+ instance_thumb_path = instance.data.get("thumbnailPath")
+ if not instance_thumb_path or not os.path.isfile(instance_thumb_path):
+ instance.data["thumbnailPath"] = thumb_path
+
+ write_node["file"].setValue(thumb_path)
write_node["file_type"].setValue("jpg")
write_node["raw"].setValue(1)
write_node.setInput(0, previous_node)
temporary_nodes.append(write_node)
- tags = ["thumbnail", "publish_on_farm"]
repre = {
- 'name': name,
+ 'name': thumb_name,
'ext': "jpg",
- "outputName": "thumb",
+ "outputName": thumb_name,
'files': file,
"stagingDir": staging_dir,
- "tags": tags
+ "tags": ["thumbnail", "publish_on_farm", "delete"]
}
instance.data["representations"].append(repre)
diff --git a/openpype/modules/avalon_apps/avalon_app.py b/openpype/modules/avalon_apps/avalon_app.py
index a0226ecc5c..57754793c4 100644
--- a/openpype/modules/avalon_apps/avalon_app.py
+++ b/openpype/modules/avalon_apps/avalon_app.py
@@ -1,5 +1,6 @@
import os
+from openpype import AYON_SERVER_ENABLED
from openpype.modules import OpenPypeModule, ITrayModule
@@ -75,20 +76,11 @@ class AvalonModule(OpenPypeModule, ITrayModule):
def show_library_loader(self):
if self._library_loader_window is None:
- from qtpy import QtCore
- from openpype.tools.libraryloader import LibraryLoaderWindow
from openpype.pipeline import install_openpype_plugins
-
- libraryloader = LibraryLoaderWindow(
- show_projects=True,
- show_libraries=True
- )
- # Remove always on top flag for tray
- window_flags = libraryloader.windowFlags()
- if window_flags | QtCore.Qt.WindowStaysOnTopHint:
- window_flags ^= QtCore.Qt.WindowStaysOnTopHint
- libraryloader.setWindowFlags(window_flags)
- self._library_loader_window = libraryloader
+ if AYON_SERVER_ENABLED:
+ self._init_ayon_loader()
+ else:
+ self._init_library_loader()
install_openpype_plugins()
@@ -106,3 +98,25 @@ class AvalonModule(OpenPypeModule, ITrayModule):
if self.tray_initialized:
from .rest_api import AvalonRestApiResource
self.rest_api_obj = AvalonRestApiResource(self, server_manager)
+
+ def _init_library_loader(self):
+ from qtpy import QtCore
+ from openpype.tools.libraryloader import LibraryLoaderWindow
+
+ libraryloader = LibraryLoaderWindow(
+ show_projects=True,
+ show_libraries=True
+ )
+ # Remove always on top flag for tray
+ window_flags = libraryloader.windowFlags()
+ if window_flags | QtCore.Qt.WindowStaysOnTopHint:
+ window_flags ^= QtCore.Qt.WindowStaysOnTopHint
+ libraryloader.setWindowFlags(window_flags)
+ self._library_loader_window = libraryloader
+
+ def _init_ayon_loader(self):
+ from openpype.tools.ayon_loader.ui import LoaderWindow
+
+ libraryloader = LoaderWindow()
+
+ self._library_loader_window = libraryloader
diff --git a/openpype/pipeline/create/subset_name.py b/openpype/pipeline/create/subset_name.py
index 3f0692b46a..00025b19b8 100644
--- a/openpype/pipeline/create/subset_name.py
+++ b/openpype/pipeline/create/subset_name.py
@@ -14,6 +14,13 @@ class TaskNotSetError(KeyError):
super(TaskNotSetError, self).__init__(msg)
+class TemplateFillError(Exception):
+ def __init__(self, msg=None):
+ if not msg:
+ msg = "Creator's subset name template is missing key value."
+ super(TemplateFillError, self).__init__(msg)
+
+
def get_subset_name_template(
project_name,
family,
@@ -112,6 +119,10 @@ def get_subset_name(
for project. Settings are queried if not passed.
family_filter (Optional[str]): Use different family for subset template
filtering. Value of 'family' is used when not passed.
+
+ Raises:
+ TemplateFillError: If filled template contains placeholder key which is not
+ collected.
"""
if not family:
@@ -154,4 +165,10 @@ def get_subset_name(
for key, value in dynamic_data.items():
fill_pairs[key] = value
- return template.format(**prepare_template_data(fill_pairs))
+ try:
+ return template.format(**prepare_template_data(fill_pairs))
+ except KeyError as exp:
+ raise TemplateFillError(
+ "Value for {} key is missing in template '{}'."
+ " Available values are {}".format(str(exp), template, fill_pairs)
+ )
diff --git a/openpype/pipeline/load/__init__.py b/openpype/pipeline/load/__init__.py
index 7320a9f0e8..ca11b26211 100644
--- a/openpype/pipeline/load/__init__.py
+++ b/openpype/pipeline/load/__init__.py
@@ -32,6 +32,7 @@ from .utils import (
loaders_from_repre_context,
loaders_from_representation,
+ filter_repre_contexts_by_loader,
any_outdated_containers,
get_outdated_containers,
@@ -85,6 +86,7 @@ __all__ = (
"loaders_from_repre_context",
"loaders_from_representation",
+ "filter_repre_contexts_by_loader",
"any_outdated_containers",
"get_outdated_containers",
diff --git a/openpype/pipeline/load/utils.py b/openpype/pipeline/load/utils.py
index b10d6032b3..c81aeff6bd 100644
--- a/openpype/pipeline/load/utils.py
+++ b/openpype/pipeline/load/utils.py
@@ -790,6 +790,24 @@ def loaders_from_repre_context(loaders, repre_context):
]
+def filter_repre_contexts_by_loader(repre_contexts, loader):
+ """Filter representation contexts for loader.
+
+ Args:
+ repre_contexts (list[dict[str, Ant]]): Representation context.
+ loader (LoaderPlugin): Loader plugin to filter contexts for.
+
+ Returns:
+ list[dict[str, Any]]: Filtered representation contexts.
+ """
+
+ return [
+ repre_context
+ for repre_context in repre_contexts
+ if is_compatible_loader(loader, repre_context)
+ ]
+
+
def loaders_from_representation(loaders, representation):
"""Return all compatible loaders for a representation."""
diff --git a/openpype/pipeline/thumbnail.py b/openpype/pipeline/thumbnail.py
index b2b3679450..63c55d0c19 100644
--- a/openpype/pipeline/thumbnail.py
+++ b/openpype/pipeline/thumbnail.py
@@ -166,8 +166,12 @@ class ServerThumbnailResolver(ThumbnailResolver):
# This is new way how thumbnails can be received from server
# - output is 'ThumbnailContent' object
- if hasattr(ayon_api, "get_thumbnail_by_id"):
- result = ayon_api.get_thumbnail_by_id(thumbnail_id)
+ # NOTE Use 'get_server_api_connection' because public function
+ # 'get_thumbnail_by_id' does not return output of 'ServerAPI'
+ # method.
+ con = ayon_api.get_server_api_connection()
+ if hasattr(con, "get_thumbnail_by_id"):
+ result = con.get_thumbnail_by_id(thumbnail_id)
if result.is_valid:
filepath = cache.store_thumbnail(
project_name,
@@ -178,7 +182,7 @@ class ServerThumbnailResolver(ThumbnailResolver):
else:
# Backwards compatibility for ayon api where 'get_thumbnail_by_id'
# is not implemented and output is filepath
- filepath = ayon_api.get_thumbnail(
+ filepath = con.get_thumbnail(
project_name, entity_type, entity_id, thumbnail_id
)
diff --git a/openpype/plugins/publish/preintegrate_thumbnail_representation.py b/openpype/plugins/publish/preintegrate_thumbnail_representation.py
index 1c95b82c97..77bf2edba5 100644
--- a/openpype/plugins/publish/preintegrate_thumbnail_representation.py
+++ b/openpype/plugins/publish/preintegrate_thumbnail_representation.py
@@ -29,13 +29,12 @@ class PreIntegrateThumbnails(pyblish.api.InstancePlugin):
if not repres:
return
- thumbnail_repre = None
+ thumbnail_repres = []
for repre in repres:
- if repre["name"] == "thumbnail":
- thumbnail_repre = repre
- break
+ if "thumbnail" in repre.get("tags", []):
+ thumbnail_repres.append(repre)
- if not thumbnail_repre:
+ if not thumbnail_repres:
return
family = instance.data["family"]
@@ -60,14 +59,15 @@ class PreIntegrateThumbnails(pyblish.api.InstancePlugin):
if not found_profile:
return
- thumbnail_repre.setdefault("tags", [])
+ for thumbnail_repre in thumbnail_repres:
+ thumbnail_repre.setdefault("tags", [])
- if not found_profile["integrate_thumbnail"]:
- if "delete" not in thumbnail_repre["tags"]:
- thumbnail_repre["tags"].append("delete")
- else:
- if "delete" in thumbnail_repre["tags"]:
- thumbnail_repre["tags"].remove("delete")
+ if not found_profile["integrate_thumbnail"]:
+ if "delete" not in thumbnail_repre["tags"]:
+ thumbnail_repre["tags"].append("delete")
+ else:
+ if "delete" in thumbnail_repre["tags"]:
+ thumbnail_repre["tags"].remove("delete")
- self.log.debug(
- "Thumbnail repre tags {}".format(thumbnail_repre["tags"]))
+ self.log.debug(
+ "Thumbnail repre tags {}".format(thumbnail_repre["tags"]))
diff --git a/openpype/settings/ayon_settings.py b/openpype/settings/ayon_settings.py
index d54d71e851..3ccb18111a 100644
--- a/openpype/settings/ayon_settings.py
+++ b/openpype/settings/ayon_settings.py
@@ -1164,19 +1164,19 @@ def _convert_global_project_settings(ayon_settings, output, default_settings):
for profile in extract_oiio_transcode_profiles:
new_outputs = {}
name_counter = {}
- for output in profile["outputs"]:
- if "name" in output:
- name = output.pop("name")
+ for profile_output in profile["outputs"]:
+ if "name" in profile_output:
+ name = profile_output.pop("name")
else:
# Backwards compatibility for setting without 'name' in model
- name = output["extension"]
+ name = profile_output["extension"]
if name in new_outputs:
name_counter[name] += 1
name = "{}_{}".format(name, name_counter[name])
else:
name_counter[name] = 0
- new_outputs[name] = output
+ new_outputs[name] = profile_output
profile["outputs"] = new_outputs
# Extract Burnin plugin
diff --git a/openpype/tools/ayon_loader/__init__.py b/openpype/tools/ayon_loader/__init__.py
new file mode 100644
index 0000000000..09ecf65f3a
--- /dev/null
+++ b/openpype/tools/ayon_loader/__init__.py
@@ -0,0 +1,6 @@
+from .control import LoaderController
+
+
+__all__ = (
+ "LoaderController",
+)
diff --git a/openpype/tools/ayon_loader/abstract.py b/openpype/tools/ayon_loader/abstract.py
new file mode 100644
index 0000000000..45042395d9
--- /dev/null
+++ b/openpype/tools/ayon_loader/abstract.py
@@ -0,0 +1,851 @@
+from abc import ABCMeta, abstractmethod
+import six
+
+from openpype.lib.attribute_definitions import (
+ AbstractAttrDef,
+ serialize_attr_defs,
+ deserialize_attr_defs,
+)
+
+
+class ProductTypeItem:
+ """Item representing product type.
+
+ Args:
+ name (str): Product type name.
+ icon (dict[str, Any]): Product type icon definition.
+ checked (bool): Is product type checked for filtering.
+ """
+
+ def __init__(self, name, icon, checked):
+ self.name = name
+ self.icon = icon
+ self.checked = checked
+
+ def to_data(self):
+ return {
+ "name": self.name,
+ "icon": self.icon,
+ "checked": self.checked,
+ }
+
+ @classmethod
+ def from_data(cls, data):
+ return cls(**data)
+
+
+class ProductItem:
+ """Product item with it versions.
+
+ Args:
+ product_id (str): Product id.
+ product_type (str): Product type.
+ product_name (str): Product name.
+ product_icon (dict[str, Any]): Product icon definition.
+ product_type_icon (dict[str, Any]): Product type icon definition.
+ product_in_scene (bool): Is product in scene (only when used in DCC).
+ group_name (str): Group name.
+ folder_id (str): Folder id.
+ folder_label (str): Folder label.
+ version_items (dict[str, VersionItem]): Version items by id.
+ """
+
+ def __init__(
+ self,
+ product_id,
+ product_type,
+ product_name,
+ product_icon,
+ product_type_icon,
+ product_in_scene,
+ group_name,
+ folder_id,
+ folder_label,
+ version_items,
+ ):
+ self.product_id = product_id
+ self.product_type = product_type
+ self.product_name = product_name
+ self.product_icon = product_icon
+ self.product_type_icon = product_type_icon
+ self.product_in_scene = product_in_scene
+ self.group_name = group_name
+ self.folder_id = folder_id
+ self.folder_label = folder_label
+ self.version_items = version_items
+
+ def to_data(self):
+ return {
+ "product_id": self.product_id,
+ "product_type": self.product_type,
+ "product_name": self.product_name,
+ "product_icon": self.product_icon,
+ "product_type_icon": self.product_type_icon,
+ "product_in_scene": self.product_in_scene,
+ "group_name": self.group_name,
+ "folder_id": self.folder_id,
+ "folder_label": self.folder_label,
+ "version_items": {
+ version_id: version_item.to_data()
+ for version_id, version_item in self.version_items.items()
+ },
+ }
+
+ @classmethod
+ def from_data(cls, data):
+ version_items = {
+ version_id: VersionItem.from_data(version)
+ for version_id, version in data["version_items"].items()
+ }
+ data["version_items"] = version_items
+ return cls(**data)
+
+
+class VersionItem:
+ """Version item.
+
+ Object have implemented comparison operators to be sortable.
+
+ Args:
+ version_id (str): Version id.
+ version (int): Version. Can be negative when is hero version.
+ is_hero (bool): Is hero version.
+ product_id (str): Product id.
+ thumbnail_id (Union[str, None]): Thumbnail id.
+ published_time (Union[str, None]): Published time in format
+ '%Y%m%dT%H%M%SZ'.
+ author (Union[str, None]): Author.
+ frame_range (Union[str, None]): Frame range.
+ duration (Union[int, None]): Duration.
+ handles (Union[str, None]): Handles.
+ step (Union[int, None]): Step.
+ comment (Union[str, None]): Comment.
+ source (Union[str, None]): Source.
+ """
+
+ def __init__(
+ self,
+ version_id,
+ version,
+ is_hero,
+ product_id,
+ thumbnail_id,
+ published_time,
+ author,
+ frame_range,
+ duration,
+ handles,
+ step,
+ comment,
+ source
+ ):
+ self.version_id = version_id
+ self.product_id = product_id
+ self.thumbnail_id = thumbnail_id
+ self.version = version
+ self.is_hero = is_hero
+ self.published_time = published_time
+ self.author = author
+ self.frame_range = frame_range
+ self.duration = duration
+ self.handles = handles
+ self.step = step
+ self.comment = comment
+ self.source = source
+
+ def __eq__(self, other):
+ if not isinstance(other, VersionItem):
+ return False
+ return (
+ self.is_hero == other.is_hero
+ and self.version == other.version
+ and self.version_id == other.version_id
+ and self.product_id == other.product_id
+ )
+
+ def __ne__(self, other):
+ return not self.__eq__(other)
+
+ def __gt__(self, other):
+ if not isinstance(other, VersionItem):
+ return False
+ if (
+ other.version == self.version
+ and self.is_hero
+ ):
+ return True
+ return other.version < self.version
+
+ def to_data(self):
+ return {
+ "version_id": self.version_id,
+ "product_id": self.product_id,
+ "thumbnail_id": self.thumbnail_id,
+ "version": self.version,
+ "is_hero": self.is_hero,
+ "published_time": self.published_time,
+ "author": self.author,
+ "frame_range": self.frame_range,
+ "duration": self.duration,
+ "handles": self.handles,
+ "step": self.step,
+ "comment": self.comment,
+ "source": self.source,
+ }
+
+ @classmethod
+ def from_data(cls, data):
+ return cls(**data)
+
+
+class RepreItem:
+ """Representation item.
+
+ Args:
+ representation_id (str): Representation id.
+ representation_name (str): Representation name.
+ representation_icon (dict[str, Any]): Representation icon definition.
+ product_name (str): Product name.
+ folder_label (str): Folder label.
+ """
+
+ def __init__(
+ self,
+ representation_id,
+ representation_name,
+ representation_icon,
+ product_name,
+ folder_label,
+ ):
+ self.representation_id = representation_id
+ self.representation_name = representation_name
+ self.representation_icon = representation_icon
+ self.product_name = product_name
+ self.folder_label = folder_label
+
+ def to_data(self):
+ return {
+ "representation_id": self.representation_id,
+ "representation_name": self.representation_name,
+ "representation_icon": self.representation_icon,
+ "product_name": self.product_name,
+ "folder_label": self.folder_label,
+ }
+
+ @classmethod
+ def from_data(cls, data):
+ return cls(**data)
+
+
+class ActionItem:
+ """Action item that can be triggered.
+
+ Action item is defined for a specific context. To trigger the action
+ use 'identifier' and context, it necessary also use 'options'.
+
+ Args:
+ identifier (str): Action identifier.
+ label (str): Action label.
+ icon (dict[str, Any]): Action icon definition.
+ tooltip (str): Action tooltip.
+ options (Union[list[AbstractAttrDef], list[qargparse.QArgument]]):
+ Action options. Note: 'qargparse' is considered as deprecated.
+ order (int): Action order.
+ project_name (str): Project name.
+ folder_ids (list[str]): Folder ids.
+ product_ids (list[str]): Product ids.
+ version_ids (list[str]): Version ids.
+ representation_ids (list[str]): Representation ids.
+ """
+
+ def __init__(
+ self,
+ identifier,
+ label,
+ icon,
+ tooltip,
+ options,
+ order,
+ project_name,
+ folder_ids,
+ product_ids,
+ version_ids,
+ representation_ids,
+ ):
+ self.identifier = identifier
+ self.label = label
+ self.icon = icon
+ self.tooltip = tooltip
+ self.options = options
+ self.order = order
+ self.project_name = project_name
+ self.folder_ids = folder_ids
+ self.product_ids = product_ids
+ self.version_ids = version_ids
+ self.representation_ids = representation_ids
+
+ def _options_to_data(self):
+ options = self.options
+ if not options:
+ return options
+ if isinstance(options[0], AbstractAttrDef):
+ return serialize_attr_defs(options)
+ # NOTE: Data conversion is not used by default in loader tool. But for
+ # future development of detached UI tools it would be better to be
+ # prepared for it.
+ raise NotImplementedError(
+ "{}.to_data is not implemented. Use Attribute definitions"
+ " from 'openpype.lib' instead of 'qargparse'.".format(
+ self.__class__.__name__
+ )
+ )
+
+ def to_data(self):
+ options = self._options_to_data()
+ return {
+ "identifier": self.identifier,
+ "label": self.label,
+ "icon": self.icon,
+ "tooltip": self.tooltip,
+ "options": options,
+ "order": self.order,
+ "project_name": self.project_name,
+ "folder_ids": self.folder_ids,
+ "product_ids": self.product_ids,
+ "version_ids": self.version_ids,
+ "representation_ids": self.representation_ids,
+ }
+
+ @classmethod
+ def from_data(cls, data):
+ options = data["options"]
+ if options:
+ options = deserialize_attr_defs(options)
+ data["options"] = options
+ return cls(**data)
+
+
+@six.add_metaclass(ABCMeta)
+class _BaseLoaderController(object):
+ """Base loader controller abstraction.
+
+ Abstract base class that is required for both frontend and backed.
+ """
+
+ @abstractmethod
+ def get_current_context(self):
+ """Current context is a context of the current scene.
+
+ Example output:
+ {
+ "project_name": "MyProject",
+ "folder_id": "0011223344-5566778-99",
+ "task_name": "Compositing",
+ }
+
+ Returns:
+ dict[str, Union[str, None]]: Context data.
+ """
+
+ pass
+
+ @abstractmethod
+ def reset(self):
+ """Reset all cached data to reload everything.
+
+ Triggers events "controller.reset.started" and
+ "controller.reset.finished".
+ """
+
+ pass
+
+ # Model wrappers
+ @abstractmethod
+ def get_folder_items(self, project_name, sender=None):
+ """Folder items for a project.
+
+ Args:
+ project_name (str): Project name.
+ sender (Optional[str]): Sender who requested the name.
+
+ Returns:
+ list[FolderItem]: Folder items for the project.
+ """
+
+ pass
+
+ # Expected selection helpers
+ @abstractmethod
+ def get_expected_selection_data(self):
+ """Full expected selection information.
+
+ Expected selection is a selection that may not be yet selected in UI
+ e.g. because of refreshing, this data tell the UI what should be
+ selected when they finish their refresh.
+
+ Returns:
+ dict[str, Any]: Expected selection data.
+ """
+
+ pass
+
+ @abstractmethod
+ def set_expected_selection(self, project_name, folder_id):
+ """Set expected selection.
+
+ Args:
+ project_name (str): Name of project to be selected.
+ folder_id (str): Id of folder to be selected.
+ """
+
+ pass
+
+
+class BackendLoaderController(_BaseLoaderController):
+ """Backend loader controller abstraction.
+
+ What backend logic requires from a controller for proper logic.
+ """
+
+ @abstractmethod
+ def emit_event(self, topic, data=None, source=None):
+ """Emit event with a certain topic, data and source.
+
+ The event should be sent to both frontend and backend.
+
+ Args:
+ topic (str): Event topic name.
+ data (Optional[dict[str, Any]]): Event data.
+ source (Optional[str]): Event source.
+ """
+
+ pass
+
+ @abstractmethod
+ def get_loaded_product_ids(self):
+ """Return set of loaded product ids.
+
+ Returns:
+ set[str]: Set of loaded product ids.
+ """
+
+ pass
+
+
+class FrontendLoaderController(_BaseLoaderController):
+ @abstractmethod
+ def register_event_callback(self, topic, callback):
+ """Register callback for an event topic.
+
+ Args:
+ topic (str): Event topic name.
+ callback (func): Callback triggered when the event is emitted.
+ """
+
+ pass
+
+ # Expected selection helpers
+ @abstractmethod
+ def expected_project_selected(self, project_name):
+ """Expected project was selected in frontend.
+
+ Args:
+ project_name (str): Project name.
+ """
+
+ pass
+
+ @abstractmethod
+ def expected_folder_selected(self, folder_id):
+ """Expected folder was selected in frontend.
+
+ Args:
+ folder_id (str): Folder id.
+ """
+
+ pass
+
+ # Model wrapper calls
+ @abstractmethod
+ def get_project_items(self, sender=None):
+ """Items for all projects available on server.
+
+ Triggers event topics "projects.refresh.started" and
+ "projects.refresh.finished" with data:
+ {
+ "sender": sender
+ }
+
+ Notes:
+ Filtering of projects is done in UI.
+
+ Args:
+ sender (Optional[str]): Sender who requested the items.
+
+ Returns:
+ list[ProjectItem]: List of project items.
+ """
+
+ pass
+
+ @abstractmethod
+ def get_product_items(self, project_name, folder_ids, sender=None):
+ """Product items for folder ids.
+
+ Triggers event topics "products.refresh.started" and
+ "products.refresh.finished" with data:
+ {
+ "project_name": project_name,
+ "folder_ids": folder_ids,
+ "sender": sender
+ }
+
+ Args:
+ project_name (str): Project name.
+ folder_ids (Iterable[str]): Folder ids.
+ sender (Optional[str]): Sender who requested the items.
+
+ Returns:
+ list[ProductItem]: List of product items.
+ """
+
+ pass
+
+ @abstractmethod
+ def get_product_item(self, project_name, product_id):
+ """Receive single product item.
+
+ Args:
+ project_name (str): Project name.
+ product_id (str): Product id.
+
+ Returns:
+ Union[ProductItem, None]: Product info or None if not found.
+ """
+
+ pass
+
+ @abstractmethod
+ def get_product_type_items(self, project_name):
+ """Product type items for a project.
+
+ Product types have defined if are checked for filtering or not.
+
+ Returns:
+ list[ProductTypeItem]: List of product type items for a project.
+ """
+
+ pass
+
+ @abstractmethod
+ def get_representation_items(
+ self, project_name, version_ids, sender=None
+ ):
+ """Representation items for version ids.
+
+ Triggers event topics "model.representations.refresh.started" and
+ "model.representations.refresh.finished" with data:
+ {
+ "project_name": project_name,
+ "version_ids": version_ids,
+ "sender": sender
+ }
+
+ Args:
+ project_name (str): Project name.
+ version_ids (Iterable[str]): Version ids.
+ sender (Optional[str]): Sender who requested the items.
+
+ Returns:
+ list[RepreItem]: List of representation items.
+ """
+
+ pass
+
+ @abstractmethod
+ def get_version_thumbnail_ids(self, project_name, version_ids):
+ """Get thumbnail ids for version ids.
+
+ Args:
+ project_name (str): Project name.
+ version_ids (Iterable[str]): Version ids.
+
+ Returns:
+ dict[str, Union[str, Any]]: Thumbnail id by version id.
+ """
+
+ pass
+
+ @abstractmethod
+ def get_folder_thumbnail_ids(self, project_name, folder_ids):
+ """Get thumbnail ids for folder ids.
+
+ Args:
+ project_name (str): Project name.
+ folder_ids (Iterable[str]): Folder ids.
+
+ Returns:
+ dict[str, Union[str, Any]]: Thumbnail id by folder id.
+ """
+
+ pass
+
+ @abstractmethod
+ def get_thumbnail_path(self, project_name, thumbnail_id):
+ """Get thumbnail path for thumbnail id.
+
+ This method should get a path to a thumbnail based on thumbnail id.
+ Which probably means to download the thumbnail from server and store
+ it locally.
+
+ Args:
+ project_name (str): Project name.
+ thumbnail_id (str): Thumbnail id.
+
+ Returns:
+ Union[str, None]: Thumbnail path or None if not found.
+ """
+
+ pass
+
+ # Selection model wrapper calls
+ @abstractmethod
+ def get_selected_project_name(self):
+ """Get selected project name.
+
+ The information is based on last selection from UI.
+
+ Returns:
+ Union[str, None]: Selected project name.
+ """
+
+ pass
+
+ @abstractmethod
+ def get_selected_folder_ids(self):
+ """Get selected folder ids.
+
+ The information is based on last selection from UI.
+
+ Returns:
+ list[str]: Selected folder ids.
+ """
+
+ pass
+
+ @abstractmethod
+ def get_selected_version_ids(self):
+ """Get selected version ids.
+
+ The information is based on last selection from UI.
+
+ Returns:
+ list[str]: Selected version ids.
+ """
+
+ pass
+
+ @abstractmethod
+ def get_selected_representation_ids(self):
+ """Get selected representation ids.
+
+ The information is based on last selection from UI.
+
+ Returns:
+ list[str]: Selected representation ids.
+ """
+
+ pass
+
+ @abstractmethod
+ def set_selected_project(self, project_name):
+ """Set selected project.
+
+ Project selection changed in UI. Method triggers event with topic
+ "selection.project.changed" with data:
+ {
+ "project_name": self._project_name
+ }
+
+ Args:
+ project_name (Union[str, None]): Selected project name.
+ """
+
+ pass
+
+ @abstractmethod
+ def set_selected_folders(self, folder_ids):
+ """Set selected folders.
+
+ Folder selection changed in UI. Method triggers event with topic
+ "selection.folders.changed" with data:
+ {
+ "project_name": project_name,
+ "folder_ids": folder_ids
+ }
+
+ Args:
+ folder_ids (Iterable[str]): Selected folder ids.
+ """
+
+ pass
+
+ @abstractmethod
+ def set_selected_versions(self, version_ids):
+ """Set selected versions.
+
+ Version selection changed in UI. Method triggers event with topic
+ "selection.versions.changed" with data:
+ {
+ "project_name": project_name,
+ "folder_ids": folder_ids,
+ "version_ids": version_ids
+ }
+
+ Args:
+ version_ids (Iterable[str]): Selected version ids.
+ """
+
+ pass
+
+ @abstractmethod
+ def set_selected_representations(self, repre_ids):
+ """Set selected representations.
+
+ Representation selection changed in UI. Method triggers event with
+ topic "selection.representations.changed" with data:
+ {
+ "project_name": project_name,
+ "folder_ids": folder_ids,
+ "version_ids": version_ids,
+ "representation_ids": representation_ids
+ }
+
+ Args:
+ repre_ids (Iterable[str]): Selected representation ids.
+ """
+
+ pass
+
+ # Load action items
+ @abstractmethod
+ def get_versions_action_items(self, project_name, version_ids):
+ """Action items for versions selection.
+
+ Args:
+ project_name (str): Project name.
+ version_ids (Iterable[str]): Version ids.
+
+ Returns:
+ list[ActionItem]: List of action items.
+ """
+
+ pass
+
+ @abstractmethod
+ def get_representations_action_items(
+ self, project_name, representation_ids
+ ):
+ """Action items for representations selection.
+
+ Args:
+ project_name (str): Project name.
+ representation_ids (Iterable[str]): Representation ids.
+
+ Returns:
+ list[ActionItem]: List of action items.
+ """
+
+ pass
+
+ @abstractmethod
+ def trigger_action_item(
+ self,
+ identifier,
+ options,
+ project_name,
+ version_ids,
+ representation_ids
+ ):
+ """Trigger action item.
+
+ Triggers event "load.started" with data:
+ {
+ "identifier": identifier,
+ "id": ,
+ }
+
+ And triggers "load.finished" with data:
+ {
+ "identifier": identifier,
+ "id": ,
+ "error_info": [...],
+ }
+
+ Args:
+ identifier (str): Action identifier.
+ options (dict[str, Any]): Action option values from UI.
+ project_name (str): Project name.
+ version_ids (Iterable[str]): Version ids.
+ representation_ids (Iterable[str]): Representation ids.
+ """
+
+ pass
+
+ @abstractmethod
+ def change_products_group(self, project_name, product_ids, group_name):
+ """Change group of products.
+
+ Triggers event "products.group.changed" with data:
+ {
+ "project_name": project_name,
+ "folder_ids": folder_ids,
+ "product_ids": product_ids,
+ "group_name": group_name,
+ }
+
+ Args:
+ project_name (str): Project name.
+ product_ids (Iterable[str]): Product ids.
+ group_name (str): New group name.
+ """
+
+ pass
+
+ @abstractmethod
+ def fill_root_in_source(self, source):
+ """Fill root in source path.
+
+ Args:
+ source (Union[str, None]): Source of a published version. Usually
+ rootless workfile path.
+ """
+
+ pass
+
+ # NOTE: Methods 'is_loaded_products_supported' and
+ # 'is_standard_projects_filter_enabled' are both based on being in host
+ # or not. Maybe we could implement only single method 'is_in_host'?
+ @abstractmethod
+ def is_loaded_products_supported(self):
+ """Is capable to get information about loaded products.
+
+ Returns:
+ bool: True if it is supported.
+ """
+
+ pass
+
+ @abstractmethod
+ def is_standard_projects_filter_enabled(self):
+ """Is standard projects filter enabled.
+
+ This is used for filtering out when loader tool is used in a host. In
+ that case only current project and library projects should be shown.
+
+ Returns:
+ bool: Frontend should filter out non-library projects, except
+ current context project.
+ """
+
+ pass
diff --git a/openpype/tools/ayon_loader/control.py b/openpype/tools/ayon_loader/control.py
new file mode 100644
index 0000000000..2b779f5c2e
--- /dev/null
+++ b/openpype/tools/ayon_loader/control.py
@@ -0,0 +1,343 @@
+import logging
+
+import ayon_api
+
+from openpype.lib.events import QueuedEventSystem
+from openpype.pipeline import Anatomy, get_current_context
+from openpype.host import ILoadHost
+from openpype.tools.ayon_utils.models import (
+ ProjectsModel,
+ HierarchyModel,
+ NestedCacheItem,
+ CacheItem,
+ ThumbnailsModel,
+)
+
+from .abstract import BackendLoaderController, FrontendLoaderController
+from .models import SelectionModel, ProductsModel, LoaderActionsModel
+
+
+class ExpectedSelection:
+ def __init__(self, controller):
+ self._project_name = None
+ self._folder_id = None
+
+ self._project_selected = True
+ self._folder_selected = True
+
+ self._controller = controller
+
+ def _emit_change(self):
+ self._controller.emit_event(
+ "expected_selection_changed",
+ self.get_expected_selection_data(),
+ )
+
+ def set_expected_selection(self, project_name, folder_id):
+ self._project_name = project_name
+ self._folder_id = folder_id
+
+ self._project_selected = False
+ self._folder_selected = False
+ self._emit_change()
+
+ def get_expected_selection_data(self):
+ project_current = False
+ folder_current = False
+ if not self._project_selected:
+ project_current = True
+ elif not self._folder_selected:
+ folder_current = True
+ return {
+ "project": {
+ "name": self._project_name,
+ "current": project_current,
+ "selected": self._project_selected,
+ },
+ "folder": {
+ "id": self._folder_id,
+ "current": folder_current,
+ "selected": self._folder_selected,
+ },
+ }
+
+ def is_expected_project_selected(self, project_name):
+ return project_name == self._project_name and self._project_selected
+
+ def is_expected_folder_selected(self, folder_id):
+ return folder_id == self._folder_id and self._folder_selected
+
+ def expected_project_selected(self, project_name):
+ if project_name != self._project_name:
+ return False
+ self._project_selected = True
+ self._emit_change()
+ return True
+
+ def expected_folder_selected(self, folder_id):
+ if folder_id != self._folder_id:
+ return False
+ self._folder_selected = True
+ self._emit_change()
+ return True
+
+
+class LoaderController(BackendLoaderController, FrontendLoaderController):
+ """
+
+ Args:
+ host (Optional[AbstractHost]): Host object. Defaults to None.
+ """
+
+ def __init__(self, host=None):
+ self._log = None
+ self._host = host
+
+ self._event_system = self._create_event_system()
+
+ self._project_anatomy_cache = NestedCacheItem(
+ levels=1, lifetime=60)
+ self._loaded_products_cache = CacheItem(
+ default_factory=set, lifetime=60)
+
+ self._selection_model = SelectionModel(self)
+ self._expected_selection = ExpectedSelection(self)
+ self._projects_model = ProjectsModel(self)
+ self._hierarchy_model = HierarchyModel(self)
+ self._products_model = ProductsModel(self)
+ self._loader_actions_model = LoaderActionsModel(self)
+ self._thumbnails_model = ThumbnailsModel()
+
+ @property
+ def log(self):
+ if self._log is None:
+ self._log = logging.getLogger(self.__class__.__name__)
+ return self._log
+
+ # ---------------------------------
+ # Implementation of abstract methods
+ # ---------------------------------
+ # Events system
+ def emit_event(self, topic, data=None, source=None):
+ """Use implemented event system to trigger event."""
+
+ if data is None:
+ data = {}
+ self._event_system.emit(topic, data, source)
+
+ def register_event_callback(self, topic, callback):
+ self._event_system.add_callback(topic, callback)
+
+ def reset(self):
+ self._emit_event("controller.reset.started")
+
+ project_name = self.get_selected_project_name()
+ folder_ids = self.get_selected_folder_ids()
+
+ self._project_anatomy_cache.reset()
+ self._loaded_products_cache.reset()
+
+ self._products_model.reset()
+ self._hierarchy_model.reset()
+ self._loader_actions_model.reset()
+ self._projects_model.reset()
+ self._thumbnails_model.reset()
+
+ self._projects_model.refresh()
+
+ if not project_name and not folder_ids:
+ context = self.get_current_context()
+ project_name = context["project_name"]
+ folder_id = context["folder_id"]
+ self.set_expected_selection(project_name, folder_id)
+
+ self._emit_event("controller.reset.finished")
+
+ # Expected selection helpers
+ def get_expected_selection_data(self):
+ return self._expected_selection.get_expected_selection_data()
+
+ def set_expected_selection(self, project_name, folder_id):
+ self._expected_selection.set_expected_selection(
+ project_name, folder_id
+ )
+
+ def expected_project_selected(self, project_name):
+ self._expected_selection.expected_project_selected(project_name)
+
+ def expected_folder_selected(self, folder_id):
+ self._expected_selection.expected_folder_selected(folder_id)
+
+ # Entity model wrappers
+ def get_project_items(self, sender=None):
+ return self._projects_model.get_project_items(sender)
+
+ def get_folder_items(self, project_name, sender=None):
+ return self._hierarchy_model.get_folder_items(project_name, sender)
+
+ def get_product_items(self, project_name, folder_ids, sender=None):
+ return self._products_model.get_product_items(
+ project_name, folder_ids, sender)
+
+ def get_product_item(self, project_name, product_id):
+ return self._products_model.get_product_item(
+ project_name, product_id
+ )
+
+ def get_product_type_items(self, project_name):
+ return self._products_model.get_product_type_items(project_name)
+
+ def get_representation_items(
+ self, project_name, version_ids, sender=None
+ ):
+ return self._products_model.get_repre_items(
+ project_name, version_ids, sender
+ )
+
+ def get_folder_thumbnail_ids(self, project_name, folder_ids):
+ return self._thumbnails_model.get_folder_thumbnail_ids(
+ project_name, folder_ids)
+
+ def get_version_thumbnail_ids(self, project_name, version_ids):
+ return self._thumbnails_model.get_version_thumbnail_ids(
+ project_name, version_ids)
+
+ def get_thumbnail_path(self, project_name, thumbnail_id):
+ return self._thumbnails_model.get_thumbnail_path(
+ project_name, thumbnail_id
+ )
+
+ def change_products_group(self, project_name, product_ids, group_name):
+ self._products_model.change_products_group(
+ project_name, product_ids, group_name
+ )
+
+ def get_versions_action_items(self, project_name, version_ids):
+ return self._loader_actions_model.get_versions_action_items(
+ project_name, version_ids)
+
+ def get_representations_action_items(
+ self, project_name, representation_ids):
+ return self._loader_actions_model.get_representations_action_items(
+ project_name, representation_ids)
+
+ def trigger_action_item(
+ self,
+ identifier,
+ options,
+ project_name,
+ version_ids,
+ representation_ids
+ ):
+ self._loader_actions_model.trigger_action_item(
+ identifier,
+ options,
+ project_name,
+ version_ids,
+ representation_ids
+ )
+
+ # Selection model wrappers
+ def get_selected_project_name(self):
+ return self._selection_model.get_selected_project_name()
+
+ def set_selected_project(self, project_name):
+ self._selection_model.set_selected_project(project_name)
+
+ # Selection model wrappers
+ def get_selected_folder_ids(self):
+ return self._selection_model.get_selected_folder_ids()
+
+ def set_selected_folders(self, folder_ids):
+ self._selection_model.set_selected_folders(folder_ids)
+
+ def get_selected_version_ids(self):
+ return self._selection_model.get_selected_version_ids()
+
+ def set_selected_versions(self, version_ids):
+ self._selection_model.set_selected_versions(version_ids)
+
+ def get_selected_representation_ids(self):
+ return self._selection_model.get_selected_representation_ids()
+
+ def set_selected_representations(self, repre_ids):
+ self._selection_model.set_selected_representations(repre_ids)
+
+ def fill_root_in_source(self, source):
+ project_name = self.get_selected_project_name()
+ anatomy = self._get_project_anatomy(project_name)
+ if anatomy is None:
+ return source
+
+ try:
+ return anatomy.fill_root(source)
+ except Exception:
+ return source
+
+ def get_current_context(self):
+ if self._host is None:
+ return {
+ "project_name": None,
+ "folder_id": None,
+ "task_name": None,
+ }
+ if hasattr(self._host, "get_current_context"):
+ context = self._host.get_current_context()
+ else:
+ context = get_current_context()
+ folder_id = None
+ project_name = context.get("project_name")
+ asset_name = context.get("asset_name")
+ if project_name and asset_name:
+ folder = ayon_api.get_folder_by_name(
+ project_name, asset_name, fields=["id"]
+ )
+ if folder:
+ folder_id = folder["id"]
+ return {
+ "project_name": project_name,
+ "folder_id": folder_id,
+ "task_name": context.get("task_name"),
+ }
+
+ def get_loaded_product_ids(self):
+ if self._host is None:
+ return set()
+
+ context = self.get_current_context()
+ project_name = context["project_name"]
+ if not project_name:
+ return set()
+
+ if not self._loaded_products_cache.is_valid:
+ if isinstance(self._host, ILoadHost):
+ containers = self._host.get_containers()
+ else:
+ containers = self._host.ls()
+ repre_ids = {c.get("representation") for c in containers}
+ repre_ids.discard(None)
+ product_ids = self._products_model.get_product_ids_by_repre_ids(
+ project_name, repre_ids
+ )
+ self._loaded_products_cache.update_data(product_ids)
+ return self._loaded_products_cache.get_data()
+
+ def is_loaded_products_supported(self):
+ return self._host is not None
+
+ def is_standard_projects_filter_enabled(self):
+ return self._host is not None
+
+ def _get_project_anatomy(self, project_name):
+ if not project_name:
+ return None
+ cache = self._project_anatomy_cache[project_name]
+ if not cache.is_valid:
+ cache.update_data(Anatomy(project_name))
+ return cache.get_data()
+
+ def _create_event_system(self):
+ return QueuedEventSystem()
+
+ def _emit_event(self, topic, data=None):
+ self._event_system.emit(topic, data or {}, "controller")
diff --git a/openpype/tools/ayon_loader/models/__init__.py b/openpype/tools/ayon_loader/models/__init__.py
new file mode 100644
index 0000000000..6adfe71d86
--- /dev/null
+++ b/openpype/tools/ayon_loader/models/__init__.py
@@ -0,0 +1,10 @@
+from .selection import SelectionModel
+from .products import ProductsModel
+from .actions import LoaderActionsModel
+
+
+__all__ = (
+ "SelectionModel",
+ "ProductsModel",
+ "LoaderActionsModel",
+)
diff --git a/openpype/tools/ayon_loader/models/actions.py b/openpype/tools/ayon_loader/models/actions.py
new file mode 100644
index 0000000000..3edb04e9eb
--- /dev/null
+++ b/openpype/tools/ayon_loader/models/actions.py
@@ -0,0 +1,870 @@
+import sys
+import traceback
+import inspect
+import copy
+import collections
+import uuid
+
+from openpype.client import (
+ get_project,
+ get_assets,
+ get_subsets,
+ get_versions,
+ get_representations,
+)
+from openpype.pipeline.load import (
+ discover_loader_plugins,
+ SubsetLoaderPlugin,
+ filter_repre_contexts_by_loader,
+ get_loader_identifier,
+ load_with_repre_context,
+ load_with_subset_context,
+ load_with_subset_contexts,
+ LoadError,
+ IncompatibleLoaderError,
+)
+from openpype.tools.ayon_utils.models import NestedCacheItem
+from openpype.tools.ayon_loader.abstract import ActionItem
+
+ACTIONS_MODEL_SENDER = "actions.model"
+NOT_SET = object()
+
+
+class LoaderActionsModel:
+ """Model for loader actions.
+
+ This is probably only part of models that requires to use codebase from
+ 'openpype.client' because of backwards compatibility with loaders logic
+ which are expecting mongo documents.
+
+ TODOs:
+ Deprecate 'qargparse' usage in loaders and implement conversion
+ of 'ActionItem' to data (and 'from_data').
+ Use controller to get entities (documents) -> possible only when
+ loaders are able to handle AYON vs. OpenPype logic.
+ Add missing site sync logic, and if possible remove it from loaders.
+ Implement loader actions to replace load plugins.
+ Ask loader actions to return action items instead of guessing them.
+ """
+
+ # Cache loader plugins for some time
+ # NOTE Set to '0' for development
+ loaders_cache_lifetime = 30
+
+ def __init__(self, controller):
+ self._controller = controller
+ self._current_context_project = NOT_SET
+ self._loaders_by_identifier = NestedCacheItem(
+ levels=1, lifetime=self.loaders_cache_lifetime)
+ self._product_loaders = NestedCacheItem(
+ levels=1, lifetime=self.loaders_cache_lifetime)
+ self._repre_loaders = NestedCacheItem(
+ levels=1, lifetime=self.loaders_cache_lifetime)
+
+ def reset(self):
+ """Reset the model with all cached items."""
+
+ self._current_context_project = NOT_SET
+ self._loaders_by_identifier.reset()
+ self._product_loaders.reset()
+ self._repre_loaders.reset()
+
+ def get_versions_action_items(self, project_name, version_ids):
+ """Get action items for given version ids.
+
+ Args:
+ project_name (str): Project name.
+ version_ids (Iterable[str]): Version ids.
+
+ Returns:
+ list[ActionItem]: List of action items.
+ """
+
+ (
+ version_context_by_id,
+ repre_context_by_id
+ ) = self._contexts_for_versions(
+ project_name,
+ version_ids
+ )
+ return self._get_action_items_for_contexts(
+ project_name,
+ version_context_by_id,
+ repre_context_by_id
+ )
+
+ def get_representations_action_items(
+ self, project_name, representation_ids
+ ):
+ """Get action items for given representation ids.
+
+ Args:
+ project_name (str): Project name.
+ representation_ids (Iterable[str]): Representation ids.
+
+ Returns:
+ list[ActionItem]: List of action items.
+ """
+
+ (
+ product_context_by_id,
+ repre_context_by_id
+ ) = self._contexts_for_representations(
+ project_name,
+ representation_ids
+ )
+ return self._get_action_items_for_contexts(
+ project_name,
+ product_context_by_id,
+ repre_context_by_id
+ )
+
+ def trigger_action_item(
+ self,
+ identifier,
+ options,
+ project_name,
+ version_ids,
+ representation_ids
+ ):
+ """Trigger action by identifier.
+
+ Triggers the action by identifier for given contexts.
+
+ Triggers events "load.started" and "load.finished". Finished event
+ also contains "error_info" key with error information if any
+ happened.
+
+ Args:
+ identifier (str): Loader identifier.
+ options (dict[str, Any]): Loader option values.
+ project_name (str): Project name.
+ version_ids (Iterable[str]): Version ids.
+ representation_ids (Iterable[str]): Representation ids.
+ """
+
+ event_data = {
+ "identifier": identifier,
+ "id": uuid.uuid4().hex,
+ }
+ self._controller.emit_event(
+ "load.started",
+ event_data,
+ ACTIONS_MODEL_SENDER,
+ )
+ loader = self._get_loader_by_identifier(project_name, identifier)
+ if representation_ids is not None:
+ error_info = self._trigger_representation_loader(
+ loader,
+ options,
+ project_name,
+ representation_ids,
+ )
+ elif version_ids is not None:
+ error_info = self._trigger_version_loader(
+ loader,
+ options,
+ project_name,
+ version_ids,
+ )
+ else:
+ raise NotImplementedError(
+ "Invalid arguments to trigger action item")
+
+ event_data["error_info"] = error_info
+ self._controller.emit_event(
+ "load.finished",
+ event_data,
+ ACTIONS_MODEL_SENDER,
+ )
+
+ def _get_current_context_project(self):
+ """Get current context project name.
+
+ The value is based on controller (host) and cached.
+
+ Returns:
+ Union[str, None]: Current context project.
+ """
+
+ if self._current_context_project is NOT_SET:
+ context = self._controller.get_current_context()
+ self._current_context_project = context["project_name"]
+ return self._current_context_project
+
+ def _get_action_label(self, loader, representation=None):
+ """Pull label info from loader class.
+
+ Args:
+ loader (LoaderPlugin): Plugin class.
+ representation (Optional[dict[str, Any]]): Representation data.
+
+ Returns:
+ str: Action label.
+ """
+
+ label = getattr(loader, "label", None)
+ if label is None:
+ label = loader.__name__
+ if representation:
+ # Add the representation as suffix
+ label = "{} ({})".format(label, representation["name"])
+ return label
+
+ def _get_action_icon(self, loader):
+ """Pull icon info from loader class.
+
+ Args:
+ loader (LoaderPlugin): Plugin class.
+
+ Returns:
+ Union[dict[str, Any], None]: Icon definition based on
+ loader plugin.
+ """
+
+ # Support font-awesome icons using the `.icon` and `.color`
+ # attributes on plug-ins.
+ icon = getattr(loader, "icon", None)
+ if icon is not None and not isinstance(icon, dict):
+ icon = {
+ "type": "awesome-font",
+ "name": icon,
+ "color": getattr(loader, "color", None) or "white"
+ }
+ return icon
+
+ def _get_action_tooltip(self, loader):
+ """Pull tooltip info from loader class.
+
+ Args:
+ loader (LoaderPlugin): Plugin class.
+
+ Returns:
+ str: Action tooltip.
+ """
+
+ # Add tooltip and statustip from Loader docstring
+ return inspect.getdoc(loader)
+
+ def _filter_loaders_by_tool_name(self, project_name, loaders):
+ """Filter loaders by tool name.
+
+ Tool names are based on OpenPype tools loader tool and library
+ loader tool. The new tool merged both into one tool and the difference
+ is based only on current project name.
+
+ Args:
+ project_name (str): Project name.
+ loaders (list[LoaderPlugin]): List of loader plugins.
+
+ Returns:
+ list[LoaderPlugin]: Filtered list of loader plugins.
+ """
+
+ # Keep filtering by tool name
+ # - if current context project name is same as project name we do
+ # expect the tool is used as OpenPype loader tool, otherwise
+ # as library loader tool.
+ if project_name == self._get_current_context_project():
+ tool_name = "loader"
+ else:
+ tool_name = "library_loader"
+ filtered_loaders = []
+ for loader in loaders:
+ tool_names = getattr(loader, "tool_names", None)
+ if (
+ tool_names is None
+ or "*" in tool_names
+ or tool_name in tool_names
+ ):
+ filtered_loaders.append(loader)
+ return filtered_loaders
+
+ def _create_loader_action_item(
+ self,
+ loader,
+ contexts,
+ project_name,
+ folder_ids=None,
+ product_ids=None,
+ version_ids=None,
+ representation_ids=None,
+ repre_name=None,
+ ):
+ label = self._get_action_label(loader)
+ if repre_name:
+ label = "{} ({})".format(label, repre_name)
+ return ActionItem(
+ get_loader_identifier(loader),
+ label=label,
+ icon=self._get_action_icon(loader),
+ tooltip=self._get_action_tooltip(loader),
+ options=loader.get_options(contexts),
+ order=loader.order,
+ project_name=project_name,
+ folder_ids=folder_ids,
+ product_ids=product_ids,
+ version_ids=version_ids,
+ representation_ids=representation_ids,
+ )
+
+ def _get_loaders(self, project_name):
+ """Loaders with loaded settings for a project.
+
+ Questions:
+ Project name is required because of settings. Should we actually
+ pass in current project name instead of project name where
+ we want to show loaders for?
+
+ Returns:
+ tuple[list[SubsetLoaderPlugin], list[LoaderPlugin]]: Discovered
+ loader plugins.
+ """
+
+ loaders_by_identifier_c = self._loaders_by_identifier[project_name]
+ product_loaders_c = self._product_loaders[project_name]
+ repre_loaders_c = self._repre_loaders[project_name]
+ if loaders_by_identifier_c.is_valid:
+ return product_loaders_c.get_data(), repre_loaders_c.get_data()
+
+ # Get all representation->loader combinations available for the
+ # index under the cursor, so we can list the user the options.
+ available_loaders = self._filter_loaders_by_tool_name(
+ project_name, discover_loader_plugins(project_name)
+ )
+
+ repre_loaders = []
+ product_loaders = []
+ loaders_by_identifier = {}
+ for loader_cls in available_loaders:
+ if not loader_cls.enabled:
+ continue
+
+ identifier = get_loader_identifier(loader_cls)
+ loaders_by_identifier[identifier] = loader_cls
+ if issubclass(loader_cls, SubsetLoaderPlugin):
+ product_loaders.append(loader_cls)
+ else:
+ repre_loaders.append(loader_cls)
+
+ loaders_by_identifier_c.update_data(loaders_by_identifier)
+ product_loaders_c.update_data(product_loaders)
+ repre_loaders_c.update_data(repre_loaders)
+ return product_loaders, repre_loaders
+
+ def _get_loader_by_identifier(self, project_name, identifier):
+ if not self._loaders_by_identifier[project_name].is_valid:
+ self._get_loaders(project_name)
+ loaders_by_identifier_c = self._loaders_by_identifier[project_name]
+ loaders_by_identifier = loaders_by_identifier_c.get_data()
+ return loaders_by_identifier.get(identifier)
+
+ def _actions_sorter(self, action_item):
+ """Sort the Loaders by their order and then their name.
+
+ Returns:
+ tuple[int, str]: Sort keys.
+ """
+
+ return action_item.order, action_item.label
+
+ def _get_version_docs(self, project_name, version_ids):
+ """Get version documents for given version ids.
+
+ This function also handles hero versions and copies data from
+ source version to it.
+
+ Todos:
+ Remove this function when this is completely rewritten to
+ use AYON calls.
+ """
+
+ version_docs = list(get_versions(
+ project_name, version_ids=version_ids, hero=True
+ ))
+ hero_versions_by_src_id = collections.defaultdict(list)
+ src_hero_version = set()
+ for version_doc in version_docs:
+ if version_doc["type"] != "hero":
+ continue
+ version_id = ""
+ src_hero_version.add(version_id)
+ hero_versions_by_src_id[version_id].append(version_doc)
+
+ src_versions = []
+ if src_hero_version:
+ src_versions = get_versions(project_name, version_ids=version_ids)
+ for src_version in src_versions:
+ src_version_id = src_version["_id"]
+ for hero_version in hero_versions_by_src_id[src_version_id]:
+ hero_version["data"] = copy.deepcopy(src_version["data"])
+
+ return version_docs
+
+ def _contexts_for_versions(self, project_name, version_ids):
+ """Get contexts for given version ids.
+
+ Prepare version contexts for 'SubsetLoaderPlugin' and representation
+ contexts for 'LoaderPlugin' for all children representations of
+ given versions.
+
+ This method is very similar to '_contexts_for_representations' but the
+ queries of documents are called in a different order.
+
+ Args:
+ project_name (str): Project name.
+ version_ids (Iterable[str]): Version ids.
+
+ Returns:
+ tuple[list[dict[str, Any]], list[dict[str, Any]]]: Version and
+ representation contexts.
+ """
+
+ # TODO fix hero version
+ version_context_by_id = {}
+ repre_context_by_id = {}
+ if not project_name and not version_ids:
+ return version_context_by_id, repre_context_by_id
+
+ version_docs = self._get_version_docs(project_name, version_ids)
+ version_docs_by_id = {}
+ version_docs_by_product_id = collections.defaultdict(list)
+ for version_doc in version_docs:
+ version_id = version_doc["_id"]
+ product_id = version_doc["parent"]
+ version_docs_by_id[version_id] = version_doc
+ version_docs_by_product_id[product_id].append(version_doc)
+
+ _product_ids = set(version_docs_by_product_id.keys())
+ _product_docs = get_subsets(project_name, subset_ids=_product_ids)
+ product_docs_by_id = {p["_id"]: p for p in _product_docs}
+
+ _folder_ids = {p["parent"] for p in product_docs_by_id.values()}
+ _folder_docs = get_assets(project_name, asset_ids=_folder_ids)
+ folder_docs_by_id = {f["_id"]: f for f in _folder_docs}
+
+ project_doc = get_project(project_name)
+ project_doc["code"] = project_doc["data"]["code"]
+
+ for version_doc in version_docs:
+ product_id = version_doc["parent"]
+ product_doc = product_docs_by_id[product_id]
+ folder_id = product_doc["parent"]
+ folder_doc = folder_docs_by_id[folder_id]
+ version_context_by_id[product_id] = {
+ "project": project_doc,
+ "asset": folder_doc,
+ "subset": product_doc,
+ "version": version_doc,
+ }
+
+ repre_docs = get_representations(
+ project_name, version_ids=version_ids)
+ for repre_doc in repre_docs:
+ version_id = repre_doc["parent"]
+ version_doc = version_docs_by_id[version_id]
+ product_id = version_doc["parent"]
+ product_doc = product_docs_by_id[product_id]
+ folder_id = product_doc["parent"]
+ folder_doc = folder_docs_by_id[folder_id]
+
+ repre_context_by_id[repre_doc["_id"]] = {
+ "project": project_doc,
+ "asset": folder_doc,
+ "subset": product_doc,
+ "version": version_doc,
+ "representation": repre_doc,
+ }
+
+ return version_context_by_id, repre_context_by_id
+
+ def _contexts_for_representations(self, project_name, repre_ids):
+ """Get contexts for given representation ids.
+
+ Prepare version contexts for 'SubsetLoaderPlugin' and representation
+ contexts for 'LoaderPlugin' for all children representations of
+ given versions.
+
+ This method is very similar to '_contexts_for_versions' but the
+ queries of documents are called in a different order.
+
+ Args:
+ project_name (str): Project name.
+ repre_ids (Iterable[str]): Representation ids.
+
+ Returns:
+ tuple[list[dict[str, Any]], list[dict[str, Any]]]: Version and
+ representation contexts.
+ """
+
+ product_context_by_id = {}
+ repre_context_by_id = {}
+ if not project_name and not repre_ids:
+ return product_context_by_id, repre_context_by_id
+
+ repre_docs = list(get_representations(
+ project_name, representation_ids=repre_ids
+ ))
+ version_ids = {r["parent"] for r in repre_docs}
+ version_docs = self._get_version_docs(project_name, version_ids)
+ version_docs_by_id = {
+ v["_id"]: v for v in version_docs
+ }
+
+ product_ids = {v["parent"] for v in version_docs_by_id.values()}
+ product_docs = get_subsets(project_name, subset_ids=product_ids)
+ product_docs_by_id = {
+ p["_id"]: p for p in product_docs
+ }
+
+ folder_ids = {p["parent"] for p in product_docs_by_id.values()}
+ folder_docs = get_assets(project_name, asset_ids=folder_ids)
+ folder_docs_by_id = {
+ f["_id"]: f for f in folder_docs
+ }
+
+ project_doc = get_project(project_name)
+ project_doc["code"] = project_doc["data"]["code"]
+
+ for product_id, product_doc in product_docs_by_id.items():
+ folder_id = product_doc["parent"]
+ folder_doc = folder_docs_by_id[folder_id]
+ product_context_by_id[product_id] = {
+ "project": project_doc,
+ "asset": folder_doc,
+ "subset": product_doc,
+ }
+
+ for repre_doc in repre_docs:
+ version_id = repre_doc["parent"]
+ version_doc = version_docs_by_id[version_id]
+ product_id = version_doc["parent"]
+ product_doc = product_docs_by_id[product_id]
+ folder_id = product_doc["parent"]
+ folder_doc = folder_docs_by_id[folder_id]
+
+ repre_context_by_id[repre_doc["_id"]] = {
+ "project": project_doc,
+ "asset": folder_doc,
+ "subset": product_doc,
+ "version": version_doc,
+ "representation": repre_doc,
+ }
+ return product_context_by_id, repre_context_by_id
+
+ def _get_action_items_for_contexts(
+ self,
+ project_name,
+ version_context_by_id,
+ repre_context_by_id
+ ):
+ """Prepare action items based on contexts.
+
+ Actions are prepared based on discovered loader plugins and contexts.
+ The context must be valid for the loader plugin.
+
+ Args:
+ project_name (str): Project name.
+ version_context_by_id (dict[str, dict[str, Any]]): Version
+ contexts by version id.
+ repre_context_by_id (dict[str, dict[str, Any]]): Representation
+ """
+
+ action_items = []
+ if not version_context_by_id and not repre_context_by_id:
+ return action_items
+
+ product_loaders, repre_loaders = self._get_loaders(project_name)
+
+ repre_contexts_by_name = collections.defaultdict(list)
+ for repre_context in repre_context_by_id.values():
+ repre_name = repre_context["representation"]["name"]
+ repre_contexts_by_name[repre_name].append(repre_context)
+
+ for loader in repre_loaders:
+ # # do not allow download whole repre, select specific repre
+ # if tools_lib.is_sync_loader(loader):
+ # continue
+
+ for repre_name, repre_contexts in repre_contexts_by_name.items():
+ filtered_repre_contexts = filter_repre_contexts_by_loader(
+ repre_contexts, loader)
+ if not filtered_repre_contexts:
+ continue
+
+ repre_ids = set()
+ repre_version_ids = set()
+ repre_product_ids = set()
+ repre_folder_ids = set()
+ for repre_context in filtered_repre_contexts:
+ repre_ids.add(repre_context["representation"]["_id"])
+ repre_product_ids.add(repre_context["subset"]["_id"])
+ repre_version_ids.add(repre_context["version"]["_id"])
+ repre_folder_ids.add(repre_context["asset"]["_id"])
+
+ item = self._create_loader_action_item(
+ loader,
+ repre_contexts,
+ project_name=project_name,
+ folder_ids=repre_folder_ids,
+ product_ids=repre_product_ids,
+ version_ids=repre_version_ids,
+ representation_ids=repre_ids,
+ repre_name=repre_name,
+ )
+ action_items.append(item)
+
+ # Subset Loaders.
+ version_ids = set(version_context_by_id.keys())
+ product_folder_ids = set()
+ product_ids = set()
+ for product_context in version_context_by_id.values():
+ product_ids.add(product_context["subset"]["_id"])
+ product_folder_ids.add(product_context["asset"]["_id"])
+
+ version_contexts = list(version_context_by_id.values())
+ for loader in product_loaders:
+ item = self._create_loader_action_item(
+ loader,
+ version_contexts,
+ project_name=project_name,
+ folder_ids=product_folder_ids,
+ product_ids=product_ids,
+ version_ids=version_ids,
+ )
+ action_items.append(item)
+
+ action_items.sort(key=self._actions_sorter)
+ return action_items
+
+ def _trigger_version_loader(
+ self,
+ loader,
+ options,
+ project_name,
+ version_ids,
+ ):
+ """Trigger version loader.
+
+ This triggers 'load' method of 'SubsetLoaderPlugin' for given version
+ ids.
+
+ Note:
+ Even when the plugin is 'SubsetLoaderPlugin' it actually expects
+ versions and should be named 'VersionLoaderPlugin'. Because it
+ is planned to refactor load system and introduce
+ 'LoaderAction' plugins it is not relevant to change it
+ anymore.
+
+ Args:
+ loader (SubsetLoaderPlugin): Loader plugin to use.
+ options (dict): Option values for loader.
+ project_name (str): Project name.
+ version_ids (Iterable[str]): Version ids.
+ """
+
+ project_doc = get_project(project_name)
+ project_doc["code"] = project_doc["data"]["code"]
+
+ version_docs = self._get_version_docs(project_name, version_ids)
+ product_ids = {v["parent"] for v in version_docs}
+ product_docs = get_subsets(project_name, subset_ids=product_ids)
+ product_docs_by_id = {f["_id"]: f for f in product_docs}
+ folder_ids = {p["parent"] for p in product_docs_by_id.values()}
+ folder_docs = get_assets(project_name, asset_ids=folder_ids)
+ folder_docs_by_id = {f["_id"]: f for f in folder_docs}
+ product_contexts = []
+ for version_doc in version_docs:
+ product_id = version_doc["parent"]
+ product_doc = product_docs_by_id[product_id]
+ folder_id = product_doc["parent"]
+ folder_doc = folder_docs_by_id[folder_id]
+ product_contexts.append({
+ "project": project_doc,
+ "asset": folder_doc,
+ "subset": product_doc,
+ "version": version_doc,
+ })
+
+ return self._load_products_by_loader(
+ loader, product_contexts, options
+ )
+
+ def _trigger_representation_loader(
+ self,
+ loader,
+ options,
+ project_name,
+ representation_ids,
+ ):
+ """Trigger representation loader.
+
+ This triggers 'load' method of 'LoaderPlugin' for given representation
+ ids. For that are prepared contexts for each representation, with
+ all parent documents.
+
+ Args:
+ loader (LoaderPlugin): Loader plugin to use.
+ options (dict): Option values for loader.
+ project_name (str): Project name.
+ representation_ids (Iterable[str]): Representation ids.
+ """
+
+ project_doc = get_project(project_name)
+ project_doc["code"] = project_doc["data"]["code"]
+ repre_docs = list(get_representations(
+ project_name, representation_ids=representation_ids
+ ))
+ version_ids = {r["parent"] for r in repre_docs}
+ version_docs = self._get_version_docs(project_name, version_ids)
+ version_docs_by_id = {v["_id"]: v for v in version_docs}
+ product_ids = {v["parent"] for v in version_docs_by_id.values()}
+ product_docs = get_subsets(project_name, subset_ids=product_ids)
+ product_docs_by_id = {p["_id"]: p for p in product_docs}
+ folder_ids = {p["parent"] for p in product_docs_by_id.values()}
+ folder_docs = get_assets(project_name, asset_ids=folder_ids)
+ folder_docs_by_id = {f["_id"]: f for f in folder_docs}
+ repre_contexts = []
+ for repre_doc in repre_docs:
+ version_id = repre_doc["parent"]
+ version_doc = version_docs_by_id[version_id]
+ product_id = version_doc["parent"]
+ product_doc = product_docs_by_id[product_id]
+ folder_id = product_doc["parent"]
+ folder_doc = folder_docs_by_id[folder_id]
+ repre_contexts.append({
+ "project": project_doc,
+ "asset": folder_doc,
+ "subset": product_doc,
+ "version": version_doc,
+ "representation": repre_doc,
+ })
+
+ return self._load_representations_by_loader(
+ loader, repre_contexts, options
+ )
+
+ def _load_representations_by_loader(self, loader, repre_contexts, options):
+ """Loops through list of repre_contexts and loads them with one loader
+
+ Args:
+ loader (LoaderPlugin): Loader plugin to use.
+ repre_contexts (list[dict]): Full info about selected
+ representations, containing repre, version, subset, asset and
+ project documents.
+ options (dict): Data from options.
+ """
+
+ error_info = []
+ for repre_context in repre_contexts:
+ version_doc = repre_context["version"]
+ if version_doc["type"] == "hero_version":
+ version_name = "Hero"
+ else:
+ version_name = version_doc.get("name")
+ try:
+ load_with_repre_context(
+ loader,
+ repre_context,
+ options=options
+ )
+
+ except IncompatibleLoaderError as exc:
+ print(exc)
+ error_info.append((
+ "Incompatible Loader",
+ None,
+ repre_context["representation"]["name"],
+ repre_context["subset"]["name"],
+ version_name
+ ))
+
+ except Exception as exc:
+ formatted_traceback = None
+ if not isinstance(exc, LoadError):
+ exc_type, exc_value, exc_traceback = sys.exc_info()
+ formatted_traceback = "".join(traceback.format_exception(
+ exc_type, exc_value, exc_traceback
+ ))
+
+ error_info.append((
+ str(exc),
+ formatted_traceback,
+ repre_context["representation"]["name"],
+ repre_context["subset"]["name"],
+ version_name
+ ))
+ return error_info
+
+ def _load_products_by_loader(self, loader, version_contexts, options):
+ """Triggers load with SubsetLoader type of loaders.
+
+ Warning:
+ Plugin is named 'SubsetLoader' but version is passed to context
+ too.
+
+ Args:
+ loader (SubsetLoder): Loader used to load.
+ version_contexts (list[dict[str, Any]]): For context for each
+ version.
+ options (dict[str, Any]): Options for loader that user could fill.
+ """
+
+ error_info = []
+ if loader.is_multiple_contexts_compatible:
+ subset_names = []
+ for context in version_contexts:
+ subset_name = context.get("subset", {}).get("name") or "N/A"
+ subset_names.append(subset_name)
+ try:
+ load_with_subset_contexts(
+ loader,
+ version_contexts,
+ options=options
+ )
+
+ except Exception as exc:
+ formatted_traceback = None
+ if not isinstance(exc, LoadError):
+ exc_type, exc_value, exc_traceback = sys.exc_info()
+ formatted_traceback = "".join(traceback.format_exception(
+ exc_type, exc_value, exc_traceback
+ ))
+ error_info.append((
+ str(exc),
+ formatted_traceback,
+ None,
+ ", ".join(subset_names),
+ None
+ ))
+ else:
+ for version_context in version_contexts:
+ subset_name = (
+ version_context.get("subset", {}).get("name") or "N/A"
+ )
+ try:
+ load_with_subset_context(
+ loader,
+ version_context,
+ options=options
+ )
+
+ except Exception as exc:
+ formatted_traceback = None
+ if not isinstance(exc, LoadError):
+ exc_type, exc_value, exc_traceback = sys.exc_info()
+ formatted_traceback = "".join(
+ traceback.format_exception(
+ exc_type, exc_value, exc_traceback
+ )
+ )
+
+ error_info.append((
+ str(exc),
+ formatted_traceback,
+ None,
+ subset_name,
+ None
+ ))
+
+ return error_info
diff --git a/openpype/tools/ayon_loader/models/products.py b/openpype/tools/ayon_loader/models/products.py
new file mode 100644
index 0000000000..33023cc164
--- /dev/null
+++ b/openpype/tools/ayon_loader/models/products.py
@@ -0,0 +1,682 @@
+import collections
+import contextlib
+
+import arrow
+import ayon_api
+from ayon_api.operations import OperationsSession
+
+from openpype.style import get_default_entity_icon_color
+from openpype.tools.ayon_utils.models import NestedCacheItem
+from openpype.tools.ayon_loader.abstract import (
+ ProductTypeItem,
+ ProductItem,
+ VersionItem,
+ RepreItem,
+)
+
+PRODUCTS_MODEL_SENDER = "products.model"
+
+
+def version_item_from_entity(version):
+ version_attribs = version["attrib"]
+ frame_start = version_attribs.get("frameStart")
+ frame_end = version_attribs.get("frameEnd")
+ handle_start = version_attribs.get("handleStart")
+ handle_end = version_attribs.get("handleEnd")
+ step = version_attribs.get("step")
+ comment = version_attribs.get("comment")
+ source = version_attribs.get("source")
+
+ frame_range = None
+ duration = None
+ handles = None
+ if frame_start is not None and frame_end is not None:
+ # Remove superfluous zeros from numbers (3.0 -> 3) to improve
+ # readability for most frame ranges
+ frame_start = int(frame_start)
+ frame_end = int(frame_end)
+ frame_range = "{}-{}".format(frame_start, frame_end)
+ duration = frame_end - frame_start + 1
+
+ if handle_start is not None and handle_end is not None:
+ handles = "{}-{}".format(int(handle_start), int(handle_end))
+
+ # NOTE There is also 'updatedAt', should be used that instead?
+ # TODO skip conversion - converting to '%Y%m%dT%H%M%SZ' is because
+ # 'PrettyTimeDelegate' expects it
+ created_at = arrow.get(version["createdAt"])
+ published_time = created_at.strftime("%Y%m%dT%H%M%SZ")
+ author = version["author"]
+ version_num = version["version"]
+ is_hero = version_num < 0
+
+ return VersionItem(
+ version_id=version["id"],
+ version=version_num,
+ is_hero=is_hero,
+ product_id=version["productId"],
+ thumbnail_id=version["thumbnailId"],
+ published_time=published_time,
+ author=author,
+ frame_range=frame_range,
+ duration=duration,
+ handles=handles,
+ step=step,
+ comment=comment,
+ source=source,
+ )
+
+
+def product_item_from_entity(
+ product_entity,
+ version_entities,
+ product_type_items_by_name,
+ folder_label,
+ product_in_scene,
+):
+ product_attribs = product_entity["attrib"]
+ group = product_attribs.get("productGroup")
+ product_type = product_entity["productType"]
+ product_type_item = product_type_items_by_name[product_type]
+ product_type_icon = product_type_item.icon
+
+ product_icon = {
+ "type": "awesome-font",
+ "name": "fa.file-o",
+ "color": get_default_entity_icon_color(),
+ }
+ version_items = {
+ version_entity["id"]: version_item_from_entity(version_entity)
+ for version_entity in version_entities
+ }
+
+ return ProductItem(
+ product_id=product_entity["id"],
+ product_type=product_type,
+ product_name=product_entity["name"],
+ product_icon=product_icon,
+ product_type_icon=product_type_icon,
+ product_in_scene=product_in_scene,
+ group_name=group,
+ folder_id=product_entity["folderId"],
+ folder_label=folder_label,
+ version_items=version_items,
+ )
+
+
+def product_type_item_from_data(product_type_data):
+ # TODO implement icon implementation
+ # icon = product_type_data["icon"]
+ # color = product_type_data["color"]
+ icon = {
+ "type": "awesome-font",
+ "name": "fa.folder",
+ "color": "#0091B2",
+ }
+ # TODO implement checked logic
+ return ProductTypeItem(product_type_data["name"], icon, True)
+
+
+class ProductsModel:
+ """Model for products, version and representation.
+
+ All of the entities are product based. This model prepares data for UI
+ and caches it for faster access.
+
+ Note:
+ Data are not used for actions model because that would require to
+ break OpenPype compatibility of 'LoaderPlugin's.
+ """
+
+ lifetime = 60 # In seconds (minute by default)
+
+ def __init__(self, controller):
+ self._controller = controller
+
+ # Mapping helpers
+ # NOTE - mapping must be cleaned up with cache cleanup
+ self._product_item_by_id = collections.defaultdict(dict)
+ self._version_item_by_id = collections.defaultdict(dict)
+ self._product_folder_ids_mapping = collections.defaultdict(dict)
+
+ # Cache helpers
+ self._product_type_items_cache = NestedCacheItem(
+ levels=1, default_factory=list, lifetime=self.lifetime)
+ self._product_items_cache = NestedCacheItem(
+ levels=2, default_factory=dict, lifetime=self.lifetime)
+ self._repre_items_cache = NestedCacheItem(
+ levels=2, default_factory=dict, lifetime=self.lifetime)
+
+ def reset(self):
+ """Reset model with all cached data."""
+
+ self._product_item_by_id.clear()
+ self._version_item_by_id.clear()
+ self._product_folder_ids_mapping.clear()
+
+ self._product_type_items_cache.reset()
+ self._product_items_cache.reset()
+ self._repre_items_cache.reset()
+
+ def get_product_type_items(self, project_name):
+ """Product type items for project.
+
+ Args:
+ project_name (str): Project name.
+
+ Returns:
+ list[ProductTypeItem]: Product type items.
+ """
+
+ cache = self._product_type_items_cache[project_name]
+ if not cache.is_valid:
+ product_types = ayon_api.get_project_product_types(project_name)
+ cache.update_data([
+ product_type_item_from_data(product_type)
+ for product_type in product_types
+ ])
+ return cache.get_data()
+
+ def get_product_items(self, project_name, folder_ids, sender):
+ """Product items with versions for project and folder ids.
+
+ Product items also contain version items. They're directly connected
+ to product items in the UI and the separation is not needed.
+
+ Args:
+ project_name (Union[str, None]): Project name.
+ folder_ids (Iterable[str]): Folder ids.
+ sender (Union[str, None]): Who triggered the method.
+
+ Returns:
+ list[ProductItem]: Product items.
+ """
+
+ if not project_name or not folder_ids:
+ return []
+
+ project_cache = self._product_items_cache[project_name]
+ output = []
+ folder_ids_to_update = set()
+ for folder_id in folder_ids:
+ cache = project_cache[folder_id]
+ if cache.is_valid:
+ output.extend(cache.get_data().values())
+ else:
+ folder_ids_to_update.add(folder_id)
+
+ self._refresh_product_items(
+ project_name, folder_ids_to_update, sender)
+
+ for folder_id in folder_ids_to_update:
+ cache = project_cache[folder_id]
+ output.extend(cache.get_data().values())
+ return output
+
+ def get_product_item(self, project_name, product_id):
+ """Get product item based on passed product id.
+
+ This method is using cached items, but if cache is not valid it also
+ can query the item.
+
+ Args:
+ project_name (Union[str, None]): Where to look for product.
+ product_id (Union[str, None]): Product id to receive.
+
+ Returns:
+ Union[ProductItem, None]: Product item or 'None' if not found.
+ """
+
+ if not any((project_name, product_id)):
+ return None
+
+ product_items_by_id = self._product_item_by_id[project_name]
+ product_item = product_items_by_id.get(product_id)
+ if product_item is not None:
+ return product_item
+ for product_item in self._query_product_items_by_ids(
+ project_name, product_ids=[product_id]
+ ).values():
+ return product_item
+
+ def get_product_ids_by_repre_ids(self, project_name, repre_ids):
+ """Get product ids based on passed representation ids.
+
+ Args:
+ project_name (str): Where to look for representations.
+ repre_ids (Iterable[str]): Representation ids.
+
+ Returns:
+ set[str]: Product ids for passed representation ids.
+ """
+
+ # TODO look out how to use single server call
+ if not repre_ids:
+ return set()
+ repres = ayon_api.get_representations(
+ project_name, repre_ids, fields=["versionId"]
+ )
+ version_ids = {repre["versionId"] for repre in repres}
+ if not version_ids:
+ return set()
+ versions = ayon_api.get_versions(
+ project_name, version_ids=version_ids, fields=["productId"]
+ )
+ return {v["productId"] for v in versions}
+
+ def get_repre_items(self, project_name, version_ids, sender):
+ """Get representation items for passed version ids.
+
+ Args:
+ project_name (str): Project name.
+ version_ids (Iterable[str]): Version ids.
+ sender (Union[str, None]): Who triggered the method.
+
+ Returns:
+ list[RepreItem]: Representation items.
+ """
+
+ output = []
+ if not any((project_name, version_ids)):
+ return output
+
+ invalid_version_ids = set()
+ project_cache = self._repre_items_cache[project_name]
+ for version_id in version_ids:
+ version_cache = project_cache[version_id]
+ if version_cache.is_valid:
+ output.extend(version_cache.get_data().values())
+ else:
+ invalid_version_ids.add(version_id)
+
+ if invalid_version_ids:
+ self.refresh_representation_items(
+ project_name, invalid_version_ids, sender
+ )
+
+ for version_id in invalid_version_ids:
+ version_cache = project_cache[version_id]
+ output.extend(version_cache.get_data().values())
+
+ return output
+
+ def change_products_group(self, project_name, product_ids, group_name):
+ """Change group name for passed product ids.
+
+ Group name is stored in 'attrib' of product entity and is used in UI
+ to group items.
+
+ Method triggers "products.group.changed" event with data:
+ {
+ "project_name": project_name,
+ "folder_ids": folder_ids,
+ "product_ids": product_ids,
+ "group_name": group_name
+ }
+
+ Args:
+ project_name (str): Project name.
+ product_ids (Iterable[str]): Product ids to change group name for.
+ group_name (str): Group name to set.
+ """
+
+ if not product_ids:
+ return
+
+ product_items = self._get_product_items_by_id(
+ project_name, product_ids
+ )
+ if not product_items:
+ return
+
+ session = OperationsSession()
+ folder_ids = set()
+ for product_item in product_items.values():
+ session.update_entity(
+ project_name,
+ "product",
+ product_item.product_id,
+ {"attrib": {"productGroup": group_name}}
+ )
+ folder_ids.add(product_item.folder_id)
+ product_item.group_name = group_name
+
+ session.commit()
+ self._controller.emit_event(
+ "products.group.changed",
+ {
+ "project_name": project_name,
+ "folder_ids": folder_ids,
+ "product_ids": product_ids,
+ "group_name": group_name,
+ },
+ PRODUCTS_MODEL_SENDER
+ )
+
+ def _get_product_items_by_id(self, project_name, product_ids):
+ product_item_by_id = self._product_item_by_id[project_name]
+ missing_product_ids = set()
+ output = {}
+ for product_id in product_ids:
+ product_item = product_item_by_id.get(product_id)
+ if product_item is not None:
+ output[product_id] = product_item
+ else:
+ missing_product_ids.add(product_id)
+
+ output.update(
+ self._query_product_items_by_ids(
+ project_name, missing_product_ids
+ )
+ )
+ return output
+
+ def _get_version_items_by_id(self, project_name, version_ids):
+ version_item_by_id = self._version_item_by_id[project_name]
+ missing_version_ids = set()
+ output = {}
+ for version_id in version_ids:
+ version_item = version_item_by_id.get(version_id)
+ if version_item is not None:
+ output[version_id] = version_item
+ else:
+ missing_version_ids.add(version_id)
+
+ output.update(
+ self._query_version_items_by_ids(
+ project_name, missing_version_ids
+ )
+ )
+ return output
+
+ def _create_product_items(
+ self,
+ project_name,
+ products,
+ versions,
+ folder_items=None,
+ product_type_items=None,
+ ):
+ if folder_items is None:
+ folder_items = self._controller.get_folder_items(project_name)
+
+ if product_type_items is None:
+ product_type_items = self.get_product_type_items(project_name)
+
+ loaded_product_ids = self._controller.get_loaded_product_ids()
+
+ versions_by_product_id = collections.defaultdict(list)
+ for version in versions:
+ versions_by_product_id[version["productId"]].append(version)
+ product_type_items_by_name = {
+ product_type_item.name: product_type_item
+ for product_type_item in product_type_items
+ }
+ output = {}
+ for product in products:
+ product_id = product["id"]
+ folder_id = product["folderId"]
+ folder_item = folder_items.get(folder_id)
+ if not folder_item:
+ continue
+ versions = versions_by_product_id[product_id]
+ if not versions:
+ continue
+ product_item = product_item_from_entity(
+ product,
+ versions,
+ product_type_items_by_name,
+ folder_item.label,
+ product_id in loaded_product_ids,
+ )
+ output[product_id] = product_item
+ return output
+
+ def _query_product_items_by_ids(
+ self,
+ project_name,
+ folder_ids=None,
+ product_ids=None,
+ folder_items=None
+ ):
+ """Query product items.
+
+ This method does get from, or store to, cache attributes.
+
+ One of 'product_ids' or 'folder_ids' must be passed to the method.
+
+ Args:
+ project_name (str): Project name.
+ folder_ids (Optional[Iterable[str]]): Folder ids under which are
+ products.
+ product_ids (Optional[Iterable[str]]): Product ids to use.
+ folder_items (Optional[Dict[str, FolderItem]]): Prepared folder
+ items from controller.
+
+ Returns:
+ dict[str, ProductItem]: Product items by product id.
+ """
+
+ if not folder_ids and not product_ids:
+ return {}
+
+ kwargs = {}
+ if folder_ids is not None:
+ kwargs["folder_ids"] = folder_ids
+
+ if product_ids is not None:
+ kwargs["product_ids"] = product_ids
+
+ products = list(ayon_api.get_products(project_name, **kwargs))
+ product_ids = {product["id"] for product in products}
+
+ versions = ayon_api.get_versions(
+ project_name, product_ids=product_ids
+ )
+
+ return self._create_product_items(
+ project_name, products, versions, folder_items=folder_items
+ )
+
+ def _query_version_items_by_ids(self, project_name, version_ids):
+ versions = list(ayon_api.get_versions(
+ project_name, version_ids=version_ids
+ ))
+ product_ids = {version["productId"] for version in versions}
+ products = list(ayon_api.get_products(
+ project_name, product_ids=product_ids
+ ))
+ product_items = self._create_product_items(
+ project_name, products, versions
+ )
+ version_items = {}
+ for product_item in product_items.values():
+ version_items.update(product_item.version_items)
+ return version_items
+
+ def _clear_product_version_items(self, project_name, folder_ids):
+ """Clear product and version items from memory.
+
+ When products are re-queried for a folders, the old product and version
+ items in '_product_item_by_id' and '_version_item_by_id' should
+ be cleaned up from memory. And mapping in stored in
+ '_product_folder_ids_mapping' is not relevant either.
+
+ Args:
+ project_name (str): Name of project.
+ folder_ids (Iterable[str]): Folder ids which are being refreshed.
+ """
+
+ project_mapping = self._product_folder_ids_mapping[project_name]
+ if not project_mapping:
+ return
+
+ product_item_by_id = self._product_item_by_id[project_name]
+ version_item_by_id = self._version_item_by_id[project_name]
+ for folder_id in folder_ids:
+ product_ids = project_mapping.pop(folder_id, None)
+ if not product_ids:
+ continue
+
+ for product_id in product_ids:
+ product_item = product_item_by_id.pop(product_id, None)
+ if product_item is None:
+ continue
+ for version_item in product_item.version_items.values():
+ version_item_by_id.pop(version_item.version_id, None)
+
+ def _refresh_product_items(self, project_name, folder_ids, sender):
+ """Refresh product items and store them in cache.
+
+ Args:
+ project_name (str): Name of project.
+ folder_ids (Iterable[str]): Folder ids which are being refreshed.
+ sender (Union[str, None]): Who triggered the refresh.
+ """
+
+ if not project_name or not folder_ids:
+ return
+
+ self._clear_product_version_items(project_name, folder_ids)
+
+ project_mapping = self._product_folder_ids_mapping[project_name]
+ product_item_by_id = self._product_item_by_id[project_name]
+ version_item_by_id = self._version_item_by_id[project_name]
+
+ for folder_id in folder_ids:
+ project_mapping[folder_id] = set()
+
+ with self._product_refresh_event_manager(
+ project_name, folder_ids, sender
+ ):
+ folder_items = self._controller.get_folder_items(project_name)
+ items_by_folder_id = {
+ folder_id: {}
+ for folder_id in folder_ids
+ }
+ product_items_by_id = self._query_product_items_by_ids(
+ project_name,
+ folder_ids=folder_ids,
+ folder_items=folder_items
+ )
+ for product_id, product_item in product_items_by_id.items():
+ folder_id = product_item.folder_id
+ items_by_folder_id[product_item.folder_id][product_id] = (
+ product_item
+ )
+
+ project_mapping[folder_id].add(product_id)
+ product_item_by_id[product_id] = product_item
+ for version_id, version_item in (
+ product_item.version_items.items()
+ ):
+ version_item_by_id[version_id] = version_item
+
+ project_cache = self._product_items_cache[project_name]
+ for folder_id, product_items in items_by_folder_id.items():
+ project_cache[folder_id].update_data(product_items)
+
+ @contextlib.contextmanager
+ def _product_refresh_event_manager(
+ self, project_name, folder_ids, sender
+ ):
+ self._controller.emit_event(
+ "products.refresh.started",
+ {
+ "project_name": project_name,
+ "folder_ids": folder_ids,
+ "sender": sender,
+ },
+ PRODUCTS_MODEL_SENDER
+ )
+ try:
+ yield
+
+ finally:
+ self._controller.emit_event(
+ "products.refresh.finished",
+ {
+ "project_name": project_name,
+ "folder_ids": folder_ids,
+ "sender": sender,
+ },
+ PRODUCTS_MODEL_SENDER
+ )
+
+ def refresh_representation_items(
+ self, project_name, version_ids, sender
+ ):
+ if not any((project_name, version_ids)):
+ return
+ self._controller.emit_event(
+ "model.representations.refresh.started",
+ {
+ "project_name": project_name,
+ "version_ids": version_ids,
+ "sender": sender,
+ },
+ PRODUCTS_MODEL_SENDER
+ )
+ failed = False
+ try:
+ self._refresh_representation_items(project_name, version_ids)
+ except Exception:
+ # TODO add more information about failed refresh
+ failed = True
+
+ self._controller.emit_event(
+ "model.representations.refresh.finished",
+ {
+ "project_name": project_name,
+ "version_ids": version_ids,
+ "sender": sender,
+ "failed": failed,
+ },
+ PRODUCTS_MODEL_SENDER
+ )
+
+ def _refresh_representation_items(self, project_name, version_ids):
+ representations = list(ayon_api.get_representations(
+ project_name,
+ version_ids=version_ids,
+ fields=["id", "name", "versionId"]
+ ))
+
+ version_items_by_id = self._get_version_items_by_id(
+ project_name, version_ids
+ )
+ product_ids = {
+ version_item.product_id
+ for version_item in version_items_by_id.values()
+ }
+ product_items_by_id = self._get_product_items_by_id(
+ project_name, product_ids
+ )
+ repre_icon = {
+ "type": "awesome-font",
+ "name": "fa.file-o",
+ "color": get_default_entity_icon_color(),
+ }
+ repre_items_by_version_id = collections.defaultdict(dict)
+ for representation in representations:
+ version_id = representation["versionId"]
+ version_item = version_items_by_id.get(version_id)
+ if version_item is None:
+ continue
+ product_item = product_items_by_id.get(version_item.product_id)
+ if product_item is None:
+ continue
+ repre_id = representation["id"]
+ repre_item = RepreItem(
+ repre_id,
+ representation["name"],
+ repre_icon,
+ product_item.product_name,
+ product_item.folder_label,
+ )
+ repre_items_by_version_id[version_id][repre_id] = repre_item
+
+ project_cache = self._repre_items_cache[project_name]
+ for version_id, repre_items in repre_items_by_version_id.items():
+ version_cache = project_cache[version_id]
+ version_cache.update_data(repre_items)
diff --git a/openpype/tools/ayon_loader/models/selection.py b/openpype/tools/ayon_loader/models/selection.py
new file mode 100644
index 0000000000..326ff835f6
--- /dev/null
+++ b/openpype/tools/ayon_loader/models/selection.py
@@ -0,0 +1,85 @@
+class SelectionModel(object):
+ """Model handling selection changes.
+
+ Triggering events:
+ - "selection.project.changed"
+ - "selection.folders.changed"
+ - "selection.versions.changed"
+ """
+
+ event_source = "selection.model"
+
+ def __init__(self, controller):
+ self._controller = controller
+
+ self._project_name = None
+ self._folder_ids = set()
+ self._version_ids = set()
+ self._representation_ids = set()
+
+ def get_selected_project_name(self):
+ return self._project_name
+
+ def set_selected_project(self, project_name):
+ if self._project_name == project_name:
+ return
+
+ self._project_name = project_name
+ self._controller.emit_event(
+ "selection.project.changed",
+ {"project_name": self._project_name},
+ self.event_source
+ )
+
+ def get_selected_folder_ids(self):
+ return self._folder_ids
+
+ def set_selected_folders(self, folder_ids):
+ if folder_ids == self._folder_ids:
+ return
+
+ self._folder_ids = folder_ids
+ self._controller.emit_event(
+ "selection.folders.changed",
+ {
+ "project_name": self._project_name,
+ "folder_ids": folder_ids,
+ },
+ self.event_source
+ )
+
+ def get_selected_version_ids(self):
+ return self._version_ids
+
+ def set_selected_versions(self, version_ids):
+ if version_ids == self._version_ids:
+ return
+
+ self._version_ids = version_ids
+ self._controller.emit_event(
+ "selection.versions.changed",
+ {
+ "project_name": self._project_name,
+ "folder_ids": self._folder_ids,
+ "version_ids": self._version_ids,
+ },
+ self.event_source
+ )
+
+ def get_selected_representation_ids(self):
+ return self._representation_ids
+
+ def set_selected_representations(self, repre_ids):
+ if repre_ids == self._representation_ids:
+ return
+
+ self._representation_ids = repre_ids
+ self._controller.emit_event(
+ "selection.representations.changed",
+ {
+ "project_name": self._project_name,
+ "folder_ids": self._folder_ids,
+ "version_ids": self._version_ids,
+ "representation_ids": self._representation_ids,
+ }
+ )
diff --git a/openpype/tools/ayon_loader/ui/__init__.py b/openpype/tools/ayon_loader/ui/__init__.py
new file mode 100644
index 0000000000..41e4418641
--- /dev/null
+++ b/openpype/tools/ayon_loader/ui/__init__.py
@@ -0,0 +1,6 @@
+from .window import LoaderWindow
+
+
+__all__ = (
+ "LoaderWindow",
+)
diff --git a/openpype/tools/ayon_loader/ui/actions_utils.py b/openpype/tools/ayon_loader/ui/actions_utils.py
new file mode 100644
index 0000000000..a269b643dc
--- /dev/null
+++ b/openpype/tools/ayon_loader/ui/actions_utils.py
@@ -0,0 +1,118 @@
+import uuid
+
+from qtpy import QtWidgets, QtGui
+import qtawesome
+
+from openpype.lib.attribute_definitions import AbstractAttrDef
+from openpype.tools.attribute_defs import AttributeDefinitionsDialog
+from openpype.tools.utils.widgets import (
+ OptionalMenu,
+ OptionalAction,
+ OptionDialog,
+)
+from openpype.tools.ayon_utils.widgets import get_qt_icon
+
+
+def show_actions_menu(action_items, global_point, one_item_selected, parent):
+ selected_action_item = None
+ selected_options = None
+
+ if not action_items:
+ menu = QtWidgets.QMenu(parent)
+ action = _get_no_loader_action(menu, one_item_selected)
+ menu.addAction(action)
+ menu.exec_(global_point)
+ return selected_action_item, selected_options
+
+ menu = OptionalMenu(parent)
+
+ action_items_by_id = {}
+ for action_item in action_items:
+ item_id = uuid.uuid4().hex
+ action_items_by_id[item_id] = action_item
+ item_options = action_item.options
+ icon = get_qt_icon(action_item.icon)
+ use_option = bool(item_options)
+ action = OptionalAction(
+ action_item.label,
+ icon,
+ use_option,
+ menu
+ )
+ if use_option:
+ # Add option box tip
+ action.set_option_tip(item_options)
+
+ tip = action_item.tooltip
+ if tip:
+ action.setToolTip(tip)
+ action.setStatusTip(tip)
+
+ action.setData(item_id)
+
+ menu.addAction(action)
+
+ action = menu.exec_(global_point)
+ if action is not None:
+ item_id = action.data()
+ selected_action_item = action_items_by_id.get(item_id)
+
+ if selected_action_item is not None:
+ selected_options = _get_options(action, selected_action_item, parent)
+
+ return selected_action_item, selected_options
+
+
+def _get_options(action, action_item, parent):
+ """Provides dialog to select value from loader provided options.
+
+ Loader can provide static or dynamically created options based on
+ AttributeDefinitions, and for backwards compatibility qargparse.
+
+ Args:
+ action (OptionalAction) - Action object in menu.
+ action_item (ActionItem) - Action item with context information.
+ parent (QtCore.QObject) - Parent object for dialog.
+
+ Returns:
+ Union[dict[str, Any], None]: Selected value from attributes or
+ 'None' if dialog was cancelled.
+ """
+
+ # Pop option dialog
+ options = action_item.options
+ if not getattr(action, "optioned", False) or not options:
+ return {}
+
+ if isinstance(options[0], AbstractAttrDef):
+ qargparse_options = False
+ dialog = AttributeDefinitionsDialog(options, parent)
+ else:
+ qargparse_options = True
+ dialog = OptionDialog(parent)
+ dialog.create(options)
+
+ dialog.setWindowTitle(action.label + " Options")
+
+ if not dialog.exec_():
+ return None
+
+ # Get option
+ if qargparse_options:
+ return dialog.parse()
+ return dialog.get_values()
+
+
+def _get_no_loader_action(menu, one_item_selected):
+ """Creates dummy no loader option in 'menu'"""
+
+ if one_item_selected:
+ submsg = "this version."
+ else:
+ submsg = "your selection."
+ msg = "No compatible loaders for {}".format(submsg)
+ icon = qtawesome.icon(
+ "fa.exclamation",
+ color=QtGui.QColor(255, 51, 0)
+ )
+ return QtWidgets.QAction(icon, ("*" + msg), menu)
diff --git a/openpype/tools/ayon_loader/ui/folders_widget.py b/openpype/tools/ayon_loader/ui/folders_widget.py
new file mode 100644
index 0000000000..b911458546
--- /dev/null
+++ b/openpype/tools/ayon_loader/ui/folders_widget.py
@@ -0,0 +1,416 @@
+import qtpy
+from qtpy import QtWidgets, QtCore, QtGui
+
+from openpype.tools.utils import (
+ RecursiveSortFilterProxyModel,
+ DeselectableTreeView,
+)
+from openpype.style import get_objected_colors
+
+from openpype.tools.ayon_utils.widgets import (
+ FoldersModel,
+ FOLDERS_MODEL_SENDER_NAME,
+)
+from openpype.tools.ayon_utils.widgets.folders_widget import ITEM_ID_ROLE
+
+if qtpy.API == "pyside":
+ from PySide.QtGui import QStyleOptionViewItemV4
+elif qtpy.API == "pyqt4":
+ from PyQt4.QtGui import QStyleOptionViewItemV4
+
+UNDERLINE_COLORS_ROLE = QtCore.Qt.UserRole + 4
+
+
+class UnderlinesFolderDelegate(QtWidgets.QItemDelegate):
+ """Item delegate drawing bars under folder label.
+
+ This is used in loader tool. Multiselection of folders
+ may group products by name under colored groups. Selected color groups are
+ then propagated back to selected folders as underlines.
+ """
+ bar_height = 3
+
+ def __init__(self, *args, **kwargs):
+ super(UnderlinesFolderDelegate, self).__init__(*args, **kwargs)
+ colors = get_objected_colors("loader", "asset-view")
+ self._selected_color = colors["selected"].get_qcolor()
+ self._hover_color = colors["hover"].get_qcolor()
+ self._selected_hover_color = colors["selected-hover"].get_qcolor()
+
+ def sizeHint(self, option, index):
+ """Add bar height to size hint."""
+ result = super(UnderlinesFolderDelegate, self).sizeHint(option, index)
+ height = result.height()
+ result.setHeight(height + self.bar_height)
+
+ return result
+
+ def paint(self, painter, option, index):
+ """Replicate painting of an item and draw color bars if needed."""
+ # Qt4 compat
+ if qtpy.API in ("pyside", "pyqt4"):
+ option = QStyleOptionViewItemV4(option)
+
+ painter.save()
+
+ item_rect = QtCore.QRect(option.rect)
+ item_rect.setHeight(option.rect.height() - self.bar_height)
+
+ subset_colors = index.data(UNDERLINE_COLORS_ROLE) or []
+
+ subset_colors_width = 0
+ if subset_colors:
+ subset_colors_width = option.rect.width() / len(subset_colors)
+
+ subset_rects = []
+ counter = 0
+ for subset_c in subset_colors:
+ new_color = None
+ new_rect = None
+ if subset_c:
+ new_color = QtGui.QColor(subset_c)
+
+ new_rect = QtCore.QRect(
+ option.rect.left() + (counter * subset_colors_width),
+ option.rect.top() + (
+ option.rect.height() - self.bar_height
+ ),
+ subset_colors_width,
+ self.bar_height
+ )
+ subset_rects.append((new_color, new_rect))
+ counter += 1
+
+ # Background
+ if option.state & QtWidgets.QStyle.State_Selected:
+ if len(subset_colors) == 0:
+ item_rect.setTop(item_rect.top() + (self.bar_height / 2))
+
+ if option.state & QtWidgets.QStyle.State_MouseOver:
+ bg_color = self._selected_hover_color
+ else:
+ bg_color = self._selected_color
+ else:
+ item_rect.setTop(item_rect.top() + (self.bar_height / 2))
+ if option.state & QtWidgets.QStyle.State_MouseOver:
+ bg_color = self._hover_color
+ else:
+ bg_color = QtGui.QColor()
+ bg_color.setAlpha(0)
+
+ # When not needed to do a rounded corners (easier and without
+ # painter restore):
+ painter.fillRect(
+ option.rect,
+ QtGui.QBrush(bg_color)
+ )
+
+ if option.state & QtWidgets.QStyle.State_Selected:
+ for color, subset_rect in subset_rects:
+ if not color or not subset_rect:
+ continue
+ painter.fillRect(subset_rect, QtGui.QBrush(color))
+
+ # Icon
+ icon_index = index.model().index(
+ index.row(), index.column(), index.parent()
+ )
+ # - Default icon_rect if not icon
+ icon_rect = QtCore.QRect(
+ item_rect.left(),
+ item_rect.top(),
+ # To make sure it's same size all the time
+ option.rect.height() - self.bar_height,
+ option.rect.height() - self.bar_height
+ )
+ icon = index.model().data(icon_index, QtCore.Qt.DecorationRole)
+
+ if icon:
+ mode = QtGui.QIcon.Normal
+ if not (option.state & QtWidgets.QStyle.State_Enabled):
+ mode = QtGui.QIcon.Disabled
+ elif option.state & QtWidgets.QStyle.State_Selected:
+ mode = QtGui.QIcon.Selected
+
+ if isinstance(icon, QtGui.QPixmap):
+ icon = QtGui.QIcon(icon)
+ option.decorationSize = icon.size() / icon.devicePixelRatio()
+
+ elif isinstance(icon, QtGui.QColor):
+ pixmap = QtGui.QPixmap(option.decorationSize)
+ pixmap.fill(icon)
+ icon = QtGui.QIcon(pixmap)
+
+ elif isinstance(icon, QtGui.QImage):
+ icon = QtGui.QIcon(QtGui.QPixmap.fromImage(icon))
+ option.decorationSize = icon.size() / icon.devicePixelRatio()
+
+ elif isinstance(icon, QtGui.QIcon):
+ state = QtGui.QIcon.Off
+ if option.state & QtWidgets.QStyle.State_Open:
+ state = QtGui.QIcon.On
+ actual_size = option.icon.actualSize(
+ option.decorationSize, mode, state
+ )
+ option.decorationSize = QtCore.QSize(
+ min(option.decorationSize.width(), actual_size.width()),
+ min(option.decorationSize.height(), actual_size.height())
+ )
+
+ state = QtGui.QIcon.Off
+ if option.state & QtWidgets.QStyle.State_Open:
+ state = QtGui.QIcon.On
+
+ icon.paint(
+ painter, icon_rect,
+ QtCore.Qt.AlignLeft, mode, state
+ )
+
+ # Text
+ text_rect = QtCore.QRect(
+ icon_rect.left() + icon_rect.width() + 2,
+ item_rect.top(),
+ item_rect.width(),
+ item_rect.height()
+ )
+
+ painter.drawText(
+ text_rect, QtCore.Qt.AlignVCenter,
+ index.data(QtCore.Qt.DisplayRole)
+ )
+
+ painter.restore()
+
+
+class LoaderFoldersModel(FoldersModel):
+ def __init__(self, *args, **kwargs):
+ super(LoaderFoldersModel, self).__init__(*args, **kwargs)
+
+ self._colored_items = set()
+
+ def _fill_item_data(self, item, folder_item):
+ """
+
+ Args:
+ item (QtGui.QStandardItem): Item to fill data.
+ folder_item (FolderItem): Folder item.
+ """
+
+ super(LoaderFoldersModel, self)._fill_item_data(item, folder_item)
+
+ def set_merged_products_selection(self, items):
+ changes = {
+ folder_id: None
+ for folder_id in self._colored_items
+ }
+
+ all_folder_ids = set()
+ for item in items:
+ folder_ids = item["folder_ids"]
+ all_folder_ids.update(folder_ids)
+
+ for folder_id in all_folder_ids:
+ changes[folder_id] = []
+
+ for item in items:
+ item_color = item["color"]
+ item_folder_ids = item["folder_ids"]
+ for folder_id in all_folder_ids:
+ folder_color = (
+ item_color
+ if folder_id in item_folder_ids
+ else None
+ )
+ changes[folder_id].append(folder_color)
+
+ for folder_id, color_value in changes.items():
+ item = self._items_by_id.get(folder_id)
+ if item is not None:
+ item.setData(color_value, UNDERLINE_COLORS_ROLE)
+
+ self._colored_items = all_folder_ids
+
+
+class LoaderFoldersWidget(QtWidgets.QWidget):
+ """Folders widget.
+
+ Widget that handles folders view, model and selection.
+
+ Expected selection handling is disabled by default. If enabled, the
+ widget will handle the expected in predefined way. Widget is listening
+ to event 'expected_selection_changed' with expected event data below,
+ the same data must be available when called method
+ 'get_expected_selection_data' on controller.
+
+ {
+ "folder": {
+ "current": bool, # Folder is what should be set now
+ "folder_id": Union[str, None], # Folder id that should be selected
+ },
+ ...
+ }
+
+ Selection is confirmed by calling method 'expected_folder_selected' on
+ controller.
+
+
+ Args:
+ controller (AbstractWorkfilesFrontend): The control object.
+ parent (QtWidgets.QWidget): The parent widget.
+ handle_expected_selection (bool): If True, the widget will handle
+ the expected selection. Defaults to False.
+ """
+
+ refreshed = QtCore.Signal()
+
+ def __init__(self, controller, parent, handle_expected_selection=False):
+ super(LoaderFoldersWidget, self).__init__(parent)
+
+ folders_view = DeselectableTreeView(self)
+ folders_view.setHeaderHidden(True)
+ folders_view.setSelectionMode(
+ QtWidgets.QAbstractItemView.ExtendedSelection)
+
+ folders_model = LoaderFoldersModel(controller)
+ folders_proxy_model = RecursiveSortFilterProxyModel()
+ folders_proxy_model.setSourceModel(folders_model)
+ folders_proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive)
+
+ folders_label_delegate = UnderlinesFolderDelegate(folders_view)
+
+ folders_view.setModel(folders_proxy_model)
+ folders_view.setItemDelegate(folders_label_delegate)
+
+ main_layout = QtWidgets.QHBoxLayout(self)
+ main_layout.setContentsMargins(0, 0, 0, 0)
+ main_layout.addWidget(folders_view, 1)
+
+ controller.register_event_callback(
+ "selection.project.changed",
+ self._on_project_selection_change,
+ )
+ controller.register_event_callback(
+ "folders.refresh.finished",
+ self._on_folders_refresh_finished
+ )
+ controller.register_event_callback(
+ "controller.refresh.finished",
+ self._on_controller_refresh
+ )
+ controller.register_event_callback(
+ "expected_selection_changed",
+ self._on_expected_selection_change
+ )
+
+ selection_model = folders_view.selectionModel()
+ selection_model.selectionChanged.connect(self._on_selection_change)
+
+ folders_model.refreshed.connect(self._on_model_refresh)
+
+ self._controller = controller
+ self._folders_view = folders_view
+ self._folders_model = folders_model
+ self._folders_proxy_model = folders_proxy_model
+ self._folders_label_delegate = folders_label_delegate
+
+ self._handle_expected_selection = handle_expected_selection
+ self._expected_selection = None
+
+ def set_name_filer(self, name):
+ """Set filter of folder name.
+
+ Args:
+ name (str): The string filter.
+ """
+
+ self._folders_proxy_model.setFilterFixedString(name)
+
+ def set_merged_products_selection(self, items):
+ """
+
+ Args:
+ items (list[dict[str, Any]]): List of merged items with folder
+ ids.
+ """
+
+ self._folders_model.set_merged_products_selection(items)
+
+ def refresh(self):
+ self._folders_model.refresh()
+
+ def _on_project_selection_change(self, event):
+ project_name = event["project_name"]
+ self._set_project_name(project_name)
+
+ def _set_project_name(self, project_name):
+ self._folders_model.set_project_name(project_name)
+
+ def _clear(self):
+ self._folders_model.clear()
+
+ def _on_folders_refresh_finished(self, event):
+ if event["sender"] != FOLDERS_MODEL_SENDER_NAME:
+ self._set_project_name(event["project_name"])
+
+ def _on_controller_refresh(self):
+ self._update_expected_selection()
+
+ def _on_model_refresh(self):
+ if self._expected_selection:
+ self._set_expected_selection()
+ self._folders_proxy_model.sort(0)
+ self.refreshed.emit()
+
+ def _get_selected_item_ids(self):
+ selection_model = self._folders_view.selectionModel()
+ item_ids = []
+ for index in selection_model.selectedIndexes():
+ item_id = index.data(ITEM_ID_ROLE)
+ if item_id is not None:
+ item_ids.append(item_id)
+ return item_ids
+
+ def _on_selection_change(self):
+ item_ids = self._get_selected_item_ids()
+ self._controller.set_selected_folders(item_ids)
+
+ # Expected selection handling
+ def _on_expected_selection_change(self, event):
+ self._update_expected_selection(event.data)
+
+ def _update_expected_selection(self, expected_data=None):
+ if not self._handle_expected_selection:
+ return
+
+ if expected_data is None:
+ expected_data = self._controller.get_expected_selection_data()
+
+ folder_data = expected_data.get("folder")
+ if not folder_data or not folder_data["current"]:
+ return
+
+ folder_id = folder_data["id"]
+ self._expected_selection = folder_id
+ if not self._folders_model.is_refreshing:
+ self._set_expected_selection()
+
+ def _set_expected_selection(self):
+ if not self._handle_expected_selection:
+ return
+
+ folder_id = self._expected_selection
+ selected_ids = self._get_selected_item_ids()
+ self._expected_selection = None
+ skip_selection = (
+ folder_id is None
+ or (
+ folder_id in selected_ids
+ and len(selected_ids) == 1
+ )
+ )
+ if not skip_selection:
+ index = self._folders_model.get_index_by_id(folder_id)
+ if index.isValid():
+ proxy_index = self._folders_proxy_model.mapFromSource(index)
+ self._folders_view.setCurrentIndex(proxy_index)
+ self._controller.expected_folder_selected(folder_id)
diff --git a/openpype/tools/ayon_loader/ui/info_widget.py b/openpype/tools/ayon_loader/ui/info_widget.py
new file mode 100644
index 0000000000..b7d1b0811f
--- /dev/null
+++ b/openpype/tools/ayon_loader/ui/info_widget.py
@@ -0,0 +1,141 @@
+import datetime
+
+from qtpy import QtWidgets
+
+from openpype.tools.utils.lib import format_version
+
+
+class VersionTextEdit(QtWidgets.QTextEdit):
+ """QTextEdit that displays version specific information.
+
+ This also overrides the context menu to add actions like copying
+ source path to clipboard or copying the raw data of the version
+ to clipboard.
+
+ """
+ def __init__(self, controller, parent):
+ super(VersionTextEdit, self).__init__(parent=parent)
+
+ self._version_item = None
+ self._product_item = None
+
+ self._controller = controller
+
+ # Reset
+ self.set_current_item()
+
+ def set_current_item(self, product_item=None, version_item=None):
+ """
+
+ Args:
+ product_item (Union[ProductItem, None]): Product item.
+ version_item (Union[VersionItem, None]): Version item to display.
+ """
+
+ self._product_item = product_item
+ self._version_item = version_item
+
+ if version_item is None:
+ # Reset state to empty
+ self.setText("")
+ return
+
+ version_label = format_version(abs(version_item.version))
+ if version_item.version < 0:
+ version_label = "Hero version {}".format(version_label)
+
+ # Define readable creation timestamp
+ created = version_item.published_time
+ created = datetime.datetime.strptime(created, "%Y%m%dT%H%M%SZ")
+ created = datetime.datetime.strftime(created, "%b %d %Y %H:%M")
+
+ comment = version_item.comment or "No comment"
+ source = version_item.source or "No source"
+
+ self.setHtml(
+ (
+ "{product_name}
"
+ "{version_label}
"
+ "Comment
"
+ "{comment}
"
+
+ "Created
"
+ "{created}
"
+
+ "Source
"
+ "{source}"
+ ).format(
+ product_name=product_item.product_name,
+ version_label=version_label,
+ comment=comment,
+ created=created,
+ source=source,
+ )
+ )
+
+ def contextMenuEvent(self, event):
+ """Context menu with additional actions"""
+ menu = self.createStandardContextMenu()
+
+ # Add additional actions when any text, so we can assume
+ # the version is set.
+ source = None
+ if self._version_item is not None:
+ source = self._version_item.source
+
+ if source:
+ menu.addSeparator()
+ action = QtWidgets.QAction(
+ "Copy source path to clipboard", menu
+ )
+ action.triggered.connect(self._on_copy_source)
+ menu.addAction(action)
+
+ menu.exec_(event.globalPos())
+
+ def _on_copy_source(self):
+ """Copy formatted source path to clipboard."""
+
+ source = self._version_item.source
+ if not source:
+ return
+
+ filled_source = self._controller.fill_root_in_source(source)
+ clipboard = QtWidgets.QApplication.clipboard()
+ clipboard.setText(filled_source)
+
+
+class InfoWidget(QtWidgets.QWidget):
+ """A Widget that display information about a specific version"""
+ def __init__(self, controller, parent):
+ super(InfoWidget, self).__init__(parent=parent)
+
+ label_widget = QtWidgets.QLabel("Version Info", self)
+ info_text_widget = VersionTextEdit(controller, self)
+ info_text_widget.setReadOnly(True)
+
+ layout = QtWidgets.QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.addWidget(label_widget, 0)
+ layout.addWidget(info_text_widget, 1)
+
+ self._controller = controller
+
+ self._info_text_widget = info_text_widget
+ self._label_widget = label_widget
+
+ def set_selected_version_info(self, project_name, items):
+ if not items or not project_name:
+ self._info_text_widget.set_current_item()
+ return
+ first_item = next(iter(items))
+ product_item = self._controller.get_product_item(
+ project_name,
+ first_item["product_id"],
+ )
+ version_id = first_item["version_id"]
+ version_item = None
+ if product_item is not None:
+ version_item = product_item.version_items.get(version_id)
+
+ self._info_text_widget.set_current_item(product_item, version_item)
diff --git a/openpype/tools/ayon_loader/ui/product_group_dialog.py b/openpype/tools/ayon_loader/ui/product_group_dialog.py
new file mode 100644
index 0000000000..5737ce58a4
--- /dev/null
+++ b/openpype/tools/ayon_loader/ui/product_group_dialog.py
@@ -0,0 +1,45 @@
+from qtpy import QtWidgets
+
+from openpype.tools.utils import PlaceholderLineEdit
+
+
+class ProductGroupDialog(QtWidgets.QDialog):
+ def __init__(self, controller, parent):
+ super(ProductGroupDialog, self).__init__(parent)
+ self.setWindowTitle("Grouping products")
+ self.setMinimumWidth(250)
+ self.setModal(True)
+
+ main_label = QtWidgets.QLabel("Group Name", self)
+
+ group_name_input = PlaceholderLineEdit(self)
+ group_name_input.setPlaceholderText("Remain blank to ungroup..")
+
+ group_btn = QtWidgets.QPushButton("Apply", self)
+ group_btn.setAutoDefault(True)
+ group_btn.setDefault(True)
+
+ layout = QtWidgets.QVBoxLayout(self)
+ layout.addWidget(main_label, 0)
+ layout.addWidget(group_name_input, 0)
+ layout.addWidget(group_btn, 0)
+
+ group_btn.clicked.connect(self._on_apply_click)
+
+ self._project_name = None
+ self._product_ids = set()
+
+ self._controller = controller
+ self._group_btn = group_btn
+ self._group_name_input = group_name_input
+
+ def set_product_ids(self, project_name, product_ids):
+ self._project_name = project_name
+ self._product_ids = product_ids
+
+ def _on_apply_click(self):
+ group_name = self._group_name_input.text().strip() or None
+ self._controller.change_products_group(
+ self._project_name, self._product_ids, group_name
+ )
+ self.close()
diff --git a/openpype/tools/ayon_loader/ui/product_types_widget.py b/openpype/tools/ayon_loader/ui/product_types_widget.py
new file mode 100644
index 0000000000..a84a7ff846
--- /dev/null
+++ b/openpype/tools/ayon_loader/ui/product_types_widget.py
@@ -0,0 +1,220 @@
+from qtpy import QtWidgets, QtGui, QtCore
+
+from openpype.tools.ayon_utils.widgets import get_qt_icon
+
+PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 1
+
+
+class ProductTypesQtModel(QtGui.QStandardItemModel):
+ refreshed = QtCore.Signal()
+ filter_changed = QtCore.Signal()
+
+ def __init__(self, controller):
+ super(ProductTypesQtModel, self).__init__()
+ self._controller = controller
+
+ self._refreshing = False
+ self._bulk_change = False
+ self._items_by_name = {}
+
+ def is_refreshing(self):
+ return self._refreshing
+
+ def get_filter_info(self):
+ """Product types filtering info.
+
+ Returns:
+ dict[str, bool]: Filtering value by product type name. False value
+ means to hide product type.
+ """
+
+ return {
+ name: item.checkState() == QtCore.Qt.Checked
+ for name, item in self._items_by_name.items()
+ }
+
+ def refresh(self, project_name):
+ self._refreshing = True
+ product_type_items = self._controller.get_product_type_items(
+ project_name)
+
+ items_to_remove = set(self._items_by_name.keys())
+ new_items = []
+ for product_type_item in product_type_items:
+ name = product_type_item.name
+ items_to_remove.discard(name)
+ item = self._items_by_name.get(product_type_item.name)
+ if item is None:
+ item = QtGui.QStandardItem(name)
+ item.setData(name, PRODUCT_TYPE_ROLE)
+ item.setEditable(False)
+ item.setCheckable(True)
+ new_items.append(item)
+ self._items_by_name[name] = item
+
+ item.setCheckState(
+ QtCore.Qt.Checked
+ if product_type_item.checked
+ else QtCore.Qt.Unchecked
+ )
+ icon = get_qt_icon(product_type_item.icon)
+ item.setData(icon, QtCore.Qt.DecorationRole)
+
+ root_item = self.invisibleRootItem()
+ if new_items:
+ root_item.appendRows(new_items)
+
+ for name in items_to_remove:
+ item = self._items_by_name.pop(name)
+ root_item.removeRow(item.row())
+
+ self._refreshing = False
+ self.refreshed.emit()
+
+ def setData(self, index, value, role=None):
+ checkstate_changed = False
+ if role is None:
+ role = QtCore.Qt.EditRole
+ elif role == QtCore.Qt.CheckStateRole:
+ checkstate_changed = True
+ output = super(ProductTypesQtModel, self).setData(index, value, role)
+ if checkstate_changed and not self._bulk_change:
+ self.filter_changed.emit()
+ return output
+
+ def change_state_for_all(self, checked):
+ if self._items_by_name:
+ self.change_states(checked, self._items_by_name.keys())
+
+ def change_states(self, checked, product_types):
+ product_types = set(product_types)
+ if not product_types:
+ return
+
+ if checked is None:
+ state = None
+ elif checked:
+ state = QtCore.Qt.Checked
+ else:
+ state = QtCore.Qt.Unchecked
+
+ self._bulk_change = True
+
+ changed = False
+ for product_type in product_types:
+ item = self._items_by_name.get(product_type)
+ if item is None:
+ continue
+ new_state = state
+ item_checkstate = item.checkState()
+ if new_state is None:
+ if item_checkstate == QtCore.Qt.Checked:
+ new_state = QtCore.Qt.Unchecked
+ else:
+ new_state = QtCore.Qt.Checked
+ elif item_checkstate == new_state:
+ continue
+ changed = True
+ item.setCheckState(new_state)
+
+ self._bulk_change = False
+
+ if changed:
+ self.filter_changed.emit()
+
+
+class ProductTypesView(QtWidgets.QListView):
+ filter_changed = QtCore.Signal()
+
+ def __init__(self, controller, parent):
+ super(ProductTypesView, self).__init__(parent)
+
+ self.setSelectionMode(
+ QtWidgets.QAbstractItemView.ExtendedSelection
+ )
+ self.setAlternatingRowColors(True)
+ self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
+
+ product_types_model = ProductTypesQtModel(controller)
+ product_types_proxy_model = QtCore.QSortFilterProxyModel()
+ product_types_proxy_model.setSourceModel(product_types_model)
+
+ self.setModel(product_types_proxy_model)
+
+ product_types_model.refreshed.connect(self._on_refresh_finished)
+ product_types_model.filter_changed.connect(self._on_filter_change)
+ self.customContextMenuRequested.connect(self._on_context_menu)
+
+ controller.register_event_callback(
+ "selection.project.changed",
+ self._on_project_change
+ )
+
+ self._controller = controller
+
+ self._product_types_model = product_types_model
+ self._product_types_proxy_model = product_types_proxy_model
+
+ def get_filter_info(self):
+ return self._product_types_model.get_filter_info()
+
+ def _on_project_change(self, event):
+ project_name = event["project_name"]
+ self._product_types_model.refresh(project_name)
+
+ def _on_refresh_finished(self):
+ self.filter_changed.emit()
+
+ def _on_filter_change(self):
+ if not self._product_types_model.is_refreshing():
+ self.filter_changed.emit()
+
+ def _change_selection_state(self, checkstate):
+ selection_model = self.selectionModel()
+ product_types = {
+ index.data(PRODUCT_TYPE_ROLE)
+ for index in selection_model.selectedIndexes()
+ }
+ product_types.discard(None)
+ self._product_types_model.change_states(checkstate, product_types)
+
+ def _on_enable_all(self):
+ self._product_types_model.change_state_for_all(True)
+
+ def _on_disable_all(self):
+ self._product_types_model.change_state_for_all(False)
+
+ def _on_context_menu(self, pos):
+ menu = QtWidgets.QMenu(self)
+
+ # Add enable all action
+ action_check_all = QtWidgets.QAction(menu)
+ action_check_all.setText("Enable All")
+ action_check_all.triggered.connect(self._on_enable_all)
+ # Add disable all action
+ action_uncheck_all = QtWidgets.QAction(menu)
+ action_uncheck_all.setText("Disable All")
+ action_uncheck_all.triggered.connect(self._on_disable_all)
+
+ menu.addAction(action_check_all)
+ menu.addAction(action_uncheck_all)
+
+ # Get mouse position
+ global_pos = self.viewport().mapToGlobal(pos)
+ menu.exec_(global_pos)
+
+ def event(self, event):
+ if event.type() == QtCore.QEvent.KeyPress:
+ if event.key() == QtCore.Qt.Key_Space:
+ self._change_selection_state(None)
+ return True
+
+ if event.key() == QtCore.Qt.Key_Backspace:
+ self._change_selection_state(False)
+ return True
+
+ if event.key() == QtCore.Qt.Key_Return:
+ self._change_selection_state(True)
+ return True
+
+ return super(ProductTypesView, self).event(event)
diff --git a/openpype/tools/ayon_loader/ui/products_delegates.py b/openpype/tools/ayon_loader/ui/products_delegates.py
new file mode 100644
index 0000000000..6729468bfa
--- /dev/null
+++ b/openpype/tools/ayon_loader/ui/products_delegates.py
@@ -0,0 +1,191 @@
+import numbers
+from qtpy import QtWidgets, QtCore, QtGui
+
+from openpype.tools.utils.lib import format_version
+
+from .products_model import (
+ PRODUCT_ID_ROLE,
+ VERSION_NAME_EDIT_ROLE,
+ VERSION_ID_ROLE,
+ PRODUCT_IN_SCENE_ROLE,
+)
+
+
+class VersionComboBox(QtWidgets.QComboBox):
+ value_changed = QtCore.Signal(str)
+
+ def __init__(self, product_id, parent):
+ super(VersionComboBox, self).__init__(parent)
+ self._product_id = product_id
+ self._items_by_id = {}
+
+ self._current_id = None
+
+ self.currentIndexChanged.connect(self._on_index_change)
+
+ def update_versions(self, version_items, current_version_id):
+ model = self.model()
+ root_item = model.invisibleRootItem()
+ version_items = list(reversed(version_items))
+ version_ids = [
+ version_item.version_id
+ for version_item in version_items
+ ]
+ if current_version_id not in version_ids and version_ids:
+ current_version_id = version_ids[0]
+ self._current_id = current_version_id
+
+ to_remove = set(self._items_by_id.keys()) - set(version_ids)
+ for item_id in to_remove:
+ item = self._items_by_id.pop(item_id)
+ root_item.removeRow(item.row())
+
+ for idx, version_item in enumerate(version_items):
+ version_id = version_item.version_id
+
+ item = self._items_by_id.get(version_id)
+ if item is None:
+ label = format_version(
+ abs(version_item.version), version_item.is_hero
+ )
+ item = QtGui.QStandardItem(label)
+ item.setData(version_id, QtCore.Qt.UserRole)
+ self._items_by_id[version_id] = item
+
+ if item.row() != idx:
+ root_item.insertRow(idx, item)
+
+ index = version_ids.index(current_version_id)
+ if self.currentIndex() != index:
+ self.setCurrentIndex(index)
+
+ def _on_index_change(self):
+ idx = self.currentIndex()
+ value = self.itemData(idx)
+ if value == self._current_id:
+ return
+ self._current_id = value
+ self.value_changed.emit(self._product_id)
+
+
+class VersionDelegate(QtWidgets.QStyledItemDelegate):
+ """A delegate that display version integer formatted as version string."""
+
+ version_changed = QtCore.Signal()
+
+ def __init__(self, *args, **kwargs):
+ super(VersionDelegate, self).__init__(*args, **kwargs)
+ self._editor_by_product_id = {}
+
+ def displayText(self, value, locale):
+ if not isinstance(value, numbers.Integral):
+ return "N/A"
+ return format_version(abs(value), value < 0)
+
+ def paint(self, painter, option, index):
+ fg_color = index.data(QtCore.Qt.ForegroundRole)
+ if fg_color:
+ if isinstance(fg_color, QtGui.QBrush):
+ fg_color = fg_color.color()
+ elif isinstance(fg_color, QtGui.QColor):
+ pass
+ else:
+ fg_color = None
+
+ if not fg_color:
+ return super(VersionDelegate, self).paint(painter, option, index)
+
+ if option.widget:
+ style = option.widget.style()
+ else:
+ style = QtWidgets.QApplication.style()
+
+ style.drawControl(
+ style.CE_ItemViewItem, option, painter, option.widget
+ )
+
+ painter.save()
+
+ text = self.displayText(
+ index.data(QtCore.Qt.DisplayRole), option.locale
+ )
+ pen = painter.pen()
+ pen.setColor(fg_color)
+ painter.setPen(pen)
+
+ text_rect = style.subElementRect(style.SE_ItemViewItemText, option)
+ text_margin = style.proxy().pixelMetric(
+ style.PM_FocusFrameHMargin, option, option.widget
+ ) + 1
+
+ painter.drawText(
+ text_rect.adjusted(text_margin, 0, - text_margin, 0),
+ option.displayAlignment,
+ text
+ )
+
+ painter.restore()
+
+ def createEditor(self, parent, option, index):
+ product_id = index.data(PRODUCT_ID_ROLE)
+ if not product_id:
+ return
+
+ editor = VersionComboBox(product_id, parent)
+ self._editor_by_product_id[product_id] = editor
+ editor.value_changed.connect(self._on_editor_change)
+
+ return editor
+
+ def _on_editor_change(self, product_id):
+ editor = self._editor_by_product_id[product_id]
+
+ # Update model data
+ self.commitData.emit(editor)
+ # Display model data
+ self.version_changed.emit()
+
+ def setEditorData(self, editor, index):
+ editor.clear()
+
+ # Current value of the index
+ versions = index.data(VERSION_NAME_EDIT_ROLE) or []
+ version_id = index.data(VERSION_ID_ROLE)
+ editor.update_versions(versions, version_id)
+
+ def setModelData(self, editor, model, index):
+ """Apply the integer version back in the model"""
+
+ version_id = editor.itemData(editor.currentIndex())
+ model.setData(index, version_id, VERSION_NAME_EDIT_ROLE)
+
+
+class LoadedInSceneDelegate(QtWidgets.QStyledItemDelegate):
+ """Delegate for Loaded in Scene state columns.
+
+ Shows "Yes" or "No" for 1 or 0 values, or "N/A" for other values.
+ Colorizes green or dark grey based on values.
+ """
+
+ def __init__(self, *args, **kwargs):
+ super(LoadedInSceneDelegate, self).__init__(*args, **kwargs)
+ self._colors = {
+ 1: QtGui.QColor(80, 170, 80),
+ 0: QtGui.QColor(90, 90, 90),
+ }
+ self._default_color = QtGui.QColor(90, 90, 90)
+
+ def displayText(self, value, locale):
+ if value == 0:
+ return "No"
+ elif value == 1:
+ return "Yes"
+ return "N/A"
+
+ def initStyleOption(self, option, index):
+ super(LoadedInSceneDelegate, self).initStyleOption(option, index)
+
+ # Colorize based on value
+ value = index.data(PRODUCT_IN_SCENE_ROLE)
+ color = self._colors.get(value, self._default_color)
+ option.palette.setBrush(QtGui.QPalette.Text, color)
diff --git a/openpype/tools/ayon_loader/ui/products_model.py b/openpype/tools/ayon_loader/ui/products_model.py
new file mode 100644
index 0000000000..741f15766b
--- /dev/null
+++ b/openpype/tools/ayon_loader/ui/products_model.py
@@ -0,0 +1,590 @@
+import collections
+
+import qtawesome
+from qtpy import QtGui, QtCore
+
+from openpype.style import get_default_entity_icon_color
+from openpype.tools.ayon_utils.widgets import get_qt_icon
+
+PRODUCTS_MODEL_SENDER_NAME = "qt_products_model"
+
+GROUP_TYPE_ROLE = QtCore.Qt.UserRole + 1
+MERGED_COLOR_ROLE = QtCore.Qt.UserRole + 2
+FOLDER_LABEL_ROLE = QtCore.Qt.UserRole + 3
+FOLDER_ID_ROLE = QtCore.Qt.UserRole + 4
+PRODUCT_ID_ROLE = QtCore.Qt.UserRole + 5
+PRODUCT_NAME_ROLE = QtCore.Qt.UserRole + 6
+PRODUCT_TYPE_ROLE = QtCore.Qt.UserRole + 7
+PRODUCT_TYPE_ICON_ROLE = QtCore.Qt.UserRole + 8
+PRODUCT_IN_SCENE_ROLE = QtCore.Qt.UserRole + 9
+VERSION_ID_ROLE = QtCore.Qt.UserRole + 10
+VERSION_HERO_ROLE = QtCore.Qt.UserRole + 11
+VERSION_NAME_ROLE = QtCore.Qt.UserRole + 12
+VERSION_NAME_EDIT_ROLE = QtCore.Qt.UserRole + 13
+VERSION_PUBLISH_TIME_ROLE = QtCore.Qt.UserRole + 14
+VERSION_AUTHOR_ROLE = QtCore.Qt.UserRole + 15
+VERSION_FRAME_RANGE_ROLE = QtCore.Qt.UserRole + 16
+VERSION_DURATION_ROLE = QtCore.Qt.UserRole + 17
+VERSION_HANDLES_ROLE = QtCore.Qt.UserRole + 18
+VERSION_STEP_ROLE = QtCore.Qt.UserRole + 19
+VERSION_AVAILABLE_ROLE = QtCore.Qt.UserRole + 20
+VERSION_THUMBNAIL_ID_ROLE = QtCore.Qt.UserRole + 21
+
+
+class ProductsModel(QtGui.QStandardItemModel):
+ refreshed = QtCore.Signal()
+ version_changed = QtCore.Signal()
+ column_labels = [
+ "Product name",
+ "Product type",
+ "Folder",
+ "Version",
+ "Time",
+ "Author",
+ "Frames",
+ "Duration",
+ "Handles",
+ "Step",
+ "In scene",
+ "Availability",
+ ]
+ merged_items_colors = [
+ ("#{0:02x}{1:02x}{2:02x}".format(*c), QtGui.QColor(*c))
+ for c in [
+ (55, 161, 222), # Light Blue
+ (231, 176, 0), # Yellow
+ (154, 13, 255), # Purple
+ (130, 184, 30), # Light Green
+ (211, 79, 63), # Light Red
+ (179, 181, 182), # Grey
+ (194, 57, 179), # Pink
+ (0, 120, 215), # Dark Blue
+ (0, 204, 106), # Dark Green
+ (247, 99, 12), # Orange
+ ]
+ ]
+
+ version_col = column_labels.index("Version")
+ published_time_col = column_labels.index("Time")
+ folders_label_col = column_labels.index("Folder")
+ in_scene_col = column_labels.index("In scene")
+
+ def __init__(self, controller):
+ super(ProductsModel, self).__init__()
+ self.setColumnCount(len(self.column_labels))
+ for idx, label in enumerate(self.column_labels):
+ self.setHeaderData(idx, QtCore.Qt.Horizontal, label)
+ self._controller = controller
+
+ # Variables to store 'QStandardItem'
+ self._items_by_id = {}
+ self._group_items_by_name = {}
+ self._merged_items_by_id = {}
+
+ # product item objects (they have version information)
+ self._product_items_by_id = {}
+ self._grouping_enabled = True
+ self._reset_merge_color = False
+ self._color_iterator = self._color_iter()
+ self._group_icon = None
+
+ self._last_project_name = None
+ self._last_folder_ids = []
+
+ def get_product_item_indexes(self):
+ return [
+ item.index()
+ for item in self._items_by_id.values()
+ ]
+
+ def get_product_item_by_id(self, product_id):
+ """
+
+ Args:
+ product_id (str): Product id.
+
+ Returns:
+ Union[ProductItem, None]: Product item with version information.
+ """
+
+ return self._product_items_by_id.get(product_id)
+
+ def set_enable_grouping(self, enable_grouping):
+ if enable_grouping is self._grouping_enabled:
+ return
+ self._grouping_enabled = enable_grouping
+ # Ignore change if groups are not available
+ self.refresh(self._last_project_name, self._last_folder_ids)
+
+ def flags(self, index):
+ # Make the version column editable
+ if index.column() == self.version_col and index.data(PRODUCT_ID_ROLE):
+ return (
+ QtCore.Qt.ItemIsEnabled
+ | QtCore.Qt.ItemIsSelectable
+ | QtCore.Qt.ItemIsEditable
+ )
+ if index.column() != 0:
+ index = self.index(index.row(), 0, index.parent())
+ return super(ProductsModel, self).flags(index)
+
+ def data(self, index, role=None):
+ if role is None:
+ role = QtCore.Qt.DisplayRole
+
+ if not index.isValid():
+ return None
+
+ col = index.column()
+ if col == 0:
+ return super(ProductsModel, self).data(index, role)
+
+ if role == QtCore.Qt.DecorationRole:
+ if col == 1:
+ role = PRODUCT_TYPE_ICON_ROLE
+ else:
+ return None
+
+ if (
+ role == VERSION_NAME_EDIT_ROLE
+ or (role == QtCore.Qt.EditRole and col == self.version_col)
+ ):
+ index = self.index(index.row(), 0, index.parent())
+ product_id = index.data(PRODUCT_ID_ROLE)
+ product_item = self._product_items_by_id.get(product_id)
+ if product_item is None:
+ return None
+ return list(product_item.version_items.values())
+
+ if role == QtCore.Qt.EditRole:
+ return None
+
+ if role == QtCore.Qt.DisplayRole:
+ if not index.data(PRODUCT_ID_ROLE):
+ return None
+ if col == self.version_col:
+ role = VERSION_NAME_ROLE
+ 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
+
+ index = self.index(index.row(), 0, index.parent())
+
+ return super(ProductsModel, self).data(index, role)
+
+ def setData(self, index, value, role=None):
+ if not index.isValid():
+ return False
+
+ if role is None:
+ role = QtCore.Qt.EditRole
+
+ col = index.column()
+ if col == self.version_col and role == QtCore.Qt.EditRole:
+ role = VERSION_NAME_EDIT_ROLE
+
+ if role == VERSION_NAME_EDIT_ROLE:
+ if col != 0:
+ index = self.index(index.row(), 0, index.parent())
+ product_id = index.data(PRODUCT_ID_ROLE)
+ product_item = self._product_items_by_id[product_id]
+ final_version_item = None
+ for v_id, version_item in product_item.version_items.items():
+ if v_id == value:
+ final_version_item = version_item
+ break
+
+ if final_version_item is None:
+ return False
+ if index.data(VERSION_ID_ROLE) == final_version_item.version_id:
+ return True
+ item = self.itemFromIndex(index)
+ self._set_version_data_to_product_item(item, final_version_item)
+ self.version_changed.emit()
+ return True
+ return super(ProductsModel, self).setData(index, value, role)
+
+ def _get_next_color(self):
+ return next(self._color_iterator)
+
+ def _color_iter(self):
+ while True:
+ for color in self.merged_items_colors:
+ if self._reset_merge_color:
+ self._reset_merge_color = False
+ break
+ yield color
+
+ def _clear(self):
+ root_item = self.invisibleRootItem()
+ root_item.removeRows(0, root_item.rowCount())
+
+ self._items_by_id = {}
+ self._group_items_by_name = {}
+ self._merged_items_by_id = {}
+ self._product_items_by_id = {}
+ self._reset_merge_color = True
+
+ def _get_group_icon(self):
+ if self._group_icon is None:
+ self._group_icon = qtawesome.icon(
+ "fa.object-group",
+ color=get_default_entity_icon_color()
+ )
+ return self._group_icon
+
+ def _get_group_model_item(self, group_name):
+ model_item = self._group_items_by_name.get(group_name)
+ if model_item is None:
+ model_item = QtGui.QStandardItem(group_name)
+ model_item.setData(
+ self._get_group_icon(), QtCore.Qt.DecorationRole
+ )
+ model_item.setData(0, GROUP_TYPE_ROLE)
+ model_item.setEditable(False)
+ model_item.setColumnCount(self.columnCount())
+ self._group_items_by_name[group_name] = model_item
+ return model_item
+
+ def _get_merged_model_item(self, path, count, hex_color):
+ model_item = self._merged_items_by_id.get(path)
+ if model_item is None:
+ model_item = QtGui.QStandardItem()
+ model_item.setData(1, GROUP_TYPE_ROLE)
+ model_item.setData(hex_color, MERGED_COLOR_ROLE)
+ model_item.setEditable(False)
+ model_item.setColumnCount(self.columnCount())
+ self._merged_items_by_id[path] = model_item
+ label = "{} ({})".format(path, count)
+ model_item.setData(label, QtCore.Qt.DisplayRole)
+ return model_item
+
+ def _set_version_data_to_product_item(self, model_item, version_item):
+ """
+
+ Args:
+ model_item (QtGui.QStandardItem): Item which should have values
+ from version item.
+ version_item (VersionItem): Item from entities model with
+ information about version.
+ """
+
+ model_item.setData(version_item.version_id, VERSION_ID_ROLE)
+ model_item.setData(version_item.version, VERSION_NAME_ROLE)
+ model_item.setData(version_item.version_id, VERSION_ID_ROLE)
+ model_item.setData(version_item.is_hero, VERSION_HERO_ROLE)
+ model_item.setData(
+ version_item.published_time, VERSION_PUBLISH_TIME_ROLE
+ )
+ model_item.setData(version_item.author, VERSION_AUTHOR_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.handles, VERSION_HANDLES_ROLE)
+ model_item.setData(version_item.step, VERSION_STEP_ROLE)
+ model_item.setData(
+ version_item.thumbnail_id, VERSION_THUMBNAIL_ID_ROLE)
+
+ def _get_product_model_item(self, product_item):
+ model_item = self._items_by_id.get(product_item.product_id)
+ versions = list(product_item.version_items.values())
+ versions.sort()
+ last_version = versions[-1]
+ if model_item is None:
+ product_id = product_item.product_id
+ model_item = QtGui.QStandardItem(product_item.product_name)
+ model_item.setEditable(False)
+ icon = get_qt_icon(product_item.product_icon)
+ product_type_icon = get_qt_icon(product_item.product_type_icon)
+ model_item.setColumnCount(self.columnCount())
+ model_item.setData(icon, QtCore.Qt.DecorationRole)
+ model_item.setData(product_id, PRODUCT_ID_ROLE)
+ model_item.setData(product_item.product_name, PRODUCT_NAME_ROLE)
+ model_item.setData(product_item.product_type, PRODUCT_TYPE_ROLE)
+ model_item.setData(product_type_icon, PRODUCT_TYPE_ICON_ROLE)
+ model_item.setData(product_item.folder_id, FOLDER_ID_ROLE)
+
+ self._product_items_by_id[product_id] = product_item
+ self._items_by_id[product_id] = model_item
+
+ model_item.setData(product_item.folder_label, FOLDER_LABEL_ROLE)
+ in_scene = 1 if product_item.product_in_scene else 0
+ model_item.setData(in_scene, PRODUCT_IN_SCENE_ROLE)
+
+ self._set_version_data_to_product_item(model_item, last_version)
+ return model_item
+
+ def get_last_project_name(self):
+ return self._last_project_name
+
+ def refresh(self, project_name, folder_ids):
+ self._clear()
+
+ self._last_project_name = project_name
+ self._last_folder_ids = folder_ids
+
+ product_items = self._controller.get_product_items(
+ project_name,
+ folder_ids,
+ sender=PRODUCTS_MODEL_SENDER_NAME
+ )
+ product_items_by_id = {
+ product_item.product_id: product_item
+ for product_item in product_items
+ }
+
+ # Prepare product groups
+ product_name_matches_by_group = collections.defaultdict(dict)
+ for product_item in product_items_by_id.values():
+ group_name = None
+ if self._grouping_enabled:
+ group_name = product_item.group_name
+
+ product_name = product_item.product_name
+ group = product_name_matches_by_group[group_name]
+ if product_name not in group:
+ group[product_name] = [product_item]
+ continue
+ group[product_name].append(product_item)
+
+ group_names = set(product_name_matches_by_group.keys())
+
+ root_item = self.invisibleRootItem()
+ new_root_items = []
+ merged_paths = set()
+ for group_name in group_names:
+ key_parts = []
+ if group_name:
+ key_parts.append(group_name)
+
+ groups = product_name_matches_by_group[group_name]
+ merged_product_items = {}
+ top_items = []
+ group_product_types = set()
+ for product_name, product_items in groups.items():
+ group_product_types |= {p.product_type for p in product_items}
+ if len(product_items) == 1:
+ top_items.append(product_items[0])
+ else:
+ path = "/".join(key_parts + [product_name])
+ merged_paths.add(path)
+ merged_product_items[path] = (
+ product_name,
+ product_items,
+ )
+
+ parent_item = None
+ if group_name:
+ parent_item = self._get_group_model_item(group_name)
+ parent_item.setData(
+ "|".join(group_product_types), PRODUCT_TYPE_ROLE)
+
+ new_items = []
+ if parent_item is not None and parent_item.row() < 0:
+ new_root_items.append(parent_item)
+
+ for product_item in top_items:
+ item = self._get_product_model_item(product_item)
+ new_items.append(item)
+
+ for path_info in merged_product_items.values():
+ product_name, product_items = path_info
+ (merged_color_hex, merged_color_qt) = self._get_next_color()
+ merged_color = qtawesome.icon(
+ "fa.circle", color=merged_color_qt)
+ merged_item = self._get_merged_model_item(
+ product_name, len(product_items), merged_color_hex)
+ merged_item.setData(merged_color, QtCore.Qt.DecorationRole)
+ new_items.append(merged_item)
+
+ merged_product_types = set()
+ new_merged_items = []
+ for product_item in product_items:
+ item = self._get_product_model_item(product_item)
+ new_merged_items.append(item)
+ merged_product_types.add(product_item.product_type)
+
+ merged_item.setData(
+ "|".join(merged_product_types), PRODUCT_TYPE_ROLE)
+ if new_merged_items:
+ merged_item.appendRows(new_merged_items)
+
+ if not new_items:
+ continue
+
+ if parent_item is None:
+ new_root_items.extend(new_items)
+ else:
+ parent_item.appendRows(new_items)
+
+ if new_root_items:
+ root_item.appendRows(new_root_items)
+
+ self.refreshed.emit()
+ # ---------------------------------
+ # This implementation does not call '_clear' at the start
+ # but is more complex and probably slower
+ # ---------------------------------
+ # def _remove_items(self, items):
+ # if not items:
+ # return
+ # root_item = self.invisibleRootItem()
+ # for item in items:
+ # row = item.row()
+ # if row < 0:
+ # continue
+ # parent = item.parent()
+ # if parent is None:
+ # parent = root_item
+ # parent.removeRow(row)
+ #
+ # def _remove_group_items(self, group_names):
+ # group_items = [
+ # self._group_items_by_name.pop(group_name)
+ # for group_name in group_names
+ # ]
+ # self._remove_items(group_items)
+ #
+ # def _remove_merged_items(self, paths):
+ # merged_items = [
+ # self._merged_items_by_id.pop(path)
+ # for path in paths
+ # ]
+ # self._remove_items(merged_items)
+ #
+ # def _remove_product_items(self, product_ids):
+ # product_items = []
+ # for product_id in product_ids:
+ # self._product_items_by_id.pop(product_id)
+ # product_items.append(self._items_by_id.pop(product_id))
+ # self._remove_items(product_items)
+ #
+ # def _add_to_new_items(self, item, parent_item, new_items, root_item):
+ # if item.row() < 0:
+ # new_items.append(item)
+ # else:
+ # item_parent = item.parent()
+ # if item_parent is not parent_item:
+ # if item_parent is None:
+ # item_parent = root_item
+ # item_parent.takeRow(item.row())
+ # new_items.append(item)
+
+ # def refresh(self, project_name, folder_ids):
+ # product_items = self._controller.get_product_items(
+ # project_name,
+ # folder_ids,
+ # sender=PRODUCTS_MODEL_SENDER_NAME
+ # )
+ # product_items_by_id = {
+ # product_item.product_id: product_item
+ # for product_item in product_items
+ # }
+ # # Remove product items that are not available
+ # product_ids_to_remove = (
+ # set(self._items_by_id.keys()) - set(product_items_by_id.keys())
+ # )
+ # self._remove_product_items(product_ids_to_remove)
+ #
+ # # Prepare product groups
+ # product_name_matches_by_group = collections.defaultdict(dict)
+ # for product_item in product_items_by_id.values():
+ # group_name = None
+ # if self._grouping_enabled:
+ # group_name = product_item.group_name
+ #
+ # product_name = product_item.product_name
+ # group = product_name_matches_by_group[group_name]
+ # if product_name not in group:
+ # group[product_name] = [product_item]
+ # continue
+ # group[product_name].append(product_item)
+ #
+ # group_names = set(product_name_matches_by_group.keys())
+ #
+ # root_item = self.invisibleRootItem()
+ # new_root_items = []
+ # merged_paths = set()
+ # for group_name in group_names:
+ # key_parts = []
+ # if group_name:
+ # key_parts.append(group_name)
+ #
+ # groups = product_name_matches_by_group[group_name]
+ # merged_product_items = {}
+ # top_items = []
+ # for product_name, product_items in groups.items():
+ # if len(product_items) == 1:
+ # top_items.append(product_items[0])
+ # else:
+ # path = "/".join(key_parts + [product_name])
+ # merged_paths.add(path)
+ # merged_product_items[path] = product_items
+ #
+ # parent_item = None
+ # if group_name:
+ # parent_item = self._get_group_model_item(group_name)
+ #
+ # new_items = []
+ # if parent_item is not None and parent_item.row() < 0:
+ # new_root_items.append(parent_item)
+ #
+ # for product_item in top_items:
+ # item = self._get_product_model_item(product_item)
+ # self._add_to_new_items(
+ # item, parent_item, new_items, root_item
+ # )
+ #
+ # for path, product_items in merged_product_items.items():
+ # merged_item = self._get_merged_model_item(path)
+ # self._add_to_new_items(
+ # merged_item, parent_item, new_items, root_item
+ # )
+ #
+ # new_merged_items = []
+ # for product_item in product_items:
+ # item = self._get_product_model_item(product_item)
+ # self._add_to_new_items(
+ # item, merged_item, new_merged_items, root_item
+ # )
+ #
+ # if new_merged_items:
+ # merged_item.appendRows(new_merged_items)
+ #
+ # if not new_items:
+ # continue
+ #
+ # if parent_item is not None:
+ # parent_item.appendRows(new_items)
+ # continue
+ #
+ # new_root_items.extend(new_items)
+ #
+ # root_item.appendRows(new_root_items)
+ #
+ # merged_item_ids_to_remove = (
+ # set(self._merged_items_by_id.keys()) - merged_paths
+ # )
+ # group_names_to_remove = (
+ # set(self._group_items_by_name.keys()) - set(group_names)
+ # )
+ # self._remove_merged_items(merged_item_ids_to_remove)
+ # self._remove_group_items(group_names_to_remove)
diff --git a/openpype/tools/ayon_loader/ui/products_widget.py b/openpype/tools/ayon_loader/ui/products_widget.py
new file mode 100644
index 0000000000..cfc18431a6
--- /dev/null
+++ b/openpype/tools/ayon_loader/ui/products_widget.py
@@ -0,0 +1,400 @@
+import collections
+
+from qtpy import QtWidgets, QtCore
+
+from openpype.tools.utils import (
+ RecursiveSortFilterProxyModel,
+ DeselectableTreeView,
+)
+from openpype.tools.utils.delegates import PrettyTimeDelegate
+
+from .products_model import (
+ ProductsModel,
+ PRODUCTS_MODEL_SENDER_NAME,
+ PRODUCT_TYPE_ROLE,
+ GROUP_TYPE_ROLE,
+ MERGED_COLOR_ROLE,
+ FOLDER_ID_ROLE,
+ PRODUCT_ID_ROLE,
+ VERSION_ID_ROLE,
+ VERSION_THUMBNAIL_ID_ROLE,
+)
+from .products_delegates import VersionDelegate, LoadedInSceneDelegate
+from .actions_utils import show_actions_menu
+
+
+class ProductsProxyModel(RecursiveSortFilterProxyModel):
+ def __init__(self, parent=None):
+ super(ProductsProxyModel, self).__init__(parent)
+
+ self._product_type_filters = {}
+ self._ascending_sort = True
+
+ def set_product_type_filters(self, product_type_filters):
+ self._product_type_filters = product_type_filters
+ self.invalidateFilter()
+
+ def filterAcceptsRow(self, source_row, source_parent):
+ source_model = self.sourceModel()
+ index = source_model.index(source_row, 0, source_parent)
+ product_types_s = source_model.data(index, PRODUCT_TYPE_ROLE)
+ product_types = []
+ if product_types_s:
+ product_types = product_types_s.split("|")
+
+ for product_type in product_types:
+ if not self._product_type_filters.get(product_type, True):
+ return False
+ return super(ProductsProxyModel, self).filterAcceptsRow(
+ source_row, source_parent)
+
+ def lessThan(self, left, right):
+ l_model = left.model()
+ r_model = right.model()
+ left_group_type = l_model.data(left, GROUP_TYPE_ROLE)
+ right_group_type = r_model.data(right, GROUP_TYPE_ROLE)
+ # Groups are always on top, merged product types are below
+ # and items without group at the bottom
+ # QUESTION Do we need to do it this way?
+ if left_group_type != right_group_type:
+ if left_group_type is None:
+ output = False
+ elif right_group_type is None:
+ output = True
+ else:
+ output = left_group_type < right_group_type
+ if not self._ascending_sort:
+ output = not output
+ return output
+ return super(ProductsProxyModel, self).lessThan(left, right)
+
+ def sort(self, column, order=None):
+ if order is None:
+ order = QtCore.Qt.AscendingOrder
+ self._ascending_sort = order == QtCore.Qt.AscendingOrder
+ super(ProductsProxyModel, self).sort(column, order)
+
+
+class ProductsWidget(QtWidgets.QWidget):
+ refreshed = QtCore.Signal()
+ merged_products_selection_changed = QtCore.Signal()
+ selection_changed = QtCore.Signal()
+ version_changed = QtCore.Signal()
+ default_widths = (
+ 200, # Product name
+ 90, # Product type
+ 130, # Folder label
+ 60, # Version
+ 125, # Time
+ 75, # Author
+ 75, # Frames
+ 60, # Duration
+ 55, # Handles
+ 10, # Step
+ 25, # Loaded in scene
+ 65, # Site info (maybe?)
+ )
+
+ def __init__(self, controller, parent):
+ super(ProductsWidget, self).__init__(parent)
+
+ self._controller = controller
+
+ products_view = DeselectableTreeView(self)
+ # TODO - define custom object name in style
+ products_view.setObjectName("SubsetView")
+ products_view.setSelectionMode(
+ QtWidgets.QAbstractItemView.ExtendedSelection
+ )
+ products_view.setAllColumnsShowFocus(True)
+ # TODO - add context menu
+ products_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
+ products_view.setSortingEnabled(True)
+ # Sort by product type
+ products_view.sortByColumn(1, QtCore.Qt.AscendingOrder)
+ products_view.setAlternatingRowColors(True)
+
+ products_model = ProductsModel(controller)
+ products_proxy_model = ProductsProxyModel()
+ products_proxy_model.setSourceModel(products_model)
+
+ products_view.setModel(products_proxy_model)
+
+ for idx, width in enumerate(self.default_widths):
+ products_view.setColumnWidth(idx, width)
+
+ version_delegate = VersionDelegate()
+ products_view.setItemDelegateForColumn(
+ products_model.version_col, version_delegate)
+
+ time_delegate = PrettyTimeDelegate()
+ products_view.setItemDelegateForColumn(
+ products_model.published_time_col, time_delegate)
+
+ in_scene_delegate = LoadedInSceneDelegate()
+ products_view.setItemDelegateForColumn(
+ products_model.in_scene_col, in_scene_delegate)
+
+ main_layout = QtWidgets.QHBoxLayout(self)
+ main_layout.setContentsMargins(0, 0, 0, 0)
+ main_layout.addWidget(products_view, 1)
+
+ products_proxy_model.rowsInserted.connect(self._on_rows_inserted)
+ products_proxy_model.rowsMoved.connect(self._on_rows_moved)
+ products_model.refreshed.connect(self._on_refresh)
+ products_view.customContextMenuRequested.connect(
+ self._on_context_menu)
+ products_view.selectionModel().selectionChanged.connect(
+ self._on_selection_change)
+ products_model.version_changed.connect(self._on_version_change)
+
+ controller.register_event_callback(
+ "selection.folders.changed",
+ self._on_folders_selection_change,
+ )
+ controller.register_event_callback(
+ "products.refresh.finished",
+ self._on_products_refresh_finished
+ )
+ controller.register_event_callback(
+ "products.group.changed",
+ self._on_group_changed
+ )
+
+ self._products_view = products_view
+ self._products_model = products_model
+ self._products_proxy_model = products_proxy_model
+
+ self._version_delegate = version_delegate
+ self._time_delegate = time_delegate
+
+ self._selected_project_name = None
+ self._selected_folder_ids = set()
+
+ self._selected_merged_products = []
+ self._selected_versions_info = []
+
+ # Set initial state of widget
+ # - Hide folders column
+ self._update_folders_label_visible()
+ # - Hide in scene column if is not supported (this won't change)
+ products_view.setColumnHidden(
+ products_model.in_scene_col,
+ not controller.is_loaded_products_supported()
+ )
+
+ def set_name_filer(self, name):
+ """Set filter of product name.
+
+ Args:
+ name (str): The string filter.
+ """
+
+ self._products_proxy_model.setFilterFixedString(name)
+
+ def set_product_type_filter(self, product_type_filters):
+ """
+
+ Args:
+ product_type_filters (dict[str, bool]): The filter of product
+ types.
+ """
+
+ self._products_proxy_model.set_product_type_filters(
+ product_type_filters
+ )
+
+ def set_enable_grouping(self, enable_grouping):
+ self._products_model.set_enable_grouping(enable_grouping)
+
+ def get_selected_merged_products(self):
+ return self._selected_merged_products
+
+ def get_selected_version_info(self):
+ return self._selected_versions_info
+
+ def refresh(self):
+ self._refresh_model()
+
+ def _fill_version_editor(self):
+ model = self._products_proxy_model
+ index_queue = collections.deque()
+ for row in range(model.rowCount()):
+ index_queue.append((row, None))
+
+ version_col = self._products_model.version_col
+ while index_queue:
+ (row, parent_index) = index_queue.popleft()
+ args = [row, 0]
+ if parent_index is not None:
+ args.append(parent_index)
+ index = model.index(*args)
+ rows = model.rowCount(index)
+ for row in range(rows):
+ index_queue.append((row, index))
+
+ product_id = model.data(index, PRODUCT_ID_ROLE)
+ if product_id is not None:
+ args[1] = version_col
+ v_index = model.index(*args)
+ self._products_view.openPersistentEditor(v_index)
+
+ def _on_refresh(self):
+ self._fill_version_editor()
+ self.refreshed.emit()
+
+ def _on_rows_inserted(self):
+ self._fill_version_editor()
+
+ def _on_rows_moved(self):
+ self._fill_version_editor()
+
+ def _refresh_model(self):
+ self._products_model.refresh(
+ self._selected_project_name,
+ self._selected_folder_ids
+ )
+
+ def _on_context_menu(self, point):
+ selection_model = self._products_view.selectionModel()
+ model = self._products_view.model()
+ project_name = self._products_model.get_last_project_name()
+
+ version_ids = set()
+ indexes_queue = collections.deque()
+ indexes_queue.extend(selection_model.selectedIndexes())
+ while indexes_queue:
+ index = indexes_queue.popleft()
+ for row in range(model.rowCount(index)):
+ child_index = model.index(row, 0, index)
+ indexes_queue.append(child_index)
+ version_id = model.data(index, VERSION_ID_ROLE)
+ if version_id is not None:
+ version_ids.add(version_id)
+
+ action_items = self._controller.get_versions_action_items(
+ project_name, version_ids)
+
+ # Prepare global point where to show the menu
+ global_point = self._products_view.mapToGlobal(point)
+
+ result = show_actions_menu(
+ action_items,
+ global_point,
+ len(version_ids) == 1,
+ self
+ )
+ action_item, options = result
+ if action_item is None or options is None:
+ return
+
+ self._controller.trigger_action_item(
+ action_item.identifier,
+ options,
+ action_item.project_name,
+ version_ids=action_item.version_ids,
+ representation_ids=action_item.representation_ids,
+ )
+
+ def _on_selection_change(self):
+ selected_merged_products = []
+ selection_model = self._products_view.selectionModel()
+ model = self._products_view.model()
+ indexes_queue = collections.deque()
+ indexes_queue.extend(selection_model.selectedIndexes())
+
+ # Helper for 'version_items' to avoid duplicated items
+ all_product_ids = set()
+ selected_version_ids = set()
+ # Version items contains information about selected version items
+ selected_versions_info = []
+ while indexes_queue:
+ index = indexes_queue.popleft()
+ if index.column() != 0:
+ continue
+
+ group_type = model.data(index, GROUP_TYPE_ROLE)
+ if group_type is None:
+ product_id = model.data(index, PRODUCT_ID_ROLE)
+ # Skip duplicates - when group and item are selected the item
+ # would be in the loop multiple times
+ if product_id in all_product_ids:
+ continue
+
+ all_product_ids.add(product_id)
+
+ version_id = model.data(index, VERSION_ID_ROLE)
+ selected_version_ids.add(version_id)
+
+ thumbnail_id = model.data(index, VERSION_THUMBNAIL_ID_ROLE)
+ selected_versions_info.append({
+ "folder_id": model.data(index, FOLDER_ID_ROLE),
+ "product_id": product_id,
+ "version_id": version_id,
+ "thumbnail_id": thumbnail_id,
+ })
+ continue
+
+ if group_type == 0:
+ for row in range(model.rowCount(index)):
+ child_index = model.index(row, 0, index)
+ indexes_queue.append(child_index)
+ continue
+
+ if group_type != 1:
+ continue
+
+ item_folder_ids = set()
+ for row in range(model.rowCount(index)):
+ child_index = model.index(row, 0, index)
+ indexes_queue.append(child_index)
+
+ folder_id = model.data(child_index, FOLDER_ID_ROLE)
+ item_folder_ids.add(folder_id)
+
+ if not item_folder_ids:
+ continue
+
+ hex_color = model.data(index, MERGED_COLOR_ROLE)
+ item_data = {
+ "color": hex_color,
+ "folder_ids": item_folder_ids
+ }
+ selected_merged_products.append(item_data)
+
+ prev_selected_merged_products = self._selected_merged_products
+ self._selected_merged_products = selected_merged_products
+ self._selected_versions_info = selected_versions_info
+
+ if selected_merged_products != prev_selected_merged_products:
+ self.merged_products_selection_changed.emit()
+ self.selection_changed.emit()
+ self._controller.set_selected_versions(selected_version_ids)
+
+ def _on_version_change(self):
+ self._on_selection_change()
+
+ def _on_folders_selection_change(self, event):
+ self._selected_project_name = event["project_name"]
+ self._selected_folder_ids = event["folder_ids"]
+ self._refresh_model()
+ self._update_folders_label_visible()
+
+ def _update_folders_label_visible(self):
+ folders_label_hidden = len(self._selected_folder_ids) <= 1
+ self._products_view.setColumnHidden(
+ self._products_model.folders_label_col,
+ folders_label_hidden
+ )
+
+ def _on_products_refresh_finished(self, event):
+ if event["sender"] != PRODUCTS_MODEL_SENDER_NAME:
+ self._refresh_model()
+
+ def _on_group_changed(self, event):
+ if event["project_name"] != self._selected_project_name:
+ return
+ folder_ids = event["folder_ids"]
+ if not set(folder_ids).intersection(set(self._selected_folder_ids)):
+ return
+ self.refresh()
diff --git a/openpype/tools/ayon_loader/ui/repres_widget.py b/openpype/tools/ayon_loader/ui/repres_widget.py
new file mode 100644
index 0000000000..7de582e629
--- /dev/null
+++ b/openpype/tools/ayon_loader/ui/repres_widget.py
@@ -0,0 +1,338 @@
+import collections
+
+from qtpy import QtWidgets, QtGui, QtCore
+import qtawesome
+
+from openpype.style import get_default_entity_icon_color
+from openpype.tools.ayon_utils.widgets import get_qt_icon
+from openpype.tools.utils import DeselectableTreeView
+
+from .actions_utils import show_actions_menu
+
+REPRESENTAION_NAME_ROLE = QtCore.Qt.UserRole + 1
+REPRESENTATION_ID_ROLE = QtCore.Qt.UserRole + 2
+PRODUCT_NAME_ROLE = QtCore.Qt.UserRole + 3
+FOLDER_LABEL_ROLE = QtCore.Qt.UserRole + 4
+GROUP_TYPE_ROLE = QtCore.Qt.UserRole + 5
+
+
+class RepresentationsModel(QtGui.QStandardItemModel):
+ refreshed = QtCore.Signal()
+ colums_info = [
+ ("Name", 120),
+ ("Product name", 125),
+ ("Folder", 125),
+ # ("Active site", 85),
+ # ("Remote site", 85)
+ ]
+ column_labels = [label for label, _ in colums_info]
+ column_widths = [width for _, width in colums_info]
+ folder_column = column_labels.index("Product name")
+
+ def __init__(self, controller):
+ super(RepresentationsModel, self).__init__()
+
+ self.setColumnCount(len(self.column_labels))
+
+ for idx, label in enumerate(self.column_labels):
+ self.setHeaderData(idx, QtCore.Qt.Horizontal, label)
+
+ controller.register_event_callback(
+ "selection.project.changed",
+ self._on_project_change
+ )
+ controller.register_event_callback(
+ "selection.versions.changed",
+ self._on_version_change
+ )
+ self._selected_project_name = None
+ self._selected_version_ids = None
+
+ self._group_icon = None
+
+ self._items_by_id = {}
+ self._groups_items_by_name = {}
+
+ self._controller = controller
+
+ def refresh(self):
+ repre_items = self._controller.get_representation_items(
+ self._selected_project_name, self._selected_version_ids
+ )
+ self._fill_items(repre_items)
+ self.refreshed.emit()
+
+ def data(self, index, role=None):
+ if role is None:
+ role = QtCore.Qt.DisplayRole
+
+ col = index.column()
+ if col != 0:
+ if role == QtCore.Qt.DecorationRole:
+ return None
+
+ if role == QtCore.Qt.DisplayRole:
+ if col == 1:
+ role = PRODUCT_NAME_ROLE
+ elif col == 2:
+ role = FOLDER_LABEL_ROLE
+ index = self.index(index.row(), 0, index.parent())
+ return super(RepresentationsModel, self).data(index, role)
+
+ def setData(self, index, value, role=None):
+ if role is None:
+ role = QtCore.Qt.EditRole
+ return super(RepresentationsModel, self).setData(index, value, role)
+
+ def _clear_items(self):
+ self._items_by_id = {}
+ root_item = self.invisibleRootItem()
+ root_item.removeRows(0, root_item.rowCount())
+
+ def _get_repre_item(self, repre_item):
+ repre_id = repre_item.representation_id
+ repre_name = repre_item.representation_name
+ repre_icon = repre_item.representation_icon
+ item = self._items_by_id.get(repre_id)
+ is_new_item = False
+ if item is None:
+ is_new_item = True
+ item = QtGui.QStandardItem()
+ self._items_by_id[repre_id] = item
+ item.setColumnCount(self.columnCount())
+ item.setEditable(False)
+
+ icon = get_qt_icon(repre_icon)
+ item.setData(repre_name, QtCore.Qt.DisplayRole)
+ item.setData(icon, QtCore.Qt.DecorationRole)
+ item.setData(repre_name, REPRESENTAION_NAME_ROLE)
+ item.setData(repre_id, REPRESENTATION_ID_ROLE)
+ item.setData(repre_item.product_name, PRODUCT_NAME_ROLE)
+ item.setData(repre_item.folder_label, FOLDER_LABEL_ROLE)
+ return is_new_item, item
+
+ def _get_group_icon(self):
+ if self._group_icon is None:
+ self._group_icon = qtawesome.icon(
+ "fa.folder",
+ color=get_default_entity_icon_color()
+ )
+ return self._group_icon
+
+ def _get_group_item(self, repre_name):
+ item = self._groups_items_by_name.get(repre_name)
+ if item is not None:
+ return False, item
+
+ # TODO add color
+ item = QtGui.QStandardItem()
+ item.setColumnCount(self.columnCount())
+ item.setData(repre_name, QtCore.Qt.DisplayRole)
+ item.setData(self._get_group_icon(), QtCore.Qt.DecorationRole)
+ item.setData(0, GROUP_TYPE_ROLE)
+ item.setEditable(False)
+ self._groups_items_by_name[repre_name] = item
+ return True, item
+
+ def _fill_items(self, repre_items):
+ items_to_remove = set(self._items_by_id.keys())
+ repre_items_by_name = collections.defaultdict(list)
+ for repre_item in repre_items:
+ items_to_remove.discard(repre_item.representation_id)
+ repre_name = repre_item.representation_name
+ repre_items_by_name[repre_name].append(repre_item)
+
+ root_item = self.invisibleRootItem()
+ for repre_id in items_to_remove:
+ item = self._items_by_id.pop(repre_id)
+ parent_item = item.parent()
+ if parent_item is None:
+ parent_item = root_item
+ parent_item.removeRow(item.row())
+
+ group_names = set()
+ new_root_items = []
+ for repre_name, repre_name_items in repre_items_by_name.items():
+ group_item = None
+ parent_is_group = False
+ if len(repre_name_items) > 1:
+ group_names.add(repre_name)
+ is_new_group, group_item = self._get_group_item(repre_name)
+ if is_new_group:
+ new_root_items.append(group_item)
+ parent_is_group = True
+
+ new_group_items = []
+ for repre_item in repre_name_items:
+ is_new_item, item = self._get_repre_item(repre_item)
+ item_parent = item.parent()
+ if item_parent is None:
+ item_parent = root_item
+
+ if not is_new_item:
+ if parent_is_group:
+ if item_parent is group_item:
+ continue
+ elif item_parent is root_item:
+ continue
+ item_parent.takeRow(item.row())
+ is_new_item = True
+
+ if is_new_item:
+ new_group_items.append(item)
+
+ if not new_group_items:
+ continue
+
+ if group_item is not None:
+ group_item.appendRows(new_group_items)
+ else:
+ new_root_items.extend(new_group_items)
+
+ if new_root_items:
+ root_item.appendRows(new_root_items)
+
+ for group_name in set(self._groups_items_by_name) - group_names:
+ item = self._groups_items_by_name.pop(group_name)
+ parent_item = item.parent()
+ if parent_item is None:
+ parent_item = root_item
+ parent_item.removeRow(item.row())
+
+ def _on_project_change(self, event):
+ self._selected_project_name = event["project_name"]
+
+ def _on_version_change(self, event):
+ self._selected_version_ids = event["version_ids"]
+ self.refresh()
+
+
+class RepresentationsWidget(QtWidgets.QWidget):
+ def __init__(self, controller, parent):
+ super(RepresentationsWidget, self).__init__(parent)
+
+ repre_view = DeselectableTreeView(self)
+ repre_view.setSelectionMode(
+ QtWidgets.QAbstractItemView.ExtendedSelection
+ )
+ repre_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
+ repre_view.setSortingEnabled(True)
+ repre_view.setAlternatingRowColors(True)
+
+ repre_model = RepresentationsModel(controller)
+ repre_proxy_model = QtCore.QSortFilterProxyModel()
+ repre_proxy_model.setSourceModel(repre_model)
+ repre_proxy_model.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive)
+ repre_view.setModel(repre_proxy_model)
+
+ for idx, width in enumerate(repre_model.column_widths):
+ repre_view.setColumnWidth(idx, width)
+
+ main_layout = QtWidgets.QVBoxLayout(self)
+ main_layout.setContentsMargins(0, 0, 0, 0)
+ main_layout.addWidget(repre_view, 1)
+
+ repre_view.customContextMenuRequested.connect(
+ self._on_context_menu)
+ repre_view.selectionModel().selectionChanged.connect(
+ self._on_selection_change)
+ repre_model.refreshed.connect(self._on_model_refresh)
+
+ controller.register_event_callback(
+ "selection.project.changed",
+ self._on_project_change
+ )
+ controller.register_event_callback(
+ "selection.folders.changed",
+ self._on_folder_change
+ )
+
+ self._controller = controller
+ self._selected_project_name = None
+ self._selected_multiple_folders = None
+
+ self._repre_view = repre_view
+ self._repre_model = repre_model
+ self._repre_proxy_model = repre_proxy_model
+
+ self._set_multiple_folders_selected(False)
+
+ def refresh(self):
+ self._repre_model.refresh()
+
+ def _on_folder_change(self, event):
+ self._set_multiple_folders_selected(len(event["folder_ids"]) > 1)
+
+ def _on_project_change(self, event):
+ self._selected_project_name = event["project_name"]
+
+ def _set_multiple_folders_selected(self, selected_multiple_folders):
+ if selected_multiple_folders == self._selected_multiple_folders:
+ return
+ self._selected_multiple_folders = selected_multiple_folders
+ self._repre_view.setColumnHidden(
+ self._repre_model.folder_column,
+ not self._selected_multiple_folders
+ )
+
+ def _on_model_refresh(self):
+ self._repre_proxy_model.sort(0)
+
+ def _get_selected_repre_indexes(self):
+ selection_model = self._repre_view.selectionModel()
+ model = self._repre_view.model()
+ indexes_queue = collections.deque()
+ indexes_queue.extend(selection_model.selectedIndexes())
+
+ selected_indexes = []
+ while indexes_queue:
+ index = indexes_queue.popleft()
+ if index.column() != 0:
+ continue
+
+ group_type = model.data(index, GROUP_TYPE_ROLE)
+ if group_type is None:
+ selected_indexes.append(index)
+
+ elif group_type == 0:
+ for row in range(model.rowCount(index)):
+ child_index = model.index(row, 0, index)
+ indexes_queue.append(child_index)
+
+ return selected_indexes
+
+ def _get_selected_repre_ids(self):
+ repre_ids = {
+ index.data(REPRESENTATION_ID_ROLE)
+ for index in self._get_selected_repre_indexes()
+ }
+ repre_ids.discard(None)
+ return repre_ids
+
+ def _on_selection_change(self):
+ selected_repre_ids = self._get_selected_repre_ids()
+ self._controller.set_selected_representations(selected_repre_ids)
+
+ def _on_context_menu(self, point):
+ repre_ids = self._get_selected_repre_ids()
+ action_items = self._controller.get_representations_action_items(
+ self._selected_project_name, repre_ids
+ )
+ global_point = self._repre_view.mapToGlobal(point)
+ result = show_actions_menu(
+ action_items,
+ global_point,
+ len(repre_ids) == 1,
+ self
+ )
+ action_item, options = result
+ if action_item is None or options is None:
+ return
+
+ self._controller.trigger_action_item(
+ action_item.identifier,
+ options,
+ action_item.project_name,
+ version_ids=action_item.version_ids,
+ representation_ids=action_item.representation_ids,
+ )
diff --git a/openpype/tools/ayon_loader/ui/window.py b/openpype/tools/ayon_loader/ui/window.py
new file mode 100644
index 0000000000..ca17e4b9fd
--- /dev/null
+++ b/openpype/tools/ayon_loader/ui/window.py
@@ -0,0 +1,511 @@
+from qtpy import QtWidgets, QtCore, QtGui
+
+from openpype.resources import get_openpype_icon_filepath
+from openpype.style import load_stylesheet
+from openpype.tools.utils import (
+ PlaceholderLineEdit,
+ ErrorMessageBox,
+ ThumbnailPainterWidget,
+ RefreshButton,
+ GoToCurrentButton,
+)
+from openpype.tools.utils.lib import center_window
+from openpype.tools.ayon_utils.widgets import ProjectsCombobox
+from openpype.tools.ayon_loader.control import LoaderController
+
+from .folders_widget import LoaderFoldersWidget
+from .products_widget import ProductsWidget
+from .product_types_widget import ProductTypesView
+from .product_group_dialog import ProductGroupDialog
+from .info_widget import InfoWidget
+from .repres_widget import RepresentationsWidget
+
+
+class LoadErrorMessageBox(ErrorMessageBox):
+ def __init__(self, messages, parent=None):
+ self._messages = messages
+ super(LoadErrorMessageBox, self).__init__("Loading failed", parent)
+
+ def _create_top_widget(self, parent_widget):
+ label_widget = QtWidgets.QLabel(parent_widget)
+ label_widget.setText(
+ "Failed to load items"
+ )
+ return label_widget
+
+ def _get_report_data(self):
+ report_data = []
+ for exc_msg, tb_text, repre, product, version in self._messages:
+ report_message = (
+ "During load error happened on Product: \"{product}\""
+ " Representation: \"{repre}\" Version: {version}"
+ "\n\nError message: {message}"
+ ).format(
+ product=product,
+ repre=repre,
+ version=version,
+ message=exc_msg
+ )
+ if tb_text:
+ report_message += "\n\n{}".format(tb_text)
+ report_data.append(report_message)
+ return report_data
+
+ def _create_content(self, content_layout):
+ item_name_template = (
+ "Product: {}
"
+ "Version: {}
"
+ "Representation: {}
"
+ )
+ exc_msg_template = "{}"
+
+ for exc_msg, tb_text, repre, product, version in self._messages:
+ line = self._create_line()
+ content_layout.addWidget(line)
+
+ item_name = item_name_template.format(product, version, repre)
+ item_name_widget = QtWidgets.QLabel(
+ item_name.replace("\n", "
"), self
+ )
+ item_name_widget.setWordWrap(True)
+ content_layout.addWidget(item_name_widget)
+
+ exc_msg = exc_msg_template.format(exc_msg.replace("\n", "
"))
+ message_label_widget = QtWidgets.QLabel(exc_msg, self)
+ message_label_widget.setWordWrap(True)
+ content_layout.addWidget(message_label_widget)
+
+ if tb_text:
+ line = self._create_line()
+ tb_widget = self._create_traceback_widget(tb_text, self)
+ content_layout.addWidget(line)
+ content_layout.addWidget(tb_widget)
+
+
+class RefreshHandler:
+ def __init__(self):
+ self._project_refreshed = False
+ self._folders_refreshed = False
+ self._products_refreshed = False
+
+ @property
+ def project_refreshed(self):
+ return self._products_refreshed
+
+ @property
+ def folders_refreshed(self):
+ return self._folders_refreshed
+
+ @property
+ def products_refreshed(self):
+ return self._products_refreshed
+
+ def reset(self):
+ self._project_refreshed = False
+ self._folders_refreshed = False
+ self._products_refreshed = False
+
+ def set_project_refreshed(self):
+ self._project_refreshed = True
+
+ def set_folders_refreshed(self):
+ self._folders_refreshed = True
+
+ def set_products_refreshed(self):
+ self._products_refreshed = True
+
+
+class LoaderWindow(QtWidgets.QWidget):
+ def __init__(self, controller=None, parent=None):
+ super(LoaderWindow, self).__init__(parent)
+
+ icon = QtGui.QIcon(get_openpype_icon_filepath())
+ self.setWindowIcon(icon)
+ self.setWindowTitle("AYON Loader")
+ self.setFocusPolicy(QtCore.Qt.StrongFocus)
+ self.setAttribute(QtCore.Qt.WA_DeleteOnClose, False)
+ self.setWindowFlags(self.windowFlags() | QtCore.Qt.Window)
+
+ if controller is None:
+ controller = LoaderController()
+
+ main_splitter = QtWidgets.QSplitter(self)
+
+ context_splitter = QtWidgets.QSplitter(main_splitter)
+ context_splitter.setOrientation(QtCore.Qt.Vertical)
+
+ # Context selection widget
+ context_widget = QtWidgets.QWidget(context_splitter)
+
+ context_top_widget = QtWidgets.QWidget(context_widget)
+ projects_combobox = ProjectsCombobox(
+ controller,
+ context_top_widget,
+ handle_expected_selection=True
+ )
+ projects_combobox.set_select_item_visible(True)
+ projects_combobox.set_libraries_separator_visible(True)
+ projects_combobox.set_standard_filter_enabled(
+ controller.is_standard_projects_filter_enabled()
+ )
+
+ go_to_current_btn = GoToCurrentButton(context_top_widget)
+ refresh_btn = RefreshButton(context_top_widget)
+
+ context_top_layout = QtWidgets.QHBoxLayout(context_top_widget)
+ context_top_layout.setContentsMargins(0, 0, 0, 0,)
+ context_top_layout.addWidget(projects_combobox, 1)
+ context_top_layout.addWidget(go_to_current_btn, 0)
+ context_top_layout.addWidget(refresh_btn, 0)
+
+ folders_filter_input = PlaceholderLineEdit(context_widget)
+ folders_filter_input.setPlaceholderText("Folder name filter...")
+
+ folders_widget = LoaderFoldersWidget(controller, context_widget)
+
+ product_types_widget = ProductTypesView(controller, context_splitter)
+
+ context_layout = QtWidgets.QVBoxLayout(context_widget)
+ context_layout.setContentsMargins(0, 0, 0, 0)
+ context_layout.addWidget(context_top_widget, 0)
+ context_layout.addWidget(folders_filter_input, 0)
+ context_layout.addWidget(folders_widget, 1)
+
+ context_splitter.addWidget(context_widget)
+ context_splitter.addWidget(product_types_widget)
+ context_splitter.setStretchFactor(0, 65)
+ context_splitter.setStretchFactor(1, 35)
+
+ # Product + version selection item
+ products_wrap_widget = QtWidgets.QWidget(main_splitter)
+
+ products_inputs_widget = QtWidgets.QWidget(products_wrap_widget)
+
+ products_filter_input = PlaceholderLineEdit(products_inputs_widget)
+ products_filter_input.setPlaceholderText("Product name filter...")
+ product_group_checkbox = QtWidgets.QCheckBox(
+ "Enable grouping", products_inputs_widget)
+ product_group_checkbox.setChecked(True)
+
+ products_widget = ProductsWidget(controller, products_wrap_widget)
+
+ products_inputs_layout = QtWidgets.QHBoxLayout(products_inputs_widget)
+ products_inputs_layout.setContentsMargins(0, 0, 0, 0)
+ products_inputs_layout.addWidget(products_filter_input, 1)
+ products_inputs_layout.addWidget(product_group_checkbox, 0)
+
+ products_wrap_layout = QtWidgets.QVBoxLayout(products_wrap_widget)
+ products_wrap_layout.setContentsMargins(0, 0, 0, 0)
+ products_wrap_layout.addWidget(products_inputs_widget, 0)
+ products_wrap_layout.addWidget(products_widget, 1)
+
+ right_panel_splitter = QtWidgets.QSplitter(main_splitter)
+ right_panel_splitter.setOrientation(QtCore.Qt.Vertical)
+
+ thumbnails_widget = ThumbnailPainterWidget(right_panel_splitter)
+ thumbnails_widget.set_use_checkboard(False)
+
+ info_widget = InfoWidget(controller, right_panel_splitter)
+
+ repre_widget = RepresentationsWidget(controller, right_panel_splitter)
+
+ right_panel_splitter.addWidget(thumbnails_widget)
+ right_panel_splitter.addWidget(info_widget)
+ right_panel_splitter.addWidget(repre_widget)
+
+ right_panel_splitter.setStretchFactor(0, 1)
+ right_panel_splitter.setStretchFactor(1, 1)
+ right_panel_splitter.setStretchFactor(2, 2)
+
+ main_splitter.addWidget(context_splitter)
+ main_splitter.addWidget(products_wrap_widget)
+ main_splitter.addWidget(right_panel_splitter)
+
+ main_splitter.setStretchFactor(0, 4)
+ main_splitter.setStretchFactor(1, 6)
+ main_splitter.setStretchFactor(2, 1)
+
+ main_layout = QtWidgets.QHBoxLayout(self)
+ main_layout.addWidget(main_splitter)
+
+ show_timer = QtCore.QTimer()
+ show_timer.setInterval(1)
+
+ show_timer.timeout.connect(self._on_show_timer)
+
+ projects_combobox.refreshed.connect(self._on_projects_refresh)
+ folders_widget.refreshed.connect(self._on_folders_refresh)
+ products_widget.refreshed.connect(self._on_products_refresh)
+ folders_filter_input.textChanged.connect(
+ self._on_folder_filter_change
+ )
+ product_types_widget.filter_changed.connect(
+ self._on_product_type_filter_change
+ )
+ products_filter_input.textChanged.connect(
+ self._on_product_filter_change
+ )
+ product_group_checkbox.stateChanged.connect(
+ self._on_product_group_change
+ )
+ products_widget.merged_products_selection_changed.connect(
+ self._on_merged_products_selection_change
+ )
+ products_widget.selection_changed.connect(
+ self._on_products_selection_change
+ )
+ go_to_current_btn.clicked.connect(
+ self._on_go_to_current_context_click
+ )
+ refresh_btn.clicked.connect(
+ self._on_refresh_click
+ )
+ controller.register_event_callback(
+ "load.finished",
+ self._on_load_finished,
+ )
+ controller.register_event_callback(
+ "selection.project.changed",
+ self._on_project_selection_changed,
+ )
+ controller.register_event_callback(
+ "selection.folders.changed",
+ self._on_folders_selection_changed,
+ )
+ controller.register_event_callback(
+ "selection.versions.changed",
+ self._on_versions_selection_changed,
+ )
+ controller.register_event_callback(
+ "controller.reset.started",
+ self._on_controller_reset_start,
+ )
+ controller.register_event_callback(
+ "controller.reset.finished",
+ self._on_controller_reset_finish,
+ )
+
+ self._group_dialog = ProductGroupDialog(controller, self)
+
+ self._main_splitter = main_splitter
+
+ self._go_to_current_btn = go_to_current_btn
+ self._refresh_btn = refresh_btn
+ self._projects_combobox = projects_combobox
+
+ self._folders_filter_input = folders_filter_input
+ self._folders_widget = folders_widget
+
+ self._product_types_widget = product_types_widget
+
+ self._products_filter_input = products_filter_input
+ self._product_group_checkbox = product_group_checkbox
+ self._products_widget = products_widget
+
+ self._right_panel_splitter = right_panel_splitter
+ self._thumbnails_widget = thumbnails_widget
+ self._info_widget = info_widget
+ self._repre_widget = repre_widget
+
+ self._controller = controller
+ self._refresh_handler = RefreshHandler()
+ self._first_show = True
+ self._reset_on_show = True
+ self._show_counter = 0
+ self._show_timer = show_timer
+ self._selected_project_name = None
+ self._selected_folder_ids = set()
+ self._selected_version_ids = set()
+
+ self._products_widget.set_enable_grouping(
+ self._product_group_checkbox.isChecked()
+ )
+
+ def refresh(self):
+ self._controller.reset()
+
+ def showEvent(self, event):
+ super(LoaderWindow, self).showEvent(event)
+
+ if self._first_show:
+ self._on_first_show()
+
+ self._show_timer.start()
+
+ def keyPressEvent(self, event):
+ modifiers = event.modifiers()
+ ctrl_pressed = QtCore.Qt.ControlModifier & modifiers
+
+ # Grouping products on pressing Ctrl + G
+ if (
+ ctrl_pressed
+ and event.key() == QtCore.Qt.Key_G
+ and not event.isAutoRepeat()
+ ):
+ self._show_group_dialog()
+ event.setAccepted(True)
+ return
+
+ super(LoaderWindow, self).keyPressEvent(event)
+
+ def _on_first_show(self):
+ self._first_show = False
+ # width, height = 1800, 900
+ width, height = 1500, 750
+
+ self.resize(width, height)
+
+ mid_width = int(width / 1.8)
+ sides_width = int((width - mid_width) * 0.5)
+ self._main_splitter.setSizes(
+ [sides_width, mid_width, sides_width]
+ )
+
+ thumbnail_height = int(height / 3.6)
+ info_height = int((height - thumbnail_height) * 0.5)
+ self._right_panel_splitter.setSizes(
+ [thumbnail_height, info_height, info_height]
+ )
+ self.setStyleSheet(load_stylesheet())
+ center_window(self)
+
+ def _on_show_timer(self):
+ if self._show_counter < 2:
+ self._show_counter += 1
+ return
+
+ self._show_counter = 0
+ self._show_timer.stop()
+
+ if self._reset_on_show:
+ self._reset_on_show = False
+ self._controller.reset()
+
+ def _show_group_dialog(self):
+ project_name = self._projects_combobox.get_current_project_name()
+ if not project_name:
+ return
+
+ product_ids = {
+ i["product_id"]
+ for i in self._products_widget.get_selected_version_info()
+ }
+ if not product_ids:
+ return
+
+ self._group_dialog.set_product_ids(project_name, product_ids)
+ self._group_dialog.show()
+
+ def _on_folder_filter_change(self, text):
+ self._folders_widget.set_name_filer(text)
+
+ def _on_product_group_change(self):
+ self._products_widget.set_enable_grouping(
+ self._product_group_checkbox.isChecked()
+ )
+
+ def _on_product_filter_change(self, text):
+ self._products_widget.set_name_filer(text)
+
+ def _on_product_type_filter_change(self):
+ self._products_widget.set_product_type_filter(
+ self._product_types_widget.get_filter_info()
+ )
+
+ def _on_merged_products_selection_change(self):
+ items = self._products_widget.get_selected_merged_products()
+ self._folders_widget.set_merged_products_selection(items)
+
+ def _on_products_selection_change(self):
+ items = self._products_widget.get_selected_version_info()
+ self._info_widget.set_selected_version_info(
+ self._projects_combobox.get_current_project_name(),
+ items
+ )
+
+ def _on_go_to_current_context_click(self):
+ context = self._controller.get_current_context()
+ self._controller.set_expected_selection(
+ context["project_name"],
+ context["folder_id"],
+ )
+
+ def _on_refresh_click(self):
+ self._controller.reset()
+
+ def _on_controller_reset_start(self):
+ self._refresh_handler.reset()
+
+ def _on_controller_reset_finish(self):
+ context = self._controller.get_current_context()
+ project_name = context["project_name"]
+ self._go_to_current_btn.setVisible(bool(project_name))
+ self._projects_combobox.set_current_context_project(project_name)
+ if not self._refresh_handler.project_refreshed:
+ self._projects_combobox.refresh()
+
+ def _on_load_finished(self, event):
+ error_info = event["error_info"]
+ if not error_info:
+ return
+
+ box = LoadErrorMessageBox(error_info, self)
+ box.show()
+
+ def _on_project_selection_changed(self, event):
+ self._selected_project_name = event["project_name"]
+
+ def _on_folders_selection_changed(self, event):
+ self._selected_folder_ids = set(event["folder_ids"])
+ self._update_thumbnails()
+
+ def _on_versions_selection_changed(self, event):
+ self._selected_version_ids = set(event["version_ids"])
+ self._update_thumbnails()
+
+ def _update_thumbnails(self):
+ project_name = self._selected_project_name
+ thumbnail_ids = set()
+ if self._selected_version_ids:
+ thumbnail_id_by_entity_id = (
+ self._controller.get_version_thumbnail_ids(
+ project_name,
+ self._selected_version_ids
+ )
+ )
+ thumbnail_ids = set(thumbnail_id_by_entity_id.values())
+ elif self._selected_folder_ids:
+ thumbnail_id_by_entity_id = (
+ self._controller.get_folder_thumbnail_ids(
+ project_name,
+ self._selected_folder_ids
+ )
+ )
+ thumbnail_ids = set(thumbnail_id_by_entity_id.values())
+
+ thumbnail_ids.discard(None)
+
+ if not thumbnail_ids:
+ self._thumbnails_widget.set_current_thumbnails(None)
+ return
+
+ thumbnail_paths = set()
+ for thumbnail_id in thumbnail_ids:
+ thumbnail_path = self._controller.get_thumbnail_path(
+ project_name, thumbnail_id)
+ thumbnail_paths.add(thumbnail_path)
+ thumbnail_paths.discard(None)
+ self._thumbnails_widget.set_current_thumbnail_paths(thumbnail_paths)
+
+ def _on_projects_refresh(self):
+ self._refresh_handler.set_project_refreshed()
+ if not self._refresh_handler.folders_refreshed:
+ self._folders_widget.refresh()
+
+ def _on_folders_refresh(self):
+ self._refresh_handler.set_folders_refreshed()
+ if not self._refresh_handler.products_refreshed:
+ self._products_widget.refresh()
+
+ def _on_products_refresh(self):
+ self._refresh_handler.set_products_refreshed()
diff --git a/openpype/tools/ayon_utils/models/__init__.py b/openpype/tools/ayon_utils/models/__init__.py
index 1434282c5b..69722b5e21 100644
--- a/openpype/tools/ayon_utils/models/__init__.py
+++ b/openpype/tools/ayon_utils/models/__init__.py
@@ -12,6 +12,7 @@ from .hierarchy import (
HierarchyModel,
HIERARCHY_MODEL_SENDER,
)
+from .thumbnails import ThumbnailsModel
__all__ = (
@@ -26,4 +27,6 @@ __all__ = (
"TaskItem",
"HierarchyModel",
"HIERARCHY_MODEL_SENDER",
+
+ "ThumbnailsModel",
)
diff --git a/openpype/tools/ayon_utils/models/hierarchy.py b/openpype/tools/ayon_utils/models/hierarchy.py
index 93f4c48d98..6c30d22f3a 100644
--- a/openpype/tools/ayon_utils/models/hierarchy.py
+++ b/openpype/tools/ayon_utils/models/hierarchy.py
@@ -29,9 +29,8 @@ class FolderItem:
parent_id (Union[str, None]): Parent folder id. If 'None' then project
is parent.
name (str): Name of folder.
- label (str): Folder label.
- icon_name (str): Name of icon from font awesome.
- icon_color (str): Hex color string that will be used for icon.
+ label (Union[str, None]): Folder label.
+ icon (Union[dict[str, Any], None]): Icon definition.
"""
def __init__(
@@ -240,23 +239,65 @@ class HierarchyModel(object):
self._refresh_tasks_cache(project_name, folder_id, sender)
return task_cache.get_data()
+ def get_folder_entities(self, project_name, folder_ids):
+ """Get folder entities by ids.
+
+ Args:
+ project_name (str): Project name.
+ folder_ids (Iterable[str]): Folder ids.
+
+ Returns:
+ dict[str, Any]: Folder entities by id.
+ """
+
+ output = {}
+ folder_ids = set(folder_ids)
+ if not project_name or not folder_ids:
+ return output
+
+ folder_ids_to_query = set()
+ for folder_id in folder_ids:
+ cache = self._folders_by_id[project_name][folder_id]
+ if cache.is_valid:
+ output[folder_id] = cache.get_data()
+ elif folder_id:
+ folder_ids_to_query.add(folder_id)
+ else:
+ output[folder_id] = None
+ self._query_folder_entities(project_name, folder_ids_to_query)
+ for folder_id in folder_ids_to_query:
+ cache = self._folders_by_id[project_name][folder_id]
+ output[folder_id] = cache.get_data()
+ return output
+
def get_folder_entity(self, project_name, folder_id):
- cache = self._folders_by_id[project_name][folder_id]
- if not cache.is_valid:
- entity = None
- if folder_id:
- entity = ayon_api.get_folder_by_id(project_name, folder_id)
- cache.update_data(entity)
- return cache.get_data()
+ output = self.get_folder_entities(project_name, {folder_id})
+ return output[folder_id]
+
+ def get_task_entities(self, project_name, task_ids):
+ output = {}
+ task_ids = set(task_ids)
+ if not project_name or not task_ids:
+ return output
+
+ task_ids_to_query = set()
+ for task_id in task_ids:
+ cache = self._tasks_by_id[project_name][task_id]
+ if cache.is_valid:
+ output[task_id] = cache.get_data()
+ elif task_id:
+ task_ids_to_query.add(task_id)
+ else:
+ output[task_id] = None
+ self._query_task_entities(project_name, task_ids_to_query)
+ for task_id in task_ids_to_query:
+ cache = self._tasks_by_id[project_name][task_id]
+ output[task_id] = cache.get_data()
+ return output
def get_task_entity(self, project_name, task_id):
- cache = self._tasks_by_id[project_name][task_id]
- if not cache.is_valid:
- entity = None
- if task_id:
- entity = ayon_api.get_task_by_id(project_name, task_id)
- cache.update_data(entity)
- return cache.get_data()
+ output = self.get_task_entities(project_name, {task_id})
+ return output[task_id]
@contextlib.contextmanager
def _folder_refresh_event_manager(self, project_name, sender):
@@ -326,6 +367,25 @@ class HierarchyModel(object):
hierachy_queue.extend(item["children"] or [])
return folder_items
+ def _query_folder_entities(self, project_name, folder_ids):
+ if not project_name or not folder_ids:
+ return
+ project_cache = self._folders_by_id[project_name]
+ folders = ayon_api.get_folders(project_name, folder_ids=folder_ids)
+ for folder in folders:
+ folder_id = folder["id"]
+ project_cache[folder_id].update_data(folder)
+
+ def _query_task_entities(self, project_name, task_ids):
+ if not project_name or not task_ids:
+ return
+
+ project_cache = self._tasks_by_id[project_name]
+ tasks = ayon_api.get_tasks(project_name, task_ids=task_ids)
+ for task in tasks:
+ task_id = task["id"]
+ project_cache[task_id].update_data(task)
+
def _refresh_tasks_cache(self, project_name, folder_id, sender=None):
if folder_id in self._tasks_refreshing:
return
diff --git a/openpype/tools/ayon_utils/models/projects.py b/openpype/tools/ayon_utils/models/projects.py
index ae3eeecea4..4ad53fbbfa 100644
--- a/openpype/tools/ayon_utils/models/projects.py
+++ b/openpype/tools/ayon_utils/models/projects.py
@@ -29,13 +29,14 @@ class ProjectItem:
is parent.
"""
- def __init__(self, name, active, icon=None):
+ def __init__(self, name, active, is_library, icon=None):
self.name = name
self.active = active
+ self.is_library = is_library
if icon is None:
icon = {
"type": "awesome-font",
- "name": "fa.map",
+ "name": "fa.book" if is_library else "fa.map",
"color": get_default_entity_icon_color(),
}
self.icon = icon
@@ -50,6 +51,7 @@ class ProjectItem:
return {
"name": self.name,
"active": self.active,
+ "is_library": self.is_library,
"icon": self.icon,
}
@@ -78,7 +80,7 @@ def _get_project_items_from_entitiy(projects):
"""
return [
- ProjectItem(project["name"], project["active"])
+ ProjectItem(project["name"], project["active"], project["library"])
for project in projects
]
@@ -141,5 +143,5 @@ class ProjectsModel(object):
self._projects_cache.update_data(project_items)
def _query_projects(self):
- projects = ayon_api.get_projects(fields=["name", "active"])
+ projects = ayon_api.get_projects(fields=["name", "active", "library"])
return _get_project_items_from_entitiy(projects)
diff --git a/openpype/tools/ayon_utils/models/thumbnails.py b/openpype/tools/ayon_utils/models/thumbnails.py
new file mode 100644
index 0000000000..40892338df
--- /dev/null
+++ b/openpype/tools/ayon_utils/models/thumbnails.py
@@ -0,0 +1,118 @@
+import collections
+
+import ayon_api
+
+from openpype.client.server.thumbnails import AYONThumbnailCache
+
+from .cache import NestedCacheItem
+
+
+class ThumbnailsModel:
+ entity_cache_lifetime = 240 # In seconds
+
+ def __init__(self):
+ self._thumbnail_cache = AYONThumbnailCache()
+ self._paths_cache = collections.defaultdict(dict)
+ self._folders_cache = NestedCacheItem(
+ levels=2, lifetime=self.entity_cache_lifetime)
+ self._versions_cache = NestedCacheItem(
+ levels=2, lifetime=self.entity_cache_lifetime)
+
+ def reset(self):
+ self._paths_cache = collections.defaultdict(dict)
+ self._folders_cache.reset()
+ self._versions_cache.reset()
+
+ def get_thumbnail_path(self, project_name, thumbnail_id):
+ return self._get_thumbnail_path(project_name, thumbnail_id)
+
+ def get_folder_thumbnail_ids(self, project_name, folder_ids):
+ project_cache = self._folders_cache[project_name]
+ output = {}
+ missing_cache = set()
+ for folder_id in folder_ids:
+ cache = project_cache[folder_id]
+ if cache.is_valid:
+ output[folder_id] = cache.get_data()
+ else:
+ missing_cache.add(folder_id)
+ self._query_folder_thumbnail_ids(project_name, missing_cache)
+ for folder_id in missing_cache:
+ cache = project_cache[folder_id]
+ output[folder_id] = cache.get_data()
+ return output
+
+ def get_version_thumbnail_ids(self, project_name, version_ids):
+ project_cache = self._versions_cache[project_name]
+ output = {}
+ missing_cache = set()
+ for version_id in version_ids:
+ cache = project_cache[version_id]
+ if cache.is_valid:
+ output[version_id] = cache.get_data()
+ else:
+ missing_cache.add(version_id)
+ self._query_version_thumbnail_ids(project_name, missing_cache)
+ for version_id in missing_cache:
+ cache = project_cache[version_id]
+ output[version_id] = cache.get_data()
+ return output
+
+ def _get_thumbnail_path(self, project_name, thumbnail_id):
+ if not thumbnail_id:
+ return None
+
+ project_cache = self._paths_cache[project_name]
+ if thumbnail_id in project_cache:
+ return project_cache[thumbnail_id]
+
+ filepath = self._thumbnail_cache.get_thumbnail_filepath(
+ project_name, thumbnail_id
+ )
+ if filepath is not None:
+ project_cache[thumbnail_id] = filepath
+ return filepath
+
+ # 'ayon_api' had a bug, public function
+ # 'get_thumbnail_by_id' did not return output of
+ # 'ServerAPI' method.
+ con = ayon_api.get_server_api_connection()
+ result = con.get_thumbnail_by_id(project_name, thumbnail_id)
+ if result is None:
+ pass
+
+ elif result.is_valid:
+ filepath = self._thumbnail_cache.store_thumbnail(
+ project_name,
+ thumbnail_id,
+ result.content,
+ result.content_type
+ )
+ project_cache[thumbnail_id] = filepath
+ return filepath
+
+ def _query_folder_thumbnail_ids(self, project_name, folder_ids):
+ if not project_name or not folder_ids:
+ return
+
+ folders = ayon_api.get_folders(
+ project_name,
+ folder_ids=folder_ids,
+ fields=["id", "thumbnailId"]
+ )
+ project_cache = self._folders_cache[project_name]
+ for folder in folders:
+ project_cache[folder["id"]] = folder["thumbnailId"]
+
+ def _query_version_thumbnail_ids(self, project_name, version_ids):
+ if not project_name or not version_ids:
+ return
+
+ versions = ayon_api.get_versions(
+ project_name,
+ version_ids=version_ids,
+ fields=["id", "thumbnailId"]
+ )
+ project_cache = self._versions_cache[project_name]
+ for version in versions:
+ project_cache[version["id"]] = version["thumbnailId"]
diff --git a/openpype/tools/ayon_utils/widgets/__init__.py b/openpype/tools/ayon_utils/widgets/__init__.py
index 59aef98faf..432a249a73 100644
--- a/openpype/tools/ayon_utils/widgets/__init__.py
+++ b/openpype/tools/ayon_utils/widgets/__init__.py
@@ -8,11 +8,13 @@ from .projects_widget import (
from .folders_widget import (
FoldersWidget,
FoldersModel,
+ FOLDERS_MODEL_SENDER_NAME,
)
from .tasks_widget import (
TasksWidget,
TasksModel,
+ TASKS_MODEL_SENDER_NAME,
)
from .utils import (
get_qt_icon,
@@ -28,9 +30,11 @@ __all__ = (
"FoldersWidget",
"FoldersModel",
+ "FOLDERS_MODEL_SENDER_NAME",
"TasksWidget",
"TasksModel",
+ "TASKS_MODEL_SENDER_NAME",
"get_qt_icon",
"RefreshThread",
diff --git a/openpype/tools/ayon_utils/widgets/folders_widget.py b/openpype/tools/ayon_utils/widgets/folders_widget.py
index 4f44881081..b57ffb126a 100644
--- a/openpype/tools/ayon_utils/widgets/folders_widget.py
+++ b/openpype/tools/ayon_utils/widgets/folders_widget.py
@@ -9,7 +9,7 @@ from openpype.tools.utils import (
from .utils import RefreshThread, get_qt_icon
-SENDER_NAME = "qt_folders_model"
+FOLDERS_MODEL_SENDER_NAME = "qt_folders_model"
ITEM_ID_ROLE = QtCore.Qt.UserRole + 1
ITEM_NAME_ROLE = QtCore.Qt.UserRole + 2
@@ -112,7 +112,7 @@ class FoldersModel(QtGui.QStandardItemModel):
project_name,
self._controller.get_folder_items,
project_name,
- SENDER_NAME
+ FOLDERS_MODEL_SENDER_NAME
)
self._current_refresh_thread = thread
self._refresh_threads[thread.id] = thread
@@ -142,6 +142,21 @@ class FoldersModel(QtGui.QStandardItemModel):
self._fill_items(thread.get_result())
+ def _fill_item_data(self, item, folder_item):
+ """
+
+ Args:
+ item (QtGui.QStandardItem): Item to fill data.
+ folder_item (FolderItem): Folder item.
+ """
+
+ icon = get_qt_icon(folder_item.icon)
+ item.setData(folder_item.entity_id, ITEM_ID_ROLE)
+ item.setData(folder_item.name, ITEM_NAME_ROLE)
+ item.setData(folder_item.label, QtCore.Qt.DisplayRole)
+ item.setData(icon, QtCore.Qt.DecorationRole)
+
+
def _fill_items(self, folder_items_by_id):
if not folder_items_by_id:
if folder_items_by_id is not None:
@@ -195,11 +210,7 @@ class FoldersModel(QtGui.QStandardItemModel):
else:
is_new = self._parent_id_by_id[item_id] != parent_id
- icon = get_qt_icon(folder_item.icon)
- item.setData(item_id, ITEM_ID_ROLE)
- item.setData(folder_item.name, ITEM_NAME_ROLE)
- item.setData(folder_item.label, QtCore.Qt.DisplayRole)
- item.setData(icon, QtCore.Qt.DecorationRole)
+ self._fill_item_data(item, folder_item)
if is_new:
new_items.append(item)
self._items_by_id[item_id] = item
@@ -320,7 +331,7 @@ class FoldersWidget(QtWidgets.QWidget):
self._folders_model.set_project_name(project_name)
def _on_folders_refresh_finished(self, event):
- if event["sender"] != SENDER_NAME:
+ if event["sender"] != FOLDERS_MODEL_SENDER_NAME:
self._set_project_name(event["project_name"])
def _on_controller_refresh(self):
diff --git a/openpype/tools/ayon_utils/widgets/projects_widget.py b/openpype/tools/ayon_utils/widgets/projects_widget.py
index 818d574910..11bb5de51b 100644
--- a/openpype/tools/ayon_utils/widgets/projects_widget.py
+++ b/openpype/tools/ayon_utils/widgets/projects_widget.py
@@ -5,6 +5,9 @@ from .utils import RefreshThread, get_qt_icon
PROJECT_NAME_ROLE = QtCore.Qt.UserRole + 1
PROJECT_IS_ACTIVE_ROLE = QtCore.Qt.UserRole + 2
+PROJECT_IS_LIBRARY_ROLE = QtCore.Qt.UserRole + 3
+PROJECT_IS_CURRENT_ROLE = QtCore.Qt.UserRole + 4
+LIBRARY_PROJECT_SEPARATOR_ROLE = QtCore.Qt.UserRole + 5
class ProjectsModel(QtGui.QStandardItemModel):
@@ -15,10 +18,23 @@ class ProjectsModel(QtGui.QStandardItemModel):
self._controller = controller
self._project_items = {}
+ self._has_libraries = False
self._empty_item = None
self._empty_item_added = False
+ self._select_item = None
+ self._select_item_added = False
+ self._select_item_visible = None
+
+ self._libraries_sep_item = None
+ self._libraries_sep_item_added = False
+ self._libraries_sep_item_visible = False
+
+ self._current_context_project = None
+
+ self._selected_project = None
+
self._is_refreshing = False
self._refresh_thread = None
@@ -32,21 +48,63 @@ class ProjectsModel(QtGui.QStandardItemModel):
def has_content(self):
return len(self._project_items) > 0
+ def set_select_item_visible(self, visible):
+ if self._select_item_visible is visible:
+ return
+ self._select_item_visible = visible
+
+ if self._selected_project is None:
+ self._add_select_item()
+
+ def set_libraries_separator_visible(self, visible):
+ if self._libraries_sep_item_visible is visible:
+ return
+ self._libraries_sep_item_visible = visible
+
+ def set_selected_project(self, project_name):
+ if not self._select_item_visible:
+ return
+
+ self._selected_project = project_name
+ if project_name is None:
+ self._add_select_item()
+ else:
+ self._remove_select_item()
+
+ def set_current_context_project(self, project_name):
+ if project_name == self._current_context_project:
+ return
+ self._unset_current_context_project(self._current_context_project)
+ self._current_context_project = project_name
+ self._set_current_context_project(project_name)
+
+ def _set_current_context_project(self, project_name):
+ item = self._project_items.get(project_name)
+ if item is None:
+ return
+ item.setData(True, PROJECT_IS_CURRENT_ROLE)
+
+ def _unset_current_context_project(self, project_name):
+ item = self._project_items.get(project_name)
+ if item is None:
+ return
+ item.setData(False, PROJECT_IS_CURRENT_ROLE)
+
def _add_empty_item(self):
+ if self._empty_item_added:
+ return
+ self._empty_item_added = True
item = self._get_empty_item()
- if not self._empty_item_added:
- root_item = self.invisibleRootItem()
- root_item.appendRow(item)
- self._empty_item_added = True
+ root_item = self.invisibleRootItem()
+ root_item.appendRow(item)
def _remove_empty_item(self):
if not self._empty_item_added:
return
-
+ self._empty_item_added = False
root_item = self.invisibleRootItem()
item = self._get_empty_item()
root_item.takeRow(item.row())
- self._empty_item_added = False
def _get_empty_item(self):
if self._empty_item is None:
@@ -55,6 +113,61 @@ class ProjectsModel(QtGui.QStandardItemModel):
self._empty_item = item
return self._empty_item
+ def _get_library_sep_item(self):
+ if self._libraries_sep_item is not None:
+ return self._libraries_sep_item
+
+ item = QtGui.QStandardItem()
+ item.setData("Libraries", QtCore.Qt.DisplayRole)
+ item.setData(True, LIBRARY_PROJECT_SEPARATOR_ROLE)
+ item.setFlags(QtCore.Qt.NoItemFlags)
+ self._libraries_sep_item = item
+ return item
+
+ def _add_library_sep_item(self):
+ if (
+ not self._libraries_sep_item_visible
+ or self._libraries_sep_item_added
+ ):
+ return
+ self._libraries_sep_item_added = True
+ item = self._get_library_sep_item()
+ root_item = self.invisibleRootItem()
+ root_item.appendRow(item)
+
+ def _remove_library_sep_item(self):
+ if (
+ not self._libraries_sep_item_added
+ ):
+ return
+ self._libraries_sep_item_added = False
+ item = self._get_library_sep_item()
+ root_item = self.invisibleRootItem()
+ root_item.takeRow(item.row())
+
+ def _add_select_item(self):
+ if self._select_item_added:
+ return
+ self._select_item_added = True
+ item = self._get_select_item()
+ root_item = self.invisibleRootItem()
+ root_item.appendRow(item)
+
+ def _remove_select_item(self):
+ if not self._select_item_added:
+ return
+ self._select_item_added = False
+ root_item = self.invisibleRootItem()
+ item = self._get_select_item()
+ root_item.takeRow(item.row())
+
+ def _get_select_item(self):
+ if self._select_item is None:
+ item = QtGui.QStandardItem("< Select project >")
+ item.setEditable(False)
+ self._select_item = item
+ return self._select_item
+
def _refresh(self):
if self._is_refreshing:
return
@@ -80,44 +193,118 @@ class ProjectsModel(QtGui.QStandardItemModel):
self.refreshed.emit()
def _fill_items(self, project_items):
- items_to_remove = set(self._project_items.keys())
+ new_project_names = {
+ project_item.name
+ for project_item in project_items
+ }
+
+ # Handle "Select item" visibility
+ if self._select_item_visible:
+ # Add select project. if previously selected project is not in
+ # project items
+ if self._selected_project not in new_project_names:
+ self._add_select_item()
+ else:
+ self._remove_select_item()
+
+ root_item = self.invisibleRootItem()
+
+ items_to_remove = set(self._project_items.keys()) - new_project_names
+ for project_name in items_to_remove:
+ item = self._project_items.pop(project_name)
+ root_item.takeRow(item.row())
+
+ has_library_project = False
new_items = []
for project_item in project_items:
project_name = project_item.name
- items_to_remove.discard(project_name)
item = self._project_items.get(project_name)
+ if project_item.is_library:
+ has_library_project = True
if item is None:
item = QtGui.QStandardItem()
+ item.setEditable(False)
new_items.append(item)
icon = get_qt_icon(project_item.icon)
item.setData(project_name, QtCore.Qt.DisplayRole)
item.setData(icon, QtCore.Qt.DecorationRole)
item.setData(project_name, PROJECT_NAME_ROLE)
item.setData(project_item.active, PROJECT_IS_ACTIVE_ROLE)
+ item.setData(project_item.is_library, PROJECT_IS_LIBRARY_ROLE)
+ is_current = project_name == self._current_context_project
+ item.setData(is_current, PROJECT_IS_CURRENT_ROLE)
self._project_items[project_name] = item
- root_item = self.invisibleRootItem()
+ self._set_current_context_project(self._current_context_project)
+
+ self._has_libraries = has_library_project
+
if new_items:
root_item.appendRows(new_items)
- for project_name in items_to_remove:
- item = self._project_items.pop(project_name)
- root_item.removeRow(item.row())
-
if self.has_content():
+ # Make sure "No projects" item is removed
self._remove_empty_item()
+ if has_library_project:
+ self._add_library_sep_item()
+ else:
+ self._remove_library_sep_item()
else:
+ # Keep only "No projects" item
self._add_empty_item()
+ self._remove_select_item()
+ self._remove_library_sep_item()
class ProjectSortFilterProxy(QtCore.QSortFilterProxyModel):
def __init__(self, *args, **kwargs):
super(ProjectSortFilterProxy, self).__init__(*args, **kwargs)
self._filter_inactive = True
+ self._filter_standard = False
+ self._filter_library = False
+ self._sort_by_type = True
# Disable case sensitivity
self.setSortCaseSensitivity(QtCore.Qt.CaseInsensitive)
+ def _type_sort(self, l_index, r_index):
+ if not self._sort_by_type:
+ return None
+
+ l_is_library = l_index.data(PROJECT_IS_LIBRARY_ROLE)
+ r_is_library = r_index.data(PROJECT_IS_LIBRARY_ROLE)
+ # Both hare project items
+ if l_is_library is not None and r_is_library is not None:
+ if l_is_library is r_is_library:
+ return None
+ if l_is_library:
+ return False
+ return True
+
+ if l_index.data(LIBRARY_PROJECT_SEPARATOR_ROLE):
+ if r_is_library is None:
+ return False
+ return r_is_library
+
+ if r_index.data(LIBRARY_PROJECT_SEPARATOR_ROLE):
+ if l_is_library is None:
+ return True
+ return l_is_library
+ return None
+
def lessThan(self, left_index, right_index):
+ # Current project always on top
+ # - make sure this is always first, before any other sorting
+ # e.g. type sort would move the item lower
+ if left_index.data(PROJECT_IS_CURRENT_ROLE):
+ return True
+ if right_index.data(PROJECT_IS_CURRENT_ROLE):
+ return False
+
+ # Library separator should be before library projects
+ result = self._type_sort(left_index, right_index)
+ if result is not None:
+ return result
+
if left_index.data(PROJECT_NAME_ROLE) is None:
return True
@@ -137,21 +324,43 @@ class ProjectSortFilterProxy(QtCore.QSortFilterProxyModel):
def filterAcceptsRow(self, source_row, source_parent):
index = self.sourceModel().index(source_row, 0, source_parent)
+ project_name = index.data(PROJECT_NAME_ROLE)
+ if project_name is None:
+ return True
+
string_pattern = self.filterRegularExpression().pattern()
+ if string_pattern:
+ return string_pattern.lower() in project_name.lower()
+
+ # Current project keep always visible
+ default = super(ProjectSortFilterProxy, self).filterAcceptsRow(
+ source_row, source_parent
+ )
+ if not default:
+ return default
+
+ # Make sure current project is visible
+ if index.data(PROJECT_IS_CURRENT_ROLE):
+ return True
+
if (
self._filter_inactive
and not index.data(PROJECT_IS_ACTIVE_ROLE)
):
return False
- if string_pattern:
- project_name = index.data(PROJECT_IS_ACTIVE_ROLE)
- if project_name is not None:
- return string_pattern.lower() in project_name.lower()
+ if (
+ self._filter_standard
+ and not index.data(PROJECT_IS_LIBRARY_ROLE)
+ ):
+ return False
- return super(ProjectSortFilterProxy, self).filterAcceptsRow(
- source_row, source_parent
- )
+ if (
+ self._filter_library
+ and index.data(PROJECT_IS_LIBRARY_ROLE)
+ ):
+ return False
+ return True
def _custom_index_filter(self, index):
return bool(index.data(PROJECT_IS_ACTIVE_ROLE))
@@ -159,14 +368,34 @@ class ProjectSortFilterProxy(QtCore.QSortFilterProxyModel):
def is_active_filter_enabled(self):
return self._filter_inactive
- def set_active_filter_enabled(self, value):
- if self._filter_inactive == value:
+ def set_active_filter_enabled(self, enabled):
+ if self._filter_inactive == enabled:
return
- self._filter_inactive = value
+ self._filter_inactive = enabled
self.invalidateFilter()
+ def set_library_filter_enabled(self, enabled):
+ if self._filter_library == enabled:
+ return
+ self._filter_library = enabled
+ self.invalidateFilter()
+
+ def set_standard_filter_enabled(self, enabled):
+ if self._filter_standard == enabled:
+ return
+ self._filter_standard = enabled
+ self.invalidateFilter()
+
+ def set_sort_by_type(self, enabled):
+ if self._sort_by_type is enabled:
+ return
+ self._sort_by_type = enabled
+ self.invalidate()
+
class ProjectsCombobox(QtWidgets.QWidget):
+ refreshed = QtCore.Signal()
+
def __init__(self, controller, parent, handle_expected_selection=False):
super(ProjectsCombobox, self).__init__(parent)
@@ -203,6 +432,7 @@ class ProjectsCombobox(QtWidgets.QWidget):
self._controller = controller
self._listen_selection_change = True
+ self._select_item_visible = False
self._handle_expected_selection = handle_expected_selection
self._expected_selection = None
@@ -264,17 +494,56 @@ class ProjectsCombobox(QtWidgets.QWidget):
return None
return self._projects_combobox.itemData(idx, PROJECT_NAME_ROLE)
+ def set_current_context_project(self, project_name):
+ self._projects_model.set_current_context_project(project_name)
+ self._projects_proxy_model.invalidateFilter()
+
+ def _update_select_item_visiblity(self, **kwargs):
+ if not self._select_item_visible:
+ return
+ if "project_name" not in kwargs:
+ project_name = self.get_current_project_name()
+ else:
+ project_name = kwargs.get("project_name")
+
+ # Hide the item if a project is selected
+ self._projects_model.set_selected_project(project_name)
+
+ def set_select_item_visible(self, visible):
+ self._select_item_visible = visible
+ self._projects_model.set_select_item_visible(visible)
+ self._update_select_item_visiblity()
+
+ def set_libraries_separator_visible(self, visible):
+ self._projects_model.set_libraries_separator_visible(visible)
+
+ def is_active_filter_enabled(self):
+ return self._projects_proxy_model.is_active_filter_enabled()
+
+ def set_active_filter_enabled(self, enabled):
+ return self._projects_proxy_model.set_active_filter_enabled(enabled)
+
+ def set_standard_filter_enabled(self, enabled):
+ return self._projects_proxy_model.set_standard_filter_enabled(enabled)
+
+ def set_library_filter_enabled(self, enabled):
+ return self._projects_proxy_model.set_library_filter_enabled(enabled)
+
def _on_current_index_changed(self, idx):
if not self._listen_selection_change:
return
project_name = self._projects_combobox.itemData(
idx, PROJECT_NAME_ROLE)
+ self._update_select_item_visiblity(project_name=project_name)
self._controller.set_selected_project(project_name)
def _on_model_refresh(self):
self._projects_proxy_model.sort(0)
+ self._projects_proxy_model.invalidateFilter()
if self._expected_selection:
self._set_expected_selection()
+ self._update_select_item_visiblity()
+ self.refreshed.emit()
def _on_projects_refresh_finished(self, event):
if event["sender"] != PROJECTS_MODEL_SENDER:
diff --git a/openpype/tools/ayon_utils/widgets/tasks_widget.py b/openpype/tools/ayon_utils/widgets/tasks_widget.py
index 0af506863a..da745bd810 100644
--- a/openpype/tools/ayon_utils/widgets/tasks_widget.py
+++ b/openpype/tools/ayon_utils/widgets/tasks_widget.py
@@ -5,7 +5,7 @@ from openpype.tools.utils import DeselectableTreeView
from .utils import RefreshThread, get_qt_icon
-SENDER_NAME = "qt_tasks_model"
+TASKS_MODEL_SENDER_NAME = "qt_tasks_model"
ITEM_ID_ROLE = QtCore.Qt.UserRole + 1
PARENT_ID_ROLE = QtCore.Qt.UserRole + 2
ITEM_NAME_ROLE = QtCore.Qt.UserRole + 3
@@ -362,7 +362,7 @@ class TasksWidget(QtWidgets.QWidget):
# Refresh only if current folder id is the same
if (
- event["sender"] == SENDER_NAME
+ event["sender"] == TASKS_MODEL_SENDER_NAME
or event["folder_id"] != self._selected_folder_id
):
return
diff --git a/openpype/tools/utils/__init__.py b/openpype/tools/utils/__init__.py
index 018088e916..ed41d93f0d 100644
--- a/openpype/tools/utils/__init__.py
+++ b/openpype/tools/utils/__init__.py
@@ -43,6 +43,7 @@ from .overlay_messages import (
MessageOverlayObject,
)
from .multiselection_combobox import MultiSelectionComboBox
+from .thumbnail_paint_widget import ThumbnailPainterWidget
__all__ = (
@@ -90,4 +91,6 @@ __all__ = (
"MessageOverlayObject",
"MultiSelectionComboBox",
+
+ "ThumbnailPainterWidget",
)
diff --git a/openpype/tools/utils/host_tools.py b/openpype/tools/utils/host_tools.py
index 2ebc973a47..ca23945339 100644
--- a/openpype/tools/utils/host_tools.py
+++ b/openpype/tools/utils/host_tools.py
@@ -86,12 +86,22 @@ class HostToolsHelper:
def get_loader_tool(self, parent):
"""Create, cache and return loader tool window."""
if self._loader_tool is None:
- from openpype.tools.loader import LoaderWindow
-
host = registered_host()
ILoadHost.validate_load_methods(host)
+ if AYON_SERVER_ENABLED:
+ from openpype.tools.ayon_loader.ui import LoaderWindow
+ from openpype.tools.ayon_loader import LoaderController
- loader_window = LoaderWindow(parent=parent or self._parent)
+ controller = LoaderController(host=host)
+ loader_window = LoaderWindow(
+ controller=controller,
+ parent=parent or self._parent
+ )
+
+ else:
+ from openpype.tools.loader import LoaderWindow
+
+ loader_window = LoaderWindow(parent=parent or self._parent)
self._loader_tool = loader_window
return self._loader_tool
@@ -109,7 +119,7 @@ class HostToolsHelper:
if use_context is None:
use_context = False
- if use_context:
+ if not AYON_SERVER_ENABLED and use_context:
context = {"asset": get_current_asset_name()}
loader_tool.set_context(context, refresh=True)
else:
@@ -187,6 +197,9 @@ class HostToolsHelper:
def get_library_loader_tool(self, parent):
"""Create, cache and return library loader tool window."""
+ if AYON_SERVER_ENABLED:
+ return self.get_loader_tool(parent)
+
if self._library_loader_tool is None:
from openpype.tools.libraryloader import LibraryLoaderWindow
@@ -199,6 +212,9 @@ class HostToolsHelper:
def show_library_loader(self, parent=None):
"""Loader tool for loading representations from library project."""
+ if AYON_SERVER_ENABLED:
+ return self.show_loader(parent)
+
with qt_app_context():
library_loader_tool = self.get_library_loader_tool(parent)
library_loader_tool.show()
diff --git a/openpype/tools/utils/images/__init__.py b/openpype/tools/utils/images/__init__.py
new file mode 100644
index 0000000000..3f437fcc8c
--- /dev/null
+++ b/openpype/tools/utils/images/__init__.py
@@ -0,0 +1,56 @@
+import os
+from qtpy import QtGui
+
+IMAGES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)))
+
+
+def get_image_path(filename):
+ """Get image path from './images'.
+
+ Returns:
+ Union[str, None]: Path to image file or None if not found.
+ """
+
+ path = os.path.join(IMAGES_DIR, filename)
+ if os.path.exists(path):
+ return path
+ return None
+
+
+def get_image(filename):
+ """Load image from './images' as QImage.
+
+ Returns:
+ Union[QtGui.QImage, None]: QImage or None if not found.
+ """
+
+ path = get_image_path(filename)
+ if path:
+ return QtGui.QImage(path)
+ return None
+
+
+def get_pixmap(filename):
+ """Load image from './images' as QPixmap.
+
+ Returns:
+ Union[QtGui.QPixmap, None]: QPixmap or None if not found.
+ """
+
+ path = get_image_path(filename)
+ if path:
+ return QtGui.QPixmap(path)
+ return None
+
+
+def get_icon(filename):
+ """Load image from './images' as QIcon.
+
+ Returns:
+ Union[QtGui.QIcon, None]: QIcon or None if not found.
+ """
+
+ pix = get_pixmap(filename)
+ if pix:
+ return QtGui.QIcon(pix)
+ return None
diff --git a/openpype/tools/utils/images/thumbnail.png b/openpype/tools/utils/images/thumbnail.png
new file mode 100644
index 0000000000..adea862e5b
Binary files /dev/null and b/openpype/tools/utils/images/thumbnail.png differ
diff --git a/openpype/tools/utils/thumbnail_paint_widget.py b/openpype/tools/utils/thumbnail_paint_widget.py
new file mode 100644
index 0000000000..130942aaf0
--- /dev/null
+++ b/openpype/tools/utils/thumbnail_paint_widget.py
@@ -0,0 +1,366 @@
+from qtpy import QtWidgets, QtCore, QtGui
+
+from openpype.style import get_objected_colors
+
+from .lib import paint_image_with_color
+from .images import get_image
+
+
+class ThumbnailPainterWidget(QtWidgets.QWidget):
+ """Widget for painting of thumbnails.
+
+ The widget use is to paint thumbnail or multiple thumbnails in a defined
+ area. Is not meant to show them in a grid but in overlay.
+
+ It is expected that there is a logic that will provide thumbnails to
+ paint and set them using 'set_current_thumbnails' or
+ 'set_current_thumbnail_paths'.
+ """
+
+ width_ratio = 3.0
+ height_ratio = 2.0
+ border_width = 1
+ max_thumbnails = 3
+ offset_sep = 4
+ checker_boxes_count = 20
+
+ def __init__(self, parent):
+ super(ThumbnailPainterWidget, self).__init__(parent)
+
+ border_color = get_objected_colors("bg-buttons").get_qcolor()
+ thumbnail_bg_color = get_objected_colors("bg-view").get_qcolor()
+
+ default_image = get_image("thumbnail.png")
+ default_pix = paint_image_with_color(default_image, border_color)
+
+ self._border_color = border_color
+ self._thumbnail_bg_color = thumbnail_bg_color
+ self._default_pix = default_pix
+
+ self._cached_pix = None
+ self._current_pixes = None
+ self._has_pixes = False
+
+ self._bg_color = QtCore.Qt.transparent
+ self._use_checker = True
+ self._checker_color_1 = QtGui.QColor(89, 89, 89)
+ self._checker_color_2 = QtGui.QColor(188, 187, 187)
+
+ def set_background_color(self, color):
+ self._bg_color = color
+ self.repaint()
+
+ def set_use_checkboard(self, use_checker):
+ if self._use_checker is use_checker:
+ return
+ self._use_checker = use_checker
+ self.repaint()
+
+ def set_checker_colors(self, color_1, color_2):
+ self._checker_color_1 = color_1
+ self._checker_color_2 = color_2
+ self.repaint()
+
+ def set_border_color(self, color):
+ """Change border color.
+
+ Args:
+ color (QtGui.QColor): Color to set.
+ """
+
+ self._border_color = color
+ self._default_pix = None
+ self.clear_cache()
+
+ def set_thumbnail_bg_color(self, color):
+ """Change background color.
+
+ Args:
+ color (QtGui.QColor): Color to set.
+ """
+
+ self._thumbnail_bg_color = color
+ self.clear_cache()
+
+ @property
+ def has_pixes(self):
+ """Has set thumbnails.
+
+ Returns:
+ bool: True if widget has thumbnails to paint.
+ """
+
+ return self._has_pixes
+
+ def clear_cache(self):
+ """Clear cache of resized thumbnails and repaint widget."""
+
+ self._cached_pix = None
+ self.repaint()
+
+ def set_current_thumbnails(self, pixmaps=None):
+ """Set current thumbnails.
+
+ Args:
+ pixmaps (Optional[List[QtGui.QPixmap]]): List of pixmaps.
+ """
+
+ self._current_pixes = pixmaps or None
+ self._has_pixes = self._current_pixes is not None
+ self.clear_cache()
+
+ def set_current_thumbnail_paths(self, thumbnail_paths=None):
+ """Set current thumbnails.
+
+ Set current thumbnails using paths to a files.
+
+ Args:
+ thumbnail_paths (Optional[List[str]]): List of paths to thumbnail
+ sources.
+ """
+
+ pixes = []
+ if thumbnail_paths:
+ for thumbnail_path in thumbnail_paths:
+ pixes.append(QtGui.QPixmap(thumbnail_path))
+
+ self.set_current_thumbnails(pixes)
+
+ def paintEvent(self, event):
+ if self._cached_pix is None:
+ self._cache_pix()
+
+ painter = QtGui.QPainter()
+ painter.begin(self)
+ painter.setRenderHint(QtGui.QPainter.Antialiasing)
+ painter.drawPixmap(0, 0, self._cached_pix)
+ painter.end()
+
+ def resizeEvent(self, event):
+ self._cached_pix = None
+ super(ThumbnailPainterWidget, self).resizeEvent(event)
+
+ def _get_default_pix(self):
+ if self._default_pix is None:
+ default_image = get_image("thumbnail")
+ default_pix = paint_image_with_color(
+ default_image, self._border_color)
+ self._default_pix = default_pix
+ return self._default_pix
+
+ def _paint_tile(self, width, height):
+ if not self._use_checker:
+ tile_pix = QtGui.QPixmap(width, width)
+ tile_pix.fill(self._bg_color)
+ return tile_pix
+
+ checker_size = int(float(width) / self.checker_boxes_count)
+ if checker_size < 1:
+ checker_size = 1
+
+ checker_pix = QtGui.QPixmap(checker_size * 2, checker_size * 2)
+ checker_pix.fill(QtCore.Qt.transparent)
+ checker_painter = QtGui.QPainter()
+ checker_painter.begin(checker_pix)
+ checker_painter.setPen(QtCore.Qt.NoPen)
+ checker_painter.setBrush(self._checker_color_1)
+ checker_painter.drawRect(
+ 0, 0, checker_pix.width(), checker_pix.height()
+ )
+ checker_painter.setBrush(self._checker_color_2)
+ checker_painter.drawRect(
+ 0, 0, checker_size, checker_size
+ )
+ checker_painter.drawRect(
+ checker_size, checker_size, checker_size, checker_size
+ )
+ checker_painter.end()
+ return checker_pix
+
+ def _paint_default_pix(self, pix_width, pix_height):
+ full_border_width = 2 * self.border_width
+ width = pix_width - full_border_width
+ height = pix_height - full_border_width
+ if width > 100:
+ width = int(width * 0.6)
+ height = int(height * 0.6)
+
+ scaled_pix = self._get_default_pix().scaled(
+ width,
+ height,
+ QtCore.Qt.KeepAspectRatio,
+ QtCore.Qt.SmoothTransformation
+ )
+ pos_x = int(
+ (pix_width - scaled_pix.width()) / 2
+ )
+ pos_y = int(
+ (pix_height - scaled_pix.height()) / 2
+ )
+ new_pix = QtGui.QPixmap(pix_width, pix_height)
+ new_pix.fill(QtCore.Qt.transparent)
+ pix_painter = QtGui.QPainter()
+ pix_painter.begin(new_pix)
+ render_hints = (
+ QtGui.QPainter.Antialiasing
+ | QtGui.QPainter.SmoothPixmapTransform
+ )
+ if hasattr(QtGui.QPainter, "HighQualityAntialiasing"):
+ render_hints |= QtGui.QPainter.HighQualityAntialiasing
+
+ pix_painter.setRenderHints(render_hints)
+ pix_painter.drawPixmap(pos_x, pos_y, scaled_pix)
+ pix_painter.end()
+ return new_pix
+
+ def _draw_thumbnails(self, thumbnails, pix_width, pix_height):
+ full_border_width = 2 * self.border_width
+
+ checker_pix = self._paint_tile(pix_width, pix_height)
+
+ backgrounded_images = []
+ for src_pix in thumbnails:
+ scaled_pix = src_pix.scaled(
+ pix_width - full_border_width,
+ pix_height - full_border_width,
+ QtCore.Qt.KeepAspectRatio,
+ QtCore.Qt.SmoothTransformation
+ )
+ pos_x = int(
+ (pix_width - scaled_pix.width()) / 2
+ )
+ pos_y = int(
+ (pix_height - scaled_pix.height()) / 2
+ )
+
+ new_pix = QtGui.QPixmap(pix_width, pix_height)
+ new_pix.fill(QtCore.Qt.transparent)
+ pix_painter = QtGui.QPainter()
+ pix_painter.begin(new_pix)
+ render_hints = (
+ QtGui.QPainter.Antialiasing
+ | QtGui.QPainter.SmoothPixmapTransform
+ )
+ if hasattr(QtGui.QPainter, "HighQualityAntialiasing"):
+ render_hints |= QtGui.QPainter.HighQualityAntialiasing
+ pix_painter.setRenderHints(render_hints)
+
+ tiled_rect = QtCore.QRectF(
+ pos_x, pos_y, scaled_pix.width(), scaled_pix.height()
+ )
+ pix_painter.drawTiledPixmap(
+ tiled_rect,
+ checker_pix,
+ QtCore.QPointF(0.0, 0.0)
+ )
+ pix_painter.drawPixmap(pos_x, pos_y, scaled_pix)
+ pix_painter.end()
+ backgrounded_images.append(new_pix)
+ return backgrounded_images
+
+ def _paint_dash_line(self, painter, rect):
+ pen = QtGui.QPen()
+ pen.setWidth(1)
+ pen.setBrush(QtCore.Qt.darkGray)
+ pen.setStyle(QtCore.Qt.DashLine)
+
+ new_rect = rect.adjusted(1, 1, -1, -1)
+ painter.setPen(pen)
+ painter.setBrush(QtCore.Qt.transparent)
+ # painter.drawRect(rect)
+ painter.drawRect(new_rect)
+
+ def _cache_pix(self):
+ rect = self.rect()
+ rect_width = rect.width()
+ rect_height = rect.height()
+
+ pix_x_offset = 0
+ pix_y_offset = 0
+ expected_height = int(
+ (rect_width / self.width_ratio) * self.height_ratio
+ )
+ if expected_height > rect_height:
+ expected_height = rect_height
+ expected_width = int(
+ (rect_height / self.height_ratio) * self.width_ratio
+ )
+ pix_x_offset = (rect_width - expected_width) / 2
+ else:
+ expected_width = rect_width
+ pix_y_offset = (rect_height - expected_height) / 2
+
+ if self._current_pixes is None:
+ used_default_pix = True
+ pixes_to_draw = None
+ pixes_len = 1
+ else:
+ used_default_pix = False
+ pixes_to_draw = self._current_pixes
+ if len(pixes_to_draw) > self.max_thumbnails:
+ pixes_to_draw = pixes_to_draw[:-self.max_thumbnails]
+ pixes_len = len(pixes_to_draw)
+
+ width_offset, height_offset = self._get_pix_offset_size(
+ expected_width, expected_height, pixes_len
+ )
+ pix_width = expected_width - width_offset
+ pix_height = expected_height - height_offset
+
+ if used_default_pix:
+ thumbnail_images = [self._paint_default_pix(pix_width, pix_height)]
+ else:
+ thumbnail_images = self._draw_thumbnails(
+ pixes_to_draw, pix_width, pix_height
+ )
+
+ if pixes_len == 1:
+ width_offset_part = 0
+ height_offset_part = 0
+ else:
+ width_offset_part = int(float(width_offset) / (pixes_len - 1))
+ height_offset_part = int(float(height_offset) / (pixes_len - 1))
+ full_width_offset = width_offset + pix_x_offset
+
+ final_pix = QtGui.QPixmap(rect_width, rect_height)
+ final_pix.fill(QtCore.Qt.transparent)
+
+ bg_pen = QtGui.QPen()
+ bg_pen.setWidth(self.border_width)
+ bg_pen.setColor(self._border_color)
+
+ final_painter = QtGui.QPainter()
+ final_painter.begin(final_pix)
+ render_hints = (
+ QtGui.QPainter.Antialiasing
+ | QtGui.QPainter.SmoothPixmapTransform
+ )
+ if hasattr(QtGui.QPainter, "HighQualityAntialiasing"):
+ render_hints |= QtGui.QPainter.HighQualityAntialiasing
+
+ final_painter.setRenderHints(render_hints)
+
+ final_painter.setBrush(QtGui.QBrush(self._thumbnail_bg_color))
+ final_painter.setPen(bg_pen)
+ final_painter.drawRect(rect)
+
+ for idx, pix in enumerate(thumbnail_images):
+ x_offset = full_width_offset - (width_offset_part * idx)
+ y_offset = (height_offset_part * idx) + pix_y_offset
+ final_painter.drawPixmap(x_offset, y_offset, pix)
+
+ # Draw drop enabled dashes
+ if used_default_pix:
+ self._paint_dash_line(final_painter, rect)
+
+ final_painter.end()
+
+ self._cached_pix = final_pix
+
+ def _get_pix_offset_size(self, width, height, image_count):
+ if image_count == 1:
+ return 0, 0
+
+ part_width = width / self.offset_sep
+ part_height = height / self.offset_sep
+ return part_width, part_height
diff --git a/openpype/version.py b/openpype/version.py
index 1a316df989..b0a79162b2 100644
--- a/openpype/version.py
+++ b/openpype/version.py
@@ -1,3 +1,3 @@
# -*- coding: utf-8 -*-
"""Package declaring Pype version."""
-__version__ = "3.17.2-nightly.4"
+__version__ = "3.17.2"
diff --git a/pyproject.toml b/pyproject.toml
index 2460185bdd..ad93b70c0f 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[tool.poetry]
name = "OpenPype"
-version = "3.17.1" # OpenPype
+version = "3.17.2" # OpenPype
description = "Open VFX and Animation pipeline with support."
authors = ["OpenPype Team "]
license = "MIT License"
diff --git a/tests/integration/hosts/nuke/test_publish_in_nuke.py b/tests/integration/hosts/nuke/test_publish_in_nuke.py
index bfd84e4fd5..b7bb8716c0 100644
--- a/tests/integration/hosts/nuke/test_publish_in_nuke.py
+++ b/tests/integration/hosts/nuke/test_publish_in_nuke.py
@@ -68,7 +68,7 @@ class TestPublishInNuke(NukeLocalPublishTestClass):
name="workfileTest_task"))
failures.append(
- DBAssert.count_of_types(dbcon, "representation", 4))
+ DBAssert.count_of_types(dbcon, "representation", 3))
additional_args = {"context.subset": "workfileTest_task",
"context.ext": "nk"}
@@ -85,7 +85,7 @@ class TestPublishInNuke(NukeLocalPublishTestClass):
additional_args = {"context.subset": "renderTest_taskMain",
"name": "thumbnail"}
failures.append(
- DBAssert.count_of_types(dbcon, "representation", 1,
+ DBAssert.count_of_types(dbcon, "representation", 0,
additional_args=additional_args))
additional_args = {"context.subset": "renderTest_taskMain",
diff --git a/tests/lib/testing_classes.py b/tests/lib/testing_classes.py
index e82e438e54..277b332e19 100644
--- a/tests/lib/testing_classes.py
+++ b/tests/lib/testing_classes.py
@@ -105,7 +105,7 @@ class ModuleUnitTest(BaseTest):
yield path
@pytest.fixture(scope="module")
- def env_var(self, monkeypatch_session, download_test_data):
+ def env_var(self, monkeypatch_session, download_test_data, mongo_url):
"""Sets temporary env vars from json file."""
env_url = os.path.join(download_test_data, "input",
"env_vars", "env_var.json")
@@ -129,6 +129,9 @@ class ModuleUnitTest(BaseTest):
monkeypatch_session.setenv(key, str(value))
#reset connection to openpype DB with new env var
+ if mongo_url:
+ monkeypatch_session.setenv("OPENPYPE_MONGO", mongo_url)
+
import openpype.settings.lib as sett_lib
sett_lib._SETTINGS_HANDLER = None
sett_lib._LOCAL_SETTINGS_HANDLER = None
@@ -150,8 +153,7 @@ class ModuleUnitTest(BaseTest):
request, mongo_url):
"""Restore prepared MongoDB dumps into selected DB."""
backup_dir = os.path.join(download_test_data, "input", "dumps")
-
- uri = mongo_url or os.environ.get("OPENPYPE_MONGO")
+ uri = os.environ.get("OPENPYPE_MONGO")
db_handler = DBHandler(uri)
db_handler.setup_from_dump(self.TEST_DB_NAME, backup_dir,
overwrite=True,