mirror of
https://github.com/ynput/ayon-core.git
synced 2025-12-24 21:04:40 +01:00
* implemented 'get_workfile_info' in entities * removed 'prepare_asset_update_data' which is not used * disable settings and project manager if in v4 mode * prepared conversion helper functions for v4 entities * prepared conversion functions for hero versions * fix hero versions * implemented get_archived_representations * fix get latest versions * return prepared changes * handle archived representation * raise exception on failed json conversion * map archived to active properly * make sure default fields are added * fix conversion of hero version entity * fix conversion of archived representations * fix some conversions of representations and versions * changed active behavior in queries * fixed hero versions * implemented basic thumbnail caching * added raw variants of crud methods * implemented methods to get and create thumbnail * fix from flat dict * implemented some basic folder conversion for updates * fix thumbnail updates for version * implemented v4 thumbnail integrator * simplified data mapping * 'get_thumbnail' function also expect entity type and entity id for which is the thumbnail received * implemented 'get_thumbnail' for server * fix how thumbnail id is received from entity * removed unnecessary method 'get_thumbnail_id_from_source' * implemented thumbnail resolver for v4 * removed unnecessary print * move create and delete project directly to server api * disable local settings action too on v4 * OP-3521 - added method to check and download updated addons from v4 server * OP-3521 - added more descriptive error message for missing source * OP-3521 - added default implementation of addon downloader to import * OP-3521 - added check for dependency package zips WIP - server doesn't contain required endpoint. Testing only with mockup data for now. * OP-3521 - fixed parsing of DependencyItem Added Server Url type and ServerAddonDownloader - v4 server doesn't know its own DNS for static files so it is sending unique name and url must be created during runtime. * OP-3521 - fixed creation of targed directories * change nev keys to look for and don't set them automatically * fix task type conversion * implemented base of loading v4 addons in v3 * Refactored argument name in Downloaders * Updated parsing to DependencyItem according to current schema * Implemented downloading of package from server * Updated resolving of failures Uses Enum items. * Introduced passing of authorization token Better to inject it than to have it from env var. * Remove weird parsing of server_url Not necessary, endpoints have same prefix. * Fix doubling asset version name in addons folder Zip file should already contain `addonName_addonVersion` as first subfolder * Fix doubling asset version name in addons folder Zip file should already contain `addonName_addonVersion` as first subfolder * Made server_endpoint optional Argument should be better for testing, but for calling from separate methods it would be better to encapsulate it. Removed unwanted temporary productionPackage value * Use existing method to pull addon info from Server to load v4 version of addon * Raise exception when server doesn't have any production dependency package * added ability to specify v3 alias of addon name * expect v3_alias as uppered constant * Re-implemented method to get addon info Previous implementation wouldn't work in Python2 hosts. Will be refactored in the future. * fix '__getattr__' * added ayon api to pyproject.toml and lock file * use ayon api in common connection * added mapping for label * use ayon_api in client codebase * separated clearing cache of url and username * bump ayon api version * rename env 'OP4_TEST' to 'USE_AYON_SERVER' * Move and renamend get_addons_info to get_addons_info_as_dict in addon_distribution Should be moved to ayon_api later * Replaced requests calls with ayon_api * Replaced OP4_TEST_ENABLED with AYON_SERVER_ENABLED fixed endpoints * Hound * Hound * OP-3521 - fix wrong key in get_representation_parents parents overloads parents * OP-3521 - changes for v4 of SiteSync addon * OP-3521 - fix names * OP-3521 - remove storing project_name It should be safer to go thorug self.dbcon apparently * OP-3521 - remove unwanted "context["folder"]" can be only in dummy test data * OP-3521 - move site sync loaders to addon * Use only project instead of self.project * OP-3521 - added missed get_progress_for_repre * base of settings conversion script * simplified ayon functions in start.py * added loading of settings from ayon server * added a note about colors * fix global and local settings functions * AvalonMongoDB is not using mongo connection on ayon server enabled * 'get_dynamic_modules_dirs' is not checking system settings for paths in setting * log viewer is disabled when ayon server is enabled * basic logic of enabling/disabled addons * don't use mongo logging if ayon server is enabled * update ayon api * bump ayon api again * use ayon_api to get addons info in modules/base * update ayon api * moved helper functions to get addons and dependencies dir to common functions * Initialization of AddonInfo is not crashing on unkonwn sources * renamed 'DependencyDownloader' to 'AyonServerDownloader' * renamed function 'default_addon_downloader' to 'get_default_addon_downloader' * Added ability to convert 'WebAddonSource' to 'ServerResourceSorce' * missing dependency package on server won't cause crash * data sent to downloaders don't contain ayon specific headers * modified addon distribution to not duplicate 'ayon_api' functionality * fix doubled function defintioin * unzip client file to addon destination * formatting - unify quotes * disable usage of mongo connection if in ayon mode * renamed window.py to login_window.py * added webpublisher settings conversion * added maya conversion function * reuse variable * reuse variable (similar to previous commit) * fix ayon addons loading * fix typo 'AyonSettingsCahe' -> 'AyonSettingsCache' * fix enabled state changes * fix rr_path in royal render conversion * avoid mongo calls in AYON state * implemented custom AYON start script * fix formatting (after black) * ayon_start cleanup * 'get_addons_dir' and 'get_dependencies_dir' store value to environment variable * add docstrings to local dir functions * addon info has full name * fix modules enabled states * removed unused 'run_disk_mapping_commands' * removed ayon logic from 'start.py' * fix warning message * renamed 'openpype_common' to 'ayon_common' * removed unused import * don't import igniter * removed startup validations of third parties * change what's shown in version info * fix which keys are applied from ayon values * fix method name * get applications from attribs * Implemented UI basics to be able change user or logout * merged server.py and credentials.py * add more metadata to urls * implemented change token * implemented change user ui functionality * implemented change user ui * modify window to handle username and token value * pass username to add server * fix show UI cases * added loggin action to tray * update ayon api * added missing dependency * convert applications to config in a right way * initial implementation of 'nuke' settings conversion * removed few nuke comments * implemented hiero conversion * added imageio conversion * added run ayon tray script * fix few settings conversions * Renamed class of source classes as they are not just for addons * implemented objec to track source transfer progress * Implemented distribution item with multiple sources * Implemented ayon distribution wrapper to care about multiple things during distribution * added 'cleanup' method for downlaoders * download gets tranfer progress object * Change UploadState enum * added missing imports * use AyonDistribution in ayon_start.py * removed unused functions * removed implemented TODOs * fix import * fix key used for Web source * removed temp development fix * formatting fix * keep information if source require distribution * handle 'require_distribution' attribute in distribution process * added path attribute to server source * added option to pass addons infor to ayon distribution * fix tests * fix formatting * Fix typo * Fix typo * remove '_try_convert_to_server_source' * renamed attributes and methods to match their content * it is possible to pass dependency package info to AyonDistribution * fix called methods in tests * added public properties for error message and error detail * Added filename to WebSourceInfo Useful for GDrive sharable links where target file name is unknown/unparsable, it should be provided explicitly. * unify source conversion by adding 'convert_source' function * Fix error message Co-authored-by: Roy Nieterau <roy_nieterau@hotmail.com> * added docstring for 'transfer_progress' * don't create metadata file on read * added few docstrings * add default folder fields to folder/task queries * fix generators * add dependencies when runnign from code * add sys paths from distribution to pythonpath env * fix missing applications * added missing conversions for maya renderers * fix formatting * update ayon api * fix hashes in lock file * Use better exception Co-authored-by: Ondřej Samohel <33513211+antirotor@users.noreply.github.com> * Use Python 3 syntax Co-authored-by: Ondřej Samohel <33513211+antirotor@users.noreply.github.com> * apply some of sugested changes in ayon_start * added some docstrings and suggested modifications * copy create env from develop * fix rendersettings conversion * change code by suggestions * added missing args to docstring * added missing docstrings * separated downloader and download factory * fix ayon settings * added some basic file docstring to ayon_settings * join else conditions * fix project settings conversion * fix created at conversion * fix workfile info query * fix publisher UI * added utils function 'get_ayon_appdirs' * fix 'get_all_current_info' * fix server url assignment when url is set * updated ayon api * added utils functions to create local site id for ayon * added helper functions to create global connection * create global connection in ayon start to start use site id * use ayon site id in ayon mode * formatting cleanup * added header docstring * fixes after ayon_api update * load addons from ynput appdirs * fix function call * added docstring * update ayon pyton api * fix settings access * use ayon_api to get root overrides in Anatomy * bumbayon version to 0.1.13 * nuke: fixing settings keys from settings * fix burnins definitions * change v4 to AYON in thumbnail integrate * fix one more v4 information * Fixes after rebase * fix extract burnin conversion * additional fix of extract burnin * SiteSync:added missed loaders or v3 compatibility (#4587) * Added site sync loaders for v3 compatibility * Fix get_progress_for_repre * use 'files.name' instead of 'files.baseName' * update ayon api to 0.1.14 * add common to include files * change arguments for hero version creation * skip shotgrid settings conversion if different ayon addon is used * added ayon icons * fix labels of application variants * added option to show login window always on top * login window on invalid credentials is always on top * update ayon api * update ayon api * add entityType to project and folders * AYON: Editorial hierarchy creation (#4699) * disable extract hierarchy avalon when ayon mode is enabled * implemented extract hierarchy to AYON --------- Co-authored-by: Petr Kalis <petr.kalis@gmail.com> Co-authored-by: Roy Nieterau <roy_nieterau@hotmail.com> Co-authored-by: Ondřej Samohel <33513211+antirotor@users.noreply.github.com> Co-authored-by: Jakub Jezek <jakubjezek001@gmail.com>
1750 lines
56 KiB
Python
1750 lines
56 KiB
Python
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(
|
|
"<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(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((
|
|
"<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, 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
|