import os import sys import datetime import pprint import traceback import collections from qtpy 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, LoadError, IncompatibleLoaderError, ) from openpype.tools.utils import ( ErrorMessageBox, lib as tools_lib ) from openpype.tools.utils.lib import checkstate_int_to_enum 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.utils.constants import ( LOCAL_PROVIDER_ROLE, REMOTE_PROVIDER_ROLE, LOCAL_AVAILABILITY_ROLE, REMOTE_AVAILABILITY_ROLE, ) 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 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( "Failed to load items" ) 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 = ( "Subset: {}
" "Version: {}
" "Representation: {}
" ) exc_msg_template = "{}" 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", "
"), self ) item_name_widget.setWordWrap(True) content_layout.addWidget(item_name_widget) exc_msg = exc_msg_template.format(exc_msg.replace("\n", "
")) message_label_widget = QtWidgets.QLabel(exc_msg, self) message_label_widget.setWordWrap(True) content_layout.addWidget(message_label_widget) if tb_text: line = self._create_line() tb_widget = self._create_traceback_widget(tb_text, self) content_layout.addWidget(line) content_layout.addWidget(tb_widget) class 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(self._subset_changed) 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 _subset_changed(self, text): if hasattr(self.proxy, "setFilterRegExp"): self.proxy.setFilterRegExp(text) else: self.proxy.setFilterRegularExpression(text) self.view.expandAll() 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", "data", "context"] ) 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: if not loader.enabled: continue # 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(( "

{subset}

" "

{version}

" "Comment
" "{comment}

" "Created
" "{created}

" "Source
" "{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, src_type, src_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) checked = checkstate_int_to_enum( index.data(QtCore.Qt.CheckStateRole) ) if checked == 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 = checkstate_int_to_enum( index.data(QtCore.Qt.CheckStateRole) ) if index_state == state: continue new_state = state if new_state is None: if index_state in 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", "data", "context"] )) 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: if not loader.enabled: continue # 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") site_name = "{}_site_name".format(selected_side) is_sync_loader = tools_lib.is_sync_loader(loader) for item in items: repre_id = item["_id"] repre_ids.append(repre_id) if not is_sync_loader: continue data_site_name = item.get(site_name) if not data_site_name: continue data_by_repre_id[repre_id] = { "site_name": data_site_name } 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: repre_id = repre_context["representation"]["_id"] data = data_by_repre_id.get(repre_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: formatted_traceback = None if not isinstance(exc, LoadError): exc_type, exc_value, exc_traceback = sys.exc_info() formatted_traceback = "".join(traceback.format_exception( exc_type, exc_value, exc_traceback )) error_info.append(( str(exc), formatted_traceback, repre_context["representation"]["name"], repre_context["subset"]["name"], version_name )) return error_info def _load_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 error_info 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: formatted_traceback = None if not isinstance(exc, LoadError): exc_type, exc_value, exc_traceback = sys.exc_info() formatted_traceback = "".join(traceback.format_exception( exc_type, exc_value, exc_traceback )) error_info.append(( str(exc), formatted_traceback, None, ", ".join(subset_names), None )) else: for 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: formatted_traceback = None if not isinstance(exc, LoadError): exc_type, exc_value, exc_traceback = sys.exc_info() formatted_traceback = "".join(traceback.format_exception( exc_type, exc_value, exc_traceback )) error_info.append(( str(exc), formatted_traceback, None, subset_name, None )) return error_info 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