diff --git a/openpype/tools/libraryloader/__init__.py b/openpype/tools/libraryloader/__init__.py
new file mode 100644
index 0000000000..bbf4a1087d
--- /dev/null
+++ b/openpype/tools/libraryloader/__init__.py
@@ -0,0 +1,11 @@
+from .app import (
+ LibraryLoaderWindow,
+ show,
+ cli
+)
+
+__all__ = [
+ "LibraryLoaderWindow",
+ "show",
+ "cli",
+]
diff --git a/openpype/tools/libraryloader/__main__.py b/openpype/tools/libraryloader/__main__.py
new file mode 100644
index 0000000000..d77bc585c5
--- /dev/null
+++ b/openpype/tools/libraryloader/__main__.py
@@ -0,0 +1,5 @@
+from . import cli
+
+if __name__ == '__main__':
+ import sys
+ sys.exit(cli(sys.argv[1:]))
diff --git a/openpype/tools/libraryloader/app.py b/openpype/tools/libraryloader/app.py
new file mode 100644
index 0000000000..362d05cce6
--- /dev/null
+++ b/openpype/tools/libraryloader/app.py
@@ -0,0 +1,591 @@
+import sys
+import time
+
+from Qt import QtWidgets, QtCore, QtGui
+
+from avalon import style
+from avalon.api import AvalonMongoDB
+from openpype.tools.utils import lib as tools_lib
+from openpype.tools.loader.widgets import (
+ ThumbnailWidget,
+ VersionWidget,
+ FamilyListWidget,
+ RepresentationWidget
+)
+from openpype.tools.utils.widgets import AssetWidget
+
+from openpype.modules import ModulesManager
+
+from . import lib
+from .widgets import LibrarySubsetWidget
+
+module = sys.modules[__name__]
+module.window = None
+
+
+class LibraryLoaderWindow(QtWidgets.QDialog):
+ """Asset library loader interface"""
+
+ tool_title = "Library Loader 0.5"
+ tool_name = "library_loader"
+
+ def __init__(
+ self, parent=None, icon=None, show_projects=False, show_libraries=True
+ ):
+ super(LibraryLoaderWindow, self).__init__(parent)
+
+ self._initial_refresh = False
+ self._ignore_project_change = False
+
+ # Enable minimize and maximize for app
+ self.setWindowTitle(self.tool_title)
+ self.setWindowFlags(QtCore.Qt.Window)
+ self.setFocusPolicy(QtCore.Qt.StrongFocus)
+ if icon is not None:
+ self.setWindowIcon(icon)
+ # self.setAttribute(QtCore.Qt.WA_DeleteOnClose)
+
+ body = QtWidgets.QWidget()
+ footer = QtWidgets.QWidget()
+ footer.setFixedHeight(20)
+
+ container = QtWidgets.QWidget()
+
+ self.dbcon = AvalonMongoDB()
+ self.dbcon.install()
+ self.dbcon.Session["AVALON_PROJECT"] = None
+
+ self.show_projects = show_projects
+ self.show_libraries = show_libraries
+
+ # Groups config
+ self.groups_config = tools_lib.GroupsConfig(self.dbcon)
+ self.family_config_cache = tools_lib.FamilyConfigCache(self.dbcon)
+
+ assets = AssetWidget(
+ self.dbcon, multiselection=True, parent=self
+ )
+ families = FamilyListWidget(
+ self.dbcon, self.family_config_cache, parent=self
+ )
+ subsets = LibrarySubsetWidget(
+ self.dbcon,
+ self.groups_config,
+ self.family_config_cache,
+ tool_name=self.tool_name,
+ parent=self
+ )
+
+ version = VersionWidget(self.dbcon)
+ thumbnail = ThumbnailWidget(self.dbcon)
+
+ # Project
+ self.combo_projects = QtWidgets.QComboBox()
+
+ # Create splitter to show / hide family filters
+ asset_filter_splitter = QtWidgets.QSplitter()
+ asset_filter_splitter.setOrientation(QtCore.Qt.Vertical)
+ asset_filter_splitter.addWidget(self.combo_projects)
+ asset_filter_splitter.addWidget(assets)
+ asset_filter_splitter.addWidget(families)
+ asset_filter_splitter.setStretchFactor(1, 65)
+ asset_filter_splitter.setStretchFactor(2, 35)
+
+ manager = ModulesManager()
+ sync_server = manager.modules_by_name["sync_server"]
+
+ representations = RepresentationWidget(self.dbcon)
+ thumb_ver_splitter = QtWidgets.QSplitter()
+ thumb_ver_splitter.setOrientation(QtCore.Qt.Vertical)
+ thumb_ver_splitter.addWidget(thumbnail)
+ thumb_ver_splitter.addWidget(version)
+ if sync_server.enabled:
+ thumb_ver_splitter.addWidget(representations)
+ thumb_ver_splitter.setStretchFactor(0, 30)
+ thumb_ver_splitter.setStretchFactor(1, 35)
+
+ container_layout = QtWidgets.QHBoxLayout(container)
+ container_layout.setContentsMargins(0, 0, 0, 0)
+ split = QtWidgets.QSplitter()
+ split.addWidget(asset_filter_splitter)
+ split.addWidget(subsets)
+ split.addWidget(thumb_ver_splitter)
+ split.setSizes([180, 950, 200])
+ container_layout.addWidget(split)
+
+ body_layout = QtWidgets.QHBoxLayout(body)
+ body_layout.addWidget(container)
+ body_layout.setContentsMargins(0, 0, 0, 0)
+
+ message = QtWidgets.QLabel()
+ message.hide()
+
+ footer_layout = QtWidgets.QVBoxLayout(footer)
+ footer_layout.addWidget(message)
+ footer_layout.setContentsMargins(0, 0, 0, 0)
+
+ layout = QtWidgets.QVBoxLayout(self)
+ layout.addWidget(body)
+ layout.addWidget(footer)
+
+ self.data = {
+ "widgets": {
+ "families": families,
+ "assets": assets,
+ "subsets": subsets,
+ "version": version,
+ "thumbnail": thumbnail,
+ "representations": representations
+ },
+ "label": {
+ "message": message,
+ },
+ "state": {
+ "assetIds": None
+ }
+ }
+
+ families.active_changed.connect(subsets.set_family_filters)
+ assets.selection_changed.connect(self.on_assetschanged)
+ assets.refresh_triggered.connect(self.on_assetschanged)
+ assets.view.clicked.connect(self.on_assetview_click)
+ subsets.active_changed.connect(self.on_subsetschanged)
+ subsets.version_changed.connect(self.on_versionschanged)
+ self.combo_projects.currentTextChanged.connect(self.on_project_change)
+
+ self.sync_server = sync_server
+
+ # Set default thumbnail on start
+ thumbnail.set_thumbnail(None)
+
+ # Defaults
+ if sync_server.enabled:
+ split.setSizes([250, 1000, 550])
+ self.resize(1800, 900)
+ else:
+ split.setSizes([250, 850, 200])
+ self.resize(1300, 700)
+
+ def showEvent(self, event):
+ super(LibraryLoaderWindow, self).showEvent(event)
+ if not self._initial_refresh:
+ self.refresh()
+
+ def on_assetview_click(self, *args):
+ subsets_widget = self.data["widgets"]["subsets"]
+ selection_model = subsets_widget.view.selectionModel()
+ if selection_model.selectedIndexes():
+ selection_model.clearSelection()
+
+ def _set_projects(self):
+ # Store current project
+ old_project_name = self.current_project
+
+ self._ignore_project_change = True
+
+ # Cleanup
+ self.combo_projects.clear()
+
+ # Fill combobox with projects
+ select_project_item = QtGui.QStandardItem("< Select project >")
+ select_project_item.setData(None, QtCore.Qt.UserRole + 1)
+
+ combobox_items = [select_project_item]
+
+ project_names = self.get_filtered_projects()
+
+ for project_name in sorted(project_names):
+ item = QtGui.QStandardItem(project_name)
+ item.setData(project_name, QtCore.Qt.UserRole + 1)
+ combobox_items.append(item)
+
+ root_item = self.combo_projects.model().invisibleRootItem()
+ root_item.appendRows(combobox_items)
+
+ index = 0
+ self._ignore_project_change = False
+
+ if old_project_name:
+ index = self.combo_projects.findText(
+ old_project_name, QtCore.Qt.MatchFixedString
+ )
+
+ self.combo_projects.setCurrentIndex(index)
+
+ def get_filtered_projects(self):
+ projects = list()
+ for project in self.dbcon.projects():
+ is_library = project.get("data", {}).get("library_project", False)
+ if (
+ (is_library and self.show_libraries) or
+ (not is_library and self.show_projects)
+ ):
+ projects.append(project["name"])
+
+ return projects
+
+ def on_project_change(self):
+ if self._ignore_project_change:
+ return
+
+ row = self.combo_projects.currentIndex()
+ index = self.combo_projects.model().index(row, 0)
+ project_name = index.data(QtCore.Qt.UserRole + 1)
+
+ self.dbcon.Session["AVALON_PROJECT"] = project_name
+
+ _config = lib.find_config()
+ if hasattr(_config, "install"):
+ _config.install()
+ else:
+ print(
+ "Config `%s` has no function `install`" % _config.__name__
+ )
+
+ self.family_config_cache.refresh()
+ self.groups_config.refresh()
+
+ self._refresh_assets()
+ self._assetschanged()
+
+ project_name = self.dbcon.active_project() or "No project selected"
+ title = "{} - {}".format(self.tool_title, project_name)
+ self.setWindowTitle(title)
+
+ subsets = self.data["widgets"]["subsets"]
+ subsets.on_project_change(self.dbcon.Session["AVALON_PROJECT"])
+
+ representations = self.data["widgets"]["representations"]
+ representations.on_project_change(self.dbcon.Session["AVALON_PROJECT"])
+
+ @property
+ def current_project(self):
+ if (
+ not self.dbcon.active_project() or
+ self.dbcon.active_project() == ""
+ ):
+ return None
+
+ return self.dbcon.active_project()
+
+ # -------------------------------
+ # Delay calling blocking methods
+ # -------------------------------
+
+ def refresh(self):
+ self.echo("Fetching results..")
+ tools_lib.schedule(self._refresh, 50, channel="mongo")
+
+ def on_assetschanged(self, *args):
+ self.echo("Fetching asset..")
+ tools_lib.schedule(self._assetschanged, 50, channel="mongo")
+
+ def on_subsetschanged(self, *args):
+ self.echo("Fetching subset..")
+ tools_lib.schedule(self._subsetschanged, 50, channel="mongo")
+
+ def on_versionschanged(self, *args):
+ self.echo("Fetching version..")
+ tools_lib.schedule(self._versionschanged, 150, channel="mongo")
+
+ def set_context(self, context, refresh=True):
+ self.echo("Setting context: {}".format(context))
+ lib.schedule(
+ lambda: self._set_context(context, refresh=refresh),
+ 50, channel="mongo"
+ )
+
+ # ------------------------------
+ def _refresh(self):
+ if not self._initial_refresh:
+ self._initial_refresh = True
+ self._set_projects()
+
+ def _refresh_assets(self):
+ """Load assets from database"""
+ if self.current_project is not None:
+ # Ensure a project is loaded
+ project_doc = self.dbcon.find_one(
+ {"type": "project"},
+ {"type": 1}
+ )
+ assert project_doc, "This is a bug"
+
+ assets_widget = self.data["widgets"]["assets"]
+ assets_widget.model.stop_fetch_thread()
+ assets_widget.refresh()
+ assets_widget.setFocus()
+
+ families = self.data["widgets"]["families"]
+ families.refresh()
+
+ def clear_assets_underlines(self):
+ last_asset_ids = self.data["state"]["assetIds"]
+ if not last_asset_ids:
+ return
+
+ assets_widget = self.data["widgets"]["assets"]
+ id_role = assets_widget.model.ObjectIdRole
+
+ for index in tools_lib.iter_model_rows(assets_widget.model, 0):
+ if index.data(id_role) not in last_asset_ids:
+ continue
+
+ assets_widget.model.setData(
+ index, [], assets_widget.model.subsetColorsRole
+ )
+
+ def _assetschanged(self):
+ """Selected assets have changed"""
+ t1 = time.time()
+
+ assets_widget = self.data["widgets"]["assets"]
+ subsets_widget = self.data["widgets"]["subsets"]
+ subsets_model = subsets_widget.model
+
+ subsets_model.clear()
+ self.clear_assets_underlines()
+
+ if not self.dbcon.Session.get("AVALON_PROJECT"):
+ subsets_widget.set_loading_state(
+ loading=False,
+ empty=True
+ )
+ return
+
+ # filter None docs they are silo
+ asset_docs = assets_widget.get_selected_assets()
+ if len(asset_docs) == 0:
+ return
+
+ asset_ids = [asset_doc["_id"] for asset_doc in asset_docs]
+ # Start loading
+ subsets_widget.set_loading_state(
+ loading=bool(asset_ids),
+ empty=True
+ )
+
+ def on_refreshed(has_item):
+ empty = not has_item
+ subsets_widget.set_loading_state(loading=False, empty=empty)
+ subsets_model.refreshed.disconnect()
+ self.echo("Duration: %.3fs" % (time.time() - t1))
+
+ subsets_model.refreshed.connect(on_refreshed)
+
+ subsets_model.set_assets(asset_ids)
+ subsets_widget.view.setColumnHidden(
+ subsets_model.Columns.index("asset"),
+ len(asset_ids) < 2
+ )
+
+ # Clear the version information on asset change
+ self.data["widgets"]["version"].set_version(None)
+ self.data["widgets"]["thumbnail"].set_thumbnail(asset_docs)
+
+ self.data["state"]["assetIds"] = asset_ids
+
+ representations = self.data["widgets"]["representations"]
+ representations.set_version_ids([]) # reset repre list
+
+ self.echo("Duration: %.3fs" % (time.time() - t1))
+
+ def _subsetschanged(self):
+ asset_ids = self.data["state"]["assetIds"]
+ # Skip setting colors if not asset multiselection
+ if not asset_ids or len(asset_ids) < 2:
+ self._versionschanged()
+ return
+
+ subsets = self.data["widgets"]["subsets"]
+ selected_subsets = subsets.selected_subsets(_merged=True, _other=False)
+
+ asset_models = {}
+ asset_ids = []
+ for subset_node in selected_subsets:
+ asset_ids.extend(subset_node.get("assetIds", []))
+ asset_ids = set(asset_ids)
+
+ for subset_node in selected_subsets:
+ for asset_id in asset_ids:
+ if asset_id not in asset_models:
+ asset_models[asset_id] = []
+
+ color = None
+ if asset_id in subset_node.get("assetIds", []):
+ color = subset_node["subsetColor"]
+
+ asset_models[asset_id].append(color)
+
+ self.clear_assets_underlines()
+
+ assets_widget = self.data["widgets"]["assets"]
+ indexes = assets_widget.view.selectionModel().selectedRows()
+
+ for index in indexes:
+ id = index.data(assets_widget.model.ObjectIdRole)
+ if id not in asset_models:
+ continue
+
+ assets_widget.model.setData(
+ index, asset_models[id], assets_widget.model.subsetColorsRole
+ )
+ # Trigger repaint
+ assets_widget.view.updateGeometries()
+ # Set version in Version Widget
+ self._versionschanged()
+
+ def _versionschanged(self):
+
+ subsets = self.data["widgets"]["subsets"]
+ selection = subsets.view.selectionModel()
+
+ # Active must be in the selected rows otherwise we
+ # assume it's not actually an "active" current index.
+ version_docs = None
+ version_doc = None
+ active = selection.currentIndex()
+ rows = selection.selectedRows(column=active.column())
+ if active and active in rows:
+ item = active.data(subsets.model.ItemRole)
+ if (
+ item is not None
+ and not (item.get("isGroup") or item.get("isMerged"))
+ ):
+ version_doc = item["version_document"]
+
+ if rows:
+ version_docs = []
+ for index in rows:
+ if not index or not index.isValid():
+ continue
+ item = index.data(subsets.model.ItemRole)
+ if (
+ item is None
+ or item.get("isGroup")
+ or item.get("isMerged")
+ ):
+ continue
+ version_docs.append(item["version_document"])
+
+ self.data["widgets"]["version"].set_version(version_doc)
+
+ thumbnail_docs = version_docs
+ if not thumbnail_docs:
+ assets_widget = self.data["widgets"]["assets"]
+ asset_docs = assets_widget.get_selected_assets()
+ if len(asset_docs) > 0:
+ thumbnail_docs = asset_docs
+
+ self.data["widgets"]["thumbnail"].set_thumbnail(thumbnail_docs)
+
+ representations = self.data["widgets"]["representations"]
+ version_ids = [doc["_id"] for doc in version_docs or []]
+ representations.set_version_ids(version_ids)
+
+ def _set_context(self, context, refresh=True):
+ """Set the selection in the interface using a context.
+ The context must contain `asset` data by name.
+ Note: Prior to setting context ensure `refresh` is triggered so that
+ the "silos" are listed correctly, aside from that setting the
+ context will force a refresh further down because it changes
+ the active silo and asset.
+ Args:
+ context (dict): The context to apply.
+ Returns:
+ None
+ """
+
+ asset = context.get("asset", None)
+ if asset is None:
+ return
+
+ if refresh:
+ # Workaround:
+ # Force a direct (non-scheduled) refresh prior to setting the
+ # asset widget's silo and asset selection to ensure it's correctly
+ # displaying the silo tabs. Calling `window.refresh()` and directly
+ # `window.set_context()` the `set_context()` seems to override the
+ # scheduled refresh and the silo tabs are not shown.
+ self._refresh_assets()
+
+ asset_widget = self.data["widgets"]["assets"]
+ asset_widget.select_assets(asset)
+
+ def echo(self, message):
+ widget = self.data["label"]["message"]
+ widget.setText(str(message))
+ widget.show()
+ print(message)
+
+ tools_lib.schedule(widget.hide, 5000, channel="message")
+
+ def closeEvent(self, event):
+ # Kill on holding SHIFT
+ modifiers = QtWidgets.QApplication.queryKeyboardModifiers()
+ shift_pressed = QtCore.Qt.ShiftModifier & modifiers
+
+ if shift_pressed:
+ print("Force quitted..")
+ self.setAttribute(QtCore.Qt.WA_DeleteOnClose)
+
+ print("Good bye")
+ return super(LibraryLoaderWindow, self).closeEvent(event)
+
+
+def show(
+ debug=False, parent=None, icon=None,
+ show_projects=False, show_libraries=True
+):
+ """Display Loader GUI
+
+ Arguments:
+ debug (bool, optional): Run loader in debug-mode,
+ defaults to False
+ parent (QtCore.QObject, optional): The Qt object to parent to.
+ use_context (bool): Whether to apply the current context upon launch
+
+ """
+ # Remember window
+ if module.window is not None:
+ try:
+ module.window.show()
+
+ # If the window is minimized then unminimize it.
+ if module.window.windowState() & QtCore.Qt.WindowMinimized:
+ module.window.setWindowState(QtCore.Qt.WindowActive)
+
+ # Raise and activate the window
+ module.window.raise_() # for MacOS
+ module.window.activateWindow() # for Windows
+ module.window.refresh()
+ return
+ except RuntimeError as e:
+ if not e.message.rstrip().endswith("already deleted."):
+ raise
+
+ # Garbage collected
+ module.window = None
+
+ if debug:
+ import traceback
+ sys.excepthook = lambda typ, val, tb: traceback.print_last()
+
+ with tools_lib.application():
+ window = LibraryLoaderWindow(
+ parent, icon, show_projects, show_libraries
+ )
+ window.setStyleSheet(style.load_stylesheet())
+ window.show()
+
+ module.window = window
+
+
+def cli(args):
+
+ import argparse
+
+ parser = argparse.ArgumentParser()
+ parser.add_argument("project")
+
+ show(show_projects=True, show_libraries=True)
diff --git a/openpype/tools/libraryloader/lib.py b/openpype/tools/libraryloader/lib.py
new file mode 100644
index 0000000000..6a497a6a16
--- /dev/null
+++ b/openpype/tools/libraryloader/lib.py
@@ -0,0 +1,33 @@
+import os
+import importlib
+import logging
+from openpype.api import Anatomy
+
+log = logging.getLogger(__name__)
+
+
+# `find_config` from `pipeline`
+def find_config():
+ log.info("Finding configuration for project..")
+
+ config = os.environ["AVALON_CONFIG"]
+
+ if not config:
+ raise EnvironmentError(
+ "No configuration found in "
+ "the project nor environment"
+ )
+
+ log.info("Found %s, loading.." % config)
+ return importlib.import_module(config)
+
+
+class RegisteredRoots:
+ roots_per_project = {}
+
+ @classmethod
+ def registered_root(cls, project_name):
+ if project_name not in cls.roots_per_project:
+ cls.roots_per_project[project_name] = Anatomy(project_name).roots
+
+ return cls.roots_per_project[project_name]
diff --git a/openpype/tools/libraryloader/widgets.py b/openpype/tools/libraryloader/widgets.py
new file mode 100644
index 0000000000..45f9ea2048
--- /dev/null
+++ b/openpype/tools/libraryloader/widgets.py
@@ -0,0 +1,18 @@
+from Qt import QtWidgets
+
+from .lib import RegisteredRoots
+from openpype.tools.loader.widgets import SubsetWidget
+
+
+class LibrarySubsetWidget(SubsetWidget):
+ def on_copy_source(self):
+ """Copy formatted source path to clipboard"""
+ source = self.data.get("source", None)
+ if not source:
+ return
+
+ project_name = self.dbcon.Session["AVALON_PROJECT"]
+ root = RegisteredRoots.registered_root(project_name)
+ path = source.format(root=root)
+ clipboard = QtWidgets.QApplication.clipboard()
+ clipboard.setText(path)
diff --git a/openpype/tools/loader/__init__.py b/openpype/tools/loader/__init__.py
new file mode 100644
index 0000000000..c7bd6148a7
--- /dev/null
+++ b/openpype/tools/loader/__init__.py
@@ -0,0 +1,11 @@
+from .app import (
+ LoaderWidow,
+ show,
+ cli,
+)
+
+__all__ = (
+ "LoaderWidow",
+ "show",
+ "cli",
+)
diff --git a/openpype/tools/loader/__main__.py b/openpype/tools/loader/__main__.py
new file mode 100644
index 0000000000..27794b9bc5
--- /dev/null
+++ b/openpype/tools/loader/__main__.py
@@ -0,0 +1,35 @@
+"""Main entrypoint for standalone debugging"""
+"""
+ Used for running 'avalon.tool.loader.__main__' as a module (-m), useful for
+ debugging without need to start host.
+
+ Modify AVALON_MONGO accordingly
+"""
+from . import cli
+
+
+def my_exception_hook(exctype, value, traceback):
+ # Print the error and traceback
+ print(exctype, value, traceback)
+ # Call the normal Exception hook after
+ sys._excepthook(exctype, value, traceback)
+ sys.exit(1)
+
+
+if __name__ == '__main__':
+ import os
+ os.environ["AVALON_MONGO"] = "mongodb://localhost:27017"
+ os.environ["OPENPYPE_MONGO"] = "mongodb://localhost:27017"
+ os.environ["AVALON_DB"] = "avalon"
+ os.environ["AVALON_TIMEOUT"] = "1000"
+ os.environ["OPENPYPE_DEBUG"] = "1"
+ os.environ["AVALON_CONFIG"] = "pype"
+ os.environ["AVALON_ASSET"] = "Jungle"
+
+
+ import sys
+
+ # Set the exception hook to our wrapping function
+ sys.excepthook = my_exception_hook
+
+ sys.exit(cli(sys.argv[1:]))
diff --git a/openpype/tools/loader/app.py b/openpype/tools/loader/app.py
new file mode 100644
index 0000000000..381d6b25d8
--- /dev/null
+++ b/openpype/tools/loader/app.py
@@ -0,0 +1,674 @@
+import sys
+import time
+
+from Qt import QtWidgets, QtCore
+from avalon import api, io, style, pipeline
+
+from openpype.tools.utils.widgets import AssetWidget
+
+from openpype.tools.utils import lib
+
+from .widgets import (
+ SubsetWidget,
+ VersionWidget,
+ FamilyListWidget,
+ ThumbnailWidget,
+ RepresentationWidget,
+ OverlayFrame
+)
+
+from openpype.modules import ModulesManager
+
+module = sys.modules[__name__]
+module.window = None
+
+
+# Register callback on task change
+# - callback can't be defined in Window as it is weak reference callback
+# so `WeakSet` will remove it immidiatelly
+def on_context_task_change(*args, **kwargs):
+ if module.window:
+ module.window.on_context_task_change(*args, **kwargs)
+
+
+pipeline.on("taskChanged", on_context_task_change)
+
+
+class LoaderWidow(QtWidgets.QDialog):
+ """Asset loader interface"""
+
+ tool_name = "loader"
+
+ def __init__(self, parent=None):
+ super(LoaderWidow, self).__init__(parent)
+ title = "Asset Loader 2.1"
+ project_name = api.Session.get("AVALON_PROJECT")
+ if project_name:
+ title += " - {}".format(project_name)
+ self.setWindowTitle(title)
+
+ # Groups config
+ self.groups_config = lib.GroupsConfig(io)
+ self.family_config_cache = lib.FamilyConfigCache(io)
+
+ # Enable minimize and maximize for app
+ self.setWindowFlags(QtCore.Qt.Window)
+ self.setFocusPolicy(QtCore.Qt.StrongFocus)
+
+ body = QtWidgets.QWidget()
+ footer = QtWidgets.QWidget()
+ footer.setFixedHeight(20)
+
+ container = QtWidgets.QWidget()
+
+ assets = AssetWidget(io, multiselection=True, parent=self)
+ assets.set_current_asset_btn_visibility(True)
+
+ families = FamilyListWidget(io, self.family_config_cache, self)
+ subsets = SubsetWidget(
+ io,
+ self.groups_config,
+ self.family_config_cache,
+ tool_name=self.tool_name,
+ parent=self
+ )
+ version = VersionWidget(io)
+ thumbnail = ThumbnailWidget(io)
+ representations = RepresentationWidget(io, self.tool_name)
+
+ manager = ModulesManager()
+ sync_server = manager.modules_by_name["sync_server"]
+
+ thumb_ver_splitter = QtWidgets.QSplitter()
+ thumb_ver_splitter.setOrientation(QtCore.Qt.Vertical)
+ thumb_ver_splitter.addWidget(thumbnail)
+ thumb_ver_splitter.addWidget(version)
+ if sync_server.enabled:
+ thumb_ver_splitter.addWidget(representations)
+ thumb_ver_splitter.setStretchFactor(0, 30)
+ thumb_ver_splitter.setStretchFactor(1, 35)
+
+ # Create splitter to show / hide family filters
+ asset_filter_splitter = QtWidgets.QSplitter()
+ asset_filter_splitter.setOrientation(QtCore.Qt.Vertical)
+ asset_filter_splitter.addWidget(assets)
+ asset_filter_splitter.addWidget(families)
+ asset_filter_splitter.setStretchFactor(0, 65)
+ asset_filter_splitter.setStretchFactor(1, 35)
+
+ container_layout = QtWidgets.QHBoxLayout(container)
+ container_layout.setContentsMargins(0, 0, 0, 0)
+ split = QtWidgets.QSplitter()
+ split.addWidget(asset_filter_splitter)
+ split.addWidget(subsets)
+ split.addWidget(thumb_ver_splitter)
+
+ container_layout.addWidget(split)
+
+ body_layout = QtWidgets.QHBoxLayout(body)
+ body_layout.addWidget(container)
+ body_layout.setContentsMargins(0, 0, 0, 0)
+
+ message = QtWidgets.QLabel()
+ message.hide()
+
+ footer_layout = QtWidgets.QVBoxLayout(footer)
+ footer_layout.addWidget(message)
+ footer_layout.setContentsMargins(0, 0, 0, 0)
+
+ layout = QtWidgets.QVBoxLayout(self)
+ layout.addWidget(body)
+ layout.addWidget(footer)
+
+ self.data = {
+ "widgets": {
+ "families": families,
+ "assets": assets,
+ "subsets": subsets,
+ "version": version,
+ "thumbnail": thumbnail,
+ "representations": representations
+ },
+ "label": {
+ "message": message,
+ },
+ "state": {
+ "assetIds": None
+ }
+ }
+
+ overlay_frame = OverlayFrame("Loading...", self)
+ overlay_frame.setVisible(False)
+
+ families.active_changed.connect(subsets.set_family_filters)
+ assets.selection_changed.connect(self.on_assetschanged)
+ assets.refresh_triggered.connect(self.on_assetschanged)
+ assets.view.clicked.connect(self.on_assetview_click)
+ subsets.active_changed.connect(self.on_subsetschanged)
+ subsets.version_changed.connect(self.on_versionschanged)
+
+ subsets.load_started.connect(self._on_load_start)
+ subsets.load_ended.connect(self._on_load_end)
+ representations.load_started.connect(self._on_load_start)
+ representations.load_ended.connect(self._on_load_end)
+
+ self._overlay_frame = overlay_frame
+
+ self.family_config_cache.refresh()
+ self.groups_config.refresh()
+
+ self._refresh()
+ self._assetschanged()
+
+ # Defaults
+ if sync_server.enabled:
+ split.setSizes([250, 1000, 550])
+ self.resize(1800, 900)
+ else:
+ split.setSizes([250, 850, 200])
+ self.resize(1300, 700)
+
+ def resizeEvent(self, event):
+ super(LoaderWidow, self).resizeEvent(event)
+ self._overlay_frame.resize(self.size())
+
+ def moveEvent(self, event):
+ super(LoaderWidow, self).moveEvent(event)
+ self._overlay_frame.move(0, 0)
+
+ # -------------------------------
+ # Delay calling blocking methods
+ # -------------------------------
+
+ def on_assetview_click(self, *args):
+ subsets_widget = self.data["widgets"]["subsets"]
+ selection_model = subsets_widget.view.selectionModel()
+ if selection_model.selectedIndexes():
+ selection_model.clearSelection()
+
+ def refresh(self):
+ self.echo("Fetching results..")
+ lib.schedule(self._refresh, 50, channel="mongo")
+
+ def on_assetschanged(self, *args):
+ self.echo("Fetching asset..")
+ lib.schedule(self._assetschanged, 50, channel="mongo")
+
+ def on_subsetschanged(self, *args):
+ self.echo("Fetching subset..")
+ lib.schedule(self._subsetschanged, 50, channel="mongo")
+
+ def on_versionschanged(self, *args):
+ self.echo("Fetching version..")
+ lib.schedule(self._versionschanged, 150, channel="mongo")
+
+ def set_context(self, context, refresh=True):
+ self.echo("Setting context: {}".format(context))
+ lib.schedule(lambda: self._set_context(context, refresh=refresh),
+ 50, channel="mongo")
+
+ def _on_load_start(self):
+ # Show overlay and process events so it's repainted
+ self._overlay_frame.setVisible(True)
+ QtWidgets.QApplication.processEvents()
+
+ def _hide_overlay(self):
+ self._overlay_frame.setVisible(False)
+
+ def _on_load_end(self):
+ # Delay hiding as click events happened during loading should be
+ # blocked
+ QtCore.QTimer.singleShot(100, self._hide_overlay)
+
+ # ------------------------------
+
+ def on_context_task_change(self, *args, **kwargs):
+ # Change to context asset on context change
+ assets_widget = self.data["widgets"]["assets"]
+ assets_widget.select_assets(io.Session["AVALON_ASSET"])
+
+ def _refresh(self):
+ """Load assets from database"""
+
+ # Ensure a project is loaded
+ project = io.find_one({"type": "project"}, {"type": 1})
+ assert project, "Project was not found! This is a bug"
+
+ assets_widget = self.data["widgets"]["assets"]
+ assets_widget.refresh()
+ assets_widget.setFocus()
+
+ families = self.data["widgets"]["families"]
+ families.refresh()
+
+ def clear_assets_underlines(self):
+ """Clear colors from asset data to remove colored underlines
+ When multiple assets are selected colored underlines mark which asset
+ own selected subsets. These colors must be cleared from asset data
+ on selection change so they match current selection.
+ """
+ last_asset_ids = self.data["state"]["assetIds"]
+ if not last_asset_ids:
+ return
+
+ assets_widget = self.data["widgets"]["assets"]
+ id_role = assets_widget.model.ObjectIdRole
+
+ for index in lib.iter_model_rows(assets_widget.model, 0):
+ if index.data(id_role) not in last_asset_ids:
+ continue
+
+ assets_widget.model.setData(
+ index, [], assets_widget.model.subsetColorsRole
+ )
+
+ def _assetschanged(self):
+ """Selected assets have changed"""
+ t1 = time.time()
+
+ assets_widget = self.data["widgets"]["assets"]
+ subsets_widget = self.data["widgets"]["subsets"]
+ subsets_model = subsets_widget.model
+
+ subsets_model.clear()
+ self.clear_assets_underlines()
+
+ # filter None docs they are silo
+ asset_docs = assets_widget.get_selected_assets()
+
+ asset_ids = [asset_doc["_id"] for asset_doc in asset_docs]
+ # Start loading
+ subsets_widget.set_loading_state(
+ loading=bool(asset_ids),
+ empty=True
+ )
+
+ def on_refreshed(has_item):
+ empty = not has_item
+ subsets_widget.set_loading_state(loading=False, empty=empty)
+ subsets_model.refreshed.disconnect()
+ self.echo("Duration: %.3fs" % (time.time() - t1))
+
+ subsets_model.refreshed.connect(on_refreshed)
+
+ subsets_model.set_assets(asset_ids)
+ subsets_widget.view.setColumnHidden(
+ subsets_model.Columns.index("asset"),
+ len(asset_ids) < 2
+ )
+
+ # Clear the version information on asset change
+ self.data["widgets"]["version"].set_version(None)
+ self.data["widgets"]["thumbnail"].set_thumbnail(asset_docs)
+
+ self.data["state"]["assetIds"] = asset_ids
+
+ representations = self.data["widgets"]["representations"]
+ representations.set_version_ids([]) # reset repre list
+
+ def _subsetschanged(self):
+ asset_ids = self.data["state"]["assetIds"]
+ # Skip setting colors if not asset multiselection
+ if not asset_ids or len(asset_ids) < 2:
+ self._versionschanged()
+ return
+
+ subsets = self.data["widgets"]["subsets"]
+ selected_subsets = subsets.selected_subsets(_merged=True, _other=False)
+
+ asset_models = {}
+ asset_ids = []
+ for subset_node in selected_subsets:
+ asset_ids.extend(subset_node.get("assetIds", []))
+ asset_ids = set(asset_ids)
+
+ for subset_node in selected_subsets:
+ for asset_id in asset_ids:
+ if asset_id not in asset_models:
+ asset_models[asset_id] = []
+
+ color = None
+ if asset_id in subset_node.get("assetIds", []):
+ color = subset_node["subsetColor"]
+
+ asset_models[asset_id].append(color)
+
+ self.clear_assets_underlines()
+
+ assets_widget = self.data["widgets"]["assets"]
+ indexes = assets_widget.view.selectionModel().selectedRows()
+
+ for index in indexes:
+ id = index.data(assets_widget.model.ObjectIdRole)
+ if id not in asset_models:
+ continue
+
+ assets_widget.model.setData(
+ index, asset_models[id], assets_widget.model.subsetColorsRole
+ )
+ # Trigger repaint
+ assets_widget.view.updateGeometries()
+ # Set version in Version Widget
+ self._versionschanged()
+
+ def _versionschanged(self):
+ subsets = self.data["widgets"]["subsets"]
+ selection = subsets.view.selectionModel()
+
+ # Active must be in the selected rows otherwise we
+ # assume it's not actually an "active" current index.
+ version_docs = None
+ version_doc = None
+ active = selection.currentIndex()
+ rows = selection.selectedRows(column=active.column())
+ if active:
+ if active in rows:
+ item = active.data(subsets.model.ItemRole)
+ if (
+ item is not None and
+ not (item.get("isGroup") or item.get("isMerged"))
+ ):
+ version_doc = item["version_document"]
+
+ if rows:
+ version_docs = []
+ for index in rows:
+ if not index or not index.isValid():
+ continue
+ item = index.data(subsets.model.ItemRole)
+ if item is None:
+ continue
+ if item.get("isGroup") or item.get("isMerged"):
+ for child in item.children():
+ version_docs.append(child["version_document"])
+ else:
+ version_docs.append(item["version_document"])
+
+ self.data["widgets"]["version"].set_version(version_doc)
+
+ thumbnail_docs = version_docs
+ assets_widget = self.data["widgets"]["assets"]
+ asset_docs = assets_widget.get_selected_assets()
+ if not thumbnail_docs:
+ if len(asset_docs) > 0:
+ thumbnail_docs = asset_docs
+
+ self.data["widgets"]["thumbnail"].set_thumbnail(thumbnail_docs)
+
+ representations = self.data["widgets"]["representations"]
+ version_ids = [doc["_id"] for doc in version_docs or []]
+ representations.set_version_ids(version_ids)
+
+ # representations.change_visibility("subset", len(rows) > 1)
+ # representations.change_visibility("asset", len(asset_docs) > 1)
+
+ def _set_context(self, context, refresh=True):
+ """Set the selection in the interface using a context.
+
+ The context must contain `asset` data by name.
+
+ Note: Prior to setting context ensure `refresh` is triggered so that
+ the "silos" are listed correctly, aside from that setting the
+ context will force a refresh further down because it changes
+ the active silo and asset.
+
+ Args:
+ context (dict): The context to apply.
+
+ Returns:
+ None
+
+ """
+
+ asset = context.get("asset", None)
+ if asset is None:
+ return
+
+ if refresh:
+ # Workaround:
+ # Force a direct (non-scheduled) refresh prior to setting the
+ # asset widget's silo and asset selection to ensure it's correctly
+ # displaying the silo tabs. Calling `window.refresh()` and directly
+ # `window.set_context()` the `set_context()` seems to override the
+ # scheduled refresh and the silo tabs are not shown.
+ self._refresh()
+
+ asset_widget = self.data["widgets"]["assets"]
+ asset_widget.select_assets(asset)
+
+ def echo(self, message):
+ widget = self.data["label"]["message"]
+ widget.setText(str(message))
+ widget.show()
+ print(message)
+
+ lib.schedule(widget.hide, 5000, channel="message")
+
+ def closeEvent(self, event):
+ # Kill on holding SHIFT
+ modifiers = QtWidgets.QApplication.queryKeyboardModifiers()
+ shift_pressed = QtCore.Qt.ShiftModifier & modifiers
+
+ if shift_pressed:
+ print("Force quitted..")
+ self.setAttribute(QtCore.Qt.WA_DeleteOnClose)
+
+ print("Good bye")
+ return super(LoaderWidow, self).closeEvent(event)
+
+ def keyPressEvent(self, event):
+ modifiers = event.modifiers()
+ ctrl_pressed = QtCore.Qt.ControlModifier & modifiers
+
+ # Grouping subsets on pressing Ctrl + G
+ if (ctrl_pressed and event.key() == QtCore.Qt.Key_G and
+ not event.isAutoRepeat()):
+ self.show_grouping_dialog()
+ return
+
+ super(LoaderWidow, self).keyPressEvent(event)
+ event.setAccepted(True) # Avoid interfering other widgets
+
+ def show_grouping_dialog(self):
+ subsets = self.data["widgets"]["subsets"]
+ if not subsets.is_groupable():
+ self.echo("Grouping not enabled.")
+ return
+
+ selected = []
+ merged_items = []
+ for item in subsets.selected_subsets(_merged=True):
+ if item.get("isMerged"):
+ merged_items.append(item)
+ else:
+ selected.append(item)
+
+ for merged_item in merged_items:
+ for child_item in merged_item.children():
+ selected.append(child_item)
+
+ if not selected:
+ self.echo("No selected subset.")
+ return
+
+ dialog = SubsetGroupingDialog(
+ items=selected, groups_config=self.groups_config, parent=self
+ )
+ dialog.grouped.connect(self._assetschanged)
+ dialog.show()
+
+
+class SubsetGroupingDialog(QtWidgets.QDialog):
+ grouped = QtCore.Signal()
+
+ def __init__(self, items, groups_config, parent=None):
+ super(SubsetGroupingDialog, self).__init__(parent=parent)
+ self.setWindowTitle("Grouping Subsets")
+ self.setMinimumWidth(250)
+ self.setModal(True)
+
+ self.items = items
+ self.groups_config = groups_config
+ self.subsets = parent.data["widgets"]["subsets"]
+ self.asset_ids = parent.data["state"]["assetIds"]
+
+ name = QtWidgets.QLineEdit()
+ name.setPlaceholderText("Remain blank to ungroup..")
+
+ # Menu for pre-defined subset groups
+ name_button = QtWidgets.QPushButton()
+ name_button.setFixedWidth(18)
+ name_button.setFixedHeight(20)
+ name_menu = QtWidgets.QMenu(name_button)
+ name_button.setMenu(name_menu)
+
+ name_layout = QtWidgets.QHBoxLayout()
+ name_layout.addWidget(name)
+ name_layout.addWidget(name_button)
+ name_layout.setContentsMargins(0, 0, 0, 0)
+
+ group_btn = QtWidgets.QPushButton("Apply")
+
+ layout = QtWidgets.QVBoxLayout(self)
+ layout.addWidget(QtWidgets.QLabel("Group Name"))
+ layout.addLayout(name_layout)
+ layout.addWidget(group_btn)
+
+ group_btn.clicked.connect(self.on_group)
+ group_btn.setAutoDefault(True)
+ group_btn.setDefault(True)
+
+ self.name = name
+ self.name_menu = name_menu
+
+ self._build_menu()
+
+ def _build_menu(self):
+ menu = self.name_menu
+ button = menu.parent()
+ # Get and destroy the action group
+ group = button.findChild(QtWidgets.QActionGroup)
+ if group:
+ group.deleteLater()
+
+ active_groups = self.groups_config.active_groups(self.asset_ids)
+
+ # Build new action group
+ group = QtWidgets.QActionGroup(button)
+ group_names = list()
+ for data in sorted(active_groups, key=lambda x: x["order"]):
+ name = data["name"]
+ if name in group_names:
+ continue
+ group_names.append(name)
+ icon = data["icon"]
+
+ action = group.addAction(name)
+ action.setIcon(icon)
+ menu.addAction(action)
+
+ group.triggered.connect(self._on_action_clicked)
+ button.setEnabled(not menu.isEmpty())
+
+ def _on_action_clicked(self, action):
+ self.name.setText(action.text())
+
+ def on_group(self):
+ name = self.name.text().strip()
+ self.subsets.group_subsets(name, self.asset_ids, self.items)
+
+ with lib.preserve_selection(tree_view=self.subsets.view,
+ current_index=False):
+ self.grouped.emit()
+ self.close()
+
+
+def show(debug=False, parent=None, use_context=False):
+ """Display Loader GUI
+
+ Arguments:
+ debug (bool, optional): Run loader in debug-mode,
+ defaults to False
+ parent (QtCore.QObject, optional): The Qt object to parent to.
+ use_context (bool): Whether to apply the current context upon launch
+
+ """
+
+ # Remember window
+ if module.window is not None:
+ try:
+ module.window.show()
+
+ # If the window is minimized then unminimize it.
+ if module.window.windowState() & QtCore.Qt.WindowMinimized:
+ module.window.setWindowState(QtCore.Qt.WindowActive)
+
+ # Raise and activate the window
+ module.window.raise_() # for MacOS
+ module.window.activateWindow() # for Windows
+ module.window.refresh()
+ return
+ except (AttributeError, RuntimeError):
+ # Garbage collected
+ module.window = None
+
+ if debug:
+ import traceback
+ sys.excepthook = lambda typ, val, tb: traceback.print_last()
+
+ io.install()
+
+ any_project = next(
+ project for project in io.projects()
+ if project.get("active", True) is not False
+ )
+
+ api.Session["AVALON_PROJECT"] = any_project["name"]
+ module.project = any_project["name"]
+
+ with lib.application():
+ window = LoaderWidow(parent)
+ window.setStyleSheet(style.load_stylesheet())
+ window.show()
+
+ if use_context:
+ context = {"asset": api.Session["AVALON_ASSET"]}
+ window.set_context(context, refresh=True)
+ else:
+ window.refresh()
+
+ module.window = window
+
+ # Pull window to the front.
+ module.window.raise_()
+ module.window.activateWindow()
+
+
+def cli(args):
+
+ import argparse
+
+ parser = argparse.ArgumentParser()
+ parser.add_argument("project")
+
+ args = parser.parse_args(args)
+ project = args.project
+
+ print("Entering Project: %s" % project)
+
+ io.install()
+
+ # Store settings
+ api.Session["AVALON_PROJECT"] = project
+
+ from avalon import pipeline
+
+ # Find the set config
+ _config = pipeline.find_config()
+ if hasattr(_config, "install"):
+ _config.install()
+ else:
+ print("Config `%s` has no function `install`" %
+ _config.__name__)
+
+ show()
diff --git a/openpype/tools/loader/lib.py b/openpype/tools/loader/lib.py
new file mode 100644
index 0000000000..14ebab6c85
--- /dev/null
+++ b/openpype/tools/loader/lib.py
@@ -0,0 +1,190 @@
+import inspect
+from Qt import QtGui
+
+from avalon.vendor import qtawesome
+from openpype.tools.utils.widgets import (
+ OptionalAction,
+ OptionDialog
+)
+
+
+def change_visibility(model, view, column_name, visible):
+ """
+ Hides or shows particular 'column_name'.
+
+ "asset" and "subset" columns should be visible only in multiselect
+ """
+ index = model.Columns.index(column_name)
+ view.setColumnHidden(index, not visible)
+
+
+def get_selected_items(rows, item_role):
+ items = []
+ for row_index in rows:
+ item = row_index.data(item_role)
+ if item.get("isGroup"):
+ continue
+
+ elif item.get("isMerged"):
+ for idx in range(row_index.model().rowCount(row_index)):
+ child_index = row_index.child(idx, 0)
+ item = child_index.data(item_role)
+ if item not in items:
+ items.append(item)
+
+ else:
+ if item not in items:
+ items.append(item)
+ return items
+
+
+def get_options(action, loader, parent, repre_contexts):
+ """Provides dialog to select value from loader provided options.
+
+ Loader can provide static or dynamically created options based on
+ qargparse variants.
+
+ Args:
+ action (OptionalAction) - action in menu
+ loader (cls of api.Loader) - not initilized yet
+ parent (Qt element to parent dialog to)
+ repre_contexts (list) of dict with full info about selected repres
+ Returns:
+ (dict) - selected value from OptionDialog
+ None when dialog was closed or cancelled, in all other cases {}
+ if no options
+ """
+ # Pop option dialog
+ options = {}
+ loader_options = loader.get_options(repre_contexts)
+ if getattr(action, "optioned", False) and loader_options:
+ dialog = OptionDialog(parent)
+ dialog.setWindowTitle(action.label + " Options")
+ dialog.create(loader_options)
+
+ if not dialog.exec_():
+ return None
+
+ # Get option
+ options = dialog.parse()
+
+ return options
+
+
+def add_representation_loaders_to_menu(loaders, menu, repre_contexts):
+ """
+ Loops through provider loaders and adds them to 'menu'.
+
+ Expects loaders sorted in requested order.
+ Expects loaders de-duplicated if wanted.
+
+ Args:
+ loaders(tuple): representation - loader
+ menu (OptionalMenu):
+ repre_contexts (dict): full info about representations (contains
+ their repre_doc, asset_doc, subset_doc, version_doc),
+ keys are repre_ids
+
+ Returns:
+ menu (OptionalMenu): with new items
+ """
+ # List the available loaders
+ for representation, loader in loaders:
+ label = None
+ repre_context = None
+ if representation:
+ label = representation.get("custom_label")
+ repre_context = repre_contexts[representation["_id"]]
+
+ if not label:
+ label = get_label_from_loader(loader, representation)
+
+ icon = get_icon_from_loader(loader)
+
+ loader_options = loader.get_options([repre_context])
+
+ use_option = bool(loader_options)
+ action = OptionalAction(label, icon, use_option, menu)
+ if use_option:
+ # Add option box tip
+ action.set_option_tip(loader_options)
+
+ action.setData((representation, loader))
+
+ # Add tooltip and statustip from Loader docstring
+ tip = inspect.getdoc(loader)
+ if tip:
+ action.setToolTip(tip)
+ action.setStatusTip(tip)
+
+ menu.addAction(action)
+
+ return menu
+
+
+def remove_tool_name_from_loaders(available_loaders, tool_name):
+ if not tool_name:
+ return available_loaders
+ filtered_loaders = []
+ for loader in available_loaders:
+ if hasattr(loader, "tool_names"):
+ if not ("*" in loader.tool_names or
+ tool_name in loader.tool_names):
+ continue
+ filtered_loaders.append(loader)
+ return filtered_loaders
+
+
+def get_icon_from_loader(loader):
+ """Pull icon info from loader class"""
+ # Support font-awesome icons using the `.icon` and `.color`
+ # attributes on plug-ins.
+ icon = getattr(loader, "icon", None)
+ if icon is not None:
+ try:
+ key = "fa.{0}".format(icon)
+ color = getattr(loader, "color", "white")
+ icon = qtawesome.icon(key, color=color)
+ except Exception as e:
+ print("Unable to set icon for loader "
+ "{}: {}".format(loader, e))
+ icon = None
+ return icon
+
+
+def get_label_from_loader(loader, representation=None):
+ """Pull label info from loader class"""
+ label = getattr(loader, "label", None)
+ if label is None:
+ label = loader.__name__
+ if representation:
+ # Add the representation as suffix
+ label = "{0} ({1})".format(label, representation['name'])
+ return label
+
+
+def get_no_loader_action(menu, one_item_selected=False):
+ """Creates dummy no loader option in 'menu'"""
+ submsg = "your selection."
+ if one_item_selected:
+ submsg = "this version."
+ msg = "No compatible loaders for {}".format(submsg)
+ print(msg)
+ icon = qtawesome.icon(
+ "fa.exclamation",
+ color=QtGui.QColor(255, 51, 0)
+ )
+ action = OptionalAction(("*" + msg), icon, False, menu)
+ return action
+
+
+def sort_loaders(loaders, custom_sorter=None):
+ def sorter(value):
+ """Sort the Loaders by their order and then their name"""
+ Plugin = value[1]
+ return Plugin.order, Plugin.__name__
+
+ if not custom_sorter:
+ custom_sorter = sorter
+
+ return sorted(loaders, key=custom_sorter)
diff --git a/openpype/tools/loader/model.py b/openpype/tools/loader/model.py
new file mode 100644
index 0000000000..253341f70d
--- /dev/null
+++ b/openpype/tools/loader/model.py
@@ -0,0 +1,1191 @@
+import copy
+import re
+import math
+
+from avalon import (
+ style,
+ schema
+)
+from Qt import QtCore, QtGui
+
+from avalon.vendor import qtawesome
+from avalon.lib import HeroVersionType
+
+from openpype.tools.utils.models import TreeModel, Item
+from openpype.tools.utils import lib
+
+from openpype.modules import ModulesManager
+
+
+def is_filtering_recursible():
+ """Does Qt binding support recursive filtering for QSortFilterProxyModel?
+
+ (NOTE) Recursive filtering was introduced in Qt 5.10.
+
+ """
+ return hasattr(QtCore.QSortFilterProxyModel,
+ "setRecursiveFilteringEnabled")
+
+
+class BaseRepresentationModel(object):
+ """Methods for SyncServer useful in multiple models"""
+
+ def reset_sync_server(self, project_name=None):
+ """Sets/Resets sync server vars after every change (refresh.)"""
+ repre_icons = {}
+ sync_server = None
+ active_site = active_provider = None
+ remote_site = remote_provider = None
+
+ if not project_name:
+ project_name = self.dbcon.Session["AVALON_PROJECT"]
+ else:
+ self.dbcon.Session["AVALON_PROJECT"] = project_name
+
+ if project_name:
+ manager = ModulesManager()
+ sync_server = manager.modules_by_name["sync_server"]
+
+ if project_name in sync_server.get_enabled_projects():
+ active_site = sync_server.get_active_site(project_name)
+ active_provider = sync_server.get_provider_for_site(
+ project_name, active_site)
+ if active_site == 'studio': # for studio use explicit icon
+ active_provider = 'studio'
+
+ remote_site = sync_server.get_remote_site(project_name)
+ remote_provider = sync_server.get_provider_for_site(
+ project_name, remote_site)
+ if remote_site == 'studio': # for studio use explicit icon
+ remote_provider = 'studio'
+
+ repre_icons = lib.get_repre_icons()
+
+ self.repre_icons = repre_icons
+ self.sync_server = sync_server
+ self.active_site = active_site
+ self.active_provider = active_provider
+ self.remote_site = remote_site
+ self.remote_provider = remote_provider
+
+
+class SubsetsModel(TreeModel, BaseRepresentationModel):
+
+ doc_fetched = QtCore.Signal()
+ refreshed = QtCore.Signal(bool)
+
+ Columns = [
+ "subset",
+ "asset",
+ "family",
+ "version",
+ "time",
+ "author",
+ "frames",
+ "duration",
+ "handles",
+ "step",
+ "repre_info"
+ ]
+
+ column_labels_mapping = {
+ "subset": "Subset",
+ "asset": "Asset",
+ "family": "Family",
+ "version": "Version",
+ "time": "Time",
+ "author": "Author",
+ "frames": "Frames",
+ "duration": "Duration",
+ "handles": "Handles",
+ "step": "Step",
+ "repre_info": "Availability"
+ }
+
+ SortAscendingRole = QtCore.Qt.UserRole + 2
+ SortDescendingRole = QtCore.Qt.UserRole + 3
+ merged_subset_colors = [
+ (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
+ ]
+ not_last_hero_brush = QtGui.QBrush(QtGui.QColor(254, 121, 121))
+
+ # Should be minimum of required asset document keys
+ asset_doc_projection = {
+ "name": 1,
+ "label": 1
+ }
+ # Should be minimum of required subset document keys
+ subset_doc_projection = {
+ "name": 1,
+ "parent": 1,
+ "schema": 1,
+ "families": 1,
+ "data.subsetGroup": 1
+ }
+
+ def __init__(
+ self,
+ dbcon,
+ groups_config,
+ family_config_cache,
+ grouping=True,
+ parent=None,
+ asset_doc_projection=None,
+ subset_doc_projection=None
+ ):
+ super(SubsetsModel, self).__init__(parent=parent)
+
+ self.dbcon = dbcon
+
+ # Projections for Mongo queries
+ # - let ability to modify them if used in tools that require more than
+ # defaults
+ if asset_doc_projection:
+ self.asset_doc_projection = asset_doc_projection
+
+ if subset_doc_projection:
+ self.subset_doc_projection = subset_doc_projection
+
+ self.asset_doc_projection = asset_doc_projection
+ self.subset_doc_projection = subset_doc_projection
+
+ self.repre_icons = {}
+ self.sync_server = None
+ self.active_site = self.active_provider = None
+
+ self.columns_index = dict(
+ (key, idx) for idx, key in enumerate(self.Columns)
+ )
+ self._asset_ids = None
+
+ self.groups_config = groups_config
+ self.family_config_cache = family_config_cache
+ self._sorter = None
+ self._grouping = grouping
+ self._icons = {
+ "subset": qtawesome.icon("fa.file-o", color=style.colors.default)
+ }
+
+ self._doc_fetching_thread = None
+ self._doc_fetching_stop = False
+ self._doc_payload = {}
+
+ self.doc_fetched.connect(self.on_doc_fetched)
+
+ self.refresh()
+
+ def set_assets(self, asset_ids):
+ self._asset_ids = asset_ids
+ self.refresh()
+
+ def set_grouping(self, state):
+ self._grouping = state
+ self.on_doc_fetched()
+
+ def setData(self, index, value, role=QtCore.Qt.EditRole):
+ # Trigger additional edit when `version` column changed
+ # because it also updates the information in other columns
+ if index.column() == self.columns_index["version"]:
+ item = index.internalPointer()
+ parent = item["_id"]
+ if isinstance(value, HeroVersionType):
+ versions = list(self.dbcon.find({
+ "type": {"$in": ["version", "hero_version"]},
+ "parent": parent
+ }, sort=[("name", -1)]))
+
+ version = None
+ last_version = None
+ for __version in versions:
+ if __version["type"] == "hero_version":
+ version = __version
+ elif last_version is None:
+ last_version = __version
+
+ if version is not None and last_version is not None:
+ break
+
+ _version = None
+ for __version in versions:
+ if __version["_id"] == version["version_id"]:
+ _version = __version
+ break
+
+ version["data"] = _version["data"]
+ version["name"] = _version["name"]
+ version["is_from_latest"] = (
+ last_version["_id"] == _version["_id"]
+ )
+
+ else:
+ version = self.dbcon.find_one({
+ "name": value,
+ "type": "version",
+ "parent": parent
+ })
+
+ # update availability on active site when version changes
+ if self.sync_server.enabled and version:
+ site = self.active_site
+ query = self._repre_per_version_pipeline([version["_id"]],
+ site)
+ docs = list(self.dbcon.aggregate(query))
+ if docs:
+ repre = docs.pop()
+ version["data"].update(self._get_repre_dict(repre))
+
+ self.set_version(index, version)
+
+ return super(SubsetsModel, self).setData(index, value, role)
+
+ def set_version(self, index, version):
+ """Update the version data of the given index.
+
+ Arguments:
+ index (QtCore.QModelIndex): The model index.
+ version (dict) Version document in the database.
+
+ """
+
+ assert isinstance(index, QtCore.QModelIndex)
+ if not index.isValid():
+ return
+
+ item = index.internalPointer()
+
+ assert version["parent"] == item["_id"], (
+ "Version does not belong to subset"
+ )
+
+ # Get the data from the version
+ version_data = version.get("data", dict())
+
+ # Compute frame ranges (if data is present)
+ frame_start = version_data.get(
+ "frameStart",
+ # backwards compatibility
+ version_data.get("startFrame", None)
+ )
+ frame_end = version_data.get(
+ "frameEnd",
+ # backwards compatibility
+ version_data.get("endFrame", None)
+ )
+
+ handle_start = version_data.get("handleStart", None)
+ handle_end = version_data.get("handleEnd", None)
+ if handle_start is not None and handle_end is not None:
+ handles = "{}-{}".format(str(handle_start), str(handle_end))
+ else:
+ handles = version_data.get("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
+ start_clean = ("%f" % frame_start).rstrip("0").rstrip(".")
+ end_clean = ("%f" % frame_end).rstrip("0").rstrip(".")
+ frames = "{0}-{1}".format(start_clean, end_clean)
+ duration = frame_end - frame_start + 1
+ else:
+ frames = None
+ duration = None
+
+ schema_maj_version, _ = schema.get_schema_version(item["schema"])
+ if schema_maj_version < 3:
+ families = version_data.get("families", [None])
+ else:
+ families = item["data"]["families"]
+
+ family = None
+ if families:
+ family = families[0]
+
+ family_config = self.family_config_cache.family_config(family)
+
+ item.update({
+ "version": version["name"],
+ "version_document": version,
+ "author": version_data.get("author", None),
+ "time": version_data.get("time", None),
+ "family": family,
+ "familyLabel": family_config.get("label", family),
+ "familyIcon": family_config.get("icon", None),
+ "families": set(families),
+ "frameStart": frame_start,
+ "frameEnd": frame_end,
+ "duration": duration,
+ "handles": handles,
+ "frames": frames,
+ "step": version_data.get("step", None),
+ })
+
+ repre_info = version_data.get("repre_info")
+ if repre_info:
+ item["repre_info"] = repre_info
+ item["repre_icon"] = version_data.get("repre_icon")
+
+ def _fetch(self):
+ asset_docs = self.dbcon.find(
+ {
+ "type": "asset",
+ "_id": {"$in": self._asset_ids}
+ },
+ self.asset_doc_projection
+ )
+ asset_docs_by_id = {
+ asset_doc["_id"]: asset_doc
+ for asset_doc in asset_docs
+ }
+
+ subset_docs_by_id = {}
+ subset_docs = self.dbcon.find(
+ {
+ "type": "subset",
+ "parent": {"$in": self._asset_ids}
+ },
+ self.subset_doc_projection
+ )
+ for subset in subset_docs:
+ if self._doc_fetching_stop:
+ return
+ subset_docs_by_id[subset["_id"]] = subset
+
+ subset_ids = list(subset_docs_by_id.keys())
+ _pipeline = [
+ # Find all versions of those subsets
+ {"$match": {
+ "type": "version",
+ "parent": {"$in": subset_ids}
+ }},
+ # Sorting versions all together
+ {"$sort": {"name": 1}},
+ # Group them by "parent", but only take the last
+ {"$group": {
+ "_id": "$parent",
+ "_version_id": {"$last": "$_id"},
+ "name": {"$last": "$name"},
+ "type": {"$last": "$type"},
+ "data": {"$last": "$data"},
+ "locations": {"$last": "$locations"},
+ "schema": {"$last": "$schema"}
+ }}
+ ]
+ last_versions_by_subset_id = dict()
+ for doc in self.dbcon.aggregate(_pipeline):
+ if self._doc_fetching_stop:
+ return
+ doc["parent"] = doc["_id"]
+ doc["_id"] = doc.pop("_version_id")
+ last_versions_by_subset_id[doc["parent"]] = doc
+
+ hero_versions = self.dbcon.find({
+ "type": "hero_version",
+ "parent": {"$in": subset_ids}
+ })
+ missing_versions = []
+ for hero_version in hero_versions:
+ version_id = hero_version["version_id"]
+ if version_id not in last_versions_by_subset_id:
+ missing_versions.append(version_id)
+
+ missing_versions_by_id = {}
+ if missing_versions:
+ missing_version_docs = self.dbcon.find({
+ "type": "version",
+ "_id": {"$in": missing_versions}
+ })
+ missing_versions_by_id = {
+ missing_version_doc["_id"]: missing_version_doc
+ for missing_version_doc in missing_version_docs
+ }
+
+ for hero_version in hero_versions:
+ version_id = hero_version["version_id"]
+ subset_id = hero_version["parent"]
+
+ version_doc = last_versions_by_subset_id.get(subset_id)
+ if version_doc is None:
+ version_doc = missing_versions_by_id.get(version_id)
+ if version_doc is None:
+ continue
+
+ hero_version["data"] = version_doc["data"]
+ hero_version["name"] = HeroVersionType(version_doc["name"])
+ # Add information if hero version is from latest version
+ hero_version["is_from_latest"] = version_id == version_doc["_id"]
+
+ last_versions_by_subset_id[subset_id] = hero_version
+
+ self._doc_payload = {
+ "asset_docs_by_id": asset_docs_by_id,
+ "subset_docs_by_id": subset_docs_by_id,
+ "last_versions_by_subset_id": last_versions_by_subset_id
+ }
+
+ if self.sync_server.enabled:
+ version_ids = set()
+ for _subset_id, doc in last_versions_by_subset_id.items():
+ version_ids.add(doc["_id"])
+
+ site = self.active_site
+ query = self._repre_per_version_pipeline(list(version_ids), site)
+
+ repre_info = {}
+ for doc in self.dbcon.aggregate(query):
+ if self._doc_fetching_stop:
+ return
+ doc["provider"] = self.active_provider
+ repre_info[doc["_id"]] = doc
+
+ self._doc_payload["repre_info_by_version_id"] = repre_info
+
+ self.doc_fetched.emit()
+
+ def fetch_subset_and_version(self):
+ """Query all subsets and latest versions from aggregation
+ (NOTE) The returned version documents are NOT the real version
+ document, it's generated from the MongoDB's aggregation so
+ some of the first level field may not be presented.
+ """
+ self._doc_payload = {}
+ self._doc_fetching_stop = False
+ self._doc_fetching_thread = lib.create_qthread(self._fetch)
+ self._doc_fetching_thread.start()
+
+ def stop_fetch_thread(self):
+ if self._doc_fetching_thread is not None:
+ self._doc_fetching_stop = True
+ while self._doc_fetching_thread.isRunning():
+ pass
+
+ def refresh(self):
+ self.stop_fetch_thread()
+ self.clear()
+
+ self.reset_sync_server()
+
+ if not self._asset_ids:
+ self.doc_fetched.emit()
+ return
+
+ self.fetch_subset_and_version()
+
+ def on_doc_fetched(self):
+ self.clear()
+ self.beginResetModel()
+
+ asset_docs_by_id = self._doc_payload.get(
+ "asset_docs_by_id"
+ )
+ subset_docs_by_id = self._doc_payload.get(
+ "subset_docs_by_id"
+ )
+ last_versions_by_subset_id = self._doc_payload.get(
+ "last_versions_by_subset_id"
+ )
+
+ repre_info_by_version_id = self._doc_payload.get(
+ "repre_info_by_version_id"
+ )
+
+ if (
+ asset_docs_by_id is None
+ or subset_docs_by_id is None
+ or last_versions_by_subset_id is None
+ or len(self._asset_ids) == 0
+ ):
+ self.endResetModel()
+ self.refreshed.emit(False)
+ return
+
+ self._fill_subset_items(
+ asset_docs_by_id, subset_docs_by_id, last_versions_by_subset_id,
+ repre_info_by_version_id
+ )
+
+ def create_multiasset_group(
+ self, subset_name, asset_ids, subset_counter, parent_item=None
+ ):
+ subset_color = self.merged_subset_colors[
+ subset_counter % len(self.merged_subset_colors)
+ ]
+ merge_group = Item()
+ merge_group.update({
+ "subset": "{} ({})".format(subset_name, len(asset_ids)),
+ "isMerged": True,
+ "childRow": 0,
+ "subsetColor": subset_color,
+ "assetIds": list(asset_ids),
+ "icon": qtawesome.icon(
+ "fa.circle",
+ color="#{0:02x}{1:02x}{2:02x}".format(*subset_color)
+ )
+ })
+
+ subset_counter += 1
+ self.add_child(merge_group, parent_item)
+
+ return merge_group
+
+ def _fill_subset_items(
+ self, asset_docs_by_id, subset_docs_by_id, last_versions_by_subset_id,
+ repre_info_by_version_id
+ ):
+ _groups_tuple = self.groups_config.split_subsets_for_groups(
+ subset_docs_by_id.values(), self._grouping
+ )
+ groups, subset_docs_without_group, subset_docs_by_group = _groups_tuple
+
+ group_item_by_name = {}
+ for group_data in groups:
+ group_name = group_data["name"]
+ group_item = Item()
+ group_item.update({
+ "subset": group_name,
+ "isGroup": True,
+ "childRow": 0
+ })
+ group_item.update(group_data)
+
+ self.add_child(group_item)
+
+ group_item_by_name[group_name] = {
+ "item": group_item,
+ "index": self.index(group_item.row(), 0)
+ }
+
+ subset_counter = 0
+ for group_name, subset_docs_by_name in subset_docs_by_group.items():
+ parent_item = group_item_by_name[group_name]["item"]
+ parent_index = group_item_by_name[group_name]["index"]
+ for subset_name in sorted(subset_docs_by_name.keys()):
+ subset_docs = subset_docs_by_name[subset_name]
+ asset_ids = [
+ subset_doc["parent"] for subset_doc in subset_docs
+ ]
+ if len(subset_docs) > 1:
+ _parent_item = self.create_multiasset_group(
+ subset_name, asset_ids, subset_counter, parent_item
+ )
+ _parent_index = self.index(
+ _parent_item.row(), 0, parent_index
+ )
+ subset_counter += 1
+ else:
+ _parent_item = parent_item
+ _parent_index = parent_index
+
+ for subset_doc in subset_docs:
+ asset_id = subset_doc["parent"]
+
+ data = copy.deepcopy(subset_doc)
+ data["subset"] = subset_name
+ data["asset"] = asset_docs_by_id[asset_id]["name"]
+
+ last_version = last_versions_by_subset_id.get(
+ subset_doc["_id"]
+ )
+ data["last_version"] = last_version
+
+ # do not show subset without version
+ if not last_version:
+ continue
+
+ data.update(
+ self._get_last_repre_info(repre_info_by_version_id,
+ last_version["_id"]))
+
+ item = Item()
+ item.update(data)
+ self.add_child(item, _parent_item)
+
+ index = self.index(item.row(), 0, _parent_index)
+ self.set_version(index, last_version)
+
+ for subset_name in sorted(subset_docs_without_group.keys()):
+ subset_docs = subset_docs_without_group[subset_name]
+ asset_ids = [subset_doc["parent"] for subset_doc in subset_docs]
+ parent_item = None
+ parent_index = None
+ if len(subset_docs) > 1:
+ parent_item = self.create_multiasset_group(
+ subset_name, asset_ids, subset_counter
+ )
+ parent_index = self.index(parent_item.row(), 0)
+ subset_counter += 1
+
+ for subset_doc in subset_docs:
+ asset_id = subset_doc["parent"]
+
+ data = copy.deepcopy(subset_doc)
+ data["subset"] = subset_name
+ data["asset"] = asset_docs_by_id[asset_id]["name"]
+
+ last_version = last_versions_by_subset_id.get(
+ subset_doc["_id"]
+ )
+ data["last_version"] = last_version
+
+ # do not show subset without version
+ if not last_version:
+ continue
+
+ data.update(
+ self._get_last_repre_info(repre_info_by_version_id,
+ last_version["_id"]))
+
+ item = Item()
+ item.update(data)
+ self.add_child(item, parent_item)
+
+ index = self.index(item.row(), 0, parent_index)
+ self.set_version(index, last_version)
+
+ self.endResetModel()
+ self.refreshed.emit(True)
+
+ def data(self, index, role):
+ if not index.isValid():
+ return
+
+ if role == self.SortDescendingRole:
+ item = index.internalPointer()
+ if item.get("isGroup"):
+ # Ensure groups be on top when sorting by descending order
+ prefix = "2"
+ order = item["order"]
+ else:
+ if item.get("isMerged"):
+ prefix = "1"
+ else:
+ prefix = "0"
+ order = str(super(SubsetsModel, self).data(
+ index, QtCore.Qt.DisplayRole
+ ))
+ return prefix + order
+
+ if role == self.SortAscendingRole:
+ item = index.internalPointer()
+ if item.get("isGroup"):
+ # Ensure groups be on top when sorting by ascending order
+ prefix = "0"
+ order = item["order"]
+ else:
+ if item.get("isMerged"):
+ prefix = "1"
+ else:
+ prefix = "2"
+ order = str(super(SubsetsModel, self).data(
+ index, QtCore.Qt.DisplayRole
+ ))
+ return prefix + order
+
+ if role == QtCore.Qt.DisplayRole:
+ if index.column() == self.columns_index["family"]:
+ # Show familyLabel instead of family
+ item = index.internalPointer()
+ return item.get("familyLabel", None)
+
+ elif role == QtCore.Qt.DecorationRole:
+
+ # Add icon to subset column
+ if index.column() == self.columns_index["subset"]:
+ item = index.internalPointer()
+ if item.get("isGroup") or item.get("isMerged"):
+ return item["icon"]
+ else:
+ return self._icons["subset"]
+
+ # Add icon to family column
+ if index.column() == self.columns_index["family"]:
+ item = index.internalPointer()
+ return item.get("familyIcon", None)
+
+ if index.column() == self.columns_index.get("repre_info"):
+ item = index.internalPointer()
+ return item.get("repre_icon", None)
+
+ elif role == QtCore.Qt.ForegroundRole:
+ item = index.internalPointer()
+ version_doc = item.get("version_document")
+ if version_doc and version_doc.get("type") == "hero_version":
+ if not version_doc["is_from_latest"]:
+ return self.not_last_hero_brush
+
+ return super(SubsetsModel, self).data(index, role)
+
+ def flags(self, index):
+ flags = QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable
+
+ # Make the version column editable
+ if index.column() == self.columns_index["version"]:
+ flags |= QtCore.Qt.ItemIsEditable
+
+ return flags
+
+ def headerData(self, section, orientation, role):
+ """Remap column names to labels"""
+ if role == QtCore.Qt.DisplayRole:
+ if section < len(self.Columns):
+ key = self.Columns[section]
+ return self.column_labels_mapping.get(key) or key
+
+ super(TreeModel, self).headerData(section, orientation, role)
+
+ def _get_last_repre_info(self, repre_info_by_version_id, last_version_id):
+ data = {}
+ if repre_info_by_version_id:
+ repre_info = repre_info_by_version_id.get(last_version_id)
+ return self._get_repre_dict(repre_info)
+
+ return data
+
+ def _get_repre_dict(self, repre_info):
+ """Returns icon and str representation of availability"""
+ data = {}
+ if repre_info:
+ repres_str = "{}/{}".format(
+ int(math.floor(float(repre_info['avail_repre']))),
+ int(math.floor(float(repre_info['repre_count']))))
+
+ data["repre_info"] = repres_str
+ data["repre_icon"] = self.repre_icons.get(self.active_provider)
+
+ return data
+
+ def _repre_per_version_pipeline(self, version_ids, site):
+ query = [
+ {"$match": {"parent": {"$in": version_ids},
+ "type": "representation",
+ "files.sites.name": {"$exists": 1}}},
+ {"$unwind": "$files"},
+ {'$addFields': {
+ 'order_local': {
+ '$filter': {'input': '$files.sites', 'as': 'p',
+ 'cond': {'$eq': ['$$p.name', site]}
+ }}
+ }},
+ {'$addFields': {
+ 'progress_local': {"$arrayElemAt": [{
+ '$cond': [{'$size': "$order_local.progress"},
+ "$order_local.progress",
+ # if exists created_dt count is as available
+ {'$cond': [
+ {'$size': "$order_local.created_dt"},
+ [1],
+ [0]
+ ]}
+ ]}, 0]}
+ }},
+ {'$group': { # first group by repre
+ '_id': '$_id',
+ 'parent': {'$first': '$parent'},
+ 'files_count': {'$sum': 1},
+ 'files_avail': {'$sum': "$progress_local"},
+ 'avail_ratio': {'$first': {
+ '$divide': [{'$sum': "$progress_local"}, {'$sum': 1}]}}
+ }},
+ {'$group': { # second group by parent, eg version_id
+ '_id': '$parent',
+ 'repre_count': {'$sum': 1}, # total representations
+ # fully available representation for site
+ 'avail_repre': {'$sum': "$avail_ratio"}
+ }},
+ ]
+ return query
+
+
+class GroupMemberFilterProxyModel(QtCore.QSortFilterProxyModel):
+ """Provide the feature of filtering group by the acceptance of members
+
+ The subset group nodes will not be filtered directly, the group node's
+ acceptance depends on it's child subsets' acceptance.
+
+ """
+
+ if is_filtering_recursible():
+ def _is_group_acceptable(self, index, node):
+ # (NOTE) With the help of `RecursiveFiltering` feature from
+ # Qt 5.10, group always not be accepted by default.
+ return False
+ filter_accepts_group = _is_group_acceptable
+
+ else:
+ # Patch future function
+ setRecursiveFilteringEnabled = (lambda *args: None)
+
+ def _is_group_acceptable(self, index, model):
+ # (NOTE) This is not recursive.
+ for child_row in range(model.rowCount(index)):
+ if self.filterAcceptsRow(child_row, index):
+ return True
+ return False
+ filter_accepts_group = _is_group_acceptable
+
+ def __init__(self, *args, **kwargs):
+ super(GroupMemberFilterProxyModel, self).__init__(*args, **kwargs)
+ self.setRecursiveFilteringEnabled(True)
+
+
+class SubsetFilterProxyModel(GroupMemberFilterProxyModel):
+ def filterAcceptsRow(self, row, parent):
+ model = self.sourceModel()
+ index = model.index(row, self.filterKeyColumn(), parent)
+ item = index.internalPointer()
+ if item.get("isGroup"):
+ return self.filter_accepts_group(index, model)
+ return super(
+ SubsetFilterProxyModel, self
+ ).filterAcceptsRow(row, parent)
+
+
+class FamiliesFilterProxyModel(GroupMemberFilterProxyModel):
+ """Filters to specified families"""
+
+ def __init__(self, family_config_cache, *args, **kwargs):
+ super(FamiliesFilterProxyModel, self).__init__(*args, **kwargs)
+ self._families = set()
+ self.family_config_cache = family_config_cache
+
+ def familyFilter(self):
+ return self._families
+
+ def setFamiliesFilter(self, values):
+ """Set the families to include"""
+ assert isinstance(values, (tuple, list, set))
+ self._families = set(values)
+ self.invalidateFilter()
+
+ def filterAcceptsRow(self, row=0, parent=None):
+ if not self._families:
+ return False
+
+ model = self.sourceModel()
+ index = model.index(row, 0, parent=parent or QtCore.QModelIndex())
+
+ # Ensure index is valid
+ if not index.isValid() or index is None:
+ return True
+
+ # Get the item data and validate
+ item = model.data(index, TreeModel.ItemRole)
+
+ if item.get("isGroup"):
+ return self.filter_accepts_group(index, model)
+
+ family = item.get("family")
+ if not family:
+ return True
+
+ family_config = self.family_config_cache.family_config(family)
+ if family_config.get("hideFilter"):
+ return False
+
+ # We want to keep the families which are not in the list
+ return family in self._families
+
+ def sort(self, column, order):
+ proxy = self.sourceModel()
+ model = proxy.sourceModel()
+ # We need to know the sorting direction for pinning groups on top
+ if order == QtCore.Qt.AscendingOrder:
+ self.setSortRole(model.SortAscendingRole)
+ else:
+ self.setSortRole(model.SortDescendingRole)
+
+ super(FamiliesFilterProxyModel, self).sort(column, order)
+
+
+class RepresentationSortProxyModel(GroupMemberFilterProxyModel):
+ """To properly sort progress string"""
+ def lessThan(self, left, right):
+ source_model = self.sourceModel()
+ progress_indexes = [source_model.Columns.index("active_site"),
+ source_model.Columns.index("remote_site")]
+ if left.column() in progress_indexes:
+ left_data = self.sourceModel().data(left, QtCore.Qt.DisplayRole)
+ right_data = self.sourceModel().data(right, QtCore.Qt.DisplayRole)
+ left_val = re.sub("[^0-9]", '', left_data)
+ right_val = re.sub("[^0-9]", '', right_data)
+
+ return int(left_val) < int(right_val)
+
+ return super(RepresentationSortProxyModel, self).lessThan(left, right)
+
+
+class RepresentationModel(TreeModel, BaseRepresentationModel):
+
+ doc_fetched = QtCore.Signal()
+ refreshed = QtCore.Signal(bool)
+
+ SiteNameRole = QtCore.Qt.UserRole + 2
+ ProgressRole = QtCore.Qt.UserRole + 3
+ SiteSideRole = QtCore.Qt.UserRole + 4
+ IdRole = QtCore.Qt.UserRole + 5
+ ContextRole = QtCore.Qt.UserRole + 6
+
+ Columns = [
+ "name",
+ "subset",
+ "asset",
+ "active_site",
+ "remote_site"
+ ]
+
+ column_labels_mapping = {
+ "name": "Name",
+ "subset": "Subset",
+ "asset": "Asset",
+ "active_site": "Active",
+ "remote_site": "Remote"
+ }
+
+ def __init__(self, dbcon, header, version_ids):
+ super(RepresentationModel, self).__init__()
+ self.dbcon = dbcon
+ self._data = []
+ self._header = header
+ self.version_ids = version_ids
+
+ manager = ModulesManager()
+ sync_server = active_site = remote_site = None
+ active_provider = remote_provider = None
+
+ project = dbcon.Session["AVALON_PROJECT"]
+ if project:
+ sync_server = manager.modules_by_name["sync_server"]
+ active_site = sync_server.get_active_site(project)
+ remote_site = sync_server.get_remote_site(project)
+
+ # TODO refactor
+ active_provider = \
+ sync_server.get_provider_for_site(project,
+ active_site)
+ if active_site == 'studio':
+ active_provider = 'studio'
+
+ remote_provider = \
+ sync_server.get_provider_for_site(project,
+ remote_site)
+
+ if remote_site == 'studio':
+ remote_provider = 'studio'
+
+ self.sync_server = sync_server
+ self.active_site = active_site
+ self.active_provider = active_provider
+ self.remote_site = remote_site
+ self.remote_provider = remote_provider
+
+ self.doc_fetched.connect(self.on_doc_fetched)
+
+ self._docs = {}
+ self._icons = lib.get_repre_icons()
+ self._icons["repre"] = qtawesome.icon("fa.file-o",
+ color=style.colors.default)
+
+ def set_version_ids(self, version_ids):
+ self.version_ids = version_ids
+ self.refresh()
+
+ def data(self, index, role):
+ item = index.internalPointer()
+
+ if role == self.IdRole:
+ return item.get("_id")
+
+ if role == QtCore.Qt.DecorationRole:
+ # Add icon to subset column
+ if index.column() == self.Columns.index("name"):
+ if item.get("isMerged"):
+ return item["icon"]
+ else:
+ return self._icons["repre"]
+
+ active_index = self.Columns.index("active_site")
+ remote_index = self.Columns.index("remote_site")
+ if role == QtCore.Qt.DisplayRole:
+ progress = None
+ label = ''
+ if index.column() == active_index:
+ progress = item.get("active_site_progress", 0)
+ elif index.column() == remote_index:
+ progress = item.get("remote_site_progress", 0)
+
+ if progress is not None:
+ # site added, sync in progress
+ progress_str = "not avail."
+ if progress >= 0:
+ # progress == 0 for isMerged is unavailable
+ if progress == 0 and item.get("isMerged"):
+ progress_str = "not avail."
+ else:
+ progress_str = "{}% {}".format(int(progress * 100),
+ label)
+
+ return progress_str
+
+ if role == QtCore.Qt.DecorationRole:
+ if index.column() == active_index:
+ return item.get("active_site_icon", None)
+ if index.column() == remote_index:
+ return item.get("remote_site_icon", None)
+
+ if role == self.SiteNameRole:
+ if index.column() == active_index:
+ return item.get("active_site_name", None)
+ if index.column() == remote_index:
+ return item.get("remote_site_name", None)
+
+ if role == self.SiteSideRole:
+ if index.column() == active_index:
+ return "active"
+ if index.column() == remote_index:
+ return "remote"
+
+ if role == self.ProgressRole:
+ if index.column() == active_index:
+ return item.get("active_site_progress", 0)
+ if index.column() == remote_index:
+ return item.get("remote_site_progress", 0)
+
+ return super(RepresentationModel, self).data(index, role)
+
+ def on_doc_fetched(self):
+ self.clear()
+ self.beginResetModel()
+ subsets = set()
+ assets = set()
+ repre_groups = {}
+ repre_groups_items = {}
+ group = None
+ self._items_by_id = {}
+ for doc in self._docs:
+ if len(self.version_ids) > 1:
+ group = repre_groups.get(doc["name"])
+ if not group:
+ group_item = Item()
+ group_item.update({
+ "_id": doc["_id"],
+ "name": doc["name"],
+ "isMerged": True,
+ "childRow": 0,
+ "active_site_name": self.active_site,
+ "remote_site_name": self.remote_site,
+ "icon": qtawesome.icon(
+ "fa.folder",
+ color=style.colors.default
+ )
+ })
+ self.add_child(group_item, None)
+ repre_groups[doc["name"]] = group_item
+ repre_groups_items[doc["name"]] = 0
+ group = group_item
+
+ progress = lib.get_progress_for_repre(doc,
+ self.active_site,
+ self.remote_site)
+
+ active_site_icon = self._icons.get(self.active_provider)
+ remote_site_icon = self._icons.get(self.remote_provider)
+
+ data = {
+ "_id": doc["_id"],
+ "name": doc["name"],
+ "subset": doc["context"]["subset"],
+ "asset": doc["context"]["asset"],
+ "isMerged": False,
+
+ "active_site_icon": active_site_icon,
+ "remote_site_icon": remote_site_icon,
+ "active_site_name": self.active_site,
+ "remote_site_name": self.remote_site,
+ "active_site_progress": progress[self.active_site],
+ "remote_site_progress": progress[self.remote_site]
+ }
+ subsets.add(doc["context"]["subset"])
+ assets.add(doc["context"]["subset"])
+
+ item = Item()
+ item.update(data)
+
+ current_progress = {
+ 'active_site_progress': progress[self.active_site],
+ 'remote_site_progress': progress[self.remote_site]
+ }
+ if group:
+ group = self._sum_group_progress(doc["name"], group,
+ current_progress,
+ repre_groups_items)
+
+ self.add_child(item, group)
+
+ # finalize group average progress
+ for group_name, group in repre_groups.items():
+ items_cnt = repre_groups_items[group_name]
+ active_progress = group.get("active_site_progress", 0)
+ group["active_site_progress"] = active_progress / items_cnt
+ remote_progress = group.get("remote_site_progress", 0)
+ group["remote_site_progress"] = remote_progress / items_cnt
+
+ self.endResetModel()
+ self.refreshed.emit(False)
+
+ def refresh(self):
+ docs = []
+ session_project = self.dbcon.Session['AVALON_PROJECT']
+ if not session_project:
+ return
+
+ if self.version_ids:
+ # Simple find here for now, expected to receive lower number of
+ # representations and logic could be in Python
+ docs = list(self.dbcon.find(
+ {"type": "representation", "parent": {"$in": self.version_ids},
+ "files.sites.name": {"$exists": 1}}, self.projection()))
+ self._docs = docs
+
+ self.doc_fetched.emit()
+
+ @classmethod
+ def projection(cls):
+ return {
+ "_id": 1,
+ "name": 1,
+ "context.subset": 1,
+ "context.asset": 1,
+ "context.version": 1,
+ "context.representation": 1,
+ 'files.sites': 1
+ }
+
+ def _sum_group_progress(self, repre_name, group, current_item_progress,
+ repre_groups_items):
+ """
+ Update final group progress
+ Called after every item in group is added
+
+ Args:
+ repre_name(string)
+ group(dict): info about group of selected items
+ current_item_progress(dict): {'active_site_progress': XX,
+ 'remote_site_progress': YY}
+ repre_groups_items(dict)
+ Returns:
+ (dict): updated group info
+ """
+ repre_groups_items[repre_name] += 1
+
+ for key, progress in current_item_progress.items():
+ group[key] = (group.get(key, 0) + max(progress, 0))
+
+ return group
diff --git a/openpype/tools/loader/widgets.py b/openpype/tools/loader/widgets.py
new file mode 100644
index 0000000000..9479558026
--- /dev/null
+++ b/openpype/tools/loader/widgets.py
@@ -0,0 +1,1457 @@
+import os
+import sys
+import inspect
+import datetime
+import pprint
+import traceback
+import collections
+
+from Qt import QtWidgets, QtCore, QtGui
+
+from avalon import api, io, pipeline
+from avalon.lib import HeroVersionType
+
+from openpype.tools.utils import lib as tools_lib
+from openpype.tools.utils.delegates import (
+ VersionDelegate,
+ PrettyTimeDelegate
+)
+from openpype.tools.utils.widgets import OptionalMenu
+from openpype.tools.utils.views import (
+ TreeViewSpinner,
+ DeselectableTreeView
+)
+
+from .model import (
+ SubsetsModel,
+ SubsetFilterProxyModel,
+ FamiliesFilterProxyModel,
+ RepresentationModel,
+ RepresentationSortProxyModel
+)
+from . import lib
+
+
+class OverlayFrame(QtWidgets.QFrame):
+ def __init__(self, label, parent):
+ super(OverlayFrame, self).__init__(parent)
+
+ label_widget = QtWidgets.QLabel(label, self)
+ main_layout = QtWidgets.QVBoxLayout(self)
+ main_layout.addWidget(label_widget, 1, QtCore.Qt.AlignCenter)
+
+ self.label_widget = label_widget
+
+ label_widget.setStyleSheet("background: transparent;")
+ self.setStyleSheet((
+ "background: rgba(0, 0, 0, 127);"
+ "font-size: 60pt;"
+ ))
+
+ def set_label(self, label):
+ self.label_widget.setText(label)
+
+
+class LoadErrorMessageBox(QtWidgets.QDialog):
+ def __init__(self, messages, parent=None):
+ super(LoadErrorMessageBox, self).__init__(parent)
+ self.setWindowTitle("Loading failed")
+ self.setFocusPolicy(QtCore.Qt.StrongFocus)
+
+ body_layout = QtWidgets.QVBoxLayout(self)
+
+ main_label = (
+ "Failed to load items"
+ )
+ main_label_widget = QtWidgets.QLabel(main_label, self)
+ body_layout.addWidget(main_label_widget)
+
+ item_name_template = (
+ "Subset: {}
"
+ "Version: {}
"
+ "Representation: {}
"
+ )
+ exc_msg_template = "{}"
+
+ for exc_msg, tb, repre, subset, version in messages:
+ line = self._create_line()
+ body_layout.addWidget(line)
+
+ item_name = item_name_template.format(subset, version, repre)
+ item_name_widget = QtWidgets.QLabel(
+ item_name.replace("\n", "
"), self
+ )
+ body_layout.addWidget(item_name_widget)
+
+ exc_msg = exc_msg_template.format(exc_msg.replace("\n", "
"))
+ message_label_widget = QtWidgets.QLabel(exc_msg, self)
+ body_layout.addWidget(message_label_widget)
+
+ if tb:
+ tb_widget = QtWidgets.QLabel(tb.replace("\n", "
"), self)
+ tb_widget.setTextInteractionFlags(
+ QtCore.Qt.TextBrowserInteraction
+ )
+ body_layout.addWidget(tb_widget)
+
+ footer_widget = QtWidgets.QWidget(self)
+ footer_layout = QtWidgets.QHBoxLayout(footer_widget)
+ buttonBox = QtWidgets.QDialogButtonBox(QtCore.Qt.Vertical)
+ buttonBox.setStandardButtons(
+ QtWidgets.QDialogButtonBox.StandardButton.Ok
+ )
+ buttonBox.accepted.connect(self._on_accept)
+ footer_layout.addWidget(buttonBox, alignment=QtCore.Qt.AlignRight)
+ body_layout.addWidget(footer_widget)
+
+ def _on_accept(self):
+ self.close()
+
+ def _create_line(self):
+ line = QtWidgets.QFrame(self)
+ line.setFixedHeight(2)
+ line.setFrameShape(QtWidgets.QFrame.HLine)
+ line.setFrameShadow(QtWidgets.QFrame.Sunken)
+ return line
+
+
+class SubsetWidget(QtWidgets.QWidget):
+ """A widget that lists the published subsets for an asset"""
+
+ active_changed = QtCore.Signal() # active index changed
+ version_changed = QtCore.Signal() # version state changed for a subset
+ load_started = QtCore.Signal()
+ load_ended = QtCore.Signal()
+
+ default_widths = (
+ ("subset", 200),
+ ("asset", 130),
+ ("family", 90),
+ ("version", 60),
+ ("time", 125),
+ ("author", 75),
+ ("frames", 75),
+ ("duration", 60),
+ ("handles", 55),
+ ("step", 10),
+ ("repre_info", 65)
+ )
+
+ def __init__(
+ self,
+ dbcon,
+ groups_config,
+ family_config_cache,
+ enable_grouping=True,
+ tool_name=None,
+ parent=None
+ ):
+ super(SubsetWidget, self).__init__(parent=parent)
+
+ self.dbcon = dbcon
+ self.tool_name = tool_name
+
+ model = SubsetsModel(
+ dbcon,
+ groups_config,
+ family_config_cache,
+ grouping=enable_grouping
+ )
+ proxy = SubsetFilterProxyModel()
+ family_proxy = FamiliesFilterProxyModel(family_config_cache)
+ family_proxy.setSourceModel(proxy)
+
+ subset_filter = QtWidgets.QLineEdit()
+ subset_filter.setPlaceholderText("Filter subsets..")
+
+ groupable = QtWidgets.QCheckBox("Enable Grouping")
+ groupable.setChecked(enable_grouping)
+
+ top_bar_layout = QtWidgets.QHBoxLayout()
+ top_bar_layout.addWidget(subset_filter)
+ top_bar_layout.addWidget(groupable)
+
+ view = TreeViewSpinner()
+ view.setObjectName("SubsetView")
+ view.setIndentation(20)
+ view.setStyleSheet("""
+ QTreeView::item{
+ padding: 5px 1px;
+ border: 0px;
+ }
+ """)
+ view.setAllColumnsShowFocus(True)
+
+ # Set view delegates
+ version_delegate = VersionDelegate(self.dbcon)
+ column = model.Columns.index("version")
+ view.setItemDelegateForColumn(column, version_delegate)
+
+ time_delegate = PrettyTimeDelegate()
+ column = model.Columns.index("time")
+ view.setItemDelegateForColumn(column, time_delegate)
+
+ layout = QtWidgets.QVBoxLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.addLayout(top_bar_layout)
+ layout.addWidget(view)
+
+ view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
+ view.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
+ view.setSortingEnabled(True)
+ view.sortByColumn(1, QtCore.Qt.AscendingOrder)
+ view.setAlternatingRowColors(True)
+
+ self.data = {
+ "delegates": {
+ "version": version_delegate,
+ "time": time_delegate
+ },
+ "state": {
+ "groupable": groupable
+ }
+ }
+
+ self.proxy = proxy
+ self.model = model
+ self.view = view
+ self.filter = subset_filter
+ self.family_proxy = family_proxy
+
+ # settings and connections
+ self.proxy.setSourceModel(self.model)
+ self.proxy.setDynamicSortFilter(True)
+ self.proxy.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive)
+
+ self.view.setModel(self.family_proxy)
+ self.view.customContextMenuRequested.connect(self.on_context_menu)
+
+ for column_name, width in self.default_widths:
+ idx = model.Columns.index(column_name)
+ view.setColumnWidth(idx, width)
+
+ actual_project = dbcon.Session["AVALON_PROJECT"]
+ self.on_project_change(actual_project)
+
+ selection = view.selectionModel()
+ selection.selectionChanged.connect(self.active_changed)
+
+ version_delegate.version_changed.connect(self.version_changed)
+
+ groupable.stateChanged.connect(self.set_grouping)
+
+ self.filter.textChanged.connect(self.proxy.setFilterRegExp)
+ self.filter.textChanged.connect(self.view.expandAll)
+
+ self.model.refresh()
+
+ def set_family_filters(self, families):
+ self.family_proxy.setFamiliesFilter(families)
+
+ def is_groupable(self):
+ return self.data["state"]["groupable"].checkState()
+
+ def set_grouping(self, state):
+ with tools_lib.preserve_selection(tree_view=self.view,
+ current_index=False):
+ self.model.set_grouping(state)
+
+ def set_loading_state(self, loading, empty):
+ view = self.view
+
+ if view.is_loading != loading:
+ if loading:
+ view.spinner.repaintNeeded.connect(view.viewport().update)
+ else:
+ view.spinner.repaintNeeded.disconnect()
+
+ view.is_loading = loading
+ view.is_empty = empty
+
+ def _repre_contexts_for_loaders_filter(self, items):
+ version_docs_by_id = {
+ item["version_document"]["_id"]: item["version_document"]
+ for item in items
+ }
+ version_docs_by_subset_id = collections.defaultdict(list)
+ for item in items:
+ subset_id = item["version_document"]["parent"]
+ version_docs_by_subset_id[subset_id].append(
+ item["version_document"]
+ )
+
+ subset_docs = list(self.dbcon.find(
+ {
+ "_id": {"$in": list(version_docs_by_subset_id.keys())},
+ "type": "subset"
+ },
+ {
+ "schema": 1,
+ "data.families": 1
+ }
+ ))
+ subset_docs_by_id = {
+ subset_doc["_id"]: subset_doc
+ for subset_doc in subset_docs
+ }
+ version_ids = list(version_docs_by_id.keys())
+ repre_docs = self.dbcon.find(
+ # Query all representations for selected versions at once
+ {
+ "type": "representation",
+ "parent": {"$in": version_ids}
+ },
+ # Query only name and parent from representation
+ {
+ "name": 1,
+ "parent": 1
+ }
+ )
+ repre_docs_by_version_id = {
+ version_id: []
+ for version_id in version_ids
+ }
+ repre_context_by_id = {}
+ for repre_doc in repre_docs:
+ version_id = repre_doc["parent"]
+ repre_docs_by_version_id[version_id].append(repre_doc)
+
+ version_doc = version_docs_by_id[version_id]
+ repre_context_by_id[repre_doc["_id"]] = {
+ "representation": repre_doc,
+ "version": version_doc,
+ "subset": subset_docs_by_id[version_doc["parent"]]
+ }
+ return repre_context_by_id, repre_docs_by_version_id
+
+ def on_project_change(self, project_name):
+ """
+ Called on each project change in parent widget.
+
+ Checks if Sync Server is enabled for a project, pushes changes to
+ model.
+ """
+ enabled = False
+ if project_name:
+ self.model.reset_sync_server(project_name)
+ if self.model.sync_server:
+ enabled_proj = self.model.sync_server.get_enabled_projects()
+ enabled = project_name in enabled_proj
+
+ lib.change_visibility(self.model, self.view, "repre_info", enabled)
+
+ def on_context_menu(self, point):
+ """Shows menu with loader actions on Right-click.
+
+ Registered actions are filtered by selection and help of
+ `loaders_from_representation` from avalon api. Intersection of actions
+ is shown when more subset is selected. When there are not available
+ actions for selected subsets then special action is shown (works as
+ info message to user): "*No compatible loaders for your selection"
+
+ """
+
+ point_index = self.view.indexAt(point)
+ if not point_index.isValid():
+ return
+
+ # Get selected subsets without groups
+ selection = self.view.selectionModel()
+ rows = selection.selectedRows(column=0)
+
+ items = lib.get_selected_items(rows, self.model.ItemRole)
+
+ # Get all representation->loader combinations available for the
+ # index under the cursor, so we can list the user the options.
+ available_loaders = api.discover(api.Loader)
+ if self.tool_name:
+ available_loaders = lib.remove_tool_name_from_loaders(
+ available_loaders, self.tool_name
+ )
+
+ repre_loaders = []
+ subset_loaders = []
+ for loader in available_loaders:
+ # Skip if its a SubsetLoader.
+ if api.SubsetLoader in inspect.getmro(loader):
+ subset_loaders.append(loader)
+ else:
+ repre_loaders.append(loader)
+
+ loaders = list()
+
+ # Bool if is selected only one subset
+ one_item_selected = (len(items) == 1)
+
+ # Prepare variables for multiple selected subsets
+ first_loaders = []
+ found_combinations = None
+
+ is_first = True
+ repre_context_by_id, repre_docs_by_version_id = (
+ self._repre_contexts_for_loaders_filter(items)
+ )
+ for item in items:
+ _found_combinations = []
+ version_id = item["version_document"]["_id"]
+ repre_docs = repre_docs_by_version_id[version_id]
+ for repre_doc in repre_docs:
+ repre_context = repre_context_by_id[repre_doc["_id"]]
+ for loader in pipeline.loaders_from_repre_context(
+ repre_loaders,
+ repre_context
+ ):
+ # do not allow download whole repre, select specific repre
+ if tools_lib.is_sync_loader(loader):
+ continue
+
+ # skip multiple select variant if one is selected
+ if one_item_selected:
+ loaders.append((repre_doc, loader))
+ continue
+
+ # store loaders of first subset
+ if is_first:
+ first_loaders.append((repre_doc, loader))
+
+ # store combinations to compare with other subsets
+ _found_combinations.append(
+ (repre_doc["name"].lower(), loader)
+ )
+
+ # skip multiple select variant if one is selected
+ if one_item_selected:
+ continue
+
+ is_first = False
+ # Store first combinations to compare
+ if found_combinations is None:
+ found_combinations = _found_combinations
+ # Intersect found combinations with all previous subsets
+ else:
+ found_combinations = list(
+ set(found_combinations) & set(_found_combinations)
+ )
+
+ if not one_item_selected:
+ # Filter loaders from first subset by intersected combinations
+ for repre, loader in first_loaders:
+ if (repre["name"], loader) not in found_combinations:
+ continue
+
+ loaders.append((repre, loader))
+
+ # Subset Loaders.
+ for loader in subset_loaders:
+ loaders.append((None, loader))
+
+ loaders = lib.sort_loaders(loaders)
+
+ # Prepare menu content based on selected items
+ menu = OptionalMenu(self)
+ if not loaders:
+ action = lib.get_no_loader_action(menu, one_item_selected)
+ menu.addAction(action)
+ else:
+ repre_contexts = pipeline.get_repres_contexts(
+ repre_context_by_id.keys(), self.dbcon)
+
+ menu = lib.add_representation_loaders_to_menu(
+ loaders, menu, repre_contexts)
+
+ # Show the context action menu
+ global_point = self.view.mapToGlobal(point)
+ action = menu.exec_(global_point)
+ if not action or not action.data():
+ return
+
+ # Find the representation name and loader to trigger
+ action_representation, loader = action.data()
+
+ self.load_started.emit()
+
+ if api.SubsetLoader in inspect.getmro(loader):
+ subset_ids = []
+ subset_version_docs = {}
+ for item in items:
+ subset_id = item["version_document"]["parent"]
+ subset_ids.append(subset_id)
+ subset_version_docs[subset_id] = item["version_document"]
+
+ # get contexts only for selected menu option
+ subset_contexts_by_id = pipeline.get_subset_contexts(subset_ids,
+ self.dbcon)
+ subset_contexts = list(subset_contexts_by_id.values())
+ options = lib.get_options(action, loader, self, subset_contexts)
+
+ error_info = _load_subsets_by_loader(
+ loader, subset_contexts, options, subset_version_docs
+ )
+
+ else:
+ representation_name = action_representation["name"]
+
+ # Run the loader for all selected indices, for those that have the
+ # same representation available
+
+ # Trigger
+ repre_ids = []
+ for item in items:
+ representation = self.dbcon.find_one(
+ {
+ "type": "representation",
+ "name": representation_name,
+ "parent": item["version_document"]["_id"]
+ },
+ {"_id": 1}
+ )
+ if not representation:
+ self.echo("Subset '{}' has no representation '{}'".format(
+ item["subset"], representation_name
+ ))
+ continue
+ repre_ids.append(representation["_id"])
+
+ # get contexts only for selected menu option
+ repre_contexts = pipeline.get_repres_contexts(repre_ids,
+ self.dbcon)
+ options = lib.get_options(action, loader, self,
+ list(repre_contexts.values()))
+
+ error_info = _load_representations_by_loader(
+ loader, repre_contexts, options=options
+ )
+
+ self.load_ended.emit()
+
+ if error_info:
+ box = LoadErrorMessageBox(error_info)
+ box.show()
+
+ def selected_subsets(self, _groups=False, _merged=False, _other=True):
+ selection = self.view.selectionModel()
+ rows = selection.selectedRows(column=0)
+
+ subsets = list()
+ if not any([_groups, _merged, _other]):
+ self.echo((
+ "This is a BUG: Selected_subsets args must contain"
+ " at least one value set to True"
+ ))
+ return subsets
+
+ for row in rows:
+ item = row.data(self.model.ItemRole)
+ if item.get("isGroup"):
+ if not _groups:
+ continue
+
+ elif item.get("isMerged"):
+ if not _merged:
+ continue
+ else:
+ if not _other:
+ continue
+
+ subsets.append(item)
+
+ return subsets
+
+ def group_subsets(self, name, asset_ids, items):
+ field = "data.subsetGroup"
+
+ if name:
+ update = {"$set": {field: name}}
+ self.echo("Group subsets to '%s'.." % name)
+ else:
+ update = {"$unset": {field: ""}}
+ self.echo("Ungroup subsets..")
+
+ subsets = list()
+ for item in items:
+ subsets.append(item["subset"])
+
+ for asset_id in asset_ids:
+ filtr = {
+ "type": "subset",
+ "parent": asset_id,
+ "name": {"$in": subsets},
+ }
+ self.dbcon.update_many(filtr, update)
+
+ def echo(self, message):
+ print(message)
+
+
+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, dbcon, parent=None):
+ super(VersionTextEdit, self).__init__(parent=parent)
+ self.dbcon = dbcon
+
+ self.data = {
+ "source": None,
+ "raw": None
+ }
+
+ # Reset
+ self.set_version(None)
+
+ def set_version(self, version_doc=None, version_id=None):
+ # TODO expect only filling data (do not query them here!)
+ if not version_doc and not version_id:
+ # Reset state to empty
+ self.data = {
+ "source": None,
+ "raw": None,
+ }
+ self.setText("")
+ self.setEnabled(True)
+ return
+
+ self.setEnabled(True)
+
+ print("Querying..")
+
+ if not version_doc:
+ version_doc = self.dbcon.find_one({
+ "_id": version_id,
+ "type": {"$in": ["version", "hero_version"]}
+ })
+ assert version_doc, "Not a valid version id"
+
+ if version_doc["type"] == "hero_version":
+ _version_doc = self.dbcon.find_one({
+ "_id": version_doc["version_id"],
+ "type": "version"
+ })
+ version_doc["data"] = _version_doc["data"]
+ version_doc["name"] = HeroVersionType(
+ _version_doc["name"]
+ )
+
+ subset = self.dbcon.find_one({
+ "_id": version_doc["parent"],
+ "type": "subset"
+ })
+ assert subset, "No valid subset parent for version"
+
+ # Define readable creation timestamp
+ created = version_doc["data"]["time"]
+ created = datetime.datetime.strptime(created, "%Y%m%dT%H%M%SZ")
+ created = datetime.datetime.strftime(created, "%b %d %Y %H:%M")
+
+ comment = version_doc["data"].get("comment", None) or "No comment"
+
+ source = version_doc["data"].get("source", None)
+ source_label = source if source else "No source"
+
+ # Store source and raw data
+ self.data["source"] = source
+ self.data["raw"] = version_doc
+
+ if version_doc["type"] == "hero_version":
+ version_name = "hero"
+ else:
+ version_name = tools_lib.format_version(version_doc["name"])
+
+ data = {
+ "subset": subset["name"],
+ "version": version_name,
+ "comment": comment,
+ "created": created,
+ "source": source_label
+ }
+
+ self.setHtml((
+ "