ayon-core/openpype/tools/loader/widgets.py
2022-11-01 16:07:50 +01:00

1726 lines
56 KiB
Python

import os
import sys
import datetime
import pprint
import traceback
import collections
from Qt import QtWidgets, QtCore, QtGui
from openpype.client import (
get_subset_families,
get_subset_by_id,
get_subsets,
get_version_by_id,
get_versions,
get_representations,
get_thumbnail_id_from_source,
get_thumbnail,
)
from openpype.client.operations import OperationsSession, REMOVED_VALUE
from openpype.pipeline import HeroVersionType, Anatomy
from openpype.pipeline.thumbnail import get_thumbnail_binary
from openpype.pipeline.load import (
discover_loader_plugins,
SubsetLoaderPlugin,
loaders_from_repre_context,
get_repres_contexts,
get_subset_contexts,
load_with_repre_context,
load_with_subset_context,
load_with_subset_contexts,
IncompatibleLoaderError,
)
from openpype.tools.utils import (
ErrorMessageBox,
lib as tools_lib
)
from openpype.tools.utils.delegates import (
VersionDelegate,
PrettyTimeDelegate
)
from openpype.tools.utils.widgets import (
OptionalMenu,
PlaceholderLineEdit
)
from openpype.tools.utils.views import (
TreeViewSpinner,
DeselectableTreeView
)
from openpype.tools.assetlinks.widgets import SimpleLinkView
from .model import (
SubsetsModel,
SubsetFilterProxyModel,
FamiliesFilterProxyModel,
RepresentationModel,
RepresentationSortProxyModel,
ITEM_ID_ROLE
)
from . import lib
from .delegates import LoadedInSceneDelegate
from openpype.tools.utils.constants import (
LOCAL_PROVIDER_ROLE,
REMOTE_PROVIDER_ROLE,
LOCAL_AVAILABILITY_ROLE,
REMOTE_AVAILABILITY_ROLE
)
class OverlayFrame(QtWidgets.QFrame):
def __init__(self, label, parent):
super(OverlayFrame, self).__init__(parent)
label_widget = QtWidgets.QLabel(label, self)
label_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground)
main_layout = QtWidgets.QVBoxLayout(self)
main_layout.addWidget(label_widget, 1, QtCore.Qt.AlignCenter)
self.label_widget = label_widget
self.setStyleSheet((
"background: rgba(0, 0, 0, 127);"
"font-size: 60pt;"
))
def set_label(self, label):
self.label_widget.setText(label)
class LoadErrorMessageBox(ErrorMessageBox):
def __init__(self, messages, parent=None):
self._messages = messages
super(LoadErrorMessageBox, self).__init__("Loading failed", parent)
def _create_top_widget(self, parent_widget):
label_widget = QtWidgets.QLabel(parent_widget)
label_widget.setText(
"<span style='font-size:18pt;'>Failed to load items</span>"
)
return label_widget
def _get_report_data(self):
report_data = []
for exc_msg, tb_text, repre, subset, version in self._messages:
report_message = (
"During load error happened on Subset: \"{subset}\""
" Representation: \"{repre}\" Version: {version}"
"\n\nError message: {message}"
).format(
subset=subset,
repre=repre,
version=version,
message=exc_msg
)
if tb_text:
report_message += "\n\n{}".format(tb_text)
report_data.append(report_message)
return report_data
def _create_content(self, content_layout):
item_name_template = (
"<span style='font-weight:bold;'>Subset:</span> {}<br>"
"<span style='font-weight:bold;'>Version:</span> {}<br>"
"<span style='font-weight:bold;'>Representation:</span> {}<br>"
)
exc_msg_template = "<span style='font-weight:bold'>{}</span>"
for exc_msg, tb_text, repre, subset, version in self._messages:
line = self._create_line()
content_layout.addWidget(line)
item_name = item_name_template.format(subset, version, repre)
item_name_widget = QtWidgets.QLabel(
item_name.replace("\n", "<br>"), self
)
item_name_widget.setWordWrap(True)
content_layout.addWidget(item_name_widget)
exc_msg = exc_msg_template.format(exc_msg.replace("\n", "<br>"))
message_label_widget = QtWidgets.QLabel(exc_msg, self)
message_label_widget.setWordWrap(True)
content_layout.addWidget(message_label_widget)
if tb_text:
line = self._create_line()
tb_widget = self._create_traceback_widget(tb_text, self)
content_layout.addWidget(line)
content_layout.addWidget(tb_widget)
class 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()
refreshed = QtCore.Signal(bool)
default_widths = (
("subset", 200),
("asset", 130),
("family", 90),
("version", 60),
("time", 125),
("author", 75),
("frames", 75),
("duration", 60),
("handles", 55),
("step", 10),
("loaded_in_scene", 25),
("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()
proxy.setSourceModel(model)
proxy.setDynamicSortFilter(True)
proxy.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive)
family_proxy = FamiliesFilterProxyModel()
family_proxy.setSourceModel(proxy)
subset_filter = PlaceholderLineEdit(self)
subset_filter.setPlaceholderText("Filter subsets..")
group_checkbox = QtWidgets.QCheckBox("Enable Grouping", self)
group_checkbox.setChecked(enable_grouping)
top_bar_layout = QtWidgets.QHBoxLayout()
top_bar_layout.addWidget(subset_filter)
top_bar_layout.addWidget(group_checkbox)
view = TreeViewSpinner(self)
view.setModel(family_proxy)
view.setObjectName("SubsetView")
view.setIndentation(20)
view.setAllColumnsShowFocus(True)
view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
view.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
view.setSortingEnabled(True)
view.sortByColumn(1, QtCore.Qt.AscendingOrder)
view.setAlternatingRowColors(True)
# Set view delegates
version_delegate = VersionDelegate(self.dbcon, view)
column = model.Columns.index("version")
view.setItemDelegateForColumn(column, version_delegate)
time_delegate = PrettyTimeDelegate(view)
column = model.Columns.index("time")
view.setItemDelegateForColumn(column, time_delegate)
avail_delegate = AvailabilityDelegate(self.dbcon, view)
column = model.Columns.index("repre_info")
view.setItemDelegateForColumn(column, avail_delegate)
loaded_in_scene_delegate = LoadedInSceneDelegate(view)
column = model.Columns.index("loaded_in_scene")
view.setItemDelegateForColumn(column, loaded_in_scene_delegate)
layout = QtWidgets.QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addLayout(top_bar_layout)
layout.addWidget(view)
# settings and connections
for column_name, width in self.default_widths:
idx = model.Columns.index(column_name)
view.setColumnWidth(idx, width)
self.model = model
self.view = view
self.on_project_change(dbcon.current_project())
view.customContextMenuRequested.connect(self.on_context_menu)
selection = view.selectionModel()
selection.selectionChanged.connect(self.active_changed)
version_delegate.version_changed.connect(self.version_changed)
group_checkbox.stateChanged.connect(self.set_grouping)
subset_filter.textChanged.connect(proxy.setFilterRegExp)
subset_filter.textChanged.connect(view.expandAll)
model.refreshed.connect(self.refreshed)
self.proxy = proxy
self.family_proxy = family_proxy
self._subset_filter = subset_filter
self._group_checkbox = group_checkbox
self._version_delegate = version_delegate
self._time_delegate = time_delegate
self.model.refresh()
def get_subsets_families(self):
return self.model.get_subsets_families()
def set_family_filters(self, families):
self.family_proxy.setFamiliesFilter(families)
def is_groupable(self):
return self._group_checkbox.isChecked()
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"]
)
project_name = self.dbcon.active_project()
subset_docs = list(get_subsets(
project_name,
subset_ids=version_docs_by_subset_id.keys(),
fields=["schema", "data.families"]
))
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 = get_representations(
project_name,
version_ids=version_ids,
fields=["name", "parent"]
)
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)
sync_server = self.model.sync_server
if sync_server:
enabled = sync_server.is_project_enabled(project_name,
single=True)
lib.change_visibility(self.model, self.view, "repre_info", enabled)
def get_selected_items(self):
selection_model = self.view.selectionModel()
indexes = selection_model.selectedIndexes()
item_ids = set()
for index in indexes:
item_id = index.data(ITEM_ID_ROLE)
if item_id is not None:
item_ids.add(item_id)
output = []
for item_id in item_ids:
item = self.model.get_item_by_id(item_id)
if item is not None:
output.append(item)
return output
def get_selected_merge_items(self):
output = []
items = collections.deque(self.get_selected_items())
item_ids = set()
while items:
item = items.popleft()
if item.get("isGroup"):
for child in item.children():
items.appendleft(child)
elif item.get("isMerged"):
item_id = item["id"]
if item_id not in item_ids:
item_ids.add(item_id)
output.append(item)
return output
def get_selected_subsets(self):
output = []
items = collections.deque(self.get_selected_items())
item_ids = set()
while items:
item = items.popleft()
if item.get("isGroup") or item.get("isMerged"):
for child in item.children():
items.appendleft(child)
else:
item_id = item["id"]
if item_id not in item_ids:
item_ids.add(item_id)
output.append(item)
return output
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
items = self.get_selected_subsets()
# Get all representation->loader combinations available for the
# index under the cursor, so we can list the user the options.
project_name = self.dbcon.active_project()
available_loaders = discover_loader_plugins(project_name)
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 issubclass(loader, SubsetLoaderPlugin):
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 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"].lower(), 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 = 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 issubclass(loader, SubsetLoaderPlugin):
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 = 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
project_name = self.dbcon.active_project()
subset_name_by_version_id = dict()
for item in items:
version_id = item["version_document"]["_id"]
subset_name_by_version_id[version_id] = item["subset"]
version_ids = set(subset_name_by_version_id.keys())
repre_docs = get_representations(
project_name,
representation_names=[representation_name],
version_ids=version_ids,
fields=["_id", "parent"]
)
repre_ids = []
for repre_doc in repre_docs:
repre_ids.append(repre_doc["_id"])
# keep only version ids without representation with that name
version_id = repre_doc["parent"]
version_ids.discard(version_id)
if version_ids:
# report versions that didn't have valid representation
joined_subset_names = ", ".join([
'"{}"'.format(subset_name_by_version_id[version_id])
for version_id in version_ids
])
self.echo("Subsets {} don't have representation '{}'".format(
joined_subset_names, representation_name
))
# get contexts only for selected menu option
repre_contexts = 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, self)
box.show()
def group_subsets(self, name, asset_ids, items):
subset_ids = {
item["_id"]
for item in items
if item.get("_id")
}
if not subset_ids:
return
if name:
self.echo("Group subsets to '%s'.." % name)
else:
self.echo("Ungroup subsets..")
project_name = self.dbcon.active_project()
op_session = OperationsSession()
for subset_id in subset_ids:
op_session.update_entity(
project_name,
"subset",
subset_id,
{"data.subsetGroup": name or REMOVED_VALUE}
)
op_session.commit()
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
}
self._anatomy = 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..")
project_name = self.dbcon.active_project()
if not version_doc:
version_doc = get_version_by_id(project_name, version_id)
assert version_doc, "Not a valid version id"
if version_doc["type"] == "hero_version":
_version_doc = get_version_by_id(
project_name, version_doc["version_id"]
)
version_doc["data"] = _version_doc["data"]
version_doc["name"] = HeroVersionType(
_version_doc["name"]
)
subset = get_subset_by_id(project_name, version_doc["parent"])
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((
"<h2>{subset}</h2>"
"<h3>{version}</h3>"
"<b>Comment</b><br>"
"{comment}<br><br>"
"<b>Created</b><br>"
"{created}<br><br>"
"<b>Source</b><br>"
"{source}"
).format(**data))
def contextMenuEvent(self, event):
"""Context menu with additional actions"""
menu = self.createStandardContextMenu()
# Add additional actions when any text so we can assume
# the version is set.
if self.toPlainText().strip():
menu.addSeparator()
action = QtWidgets.QAction(
"Copy source path to clipboard", menu
)
action.triggered.connect(self.on_copy_source)
menu.addAction(action)
action = QtWidgets.QAction(
"Copy raw data to clipboard", menu
)
action.triggered.connect(self.on_copy_raw)
menu.addAction(action)
menu.exec_(event.globalPos())
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.current_project()
if self._anatomy is None or self._anatomy.project_name != project_name:
self._anatomy = Anatomy(project_name)
path = source.format(root=self._anatomy.roots)
clipboard = QtWidgets.QApplication.clipboard()
clipboard.setText(path)
def on_copy_raw(self):
"""Copy raw version data to clipboard
The data is string formatted with `pprint.pformat`.
"""
raw = self.data.get("raw", None)
if not raw:
return
raw_text = pprint.pformat(raw)
clipboard = QtWidgets.QApplication.clipboard()
clipboard.setText(raw_text)
class ThumbnailWidget(QtWidgets.QLabel):
aspect_ratio = (16, 9)
max_width = 300
def __init__(self, dbcon, parent=None):
super(ThumbnailWidget, self).__init__(parent)
self.dbcon = dbcon
self.current_thumb_id = None
self.current_thumbnail = None
self.setAlignment(QtCore.Qt.AlignCenter)
# TODO get res path much better way
default_pix_path = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"images",
"default_thumbnail.png"
)
self.default_pix = QtGui.QPixmap(default_pix_path)
self.set_pixmap()
def height(self):
width = self.width()
asp_w, asp_h = self.aspect_ratio
return (width / asp_w) * asp_h
def width(self):
width = super(ThumbnailWidget, self).width()
if width > self.max_width:
width = self.max_width
return width
def set_pixmap(self, pixmap=None):
if not pixmap:
pixmap = self.default_pix
self.current_thumb_id = None
self.current_thumbnail = pixmap
pixmap = self.scale_pixmap(pixmap)
self.setPixmap(pixmap)
def resizeEvent(self, _event):
if not self.current_thumbnail:
return
cur_pix = self.scale_pixmap(self.current_thumbnail)
self.setPixmap(cur_pix)
def scale_pixmap(self, pixmap):
return pixmap.scaled(
self.width(),
self.height(),
QtCore.Qt.KeepAspectRatio,
QtCore.Qt.SmoothTransformation
)
def set_thumbnail(self, src_type, doc_ids):
if not doc_ids:
self.set_pixmap()
return
src_id = doc_ids[0]
project_name = self.dbcon.active_project()
thumbnail_id = get_thumbnail_id_from_source(
project_name,
src_type,
src_id,
)
if thumbnail_id == self.current_thumb_id:
if self.current_thumbnail is None:
self.set_pixmap()
return
self.current_thumb_id = thumbnail_id
if not thumbnail_id:
self.set_pixmap()
return
thumbnail_ent = get_thumbnail(project_name, thumbnail_id)
if not thumbnail_ent:
return
thumbnail_bin = get_thumbnail_binary(
thumbnail_ent, "thumbnail", self.dbcon
)
if not thumbnail_bin:
self.set_pixmap()
return
thumbnail = QtGui.QPixmap()
thumbnail.loadFromData(thumbnail_bin)
self.set_pixmap(thumbnail)
class VersionWidget(QtWidgets.QWidget):
"""A Widget that display information about a specific version"""
def __init__(self, dbcon, parent=None):
super(VersionWidget, self).__init__(parent=parent)
data = VersionTextEdit(dbcon, self)
data.setReadOnly(True)
depend_widget = SimpleLinkView(dbcon, self)
tab = QtWidgets.QTabWidget()
tab.addTab(data, "Version Info")
tab.addTab(depend_widget, "Dependency")
layout = QtWidgets.QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(tab)
self.data = data
self.depend_widget = depend_widget
def set_version(self, version_doc):
self.data.set_version(version_doc)
self.depend_widget.set_version(version_doc)
class FamilyModel(QtGui.QStandardItemModel):
def __init__(self, dbcon, family_config_cache):
super(FamilyModel, self).__init__()
self.dbcon = dbcon
self.family_config_cache = family_config_cache
self._items_by_family = {}
def refresh(self):
families = set()
project_name = self.dbcon.current_project()
if project_name:
families = get_subset_families(project_name)
root_item = self.invisibleRootItem()
for family in tuple(self._items_by_family.keys()):
if family not in families:
item = self._items_by_family.pop(family)
root_item.removeRow(item.row())
self.family_config_cache.refresh()
new_items = []
for family in families:
family_config = self.family_config_cache.family_config(family)
label = family_config.get("label", family)
icon = family_config.get("icon", None)
if family_config.get("state", True):
state = QtCore.Qt.Checked
else:
state = QtCore.Qt.Unchecked
if family not in self._items_by_family:
item = QtGui.QStandardItem(label)
item.setFlags(
QtCore.Qt.ItemIsEnabled
| QtCore.Qt.ItemIsSelectable
| QtCore.Qt.ItemIsUserCheckable
)
new_items.append(item)
self._items_by_family[family] = item
else:
item = self._items_by_family[label]
item.setData(label, QtCore.Qt.DisplayRole)
item.setCheckState(state)
if icon:
item.setIcon(icon)
if new_items:
root_item.appendRows(new_items)
class FamilyProxyFiler(QtCore.QSortFilterProxyModel):
def __init__(self, *args, **kwargs):
super(FamilyProxyFiler, self).__init__(*args, **kwargs)
self._filtering_enabled = False
self._enabled_families = set()
def set_enabled_families(self, families):
if self._enabled_families == families:
return
self._enabled_families = families
if self._filtering_enabled:
self.invalidateFilter()
def is_filter_enabled(self):
return self._filtering_enabled
def set_filter_enabled(self, enabled=None):
if enabled is None:
enabled = not self._filtering_enabled
elif self._filtering_enabled == enabled:
return
self._filtering_enabled = enabled
self.invalidateFilter()
def filterAcceptsRow(self, row, parent):
if not self._filtering_enabled:
return True
if not self._enabled_families:
return False
index = self.sourceModel().index(row, self.filterKeyColumn(), parent)
if index.data(QtCore.Qt.DisplayRole) in self._enabled_families:
return True
return False
class FamilyListView(QtWidgets.QListView):
active_changed = QtCore.Signal(list)
def __init__(self, dbcon, family_config_cache, parent=None):
super(FamilyListView, self).__init__(parent=parent)
self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection)
self.setAlternatingRowColors(True)
self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
family_model = FamilyModel(dbcon, family_config_cache)
proxy_model = FamilyProxyFiler()
proxy_model.setDynamicSortFilter(True)
proxy_model.setSourceModel(family_model)
self.setModel(proxy_model)
family_model.dataChanged.connect(self._on_data_change)
self.customContextMenuRequested.connect(self._on_context_menu)
self._family_model = family_model
self._proxy_model = proxy_model
def set_enabled_families(self, families):
self._proxy_model.set_enabled_families(families)
self.set_enabled_family_filtering(True)
def set_enabled_family_filtering(self, enabled=None):
self._proxy_model.set_filter_enabled(enabled)
def refresh(self):
self._family_model.refresh()
self.active_changed.emit(self.get_enabled_families())
def get_enabled_families(self):
"""Return the checked family items"""
model = self._family_model
checked_families = []
for row in range(model.rowCount()):
index = model.index(row, 0)
if index.data(QtCore.Qt.CheckStateRole) == QtCore.Qt.Checked:
family = index.data(QtCore.Qt.DisplayRole)
checked_families.append(family)
return checked_families
def set_all_unchecked(self):
self._set_checkstates(False, self._get_all_indexes())
def set_all_checked(self):
self._set_checkstates(True, self._get_all_indexes())
def _get_all_indexes(self):
indexes = []
model = self._family_model
for row in range(model.rowCount()):
index = model.index(row, 0)
indexes.append(index)
return indexes
def _set_checkstates(self, checked, indexes):
if not indexes:
return
if checked is None:
state = None
elif checked:
state = QtCore.Qt.Checked
else:
state = QtCore.Qt.Unchecked
self.blockSignals(True)
for index in indexes:
index_state = index.data(QtCore.Qt.CheckStateRole)
if index_state == state:
continue
new_state = state
if new_state is None:
if index_state == QtCore.Qt.Checked:
new_state = QtCore.Qt.Unchecked
else:
new_state = QtCore.Qt.Checked
index.model().setData(index, new_state, QtCore.Qt.CheckStateRole)
self.blockSignals(False)
self.active_changed.emit(self.get_enabled_families())
def _change_selection_state(self, checked):
indexes = self.selectionModel().selectedIndexes()
self._set_checkstates(checked, indexes)
def _on_data_change(self, *_args):
self.active_changed.emit(self.get_enabled_families())
def _on_context_menu(self, pos):
"""Build RMB menu under mouse at current position (within widget)"""
menu = QtWidgets.QMenu(self)
# Add enable all action
action_check_all = QtWidgets.QAction(menu)
action_check_all.setText("Enable All")
action_check_all.triggered.connect(self.set_all_checked)
# Add disable all action
action_uncheck_all = QtWidgets.QAction(menu)
action_uncheck_all.setText("Disable All")
action_uncheck_all.triggered.connect(self.set_all_unchecked)
menu.addAction(action_check_all)
menu.addAction(action_uncheck_all)
# Get mouse position
global_pos = self.viewport().mapToGlobal(pos)
menu.exec_(global_pos)
def event(self, event):
if not event.type() == QtCore.QEvent.KeyPress:
pass
elif event.key() == QtCore.Qt.Key_Space:
self._change_selection_state(None)
return True
elif event.key() == QtCore.Qt.Key_Backspace:
self._change_selection_state(False)
return True
elif event.key() == QtCore.Qt.Key_Return:
self._change_selection_state(True)
return True
return super(FamilyListView, self).event(event)
class RepresentationWidget(QtWidgets.QWidget):
load_started = QtCore.Signal()
load_ended = QtCore.Signal()
default_widths = (
("name", 120),
("subset", 125),
("asset", 125),
("active_site", 85),
("remote_site", 85)
)
commands = {'active': 'Download', 'remote': 'Upload'}
def __init__(self, dbcon, tool_name=None, parent=None):
super(RepresentationWidget, self).__init__(parent=parent)
self.dbcon = dbcon
self.tool_name = tool_name
headers = [item[0] for item in self.default_widths]
model = RepresentationModel(self.dbcon, headers)
proxy_model = RepresentationSortProxyModel(self)
proxy_model.setSourceModel(model)
label = QtWidgets.QLabel("Representations", self)
tree_view = DeselectableTreeView(parent=self)
tree_view.setObjectName("RepresentationView")
tree_view.setModel(proxy_model)
tree_view.setAllColumnsShowFocus(True)
tree_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
tree_view.setSelectionMode(
QtWidgets.QAbstractItemView.ExtendedSelection)
tree_view.setSortingEnabled(True)
tree_view.sortByColumn(1, QtCore.Qt.AscendingOrder)
tree_view.setAlternatingRowColors(True)
tree_view.setIndentation(20)
tree_view.collapseAll()
for column_name, width in self.default_widths:
idx = model.Columns.index(column_name)
tree_view.setColumnWidth(idx, width)
layout = QtWidgets.QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(label)
layout.addWidget(tree_view)
# self.itemChanged.connect(self._on_item_changed)
tree_view.customContextMenuRequested.connect(self.on_context_menu)
self.tree_view = tree_view
self.model = model
self.proxy_model = proxy_model
self.sync_server_enabled = False
self.on_project_change(dbcon.current_project())
self.model.refresh()
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)
sync_server = self.model.sync_server
if sync_server:
enabled = sync_server.is_project_enabled(project_name,
single=True)
self.sync_server_enabled = enabled
lib.change_visibility(self.model, self.tree_view,
"active_site", enabled)
lib.change_visibility(self.model, self.tree_view,
"remote_site", enabled)
def _repre_contexts_for_loaders_filter(self, items):
repre_ids = []
for item in items:
repre_ids.append(item["_id"])
project_name = self.dbcon.active_project()
repre_docs = list(get_representations(
project_name,
representation_ids=repre_ids,
fields=["name", "parent"]
))
version_ids = [
repre_doc["parent"]
for repre_doc in repre_docs
]
version_docs = get_versions(
project_name,
version_ids=version_ids,
hero=True
)
version_docs_by_id = {}
version_docs_by_subset_id = collections.defaultdict(list)
for version_doc in version_docs:
version_id = version_doc["_id"]
subset_id = version_doc["parent"]
version_docs_by_id[version_id] = version_doc
version_docs_by_subset_id[subset_id].append(version_doc)
subset_docs = list(get_subsets(
project_name,
subset_ids=version_docs_by_subset_id.keys(),
fields=["schema", "data.families"]
))
subset_docs_by_id = {
subset_doc["_id"]: subset_doc
for subset_doc in subset_docs
}
repre_context_by_id = {}
for repre_doc in repre_docs:
version_id = repre_doc["parent"]
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
def get_selected_items(self):
selection_model = self.tree_view.selectionModel()
indexes = selection_model.selectedIndexes()
item_ids = set()
for index in indexes:
item_id = index.data(ITEM_ID_ROLE)
if item_id is not None:
item_ids.add(item_id)
output = []
for item_id in item_ids:
item = self.model.get_item_by_id(item_id)
if item is not None:
output.append(item)
return output
def get_selected_repre_items(self):
output = []
items = collections.deque(self.get_selected_items())
item_ids = set()
while items:
item = items.popleft()
if item.get("isGroup") or item.get("isMerged"):
for child in item.children():
items.appendleft(child)
else:
item_id = item["id"]
if item_id not in item_ids:
item_ids.add(item_id)
output.append(item)
return output
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.tree_view.indexAt(point)
if not point_index.isValid():
return
# Get selected subsets without groups
selection = self.tree_view.selectionModel()
rows = selection.selectedRows(column=0)
items = self.get_selected_repre_items()
selected_side = self._get_selected_side(point_index, rows)
# Get all representation->loader combinations available for the
# index under the cursor, so we can list the user the options.
project_name = self.dbcon.active_project()
available_loaders = discover_loader_plugins(project_name)
filtered_loaders = []
for loader in available_loaders:
# Skip subset loaders
if issubclass(loader, SubsetLoaderPlugin):
continue
if (
tools_lib.is_sync_loader(loader)
and not self.sync_server_enabled
):
continue
filtered_loaders.append(loader)
if self.tool_name:
filtered_loaders = lib.remove_tool_name_from_loaders(
filtered_loaders, self.tool_name
)
loaders = list()
already_added_loaders = set()
label_already_in_menu = set()
repre_context_by_id = (
self._repre_contexts_for_loaders_filter(items)
)
for item in items:
repre_context = repre_context_by_id[item["_id"]]
for loader in loaders_from_repre_context(
filtered_loaders,
repre_context
):
if tools_lib.is_sync_loader(loader):
both_unavailable = (
item["active_site_progress"] <= 0
and item["remote_site_progress"] <= 0
)
if both_unavailable:
continue
for selected_side in self.commands.keys():
item = item.copy()
item["custom_label"] = None
label = None
selected_site_progress = item.get(
"{}_site_progress".format(selected_side), -1)
# only remove if actually present
if tools_lib.is_remove_site_loader(loader):
label = "Remove {}".format(selected_side)
if selected_site_progress < 1:
continue
if tools_lib.is_add_site_loader(loader):
label = self.commands[selected_side]
if selected_site_progress >= 0:
label = 'Re-{} {}'.format(label, selected_side)
if not label:
continue
item["selected_side"] = selected_side
item["custom_label"] = label
if label not in label_already_in_menu:
loaders.append((item, loader))
already_added_loaders.add(loader)
label_already_in_menu.add(label)
else:
item = item.copy()
item["custom_label"] = None
if loader not in already_added_loaders:
loaders.append((item, loader))
already_added_loaders.add(loader)
loaders = lib.sort_loaders(loaders)
menu = OptionalMenu(self)
if not loaders:
action = lib.get_no_loader_action(menu)
menu.addAction(action)
else:
repre_contexts = get_repres_contexts(
repre_context_by_id.keys(), self.dbcon)
menu = lib.add_representation_loaders_to_menu(loaders, menu,
repre_contexts)
self._process_action(items, menu, point)
def _process_action(self, items, menu, point):
"""Show the context action menu and process selected
Args:
items(dict): menu items
menu(OptionalMenu)
point(PointIndex)
"""
global_point = self.tree_view.mapToGlobal(point)
action = menu.exec_(global_point)
if not action or not action.data():
return
self.load_started.emit()
# Find the representation name and loader to trigger
action_representation, loader = action.data()
repre_ids = []
data_by_repre_id = {}
selected_side = action_representation.get("selected_side")
is_sync_loader = tools_lib.is_sync_loader(loader)
for item in items:
item_id = item.get("_id")
repre_ids.append(item_id)
if not is_sync_loader:
continue
site_name = "{}_site_name".format(selected_side)
data_site_name = item.get(site_name)
if not data_site_name:
continue
data_by_repre_id[item_id] = {
"_id": item_id,
"site_name": data_site_name,
"project_name": self.dbcon.active_project()
}
repre_contexts = get_repres_contexts(repre_ids, self.dbcon)
options = lib.get_options(action, loader, self,
list(repre_contexts.values()))
errors = _load_representations_by_loader(
loader, repre_contexts,
options=options, data_by_repre_id=data_by_repre_id)
self.model.refresh()
self.load_ended.emit()
if errors:
box = LoadErrorMessageBox(errors, self)
box.show()
def _get_optional_labels(self, loaders, selected_side):
"""Each loader could have specific label
Args:
loaders (tuple of dict, dict): (item, loader)
selected_side(string): active or remote
Returns:
(dict) {loader: string}
"""
optional_labels = {}
if selected_side:
if selected_side == 'active':
txt = "Localize"
else:
txt = "Sync to Remote"
optional_labels = {loader: txt for _, loader in loaders
if tools_lib.is_sync_loader(loader)}
return optional_labels
def _get_selected_side(self, point_index, rows):
"""Returns active/remote label according to column in 'point_index'"""
selected_side = None
if self.sync_server_enabled:
if rows:
source_index = self.proxy_model.mapToSource(point_index)
selected_side = self.model.data(source_index,
self.model.SiteSideRole)
return selected_side
def set_version_ids(self, version_ids):
self.model.set_version_ids(version_ids)
def _set_download(self):
pass
def change_visibility(self, column_name, visible):
"""
Hides or shows particular 'column_name'.
"asset" and "subset" columns should be visible only in multiselect
"""
lib.change_visibility(self.model, self.tree_view, column_name, visible)
def _load_representations_by_loader(loader, repre_contexts,
options,
data_by_repre_id=None):
"""Loops through list of repre_contexts and loads them with one loader
Args:
loader (cls of LoaderPlugin) - not initialized yet
repre_contexts (dicts) - full info about selected representations
(containing repre_doc, version_doc, subset_doc, project info)
options (dict) - qargparse arguments to fill OptionDialog
data_by_repre_id (dict) - additional data applicable on top of
options to provide dynamic values
"""
error_info = []
if options is None: # not load when cancelled
return
for repre_context in repre_contexts.values():
version_doc = repre_context["version"]
if version_doc["type"] == "hero_version":
version_name = "Hero"
else:
version_name = version_doc.get("name")
try:
if data_by_repre_id:
_id = repre_context["representation"]["_id"]
data = data_by_repre_id.get(_id)
options.update(data)
load_with_repre_context(
loader,
repre_context,
options=options
)
except IncompatibleLoaderError as exc:
print(exc)
error_info.append((
"Incompatible Loader",
None,
repre_context["representation"]["name"],
repre_context["subset"]["name"],
version_name
))
except Exception as exc:
exc_type, exc_value, exc_traceback = sys.exc_info()
formatted_traceback = "".join(traceback.format_exception(
exc_type, exc_value, exc_traceback
))
error_info.append((
str(exc),
formatted_traceback,
repre_context["representation"]["name"],
repre_context["subset"]["name"],
version_name
))
return error_info
def _load_subsets_by_loader(loader, subset_contexts, options,
subset_version_docs=None):
"""
Triggers load with SubsetLoader type of loaders
Args:
loader (SubsetLoder):
subset_contexts (list):
options (dict):
subset_version_docs (dict): {subset_id: version_doc}
"""
error_info = []
if options is None: # not load when cancelled
return
if loader.is_multiple_contexts_compatible:
subset_names = []
for context in subset_contexts:
subset_name = context.get("subset", {}).get("name") or "N/A"
subset_names.append(subset_name)
context["version"] = subset_version_docs[context["subset"]["_id"]]
try:
load_with_subset_contexts(
loader,
subset_contexts,
options=options
)
except Exception as exc:
exc_type, exc_value, exc_traceback = sys.exc_info()
formatted_traceback = "".join(
traceback.format_exception(
exc_type, exc_value, exc_traceback
)
)
error_info.append((
str(exc),
formatted_traceback,
None,
", ".join(subset_names),
None
))
else:
for subset_context in subset_contexts:
subset_name = subset_context.get("subset", {}).get("name") or "N/A"
version_doc = subset_version_docs[subset_context["subset"]["_id"]]
subset_context["version"] = version_doc
try:
load_with_subset_context(
loader,
subset_context,
options=options
)
except Exception as exc:
exc_type, exc_value, exc_traceback = sys.exc_info()
formatted_traceback = "\n".join(
traceback.format_exception(
exc_type, exc_value, exc_traceback
)
)
error_info.append((
str(exc),
formatted_traceback,
None,
subset_name,
None
))
return error_info
class AvailabilityDelegate(QtWidgets.QStyledItemDelegate):
"""
Prints icons and downloaded representation ration for both sides.
"""
def __init__(self, dbcon, parent=None):
super(AvailabilityDelegate, self).__init__(parent)
self.icons = tools_lib.get_repre_icons()
def paint(self, painter, option, index):
super(AvailabilityDelegate, self).paint(painter, option, index)
option = QtWidgets.QStyleOptionViewItem(option)
option.showDecorationSelected = True
provider_active = index.data(LOCAL_PROVIDER_ROLE)
provider_remote = index.data(REMOTE_PROVIDER_ROLE)
availability_active = index.data(LOCAL_AVAILABILITY_ROLE)
availability_remote = index.data(REMOTE_AVAILABILITY_ROLE)
if not availability_active or not availability_remote: # group lines
return
idx = 0
height = width = 24
for value, provider in [(availability_active, provider_active),
(availability_remote, provider_remote)]:
icon = self.icons.get(provider)
if not icon:
continue
pixmap = icon.pixmap(icon.actualSize(QtCore.QSize(height, width)))
padding = 10 + (70 * idx)
point = QtCore.QPoint(option.rect.x() + padding,
option.rect.y() +
(option.rect.height() - pixmap.height()) / 2)
painter.drawPixmap(point, pixmap)
text_rect = option.rect.translated(padding + width + 10, 0)
painter.drawText(
text_rect,
option.displayAlignment,
value
)
idx += 1
def displayText(self, value, locale):
pass