diff --git a/openpype/hosts/blender/hooks/pre_pyside_install.py b/openpype/hosts/blender/hooks/pre_pyside_install.py
index 088a27566d..6d253300d9 100644
--- a/openpype/hosts/blender/hooks/pre_pyside_install.py
+++ b/openpype/hosts/blender/hooks/pre_pyside_install.py
@@ -1,4 +1,5 @@
import os
+import re
import subprocess
from openpype.lib import PreLaunchHook
@@ -31,10 +32,46 @@ class InstallPySideToBlender(PreLaunchHook):
def inner_execute(self):
# Get blender's python directory
+ version_regex = re.compile(r"^2\.[0-9]{2}$")
+
executable = self.launch_context.executable.executable_path
- # Blender installation contain subfolder named with it's version where
- # python binaries are stored.
- version_subfolder = self.launch_context.app_name.split("_")[1]
+ if os.path.basename(executable).lower() != "blender.exe":
+ self.log.info((
+ "Executable does not lead to blender.exe file. Can't determine"
+ " blender's python to check/install PySide2."
+ ))
+ return
+
+ executable_dir = os.path.dirname(executable)
+ version_subfolders = []
+ for name in os.listdir(executable_dir):
+ fullpath = os.path.join(name, executable_dir)
+ if not os.path.isdir(fullpath):
+ continue
+
+ if not version_regex.match(name):
+ continue
+
+ version_subfolders.append(name)
+
+ if not version_subfolders:
+ self.log.info(
+ "Didn't find version subfolder next to Blender executable"
+ )
+ return
+
+ if len(version_subfolders) > 1:
+ self.log.info((
+ "Found more than one version subfolder next"
+ " to blender executable. {}"
+ ).format(", ".join([
+ '"./{}"'.format(name)
+ for name in version_subfolders
+ ])))
+ return
+
+ version_subfolder = version_subfolders[0]
+
pythond_dir = os.path.join(
os.path.dirname(executable),
version_subfolder,
@@ -65,6 +102,7 @@ class InstallPySideToBlender(PreLaunchHook):
# Check if PySide2 is installed and skip if yes
if self.is_pyside_installed(python_executable):
+ self.log.debug("Blender has already installed PySide2.")
return
# Install PySide2 in blender's python
diff --git a/openpype/hosts/unreal/plugins/load/load_alembic_geometrycache.py b/openpype/hosts/unreal/plugins/load/load_alembic_geometrycache.py
new file mode 100644
index 0000000000..a9279bf6e0
--- /dev/null
+++ b/openpype/hosts/unreal/plugins/load/load_alembic_geometrycache.py
@@ -0,0 +1,162 @@
+import os
+
+from avalon import api, pipeline
+from avalon.unreal import lib
+from avalon.unreal import pipeline as unreal_pipeline
+import unreal
+
+
+class PointCacheAlembicLoader(api.Loader):
+ """Load Point Cache from Alembic"""
+
+ families = ["model", "pointcache"]
+ label = "Import Alembic Point Cache"
+ representations = ["abc"]
+ icon = "cube"
+ color = "orange"
+
+ def load(self, context, name, namespace, data):
+ """
+ Load and containerise representation into Content Browser.
+
+ This is two step process. First, import FBX to temporary path and
+ then call `containerise()` on it - this moves all content to new
+ directory and then it will create AssetContainer there and imprint it
+ with metadata. This will mark this path as container.
+
+ Args:
+ context (dict): application context
+ name (str): subset name
+ namespace (str): in Unreal this is basically path to container.
+ This is not passed here, so namespace is set
+ by `containerise()` because only then we know
+ real path.
+ data (dict): Those would be data to be imprinted. This is not used
+ now, data are imprinted by `containerise()`.
+
+ Returns:
+ list(str): list of container content
+ """
+
+ # Create directory for asset and avalon container
+ root = "/Game/Avalon/Assets"
+ asset = context.get('asset').get('name')
+ suffix = "_CON"
+ if asset:
+ asset_name = "{}_{}".format(asset, name)
+ else:
+ asset_name = "{}".format(name)
+
+ tools = unreal.AssetToolsHelpers().get_asset_tools()
+ asset_dir, container_name = tools.create_unique_asset_name(
+ "{}/{}/{}".format(root, asset, name), suffix="")
+
+ container_name += suffix
+
+ unreal.EditorAssetLibrary.make_directory(asset_dir)
+
+ task = unreal.AssetImportTask()
+
+ task.set_editor_property('filename', self.fname)
+ task.set_editor_property('destination_path', asset_dir)
+ task.set_editor_property('destination_name', asset_name)
+ task.set_editor_property('replace_existing', False)
+ task.set_editor_property('automated', True)
+ task.set_editor_property('save', True)
+
+ # set import options here
+ # Unreal 4.24 ignores the settings. It works with Unreal 4.26
+ options = unreal.AbcImportSettings()
+ options.set_editor_property(
+ 'import_type', unreal.AlembicImportType.GEOMETRY_CACHE)
+
+ options.geometry_cache_settings.set_editor_property(
+ 'flatten_tracks', False)
+
+ task.options = options
+ unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501
+
+ # Create Asset Container
+ lib.create_avalon_container(
+ container=container_name, path=asset_dir)
+
+ data = {
+ "schema": "openpype:container-2.0",
+ "id": pipeline.AVALON_CONTAINER_ID,
+ "asset": asset,
+ "namespace": asset_dir,
+ "container_name": container_name,
+ "asset_name": asset_name,
+ "loader": str(self.__class__.__name__),
+ "representation": context["representation"]["_id"],
+ "parent": context["representation"]["parent"],
+ "family": context["representation"]["context"]["family"]
+ }
+ unreal_pipeline.imprint(
+ "{}/{}".format(asset_dir, container_name), data)
+
+ asset_content = unreal.EditorAssetLibrary.list_assets(
+ asset_dir, recursive=True, include_folder=True
+ )
+
+ for a in asset_content:
+ unreal.EditorAssetLibrary.save_asset(a)
+
+ return asset_content
+
+ def update(self, container, representation):
+ name = container["asset_name"]
+ source_path = api.get_representation_path(representation)
+ destination_path = container["namespace"]
+
+ task = unreal.AssetImportTask()
+
+ task.set_editor_property('filename', source_path)
+ task.set_editor_property('destination_path', destination_path)
+ # strip suffix
+ task.set_editor_property('destination_name', name)
+ task.set_editor_property('replace_existing', True)
+ task.set_editor_property('automated', True)
+ task.set_editor_property('save', True)
+
+ # set import options here
+ # Unreal 4.24 ignores the settings. It works with Unreal 4.26
+ options = unreal.AbcImportSettings()
+ options.set_editor_property(
+ 'import_type', unreal.AlembicImportType.GEOMETRY_CACHE)
+
+ options.geometry_cache_settings.set_editor_property(
+ 'flatten_tracks', False)
+
+ task.options = options
+ # do import fbx and replace existing data
+ unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
+ container_path = "{}/{}".format(container["namespace"],
+ container["objectName"])
+ # update metadata
+ unreal_pipeline.imprint(
+ container_path,
+ {
+ "representation": str(representation["_id"]),
+ "parent": str(representation["parent"])
+ })
+
+ asset_content = unreal.EditorAssetLibrary.list_assets(
+ destination_path, recursive=True, include_folder=True
+ )
+
+ for a in asset_content:
+ unreal.EditorAssetLibrary.save_asset(a)
+
+ def remove(self, container):
+ path = container["namespace"]
+ parent_path = os.path.dirname(path)
+
+ unreal.EditorAssetLibrary.delete_directory(path)
+
+ asset_content = unreal.EditorAssetLibrary.list_assets(
+ parent_path, recursive=False
+ )
+
+ if len(asset_content) == 0:
+ unreal.EditorAssetLibrary.delete_directory(parent_path)
diff --git a/openpype/hosts/unreal/plugins/load/load_alembic_skeletalmesh.py b/openpype/hosts/unreal/plugins/load/load_alembic_skeletalmesh.py
new file mode 100644
index 0000000000..b652af0b89
--- /dev/null
+++ b/openpype/hosts/unreal/plugins/load/load_alembic_skeletalmesh.py
@@ -0,0 +1,156 @@
+import os
+
+from avalon import api, pipeline
+from avalon.unreal import lib
+from avalon.unreal import pipeline as unreal_pipeline
+import unreal
+
+
+class SkeletalMeshAlembicLoader(api.Loader):
+ """Load Unreal SkeletalMesh from Alembic"""
+
+ families = ["pointcache"]
+ label = "Import Alembic Skeletal Mesh"
+ representations = ["abc"]
+ icon = "cube"
+ color = "orange"
+
+ def load(self, context, name, namespace, data):
+ """
+ Load and containerise representation into Content Browser.
+
+ This is two step process. First, import FBX to temporary path and
+ then call `containerise()` on it - this moves all content to new
+ directory and then it will create AssetContainer there and imprint it
+ with metadata. This will mark this path as container.
+
+ Args:
+ context (dict): application context
+ name (str): subset name
+ namespace (str): in Unreal this is basically path to container.
+ This is not passed here, so namespace is set
+ by `containerise()` because only then we know
+ real path.
+ data (dict): Those would be data to be imprinted. This is not used
+ now, data are imprinted by `containerise()`.
+
+ Returns:
+ list(str): list of container content
+ """
+
+ # Create directory for asset and avalon container
+ root = "/Game/Avalon/Assets"
+ asset = context.get('asset').get('name')
+ suffix = "_CON"
+ if asset:
+ asset_name = "{}_{}".format(asset, name)
+ else:
+ asset_name = "{}".format(name)
+
+ tools = unreal.AssetToolsHelpers().get_asset_tools()
+ asset_dir, container_name = tools.create_unique_asset_name(
+ "{}/{}/{}".format(root, asset, name), suffix="")
+
+ container_name += suffix
+
+ unreal.EditorAssetLibrary.make_directory(asset_dir)
+
+ task = unreal.AssetImportTask()
+
+ task.set_editor_property('filename', self.fname)
+ task.set_editor_property('destination_path', asset_dir)
+ task.set_editor_property('destination_name', asset_name)
+ task.set_editor_property('replace_existing', False)
+ task.set_editor_property('automated', True)
+ task.set_editor_property('save', True)
+
+ # set import options here
+ # Unreal 4.24 ignores the settings. It works with Unreal 4.26
+ options = unreal.AbcImportSettings()
+ options.set_editor_property(
+ 'import_type', unreal.AlembicImportType.SKELETAL)
+
+ task.options = options
+ unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501
+
+ # Create Asset Container
+ lib.create_avalon_container(
+ container=container_name, path=asset_dir)
+
+ data = {
+ "schema": "openpype:container-2.0",
+ "id": pipeline.AVALON_CONTAINER_ID,
+ "asset": asset,
+ "namespace": asset_dir,
+ "container_name": container_name,
+ "asset_name": asset_name,
+ "loader": str(self.__class__.__name__),
+ "representation": context["representation"]["_id"],
+ "parent": context["representation"]["parent"],
+ "family": context["representation"]["context"]["family"]
+ }
+ unreal_pipeline.imprint(
+ "{}/{}".format(asset_dir, container_name), data)
+
+ asset_content = unreal.EditorAssetLibrary.list_assets(
+ asset_dir, recursive=True, include_folder=True
+ )
+
+ for a in asset_content:
+ unreal.EditorAssetLibrary.save_asset(a)
+
+ return asset_content
+
+ def update(self, container, representation):
+ name = container["asset_name"]
+ source_path = api.get_representation_path(representation)
+ destination_path = container["namespace"]
+
+ task = unreal.AssetImportTask()
+
+ task.set_editor_property('filename', source_path)
+ task.set_editor_property('destination_path', destination_path)
+ # strip suffix
+ task.set_editor_property('destination_name', name)
+ task.set_editor_property('replace_existing', True)
+ task.set_editor_property('automated', True)
+ task.set_editor_property('save', True)
+
+ # set import options here
+ # Unreal 4.24 ignores the settings. It works with Unreal 4.26
+ options = unreal.AbcImportSettings()
+ options.set_editor_property(
+ 'import_type', unreal.AlembicImportType.SKELETAL)
+
+ task.options = options
+ # do import fbx and replace existing data
+ unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
+ container_path = "{}/{}".format(container["namespace"],
+ container["objectName"])
+ # update metadata
+ unreal_pipeline.imprint(
+ container_path,
+ {
+ "representation": str(representation["_id"]),
+ "parent": str(representation["parent"])
+ })
+
+ asset_content = unreal.EditorAssetLibrary.list_assets(
+ destination_path, recursive=True, include_folder=True
+ )
+
+ for a in asset_content:
+ unreal.EditorAssetLibrary.save_asset(a)
+
+ def remove(self, container):
+ path = container["namespace"]
+ parent_path = os.path.dirname(path)
+
+ unreal.EditorAssetLibrary.delete_directory(path)
+
+ asset_content = unreal.EditorAssetLibrary.list_assets(
+ parent_path, recursive=False
+ )
+
+ if len(asset_content) == 0:
+ unreal.EditorAssetLibrary.delete_directory(parent_path)
diff --git a/openpype/hosts/unreal/plugins/load/load_alembic_staticmesh.py b/openpype/hosts/unreal/plugins/load/load_alembic_staticmesh.py
new file mode 100644
index 0000000000..12b9320f72
--- /dev/null
+++ b/openpype/hosts/unreal/plugins/load/load_alembic_staticmesh.py
@@ -0,0 +1,156 @@
+import os
+
+from avalon import api, pipeline
+from avalon.unreal import lib
+from avalon.unreal import pipeline as unreal_pipeline
+import unreal
+
+
+class StaticMeshAlembicLoader(api.Loader):
+ """Load Unreal StaticMesh from Alembic"""
+
+ families = ["model"]
+ label = "Import Alembic Static Mesh"
+ representations = ["abc"]
+ icon = "cube"
+ color = "orange"
+
+ def load(self, context, name, namespace, data):
+ """
+ Load and containerise representation into Content Browser.
+
+ This is two step process. First, import FBX to temporary path and
+ then call `containerise()` on it - this moves all content to new
+ directory and then it will create AssetContainer there and imprint it
+ with metadata. This will mark this path as container.
+
+ Args:
+ context (dict): application context
+ name (str): subset name
+ namespace (str): in Unreal this is basically path to container.
+ This is not passed here, so namespace is set
+ by `containerise()` because only then we know
+ real path.
+ data (dict): Those would be data to be imprinted. This is not used
+ now, data are imprinted by `containerise()`.
+
+ Returns:
+ list(str): list of container content
+ """
+
+ # Create directory for asset and avalon container
+ root = "/Game/Avalon/Assets"
+ asset = context.get('asset').get('name')
+ suffix = "_CON"
+ if asset:
+ asset_name = "{}_{}".format(asset, name)
+ else:
+ asset_name = "{}".format(name)
+
+ tools = unreal.AssetToolsHelpers().get_asset_tools()
+ asset_dir, container_name = tools.create_unique_asset_name(
+ "{}/{}/{}".format(root, asset, name), suffix="")
+
+ container_name += suffix
+
+ unreal.EditorAssetLibrary.make_directory(asset_dir)
+
+ task = unreal.AssetImportTask()
+
+ task.set_editor_property('filename', self.fname)
+ task.set_editor_property('destination_path', asset_dir)
+ task.set_editor_property('destination_name', asset_name)
+ task.set_editor_property('replace_existing', False)
+ task.set_editor_property('automated', True)
+ task.set_editor_property('save', True)
+
+ # set import options here
+ # Unreal 4.24 ignores the settings. It works with Unreal 4.26
+ options = unreal.AbcImportSettings()
+ options.set_editor_property(
+ 'import_type', unreal.AlembicImportType.STATIC_MESH)
+
+ task.options = options
+ unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task]) # noqa: E501
+
+ # Create Asset Container
+ lib.create_avalon_container(
+ container=container_name, path=asset_dir)
+
+ data = {
+ "schema": "openpype:container-2.0",
+ "id": pipeline.AVALON_CONTAINER_ID,
+ "asset": asset,
+ "namespace": asset_dir,
+ "container_name": container_name,
+ "asset_name": asset_name,
+ "loader": str(self.__class__.__name__),
+ "representation": context["representation"]["_id"],
+ "parent": context["representation"]["parent"],
+ "family": context["representation"]["context"]["family"]
+ }
+ unreal_pipeline.imprint(
+ "{}/{}".format(asset_dir, container_name), data)
+
+ asset_content = unreal.EditorAssetLibrary.list_assets(
+ asset_dir, recursive=True, include_folder=True
+ )
+
+ for a in asset_content:
+ unreal.EditorAssetLibrary.save_asset(a)
+
+ return asset_content
+
+ def update(self, container, representation):
+ name = container["asset_name"]
+ source_path = api.get_representation_path(representation)
+ destination_path = container["namespace"]
+
+ task = unreal.AssetImportTask()
+
+ task.set_editor_property('filename', source_path)
+ task.set_editor_property('destination_path', destination_path)
+ # strip suffix
+ task.set_editor_property('destination_name', name)
+ task.set_editor_property('replace_existing', True)
+ task.set_editor_property('automated', True)
+ task.set_editor_property('save', True)
+
+ # set import options here
+ # Unreal 4.24 ignores the settings. It works with Unreal 4.26
+ options = unreal.AbcImportSettings()
+ options.set_editor_property(
+ 'import_type', unreal.AlembicImportType.STATIC_MESH)
+
+ task.options = options
+ # do import fbx and replace existing data
+ unreal.AssetToolsHelpers.get_asset_tools().import_asset_tasks([task])
+ container_path = "{}/{}".format(container["namespace"],
+ container["objectName"])
+ # update metadata
+ unreal_pipeline.imprint(
+ container_path,
+ {
+ "representation": str(representation["_id"]),
+ "parent": str(representation["parent"])
+ })
+
+ asset_content = unreal.EditorAssetLibrary.list_assets(
+ destination_path, recursive=True, include_folder=True
+ )
+
+ for a in asset_content:
+ unreal.EditorAssetLibrary.save_asset(a)
+
+ def remove(self, container):
+ path = container["namespace"]
+ parent_path = os.path.dirname(path)
+
+ unreal.EditorAssetLibrary.delete_directory(path)
+
+ asset_content = unreal.EditorAssetLibrary.list_assets(
+ parent_path, recursive=False
+ )
+
+ if len(asset_content) == 0:
+ unreal.EditorAssetLibrary.delete_directory(parent_path)
diff --git a/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py b/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py
index dbea1d5951..dcb566fa4c 100644
--- a/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py
+++ b/openpype/hosts/unreal/plugins/load/load_staticmeshfbx.py
@@ -1,7 +1,6 @@
import os
from avalon import api, pipeline
-from avalon import unreal as avalon_unreal
from avalon.unreal import lib
from avalon.unreal import pipeline as unreal_pipeline
import unreal
diff --git a/openpype/modules/sync_server/tray/app.py b/openpype/modules/sync_server/tray/app.py
index 25fbf0e49a..d91ba76335 100644
--- a/openpype/modules/sync_server/tray/app.py
+++ b/openpype/modules/sync_server/tray/app.py
@@ -7,7 +7,7 @@ from openpype import resources
from openpype.modules.sync_server.tray.widgets import (
SyncProjectListWidget,
- SyncRepresentationWidget
+ SyncRepresentationSummaryWidget
)
log = PypeLogger().get_logger("SyncServer")
@@ -47,7 +47,7 @@ class SyncServerWindow(QtWidgets.QDialog):
left_column_layout.addWidget(self.pause_btn)
left_column.setLayout(left_column_layout)
- repres = SyncRepresentationWidget(
+ repres = SyncRepresentationSummaryWidget(
sync_server,
project=self.projects.current_project,
parent=self)
diff --git a/openpype/modules/sync_server/tray/lib.py b/openpype/modules/sync_server/tray/lib.py
index 3597213b31..04bd1f568e 100644
--- a/openpype/modules/sync_server/tray/lib.py
+++ b/openpype/modules/sync_server/tray/lib.py
@@ -24,6 +24,7 @@ ProgressRole = QtCore.Qt.UserRole + 4
DateRole = QtCore.Qt.UserRole + 6
FailedRole = QtCore.Qt.UserRole + 8
HeaderNameRole = QtCore.Qt.UserRole + 10
+FullItemRole = QtCore.Qt.UserRole + 12
@six.add_metaclass(abc.ABCMeta)
@@ -128,6 +129,7 @@ class FilterDefinition:
type = attr.ib()
values = attr.ib(factory=list)
+
def pretty_size(value, suffix='B'):
for unit in ['', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi']:
if abs(value) < 1024.0:
@@ -156,3 +158,9 @@ def translate_provider_for_icon(sync_server, project, site):
if site == sync_server.DEFAULT_SITE:
return sync_server.DEFAULT_SITE
return sync_server.get_provider_for_site(project, site)
+
+
+def get_item_by_id(model, object_id):
+ index = model.get_index(object_id)
+ item = model.data(index, FullItemRole)
+ return item
diff --git a/openpype/modules/sync_server/tray/models.py b/openpype/modules/sync_server/tray/models.py
index 981299c6cf..ffd81a1588 100644
--- a/openpype/modules/sync_server/tray/models.py
+++ b/openpype/modules/sync_server/tray/models.py
@@ -120,7 +120,7 @@ class _SyncRepresentationModel(QtCore.QAbstractTableModel):
self.query = self.get_query(load_records)
representations = self.dbcon.aggregate(self.query)
- self.add_page_records(self.local_site, self.remote_site,
+ self.add_page_records(self.active_site, self.remote_site,
representations)
self.endResetModel()
self.refresh_finished.emit()
@@ -158,7 +158,7 @@ class _SyncRepresentationModel(QtCore.QAbstractTableModel):
self._rec_loaded,
self._rec_loaded + items_to_fetch - 1)
- self.add_page_records(self.local_site, self.remote_site,
+ self.add_page_records(self.active_site, self.remote_site,
representations)
self.endInsertRows()
@@ -283,7 +283,7 @@ class _SyncRepresentationModel(QtCore.QAbstractTableModel):
"""
self._project = project
self.sync_server.set_sync_project_settings()
- self.local_site = self.sync_server.get_active_site(self.project)
+ self.active_site = self.sync_server.get_active_site(self.project)
self.remote_site = self.sync_server.get_remote_site(self.project)
self.refresh()
@@ -410,7 +410,7 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel):
self.sync_server = sync_server
# TODO think about admin mode
# this is for regular user, always only single local and single remote
- self.local_site = self.sync_server.get_active_site(self.project)
+ self.active_site = self.sync_server.get_active_site(self.project)
self.remote_site = self.sync_server.get_remote_site(self.project)
self.sort = self.DEFAULT_SORT
@@ -428,6 +428,9 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel):
def data(self, index, role):
item = self._data[index.row()]
+ if role == lib.FullItemRole:
+ return item
+
header_value = self._header[index.column()]
if role == lib.ProviderRole:
if header_value == 'local_site':
@@ -585,7 +588,7 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel):
}},
'order_local': {
'$filter': {'input': '$files.sites', 'as': 'p',
- 'cond': {'$eq': ['$$p.name', self.local_site]}
+ 'cond': {'$eq': ['$$p.name', self.active_site]}
}}
}},
{'$addFields': {
@@ -714,7 +717,7 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel):
"""
base_match = {
"type": "representation",
- 'files.sites.name': {'$all': [self.local_site,
+ 'files.sites.name': {'$all': [self.active_site,
self.remote_site]}
}
if not self._word_filter:
@@ -889,7 +892,7 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel):
self.sync_server = sync_server
# TODO think about admin mode
# this is for regular user, always only single local and single remote
- self.local_site = self.sync_server.get_active_site(self.project)
+ self.active_site = self.sync_server.get_active_site(self.project)
self.remote_site = self.sync_server.get_remote_site(self.project)
self.sort = self.DEFAULT_SORT
@@ -905,6 +908,9 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel):
def data(self, index, role):
item = self._data[index.row()]
+ if role == lib.FullItemRole:
+ return item
+
header_value = self._header[index.column()]
if role == lib.ProviderRole:
if header_value == 'local_site':
@@ -1042,7 +1048,7 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel):
}},
'order_local': {
'$filter': {'input': '$files.sites', 'as': 'p',
- 'cond': {'$eq': ['$$p.name', self.local_site]}
+ 'cond': {'$eq': ['$$p.name', self.active_site]}
}}
}},
{'$addFields': {
diff --git a/openpype/modules/sync_server/tray/widgets.py b/openpype/modules/sync_server/tray/widgets.py
index 6d8348becb..21236dc64a 100644
--- a/openpype/modules/sync_server/tray/widgets.py
+++ b/openpype/modules/sync_server/tray/widgets.py
@@ -135,7 +135,7 @@ class SyncProjectListWidget(ProjectListWidget):
self.refresh()
-class SyncRepresentationWidget(QtWidgets.QWidget):
+class _SyncRepresentationWidget(QtWidgets.QWidget):
"""
Summary dialog with list of representations that matches current
settings 'local_site' and 'remote_site'.
@@ -143,6 +143,283 @@ class SyncRepresentationWidget(QtWidgets.QWidget):
active_changed = QtCore.Signal() # active index changed
message_generated = QtCore.Signal(str)
+ def _selection_changed(self, _new_selected, _all_selected):
+ idxs = self.selection_model.selectedRows()
+ self._selected_ids = []
+
+ for index in idxs:
+ self._selected_ids.append(self.model.data(index, Qt.UserRole))
+
+ def _set_selection(self):
+ """
+ Sets selection to 'self._selected_id' if exists.
+
+ Keep selection during model refresh.
+ """
+ existing_ids = []
+ for selected_id in self._selected_ids:
+ index = self.model.get_index(selected_id)
+ if index and index.isValid():
+ mode = QtCore.QItemSelectionModel.Select | \
+ QtCore.QItemSelectionModel.Rows
+ self.selection_model.select(index, mode)
+ existing_ids.append(selected_id)
+
+ self._selected_ids = existing_ids
+
+ def _double_clicked(self, index):
+ """
+ Opens representation dialog with all files after doubleclick
+ """
+ _id = self.model.data(index, Qt.UserRole)
+ detail_window = SyncServerDetailWindow(
+ self.sync_server, _id, self.model.project)
+ detail_window.exec()
+
+ def _on_context_menu(self, point):
+ """
+ Shows menu with loader actions on Right-click.
+
+ Supports multiple selects - adds all available actions, each
+ action handles if it appropriate for item itself, if not it skips.
+ """
+ is_multi = len(self._selected_ids) > 1
+ point_index = self.table_view.indexAt(point)
+ if not point_index.isValid() and not is_multi:
+ return
+
+ if is_multi:
+ index = self.model.get_index(self._selected_ids[0])
+ item = self.model.data(index, lib.FullItemRole)
+ else:
+ item = self.model.data(point_index, lib.FullItemRole)
+
+ action_kwarg_map, actions_mapping, menu = self._prepare_menu(item,
+ is_multi)
+
+ result = menu.exec_(QtGui.QCursor.pos())
+ if result:
+ to_run = actions_mapping[result]
+ to_run_kwargs = action_kwarg_map.get(result, {})
+ if to_run:
+ to_run(**to_run_kwargs)
+
+ self.model.refresh()
+
+ def _prepare_menu(self, item, is_multi):
+ menu = QtWidgets.QMenu()
+
+ actions_mapping = {}
+ action_kwarg_map = {}
+
+ active_site = self.model.active_site
+ remote_site = self.model.remote_site
+
+ local_progress = item.local_progress
+ remote_progress = item.remote_progress
+
+ project = self.model.project
+
+ for site, progress in {active_site: local_progress,
+ remote_site: remote_progress}.items():
+ provider = self.sync_server.get_provider_for_site(project, site)
+ if provider == 'local_drive':
+ if 'studio' in site:
+ txt = " studio version"
+ else:
+ txt = " local version"
+ action = QtWidgets.QAction("Open in explorer" + txt)
+ if progress == 1.0 or is_multi:
+ actions_mapping[action] = self._open_in_explorer
+ action_kwarg_map[action] = \
+ self._get_action_kwargs(site)
+ menu.addAction(action)
+
+ if remote_progress == 1.0 or is_multi:
+ action = QtWidgets.QAction("Re-sync Active site")
+ action_kwarg_map[action] = self._get_action_kwargs(active_site)
+ actions_mapping[action] = self._reset_site
+ menu.addAction(action)
+
+ if local_progress == 1.0 or is_multi:
+ action = QtWidgets.QAction("Re-sync Remote site")
+ action_kwarg_map[action] = self._get_action_kwargs(remote_site)
+ actions_mapping[action] = self._reset_site
+ menu.addAction(action)
+
+ if active_site == get_local_site_id():
+ action = QtWidgets.QAction("Completely remove from local")
+ action_kwarg_map[action] = self._get_action_kwargs(active_site)
+ actions_mapping[action] = self._remove_site
+ menu.addAction(action)
+
+ # # temp for testing only !!!
+ # action = QtWidgets.QAction("Download")
+ # action_kwarg_map[action] = self._get_action_kwargs(active_site)
+ # actions_mapping[action] = self._add_site
+ # menu.addAction(action)
+
+ if not actions_mapping:
+ action = QtWidgets.QAction("< No action >")
+ actions_mapping[action] = None
+ menu.addAction(action)
+
+ return action_kwarg_map, actions_mapping, menu
+
+ def _pause(self, selected_ids=None):
+ log.debug("Pause {}".format(selected_ids))
+ for representation_id in selected_ids:
+ item = lib.get_item_by_id(self.model, representation_id)
+ if item.status not in [lib.STATUS[0], lib.STATUS[1]]:
+ continue
+ for site_name in [self.model.active_site, self.model.remote_site]:
+ check_progress = self._get_progress(item, site_name)
+ if check_progress < 1:
+ self.sync_server.pause_representation(self.model.project,
+ representation_id,
+ site_name)
+
+ self.message_generated.emit("Paused {}".format(representation_id))
+
+ def _unpause(self, selected_ids=None):
+ log.debug("UnPause {}".format(selected_ids))
+ for representation_id in selected_ids:
+ item = lib.get_item_by_id(self.model, representation_id)
+ if item.status not in lib.STATUS[3]:
+ continue
+ for site_name in [self.model.active_site, self.model.remote_site]:
+ check_progress = self._get_progress(item, site_name)
+ if check_progress < 1:
+ self.sync_server.unpause_representation(
+ self.model.project,
+ representation_id,
+ site_name)
+
+ self.message_generated.emit("Unpause {}".format(representation_id))
+
+ # temporary here for testing, will be removed TODO
+ def _add_site(self, selected_ids=None, site_name=None):
+ log.debug("Add site {}:{}".format(selected_ids, site_name))
+ for representation_id in selected_ids:
+ item = lib.get_item_by_id(self.model, representation_id)
+ if item.local_site == site_name or item.remote_site == site_name:
+ # site already exists skip
+ continue
+
+ try:
+ self.sync_server.add_site(
+ self.model.project,
+ representation_id,
+ site_name)
+ self.message_generated.emit(
+ "Site {} added for {}".format(site_name,
+ representation_id))
+ except ValueError as exp:
+ self.message_generated.emit("Error {}".format(str(exp)))
+
+ def _remove_site(self, selected_ids=None, site_name=None):
+ """
+ Removes site record AND files.
+
+ This is ONLY for representations stored on local site, which
+ cannot be same as SyncServer.DEFAULT_SITE.
+
+ This could only happen when artist work on local machine, not
+ connected to studio mounted drives.
+ """
+ log.debug("Remove site {}:{}".format(selected_ids, site_name))
+ for representation_id in selected_ids:
+ log.info("Removing {}".format(representation_id))
+ try:
+ self.sync_server.remove_site(
+ self.model.project,
+ representation_id,
+ site_name,
+ True)
+ self.message_generated.emit(
+ "Site {} removed".format(site_name))
+ except ValueError as exp:
+ self.message_generated.emit("Error {}".format(str(exp)))
+
+ self.model.refresh(
+ load_records=self.model._rec_loaded)
+
+ def _reset_site(self, selected_ids=None, site_name=None):
+ """
+ Removes errors or success metadata for particular file >> forces
+ redo of upload/download
+ """
+ log.debug("Reset site {}:{}".format(selected_ids, site_name))
+ for representation_id in selected_ids:
+ item = lib.get_item_by_id(self.model, representation_id)
+ check_progress = self._get_progress(item, site_name, True)
+
+ # do not reset if opposite side is not fully there
+ if check_progress != 1:
+ log.debug("Not fully available {} on other side, skipping".
+ format(check_progress))
+ continue
+
+ self.sync_server.reset_provider_for_file(
+ self.model.project,
+ representation_id,
+ site_name=site_name,
+ force=True)
+
+ self.model.refresh(
+ load_records=self.model._rec_loaded)
+
+ def _open_in_explorer(self, selected_ids=None, site_name=None):
+ log.debug("Open in Explorer {}:{}".format(selected_ids, site_name))
+ for selected_id in selected_ids:
+ item = lib.get_item_by_id(self.model, selected_id)
+ if not item:
+ return
+
+ fpath = item.path
+ project = self.model.project
+ fpath = self.sync_server.get_local_file_path(project,
+ site_name,
+ fpath)
+
+ fpath = os.path.normpath(os.path.dirname(fpath))
+ if os.path.isdir(fpath):
+ if 'win' in sys.platform: # windows
+ subprocess.Popen('explorer "%s"' % fpath)
+ elif sys.platform == 'darwin': # macOS
+ subprocess.Popen(['open', fpath])
+ else: # linux
+ try:
+ subprocess.Popen(['xdg-open', fpath])
+ except OSError:
+ raise OSError('unsupported xdg-open call??')
+
+ def _get_progress(self, item, site_name, opposite=False):
+ """Returns progress value according to site (side)"""
+ progress = {'local': item.local_progress,
+ 'remote': item.remote_progress}
+ side = 'remote'
+ if site_name == self.model.active_site:
+ side = 'local'
+ if opposite:
+ side = 'remote' if side == 'local' else 'local'
+
+ return progress[side]
+
+ def _get_action_kwargs(self, site_name):
+ """Default format of kwargs for action"""
+ return {"selected_ids": self._selected_ids, "site_name": site_name}
+
+ def _save_scrollbar(self):
+ self._scrollbar_pos = self.table_view.verticalScrollBar().value()
+
+ def _set_scrollbar(self):
+ if self._scrollbar_pos:
+ self.table_view.verticalScrollBar().setValue(self._scrollbar_pos)
+
+
+class SyncRepresentationSummaryWidget(_SyncRepresentationWidget):
+
default_widths = (
("asset", 190),
("subset", 170),
@@ -157,310 +434,139 @@ class SyncRepresentationWidget(QtWidgets.QWidget):
)
def __init__(self, sync_server, project=None, parent=None):
- super(SyncRepresentationWidget, self).__init__(parent)
+ super(SyncRepresentationSummaryWidget, self).__init__(parent)
self.sync_server = sync_server
- self._selected_id = None # keep last selected _id
- self.representation_id = None
- self.site_name = None # to pause/unpause representation
+ self._selected_ids = [] # keep last selected _id
- self.txt_filter = QtWidgets.QLineEdit()
- self.txt_filter.setPlaceholderText("Quick filter representations..")
- self.txt_filter.setClearButtonEnabled(True)
- self.txt_filter.addAction(qtawesome.icon("fa.filter", color="gray"),
- QtWidgets.QLineEdit.LeadingPosition)
+ txt_filter = QtWidgets.QLineEdit()
+ txt_filter.setPlaceholderText("Quick filter representations..")
+ txt_filter.setClearButtonEnabled(True)
+ txt_filter.addAction(
+ qtawesome.icon("fa.filter", color="gray"),
+ QtWidgets.QLineEdit.LeadingPosition)
+ self.txt_filter = txt_filter
self._scrollbar_pos = None
top_bar_layout = QtWidgets.QHBoxLayout()
top_bar_layout.addWidget(self.txt_filter)
- self.table_view = QtWidgets.QTableView()
+ table_view = QtWidgets.QTableView()
headers = [item[0] for item in self.default_widths]
model = SyncRepresentationSummaryModel(sync_server, headers, project)
- self.table_view.setModel(model)
- self.table_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
- self.table_view.setSelectionMode(
- QtWidgets.QAbstractItemView.SingleSelection)
- self.table_view.setSelectionBehavior(
+ table_view.setModel(model)
+ table_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
+ table_view.setSelectionMode(
+ QtWidgets.QAbstractItemView.ExtendedSelection)
+ table_view.setSelectionBehavior(
QtWidgets.QAbstractItemView.SelectRows)
- self.table_view.horizontalHeader().setSortIndicator(
+ table_view.horizontalHeader().setSortIndicator(
-1, Qt.AscendingOrder)
- self.table_view.setAlternatingRowColors(True)
- self.table_view.verticalHeader().hide()
+ table_view.setAlternatingRowColors(True)
+ table_view.verticalHeader().hide()
- column = self.table_view.model().get_header_index("local_site")
+ column = table_view.model().get_header_index("local_site")
delegate = ImageDelegate(self)
- self.table_view.setItemDelegateForColumn(column, delegate)
+ table_view.setItemDelegateForColumn(column, delegate)
- column = self.table_view.model().get_header_index("remote_site")
+ column = table_view.model().get_header_index("remote_site")
delegate = ImageDelegate(self)
- self.table_view.setItemDelegateForColumn(column, delegate)
+ table_view.setItemDelegateForColumn(column, delegate)
layout = QtWidgets.QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addLayout(top_bar_layout)
- layout.addWidget(self.table_view)
+ layout.addWidget(table_view)
- self.table_view.doubleClicked.connect(self._double_clicked)
+ self.table_view = table_view
+ self.model = model
+
+ horizontal_header = HorizontalHeader(self)
+
+ table_view.setHorizontalHeader(horizontal_header)
+ table_view.setSortingEnabled(True)
+
+ for column_name, width in self.default_widths:
+ idx = model.get_header_index(column_name)
+ table_view.setColumnWidth(idx, width)
+
+ table_view.doubleClicked.connect(self._double_clicked)
self.txt_filter.textChanged.connect(lambda: model.set_word_filter(
self.txt_filter.text()))
- self.table_view.customContextMenuRequested.connect(
- self._on_context_menu)
+ table_view.customContextMenuRequested.connect(self._on_context_menu)
model.refresh_started.connect(self._save_scrollbar)
model.refresh_finished.connect(self._set_scrollbar)
model.modelReset.connect(self._set_selection)
- self.model = model
-
self.selection_model = self.table_view.selectionModel()
self.selection_model.selectionChanged.connect(self._selection_changed)
- horizontal_header = HorizontalHeader(self)
+ def _prepare_menu(self, item, is_multi):
+ action_kwarg_map, actions_mapping, menu = \
+ super()._prepare_menu(item, is_multi)
- self.table_view.setHorizontalHeader(horizontal_header)
- self.table_view.setSortingEnabled(True)
-
- for column_name, width in self.default_widths:
- idx = model.get_header_index(column_name)
- self.table_view.setColumnWidth(idx, width)
-
- def _selection_changed(self, _new_selection):
- index = self.selection_model.currentIndex()
- self._selected_id = \
- self.model.data(index, Qt.UserRole)
-
- def _set_selection(self):
- """
- Sets selection to 'self._selected_id' if exists.
-
- Keep selection during model refresh.
- """
- if self._selected_id:
- index = self.model.get_index(self._selected_id)
- if index and index.isValid():
- mode = QtCore.QItemSelectionModel.Select | \
- QtCore.QItemSelectionModel.Rows
- self.selection_model.setCurrentIndex(index, mode)
- else:
- self._selected_id = None
-
- def _double_clicked(self, index):
- """
- Opens representation dialog with all files after doubleclick
- """
- _id = self.model.data(index, Qt.UserRole)
- detail_window = SyncServerDetailWindow(
- self.sync_server, _id, self.model.project)
- detail_window.exec()
-
- def _on_context_menu(self, point):
- """
- Shows menu with loader actions on Right-click.
- """
- point_index = self.table_view.indexAt(point)
- if not point_index.isValid():
- return
-
- self.item = self.model._data[point_index.row()]
- self.representation_id = self.item._id
- log.debug("menu representation _id:: {}".
- format(self.representation_id))
-
- menu = QtWidgets.QMenu()
- actions_mapping = {}
- actions_kwargs_mapping = {}
-
- local_site = self.item.local_site
- local_progress = self.item.local_progress
- remote_site = self.item.remote_site
- remote_progress = self.item.remote_progress
-
- for site, progress in {local_site: local_progress,
- remote_site: remote_progress}.items():
- project = self.model.project
- provider = self.sync_server.get_provider_for_site(project,
- site)
- if provider == 'local_drive':
- if 'studio' in site:
- txt = " studio version"
- else:
- txt = " local version"
- action = QtWidgets.QAction("Open in explorer" + txt)
- if progress == 1.0:
- actions_mapping[action] = self._open_in_explorer
- actions_kwargs_mapping[action] = {'site': site}
- menu.addAction(action)
-
- # progress smaller then 1.0 --> in progress or queued
- if local_progress < 1.0:
- self.site_name = local_site
- else:
- self.site_name = remote_site
-
- if self.item.status in [lib.STATUS[0], lib.STATUS[1]]:
- action = QtWidgets.QAction("Pause")
+ if item.status in [lib.STATUS[0], lib.STATUS[1]] or is_multi:
+ action = QtWidgets.QAction("Pause in queue")
actions_mapping[action] = self._pause
+ # pause handles which site_name it will pause itself
+ action_kwarg_map[action] = {"selected_ids": self._selected_ids}
menu.addAction(action)
- if self.item.status == lib.STATUS[3]:
- action = QtWidgets.QAction("Unpause")
+ if item.status == lib.STATUS[3] or is_multi:
+ action = QtWidgets.QAction("Unpause in queue")
actions_mapping[action] = self._unpause
+ action_kwarg_map[action] = {"selected_ids": self._selected_ids}
menu.addAction(action)
- # if self.item.status == lib.STATUS[1]:
- # action = QtWidgets.QAction("Open error detail")
- # actions_mapping[action] = self._show_detail
- # menu.addAction(action)
-
- if remote_progress == 1.0:
- action = QtWidgets.QAction("Re-sync Active site")
- actions_mapping[action] = self._reset_local_site
- menu.addAction(action)
-
- if local_progress == 1.0:
- action = QtWidgets.QAction("Re-sync Remote site")
- actions_mapping[action] = self._reset_remote_site
- menu.addAction(action)
-
- if local_site != self.sync_server.DEFAULT_SITE:
- action = QtWidgets.QAction("Completely remove from local")
- actions_mapping[action] = self._remove_site
- menu.addAction(action)
- else:
- action = QtWidgets.QAction("Mark for sync to local")
- actions_mapping[action] = self._add_site
- menu.addAction(action)
-
- if not actions_mapping:
- action = QtWidgets.QAction("< No action >")
- actions_mapping[action] = None
- menu.addAction(action)
-
- result = menu.exec_(QtGui.QCursor.pos())
- if result:
- to_run = actions_mapping[result]
- to_run_kwargs = actions_kwargs_mapping.get(result, {})
- if to_run:
- to_run(**to_run_kwargs)
-
- self.model.refresh()
-
- def _pause(self):
- self.sync_server.pause_representation(self.model.project,
- self.representation_id,
- self.site_name)
- self.site_name = None
- self.message_generated.emit("Paused {}".format(self.representation_id))
-
- def _unpause(self):
- self.sync_server.unpause_representation(
- self.model.project,
- self.representation_id,
- self.site_name)
- self.site_name = None
- self.message_generated.emit("Unpaused {}".format(
- self.representation_id))
-
- # temporary here for testing, will be removed TODO
- def _add_site(self):
- log.info(self.representation_id)
- project_name = self.model.project
- local_site_name = get_local_site_id()
- try:
- self.sync_server.add_site(
- project_name,
- self.representation_id,
- local_site_name
- )
- self.message_generated.emit(
- "Site {} added for {}".format(local_site_name,
- self.representation_id))
- except ValueError as exp:
- self.message_generated.emit("Error {}".format(str(exp)))
-
- def _remove_site(self):
- """
- Removes site record AND files.
-
- This is ONLY for representations stored on local site, which
- cannot be same as SyncServer.DEFAULT_SITE.
-
- This could only happen when artist work on local machine, not
- connected to studio mounted drives.
- """
- log.info("Removing {}".format(self.representation_id))
- try:
- local_site = get_local_site_id()
- self.sync_server.remove_site(
- self.model.project,
- self.representation_id,
- local_site,
- True)
- self.message_generated.emit("Site {} removed".format(local_site))
- except ValueError as exp:
- self.message_generated.emit("Error {}".format(str(exp)))
- self.model.refresh(
- load_records=self.model._rec_loaded)
-
- def _reset_local_site(self):
- """
- Removes errors or success metadata for particular file >> forces
- redo of upload/download
- """
- self.sync_server.reset_provider_for_file(
- self.model.project,
- self.representation_id,
- 'local')
- self.model.refresh(
- load_records=self.model._rec_loaded)
-
- def _reset_remote_site(self):
- """
- Removes errors or success metadata for particular file >> forces
- redo of upload/download
- """
- self.sync_server.reset_provider_for_file(
- self.model.project,
- self.representation_id,
- 'remote')
- self.model.refresh(
- load_records=self.model._rec_loaded)
-
- def _open_in_explorer(self, site):
- if not self.item:
- return
-
- fpath = self.item.path
- project = self.model.project
- fpath = self.sync_server.get_local_file_path(project,
- site,
- fpath)
-
- fpath = os.path.normpath(os.path.dirname(fpath))
- if os.path.isdir(fpath):
- if 'win' in sys.platform: # windows
- subprocess.Popen('explorer "%s"' % fpath)
- elif sys.platform == 'darwin': # macOS
- subprocess.Popen(['open', fpath])
- else: # linux
- try:
- subprocess.Popen(['xdg-open', fpath])
- except OSError:
- raise OSError('unsupported xdg-open call??')
-
- def _save_scrollbar(self):
- self._scrollbar_pos = self.table_view.verticalScrollBar().value()
-
- def _set_scrollbar(self):
- if self._scrollbar_pos:
- self.table_view.verticalScrollBar().setValue(self._scrollbar_pos)
+ return action_kwarg_map, actions_mapping, menu
-class SyncRepresentationDetailWidget(QtWidgets.QWidget):
+class SyncServerDetailWindow(QtWidgets.QDialog):
+ """Wrapper window for SyncRepresentationDetailWidget
+
+ Creates standalone window with list of files for selected repre_id.
+ """
+ def __init__(self, sync_server, _id, project, parent=None):
+ log.debug(
+ "!!! SyncServerDetailWindow _id:: {}".format(_id))
+ super(SyncServerDetailWindow, self).__init__(parent)
+ self.setWindowFlags(QtCore.Qt.Window)
+ self.setFocusPolicy(QtCore.Qt.StrongFocus)
+
+ self.setStyleSheet(style.load_stylesheet())
+ self.setWindowIcon(QtGui.QIcon(style.app_icon_path()))
+ self.resize(1000, 400)
+
+ body = QtWidgets.QWidget()
+ footer = QtWidgets.QWidget()
+ footer.setFixedHeight(20)
+
+ container = SyncRepresentationDetailWidget(sync_server, _id, project,
+ parent=self)
+ body_layout = QtWidgets.QHBoxLayout(body)
+ body_layout.addWidget(container)
+ body_layout.setContentsMargins(0, 0, 0, 0)
+
+ self.message = QtWidgets.QLabel()
+ self.message.hide()
+
+ footer_layout = QtWidgets.QVBoxLayout(footer)
+ footer_layout.addWidget(self.message)
+ footer_layout.setContentsMargins(0, 0, 0, 0)
+
+ layout = QtWidgets.QVBoxLayout(self)
+ layout.addWidget(body)
+ layout.addWidget(footer)
+
+ self.setLayout(body_layout)
+ self.setWindowTitle("Sync Representation Detail")
+
+
+class SyncRepresentationDetailWidget(_SyncRepresentationWidget):
"""
Widget to display list of synchronizable files for single repre.
@@ -484,13 +590,12 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget):
super(SyncRepresentationDetailWidget, self).__init__(parent)
log.debug("Representation_id:{}".format(_id))
- self.representation_id = _id
- self.item = None # set to item that mouse was clicked over
self.project = project
self.sync_server = sync_server
- self._selected_id = None
+ self.representation_id = _id
+ self._selected_ids = []
self.txt_filter = QtWidgets.QLineEdit()
self.txt_filter.setPlaceholderText("Quick filter representation..")
@@ -511,7 +616,7 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget):
table_view.setModel(model)
table_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
table_view.setSelectionMode(
- QtWidgets.QAbstractItemView.SingleSelection)
+ QtWidgets.QAbstractItemView.ExtendedSelection)
table_view.setSelectionBehavior(
QtWidgets.QTableView.SelectRows)
table_view.horizontalHeader().setSortIndicator(-1, Qt.AscendingOrder)
@@ -556,171 +661,118 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget):
model.refresh_finished.connect(self._set_scrollbar)
model.modelReset.connect(self._set_selection)
- def _selection_changed(self):
- index = self.selection_model.currentIndex()
- self._selected_id = self.model.data(index, Qt.UserRole)
-
- def _set_selection(self):
- """
- Sets selection to 'self._selected_id' if exists.
-
- Keep selection during model refresh.
- """
- if self._selected_id:
- index = self.model.get_index(self._selected_id)
- if index and index.isValid():
- mode = QtCore.QItemSelectionModel.Select | \
- QtCore.QItemSelectionModel.Rows
- self.selection_model.setCurrentIndex(index, mode)
- else:
- self._selected_id = None
-
- def _show_detail(self):
+ def _show_detail(self, selected_ids=None):
"""
Shows windows with error message for failed sync of a file.
"""
- dt = max(self.item.created_dt, self.item.sync_dt)
- detail_window = SyncRepresentationErrorWindow(self.item._id,
- self.project,
- dt,
- self.item.tries,
- self.item.error)
+ detail_window = SyncRepresentationErrorWindow(self.model, selected_ids)
+
detail_window.exec()
- def _on_context_menu(self, point):
- """
- Shows menu with loader actions on Right-click.
- """
- point_index = self.table_view.indexAt(point)
- if not point_index.isValid():
- return
+ def _prepare_menu(self, item, is_multi):
+ """Adds view (and model) dependent actions to default ones"""
+ action_kwarg_map, actions_mapping, menu = \
+ super()._prepare_menu(item, is_multi)
- self.item = self.model._data[point_index.row()]
-
- menu = QtWidgets.QMenu()
- actions_mapping = {}
- actions_kwargs_mapping = {}
-
- local_site = self.item.local_site
- local_progress = self.item.local_progress
- remote_site = self.item.remote_site
- remote_progress = self.item.remote_progress
-
- for site, progress in {local_site: local_progress,
- remote_site: remote_progress}.items():
- project = self.model.project
- provider = self.sync_server.get_provider_for_site(project,
- site)
- if provider == 'local_drive':
- if 'studio' in site:
- txt = " studio version"
- else:
- txt = " local version"
- action = QtWidgets.QAction("Open in explorer" + txt)
- if progress == 1:
- actions_mapping[action] = self._open_in_explorer
- actions_kwargs_mapping[action] = {'site': site}
- menu.addAction(action)
-
- if self.item.status == lib.STATUS[2]:
+ if item.status == lib.STATUS[2] or is_multi:
action = QtWidgets.QAction("Open error detail")
actions_mapping[action] = self._show_detail
+ action_kwarg_map[action] = {"selected_ids": self._selected_ids}
+
menu.addAction(action)
- if float(remote_progress) == 1.0:
- action = QtWidgets.QAction("Re-sync active site")
- actions_mapping[action] = self._reset_local_site
- menu.addAction(action)
+ return action_kwarg_map, actions_mapping, menu
- if float(local_progress) == 1.0:
- action = QtWidgets.QAction("Re-sync remote site")
- actions_mapping[action] = self._reset_remote_site
- menu.addAction(action)
-
- if not actions_mapping:
- action = QtWidgets.QAction("< No action >")
- actions_mapping[action] = None
- menu.addAction(action)
-
- result = menu.exec_(QtGui.QCursor.pos())
- if result:
- to_run = actions_mapping[result]
- to_run_kwargs = actions_kwargs_mapping.get(result, {})
- if to_run:
- to_run(**to_run_kwargs)
-
- def _reset_local_site(self):
+ def _reset_site(self, selected_ids=None, site_name=None):
"""
Removes errors or success metadata for particular file >> forces
redo of upload/download
"""
- self.sync_server.reset_provider_for_file(
- self.model.project,
- self.representation_id,
- 'local',
- self.item._id)
+ for file_id in selected_ids:
+ item = lib.get_item_by_id(self.model, file_id)
+ check_progress = self._get_progress(item, site_name, True)
+
+ # do not reset if opposite side is not fully there
+ if check_progress != 1:
+ log.debug("Not fully available {} on other side, skipping".
+ format(check_progress))
+ continue
+
+ self.sync_server.reset_provider_for_file(
+ self.model.project,
+ self.representation_id,
+ site_name=site_name,
+ file_id=file_id,
+ force=True)
self.model.refresh(
load_records=self.model._rec_loaded)
- def _reset_remote_site(self):
- """
- Removes errors or success metadata for particular file >> forces
- redo of upload/download
- """
- self.sync_server.reset_provider_for_file(
- self.model.project,
- self.representation_id,
- 'remote',
- self.item._id)
- self.model.refresh(
- load_records=self.model._rec_loaded)
- def _open_in_explorer(self, site):
- if not self.item:
- return
+class SyncRepresentationErrorWindow(QtWidgets.QDialog):
+ """Wrapper window to show errors during sync on file(s)"""
+ def __init__(self, model, selected_ids, parent=None):
+ super(SyncRepresentationErrorWindow, self).__init__(parent)
+ self.setWindowFlags(QtCore.Qt.Window)
+ self.setFocusPolicy(QtCore.Qt.StrongFocus)
- fpath = self.item.path
- project = self.project
- fpath = self.sync_server.get_local_file_path(project, site, fpath)
+ self.setStyleSheet(style.load_stylesheet())
+ self.setWindowIcon(QtGui.QIcon(style.app_icon_path()))
+ self.resize(900, 150)
- fpath = os.path.normpath(os.path.dirname(fpath))
- if os.path.isdir(fpath):
- if 'win' in sys.platform: # windows
- subprocess.Popen('explorer "%s"' % fpath)
- elif sys.platform == 'darwin': # macOS
- subprocess.Popen(['open', fpath])
- else: # linux
- try:
- subprocess.Popen(['xdg-open', fpath])
- except OSError:
- raise OSError('unsupported xdg-open call??')
+ body = QtWidgets.QWidget()
- def _save_scrollbar(self):
- self._scrollbar_pos = self.table_view.verticalScrollBar().value()
+ container = SyncRepresentationErrorWidget(model,
+ selected_ids,
+ parent=self)
+ body_layout = QtWidgets.QHBoxLayout(body)
+ body_layout.addWidget(container)
+ body_layout.setContentsMargins(0, 0, 0, 0)
- def _set_scrollbar(self):
- if self._scrollbar_pos:
- self.table_view.verticalScrollBar().setValue(self._scrollbar_pos)
+ message = QtWidgets.QLabel()
+ message.hide()
+
+ layout = QtWidgets.QVBoxLayout(self)
+ layout.addWidget(body)
+
+ self.setLayout(body_layout)
+ self.setWindowTitle("Sync Representation Error Detail")
class SyncRepresentationErrorWidget(QtWidgets.QWidget):
"""
- Dialog to show when sync error happened, prints error message
+ Dialog to show when sync error happened, prints formatted error message
"""
-
- def __init__(self, _id, dt, tries, msg, parent=None):
+ def __init__(self, model, selected_ids, parent=None):
super(SyncRepresentationErrorWidget, self).__init__(parent)
- layout = QtWidgets.QHBoxLayout(self)
+ layout = QtWidgets.QVBoxLayout(self)
- txts = []
- txts.append("{}: {}".format("Last update date", pretty_timestamp(dt)))
- txts.append("{}: {}".format("Retries", str(tries)))
- txts.append("{}: {}".format("Error message", msg))
+ no_errors = True
+ for file_id in selected_ids:
+ item = lib.get_item_by_id(model, file_id)
+ if not item.created_dt or not item.sync_dt or not item.error:
+ continue
- text_area = QtWidgets.QPlainTextEdit("\n\n".join(txts))
- text_area.setReadOnly(True)
- layout.addWidget(text_area)
+ no_errors = False
+ dt = max(item.created_dt, item.sync_dt)
+
+ txts = []
+ txts.append("{}: {}
".format("Last update date",
+ pretty_timestamp(dt)))
+ txts.append("{}: {}
".format("Retries",
+ str(item.tries)))
+ txts.append("{}: {}
".format("Error message",
+ item.error))
+
+ text_area = QtWidgets.QTextEdit("\n\n".join(txts))
+ text_area.setReadOnly(True)
+ layout.addWidget(text_area)
+
+ if no_errors:
+ text_area = QtWidgets.QTextEdit()
+ text_area.setText("