From d0f8d145ab2bc759484188b73fe5b252865847e7 Mon Sep 17 00:00:00 2001 From: simonebarbieri Date: Wed, 14 Apr 2021 16:31:44 +0000 Subject: [PATCH 001/303] Create draft PR for #1346 From 602ca108f19f7a46469afb7aab8ca9cf0fa3111e Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 21 Apr 2021 17:27:17 +0100 Subject: [PATCH 002/303] Added support to Blender for JSON layout loading and publishing --- .../hosts/blender/plugins/load/load_layout.py | 8 +- .../hosts/blender/plugins/load/load_model.py | 14 +-- .../hosts/blender/plugins/load/load_rig.py | 14 +-- .../blender/plugins/publish/extract_layout.py | 93 +++++++++++++++++++ .../unreal/plugins/publish/extract_layout.py | 6 +- 5 files changed, 116 insertions(+), 19 deletions(-) create mode 100644 openpype/hosts/blender/plugins/publish/extract_layout.py diff --git a/openpype/hosts/blender/plugins/load/load_layout.py b/openpype/hosts/blender/plugins/load/load_layout.py index f1f2fdcddd..87ef9670a6 100644 --- a/openpype/hosts/blender/plugins/load/load_layout.py +++ b/openpype/hosts/blender/plugins/load/load_layout.py @@ -367,13 +367,13 @@ class UnrealLayoutLoader(plugin.AssetLoader): # Y axis mirrored obj.location = ( location.get('x'), - -location.get('y'), + location.get('y'), location.get('z') ) obj.rotation_euler = ( - rotation.get('x') + math.pi / 2, - -rotation.get('y'), - -rotation.get('z') + rotation.get('x'), + rotation.get('y'), + rotation.get('z') ) obj.scale = ( scale.get('x'), diff --git a/openpype/hosts/blender/plugins/load/load_model.py b/openpype/hosts/blender/plugins/load/load_model.py index 7297e459a6..ed0f2faf17 100644 --- a/openpype/hosts/blender/plugins/load/load_model.py +++ b/openpype/hosts/blender/plugins/load/load_model.py @@ -108,19 +108,21 @@ class BlendModelLoader(plugin.AssetLoader): self.__class__.__name__, ) - container_metadata = container.get( - blender.pipeline.AVALON_PROPERTY) + metadata = container.get(blender.pipeline.AVALON_PROPERTY) - container_metadata["libpath"] = libpath - container_metadata["lib_container"] = lib_container + metadata["libpath"] = libpath + metadata["lib_container"] = lib_container obj_container = self._process( libpath, lib_container, container_name, None) - container_metadata["obj_container"] = obj_container + metadata["obj_container"] = obj_container # Save the list of objects in the metadata container - container_metadata["objects"] = obj_container.all_objects + metadata["objects"] = obj_container.all_objects + + metadata["parent"] = str(context["representation"]["parent"]) + metadata["family"] = context["representation"]["context"]["family"] nodes = list(container.objects) nodes.append(container) diff --git a/openpype/hosts/blender/plugins/load/load_rig.py b/openpype/hosts/blender/plugins/load/load_rig.py index c5690a6ab8..9035458c12 100644 --- a/openpype/hosts/blender/plugins/load/load_rig.py +++ b/openpype/hosts/blender/plugins/load/load_rig.py @@ -155,18 +155,20 @@ class BlendRigLoader(plugin.AssetLoader): self.__class__.__name__, ) - container_metadata = container.get( - blender.pipeline.AVALON_PROPERTY) + metadata = container.get(blender.pipeline.AVALON_PROPERTY) - container_metadata["libpath"] = libpath - container_metadata["lib_container"] = lib_container + metadata["libpath"] = libpath + metadata["lib_container"] = lib_container obj_container = self._process( libpath, lib_container, collection_name, None, None) - container_metadata["obj_container"] = obj_container + metadata["obj_container"] = obj_container # Save the list of objects in the metadata container - container_metadata["objects"] = obj_container.all_objects + metadata["objects"] = obj_container.all_objects + + metadata["parent"] = str(context["representation"]["parent"]) + metadata["family"] = context["representation"]["context"]["family"] nodes = list(container.objects) nodes.append(container) diff --git a/openpype/hosts/blender/plugins/publish/extract_layout.py b/openpype/hosts/blender/plugins/publish/extract_layout.py new file mode 100644 index 0000000000..09fae2fc12 --- /dev/null +++ b/openpype/hosts/blender/plugins/publish/extract_layout.py @@ -0,0 +1,93 @@ +import os +import json +import math + +import bpy + +from avalon import blender, io +import openpype.api + + +class ExtractLayout(openpype.api.Extractor): + """Extract a layout.""" + + label = "Extract Layout" + hosts = ["blender"] + families = ["layout"] + optional = True + + def process(self, instance): + # Define extract output file path + stagingdir = self.staging_dir(instance) + + # Perform extraction + self.log.info("Performing extraction..") + + json_data = [] + + for collection in instance: + for asset in collection.children: + collection = bpy.data.collections[asset.name] + container = bpy.data.collections[asset.name + '_CON'] + metadata = container.get(blender.pipeline.AVALON_PROPERTY) + + parent = metadata["parent"] + family = metadata["family"] + + self.log.debug("Parent: {}".format(parent)) + blend = io.find_one( + { + "type": "representation", + "parent": io.ObjectId(parent), + "name": "blend" + }, + projection={"_id": True}) + blend_id = blend["_id"] + + json_element = {} + json_element["reference"] = str(blend_id) + json_element["family"] = family + json_element["instance_name"] = asset.name + json_element["asset_name"] = metadata["lib_container"] + json_element["file_path"] = metadata["libpath"] + + obj = collection.objects[0] + + json_element["transform"] = { + "translation": { + "x": obj.location.x, + "y": obj.location.y, + "z": obj.location.z + }, + "rotation": { + "x": obj.rotation_euler.x, + "y": obj.rotation_euler.y, + "z": obj.rotation_euler.z, + }, + "scale": { + "x": obj.scale.x, + "y": obj.scale.y, + "z": obj.scale.z + } + } + json_data.append(json_element) + + json_filename = "{}.json".format(instance.name) + json_path = os.path.join(stagingdir, json_filename) + + with open(json_path, "w+") as file: + json.dump(json_data, fp=file, indent=2) + + if "representations" not in instance.data: + instance.data["representations"] = [] + + representation = { + 'name': 'json', + 'ext': 'json', + 'files': json_filename, + "stagingDir": stagingdir, + } + instance.data["representations"].append(representation) + + self.log.info("Extracted instance '%s' to: %s", + instance.name, representation) diff --git a/openpype/hosts/unreal/plugins/publish/extract_layout.py b/openpype/hosts/unreal/plugins/publish/extract_layout.py index 5924221f51..2d9f6eb3d1 100644 --- a/openpype/hosts/unreal/plugins/publish/extract_layout.py +++ b/openpype/hosts/unreal/plugins/publish/extract_layout.py @@ -78,14 +78,14 @@ class ExtractLayout(openpype.api.Extractor): json_element["transform"] = { "translation": { - "x": transform.translation.x, + "x": -transform.translation.x, "y": transform.translation.y, "z": transform.translation.z }, "rotation": { - "x": math.radians(transform.rotation.euler().x), + "x": math.radians(transform.rotation.euler().x + 90.0), "y": math.radians(transform.rotation.euler().y), - "z": math.radians(transform.rotation.euler().z), + "z": math.radians(180.0 - transform.rotation.euler().z) }, "scale": { "x": transform.scale3d.x, From bb658c3e028d41ef53b298d24e4b43cab6f4f3b6 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 21 Apr 2021 17:29:53 +0100 Subject: [PATCH 003/303] Hound fix --- openpype/hosts/blender/plugins/publish/extract_layout.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/hosts/blender/plugins/publish/extract_layout.py b/openpype/hosts/blender/plugins/publish/extract_layout.py index 09fae2fc12..c6c9bf67f5 100644 --- a/openpype/hosts/blender/plugins/publish/extract_layout.py +++ b/openpype/hosts/blender/plugins/publish/extract_layout.py @@ -1,6 +1,5 @@ import os import json -import math import bpy From 528fa8db596260011997385eb9865af4c7be1e19 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 22 Apr 2021 10:03:44 +0200 Subject: [PATCH 004/303] initial commit --- openpype/tools/project_manager/__init__.py | 6 + openpype/tools/project_manager/__main__.py | 30 + .../project_manager/__init__.py | 30 + .../project_manager/constants.py | 5 + .../project_manager/delegates.py | 23 + .../project_manager/project_manager/model.py | 562 ++++++++++++++++++ .../project_manager/project_manager/view.py | 180 ++++++ .../project_manager/project_manager/window.py | 56 ++ 8 files changed, 892 insertions(+) create mode 100644 openpype/tools/project_manager/__init__.py create mode 100644 openpype/tools/project_manager/__main__.py create mode 100644 openpype/tools/project_manager/project_manager/__init__.py create mode 100644 openpype/tools/project_manager/project_manager/constants.py create mode 100644 openpype/tools/project_manager/project_manager/delegates.py create mode 100644 openpype/tools/project_manager/project_manager/model.py create mode 100644 openpype/tools/project_manager/project_manager/view.py create mode 100644 openpype/tools/project_manager/project_manager/window.py diff --git a/openpype/tools/project_manager/__init__.py b/openpype/tools/project_manager/__init__.py new file mode 100644 index 0000000000..7d8f8bf432 --- /dev/null +++ b/openpype/tools/project_manager/__init__.py @@ -0,0 +1,6 @@ +from .project_manager import Window + + +__all__ = ( + "Window", +) diff --git a/openpype/tools/project_manager/__main__.py b/openpype/tools/project_manager/__main__.py new file mode 100644 index 0000000000..3f29eb5a21 --- /dev/null +++ b/openpype/tools/project_manager/__main__.py @@ -0,0 +1,30 @@ +import os +import sys +paths = [ + r"C:\Users\iLLiCiT\PycharmProjects\pype3\.venv\Lib\site-packages", + r"C:\Users\iLLiCiT\PycharmProjects\pype3", + r"C:\Users\iLLiCiT\PycharmProjects\pype3\repos\avalon-core" +] +for path in paths: + sys.path.append(path) + +os.environ["OPENPYPE_DATABASE_NAME"] = "openpype" +os.environ["OPENPYPE_MONGO"] = "mongodb://localhost:2707" +os.environ["AVALON_TIMEOUT"] = "1000" + +from project_manager import Window + +from Qt import QtWidgets + + +def main(): + app = QtWidgets.QApplication([]) + + window = Window() + window.show() + + sys.exit(app.exec_()) + + +if __name__ == "__main__": + main() diff --git a/openpype/tools/project_manager/project_manager/__init__.py b/openpype/tools/project_manager/project_manager/__init__.py new file mode 100644 index 0000000000..1a538baa20 --- /dev/null +++ b/openpype/tools/project_manager/project_manager/__init__.py @@ -0,0 +1,30 @@ +from .constants import ( + IDENTIFIER_ROLE +) +from .view import HierarchyView +from .model import ( + HierarchyModel, + HierarchySelectionModel, + BaseItem, + RootItem, + ProjectItem, + AssetItem, + TaskItem +) +from .window import Window + +__all__ = ( + "IDENTIFIER_ROLE", + + "HierarchyView", + + "HierarchyModel", + "HierarchySelectionModel", + "BaseItem", + "RootItem", + "ProjectItem", + "AssetItem", + "TaskItem", + + "Window" +) diff --git a/openpype/tools/project_manager/project_manager/constants.py b/openpype/tools/project_manager/project_manager/constants.py new file mode 100644 index 0000000000..6c84ee6635 --- /dev/null +++ b/openpype/tools/project_manager/project_manager/constants.py @@ -0,0 +1,5 @@ +from Qt import QtWidgets, QtCore + + +IDENTIFIER_ROLE = QtCore.Qt.UserRole + 1 +COLUMNS_ROLE = QtCore.Qt.UserRole + 2 diff --git a/openpype/tools/project_manager/project_manager/delegates.py b/openpype/tools/project_manager/project_manager/delegates.py new file mode 100644 index 0000000000..605d0cbbab --- /dev/null +++ b/openpype/tools/project_manager/project_manager/delegates.py @@ -0,0 +1,23 @@ +from Qt import QtWidgets, QtCore + + +class NumberDelegate(QtWidgets.QStyledItemDelegate): + def createEditor(self, parent, option, index): + editor = QtWidgets.QSpinBox(parent) + value = index.data(QtCore.Qt.DisplayRole) + if value is not None: + editor.setValue(value) + return editor + + # def updateEditorGeometry(self, editor, options, index): + # print(editor) + # return super().updateEditorGeometry(editor, options, index) + + +class StringDelegate(QtWidgets.QStyledItemDelegate): + def createEditor(self, parent, option, index): + editor = QtWidgets.QLineEdit(parent) + value = index.data(QtCore.Qt.DisplayRole) + if value is not None: + editor.setText(str(value)) + return editor diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py new file mode 100644 index 0000000000..d8341df8c1 --- /dev/null +++ b/openpype/tools/project_manager/project_manager/model.py @@ -0,0 +1,562 @@ +import collections +from queue import Queue +from uuid import uuid4 + +from .constants import ( + IDENTIFIER_ROLE, + COLUMNS_ROLE +) + +from avalon.api import AvalonMongoDB + +from Qt import QtWidgets, QtCore + + +class HierarchySelectionModel(QtCore.QItemSelectionModel): + def setCurrentIndex(self, index, command): + # v = "" + # if command == QtCore.QItemSelectionModel.NoUpdate: + # v += "NoUpdate" + # if command & QtCore.QItemSelectionModel.Clear: + # v += "Clear" + # if command & QtCore.QItemSelectionModel.Select: + # v += "Select" + # if command & QtCore.QItemSelectionModel.Deselect: + # v += "Deselect" + # if command & QtCore.QItemSelectionModel.Toggle: + # v += "Toggle" + # if command & QtCore.QItemSelectionModel.Current: + # v += "Current" + # if command & QtCore.QItemSelectionModel.Rows: + # v += "Rows" + # if command & QtCore.QItemSelectionModel.Columns: + # v += "Columns" + if index.column() > 0: + if ( + command & QtCore.QItemSelectionModel.Clear + and command & QtCore.QItemSelectionModel.Select + ): + command = QtCore.QItemSelectionModel.NoUpdate + super(HierarchySelectionModel, self).setCurrentIndex(index, command) + + +class HierarchyModel(QtCore.QAbstractItemModel): + columns = [ + "name", + "type", + "frameStart", + "frameEnd", + "fps", + "resolutionWidth", + "resolutionHeight" + ] + + def __init__(self, parent=None): + super(HierarchyModel, self).__init__(parent) + self._root_item = None + self._items_by_id = {} + self.dbcon = AvalonMongoDB() + + self._hierarchy_mode = True + self._reset_root_item() + + def change_edit_mode(self, hiearchy_mode): + self._hierarchy_mode = hiearchy_mode + + @property + def items_by_id(self): + return self._items_by_id + + def _reset_root_item(self): + self._root_item = RootItem(self) + + def set_project(self, project_doc): + self.clear() + + item = ProjectItem(project_doc) + self.add_item(item) + + def rowCount(self, parent=None): + if parent is None or not parent.isValid(): + parent_item = self._root_item + else: + parent_item = parent.internalPointer() + return parent_item.childCount() + + def columnCount(self, *args, **kwargs): + return len(self.columns) + + def data(self, index, role): + if not index.isValid(): + return None + + column = index.column() + key = self.columns[column] + + item = index.internalPointer() + return item.data(key, role) + + def setData(self, index, value, role=QtCore.Qt.EditRole): + if not index.isValid(): + return False + + item = index.internalPointer() + column = index.column() + key = self.columns[column] + result = item.setData(key, value, role) + if result: + self.dataChanged.emit(index, index, [role]) + + return result + + def headerData(self, section, orientation, role): + if role == QtCore.Qt.DisplayRole: + if section < len(self.columns): + return self.columns[section] + + super(HierarchyModel, self).headerData(section, orientation, role) + + def flags(self, index): + item = index.internalPointer() + column = index.column() + key = self.columns[column] + return item.flags(key) + + def parent(self, index): + item = index.internalPointer() + parent_item = item.parent() + + # If it has no parents we return invalid + if not parent_item or parent_item is self._root_item: + return QtCore.QModelIndex() + + return self.createIndex(parent_item.row(), 0, parent_item) + + def index(self, row, column, parent=None): + """Return index for row/column under parent""" + parent_item = None + if parent is not None and parent.isValid(): + parent_item = parent.internalPointer() + + return self.index_from_item(row, column, parent_item) + + def index_for_item(self, item, column=0): + return self.index_from_item( + item.row(), column, item.parent() + ) + + def index_from_item(self, row, column, parent=None): + if parent is None: + parent = self._root_item + + child_item = parent.child(row) + if child_item: + return self.createIndex(row, column, child_item) + + return QtCore.QModelIndex() + + def add_new_asset(self, source_index): + item_id = source_index.data(IDENTIFIER_ROLE) + item = self.items_by_id[item_id] + + if isinstance(item, (RootItem, ProjectItem)): + name = "eq" + parent = item + else: + name = source_index.data(QtCore.Qt.DisplayRole) + parent = item.parent() + + data = {"name": name} + new_child = AssetItem(data) + return self.add_item(new_child, parent) + + def add_new_task(self, parent): + pass + + def add_new_item(self, parent): + data = {"name": "Test {}".format(parent.childCount())} + new_child = AssetItem(data) + + return self.add_item(new_child, parent) + + def add_item(self, item, parent=None): + if parent is None: + parent = self._root_item + + idx = parent.childCount() + + parent_index = self.index_from_item(parent.row(), 0, parent.parent()) + self.beginInsertRows(parent_index, idx, idx) + + if item.parent() is not parent: + item.set_parent(parent) + + parent.add_child(item) + + if item.id not in self._items_by_id: + self._items_by_id[item.id] = item + + self.endInsertRows() + + self.rowsInserted.emit(parent_index, idx, idx) + + return self.index_from_item(idx, 0, parent) + + def remove_index(self, index): + if not index.isValid(): + return + + item_id = index.data(IDENTIFIER_ROLE) + item = self._items_by_id[item_id] + if isinstance(item, (RootItem, ProjectItem)): + return + + parent = item.parent() + all_descendants = collections.defaultdict(dict) + all_descendants[parent.id][item.id] = item + + row = item.row() + self.beginRemoveRows(self.index_for_item(parent), row, row) + + todo_queue = Queue() + todo_queue.put(item) + while not todo_queue.empty(): + _item = todo_queue.get() + for row in range(_item.childCount()): + child_item = _item.child(row) + all_descendants[_item.id][child_item.id] = child_item + todo_queue.put(child_item) + + while all_descendants: + for parent_id in tuple(all_descendants.keys()): + children = all_descendants[parent_id] + if not children: + all_descendants.pop(parent_id) + continue + + for child_id in tuple(children.keys()): + child_item = children[child_id] + if child_id in all_descendants: + continue + + children.pop(child_id) + child_item.set_parent(None) + self._items_by_id.pop(child_id) + + self.endRemoveRows() + + def move_vertical(self, index, direction): + if not index.isValid(): + return + + item_id = index.data(IDENTIFIER_ROLE) + if item_id is None: + return + + item = self._items_by_id[item_id] + if isinstance(item, (RootItem, ProjectItem)): + return + + if abs(direction) != 1: + return + + # Move under parent of parent + src_row = item.row() + src_parent = item.parent() + src_parent_index = self.index_from_item( + src_parent.row(), 0, src_parent.parent() + ) + + dst_row = None + dst_parent = None + dst_parent_index = None + + if direction == -1: + if isinstance(src_parent, (RootItem, ProjectItem)): + return + dst_parent = src_parent.parent() + dst_row = src_parent.row() + 1 + + # Move under parent before or after if before is None + elif direction == 1: + if src_parent.childCount() == 1: + return + + if item.row() == 0: + parent_row = item.row() + 1 + else: + parent_row = item.row() - 1 + dst_parent = src_parent.child(parent_row) + dst_row = dst_parent.childCount() + + if src_parent is dst_parent: + return + + if dst_parent_index is None: + dst_parent_index = self.index_from_item( + dst_parent.row(), 0, dst_parent.parent() + ) + + self.beginMoveRows( + src_parent_index, + src_row, + src_row, + dst_parent_index, + dst_row + ) + src_parent.remove_child(item) + dst_parent.add_child(item) + item.set_parent(dst_parent) + dst_parent.move_to(item, dst_row) + + self.endMoveRows() + + def move_horizontal(self, index, direction): + if not index.isValid(): + return + + item_id = index.data(IDENTIFIER_ROLE) + item = self._items_by_id[item_id] + if isinstance(item, (RootItem, ProjectItem)): + return + + if abs(direction) != 1: + return + + src_parent = item.parent() + src_parent_index = self.index_from_item( + src_parent.row(), 0, src_parent.parent() + ) + source_row = item.row() + + dst_parent = None + dst_parent_index = None + destination_row = None + _destination_row = None + # Down + if direction == 1: + if source_row < src_parent.childCount() - 1: + dst_parent_index = src_parent_index + dst_parent = src_parent + destination_row = source_row + 1 + # This row is not row number after moving but before moving + _destination_row = destination_row + 1 + else: + destination_row = 0 + parent_parent = src_parent.parent() + if not parent_parent: + return + + new_parent = parent_parent.child(src_parent.row() + 1) + if not new_parent: + return + dst_parent = new_parent + + # Up + elif direction == -1: + if source_row > 0: + dst_parent_index = src_parent_index + dst_parent = src_parent + destination_row = source_row - 1 + else: + parent_parent = src_parent.parent() + if not parent_parent: + return + + previous_parent = parent_parent.child(src_parent.row() - 1) + if not previous_parent: + return + dst_parent = previous_parent + destination_row = previous_parent.childCount() + + if dst_parent_index is None: + dst_parent_index = self.index_from_item( + dst_parent.row(), 0, dst_parent.parent() + ) + + if _destination_row is None: + _destination_row = destination_row + + self.beginMoveRows( + src_parent_index, + source_row, + source_row, + dst_parent_index, + _destination_row + ) + + if src_parent is dst_parent: + dst_parent.move_to(item, destination_row) + + else: + src_parent.remove_child(item) + dst_parent.add_child(item) + item.set_parent(dst_parent) + dst_parent.move_to(item, destination_row) + + self.endMoveRows() + + def child_removed(self, child): + self._items_by_id.pop(child.id, None) + + def column_name(self, column): + """Return column key by index""" + if column < len(self.columns): + return self.columns[column] + return None + + def clear(self): + self.beginResetModel() + self._reset_root_item() + self.endResetModel() + + +class BaseItem: + columns = ["name"] + + def __init__(self, data=None): + self._id = uuid4() + self._children = list() + self._parent = None + + self._data = { + key: None + for key in self.columns + } + self._source_data = data + if data: + for key, value in data.items(): + if key in self.columns: + self._data[key] = value + + def model(self): + return self._parent.model + + def move_to(self, item, row): + idx = self._children.index(item) + if idx == row: + return + + self._children.pop(idx) + self._children.insert(row, item) + + def data(self, key, role): + if role == IDENTIFIER_ROLE: + return self._id + + if role == COLUMNS_ROLE: + return self.columns + + if key not in self.columns: + return None + + if role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole): + value = self._data[key] + if value is None: + value = self.parent().data(key, role) + return value + + return None + + def setData(self, key, value, role): + if key not in self.columns: + return False + + if role == QtCore.Qt.EditRole: + self._data[key] = value + + # must return true if successful + return True + + return False + + @property + def id(self): + return self._id + + def childCount(self): + return len(self._children) + + def child(self, row): + if -1 < row < self.childCount(): + return self._children[row] + return None + + def children(self): + return self._children + + def child_row(self, child): + if child not in self._children: + return -1 + return self._children.index(child) + + def parent(self): + return self._parent + + def set_parent(self, parent): + if parent is self._parent: + return + + if self._parent: + self._parent.remove_child(self) + self._parent = parent + + def row(self): + if self._parent is not None: + return self._parent.child_row(self) + return -1 + + def add_child(self, item): + if item not in self._children: + self._children.append(item) + + def remove_child(self, item): + if item in self._children: + self._children.remove(item) + + def flags(self, key): + flags = QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable + if key in self.columns: + flags |= QtCore.Qt.ItemIsEditable + return flags + + +class RootItem(BaseItem): + def __init__(self, model): + super(RootItem, self).__init__() + self._model = model + + def model(self): + return self._model + + def flags(self, *args, **kwargs): + return QtCore.Qt.NoItemFlags + + +class ProjectItem(BaseItem): + def __init__(self, data=None): + super(ProjectItem, self).__init__(data) + self._data["name"] = "project" + + def flags(self, *args, **kwargs): + return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable + + +class AssetItem(BaseItem): + columns = [ + "name", + "type", + "frameStart", + "frameEnd", + "fps", + "resolutionWidth", + "resolutionHeight" + ] + + def __init__(self, data=None): + super(AssetItem, self).__init__(data) + + +class TaskItem(BaseItem): + def __init__(self, data=None): + super(TaskItem, self).__init__(data) diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py new file mode 100644 index 0000000000..d49b5e3b52 --- /dev/null +++ b/openpype/tools/project_manager/project_manager/view.py @@ -0,0 +1,180 @@ +from Qt import QtWidgets, QtCore + +from .constants import ( + IDENTIFIER_ROLE, + COLUMNS_ROLE +) +from .delegates import NumberDelegate, StringDelegate + + +class HierarchyView(QtWidgets.QTreeView): + """A tree view that deselects on clicking on an empty area in the view""" + column_delegate_defs = { + "name": StringDelegate, + "frameStart": NumberDelegate, + "frameEnd": NumberDelegate, + "fps": NumberDelegate, + "resolutionWidth": NumberDelegate, + "resolutionHeight": NumberDelegate + } + persistent_columns = [ + "frameStart", "frameEnd", "fps", "resolutionWidth", "resolutionHeight" + ] + + def __init__(self, source_model, *args, **kwargs): + super(HierarchyView, self).__init__(*args, **kwargs) + self._source_model = source_model + + main_delegate = QtWidgets.QStyledItemDelegate() + self.setItemDelegate(main_delegate) + self.setAlternatingRowColors(True) + self.setSelectionMode(HierarchyView.ContiguousSelection) + + column_delegates = {} + column_key_to_index = {} + for key, delegate_klass in self.column_delegate_defs.items(): + delegate = delegate_klass() + column = self._source_model.columns.index(key) + self.setItemDelegateForColumn(column, delegate) + column_delegates[key] = delegate + column_key_to_index[key] = column + + self._delegate = main_delegate + self._column_delegates = column_delegates + self._column_key_to_index = column_key_to_index + + def commitData(self, editor): + super(HierarchyView, self).commitData(editor) + current_index = self.currentIndex() + column = current_index.column() + row = current_index.row() + skipped_index = None + if column > 0: + indexes = [] + for index in self.selectedIndexes(): + if index.column() == column: + if index.row() == row: + skipped_index = index + else: + indexes.append(index) + + if skipped_index is not None: + value = current_index.data(QtCore.Qt.EditRole) + for index in indexes: + index.model().setData(index, value, QtCore.Qt.EditRole) + + # Update children data + self.updateEditorData() + + def _deselect_editor(self, editor): + if editor: + if isinstance(editor, QtWidgets.QSpinBox): + line_edit = editor.findChild(QtWidgets.QLineEdit) + line_edit.deselect() + + elif isinstance(editor, QtWidgets.QLineEdit): + editor.deselect() + + def edit(self, index, *args, **kwargs): + result = super(HierarchyView, self).edit(index, *args, **kwargs) + self._deselect_editor(self.indexWidget(index)) + return result + + def openPersistentEditor(self, index): + super(HierarchyView, self).openPersistentEditor(index) + self._deselect_editor(self.indexWidget(index)) + + def rowsInserted(self, parent_index, start, end): + super(HierarchyView, self).rowsInserted(parent_index, start, end) + + for row in range(start, end + 1): + index = self._source_model.index(row, 0, parent_index) + columns = index.data(COLUMNS_ROLE) or [] + for key, column in self._column_key_to_index.items(): + if key not in self.persistent_columns: + continue + col_index = self._source_model.index(row, column, parent_index) + self.openPersistentEditor(col_index) + + # Expand parent on insert + if not self.isExpanded(parent_index): + self.expand(parent_index) + + def mousePressEvent(self, event): + index = self.indexAt(event.pos()) + if not index.isValid(): + # clear the selection + self.clearSelection() + # clear the current index + self.setCurrentIndex(QtCore.QModelIndex()) + + super(HierarchyView, self).mousePressEvent(event) + + def keyPressEvent(self, event): + call_super = False + if event.key() == QtCore.Qt.Key_Delete: + self._delete_item() + + elif event.key() in (QtCore.Qt.Key_Return, QtCore.Qt.Key_Enter): + if event.modifiers() == QtCore.Qt.ShiftModifier: + self._on_shift_enter_pressed() + else: + if self.state() == HierarchyView.NoState: + self._on_enter_pressed() + + elif event.modifiers() == QtCore.Qt.ControlModifier: + if event.key() == QtCore.Qt.Key_Left: + self._on_left_ctrl_pressed() + elif event.key() == QtCore.Qt.Key_Right: + self._on_right_ctrl_pressed() + elif event.key() == QtCore.Qt.Key_Up: + self._on_up_ctrl_pressed() + elif event.key() == QtCore.Qt.Key_Down: + self._on_down_ctrl_pressed() + else: + call_super = True + + if call_super: + super(HierarchyView, self).keyPressEvent(event) + else: + event.accept() + + def _delete_item(self): + index = self.currentIndex() + self._source_model.remove_index(index) + + def _on_shift_enter_pressed(self): + index = self.currentIndex() + if not index.isValid(): + return + + # Stop editing + self.setState(HierarchyView.NoState) + QtWidgets.QApplication.processEvents() + + new_index = self._source_model.add_new_asset(index) + + # Change current index + self.setCurrentIndex(new_index) + # Start editing + self.edit(new_index) + + def _on_up_ctrl_pressed(self): + self._source_model.move_horizontal(self.currentIndex(), -1) + + def _on_down_ctrl_pressed(self): + self._source_model.move_horizontal(self.currentIndex(), 1) + + def _on_left_ctrl_pressed(self): + self._source_model.move_vertical(self.currentIndex(), -1) + + def _on_right_ctrl_pressed(self): + self._source_model.move_vertical(self.currentIndex(), 1) + + def _on_enter_pressed(self): + index = self.currentIndex() + if ( + index.isValid() + and index.flags() & QtCore.Qt.ItemIsEditable + ): + self.edit(index) diff --git a/openpype/tools/project_manager/project_manager/window.py b/openpype/tools/project_manager/project_manager/window.py new file mode 100644 index 0000000000..db5527b4ac --- /dev/null +++ b/openpype/tools/project_manager/project_manager/window.py @@ -0,0 +1,56 @@ +from Qt import QtWidgets, QtCore + +from . import ( + HierarchyModel, + HierarchySelectionModel, + HierarchyView +) + + +class Window(QtWidgets.QWidget): + def __init__(self, parent=None): + super(Window, self).__init__(parent) + + model = HierarchyModel() + view = HierarchyView(model, self) + view.setModel(model) + _selection_model = HierarchySelectionModel() + _selection_model.setModel(view.model()) + view.setSelectionModel(_selection_model) + + header = view.header() + header.setStretchLastSection(False) + header.setSectionResizeMode( + header.logicalIndex(0), QtWidgets.QHeaderView.Stretch + ) + checkbox = QtWidgets.QCheckBox(self) + # btn = QtWidgets.QPushButton("Refresh") + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.addWidget(view) + main_layout.addWidget(checkbox) + # main_layout.addWidget(btn) + # btn.clicked.connect(self._on_refresh) + + checkbox.toggled.connect(self._on_checkbox) + + self.view = view + self.model = model + # self.btn = btn + self.checkbox = checkbox + + self.change_edit_mode() + + self.resize(1200, 600) + model.set_project({"name": "test"}) + + def change_edit_mode(self, value=None): + if value is None: + value = self.checkbox.isChecked() + self.model.change_edit_mode(value) + + def _on_checkbox(self, value): + self.change_edit_mode(value) + + def _on_refresh(self): + self.model.clear() From f6f779e127033cb147d9605a108ca10b012ec307 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 22 Apr 2021 10:55:24 +0200 Subject: [PATCH 005/303] removed develop code --- openpype/tools/project_manager/__main__.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/openpype/tools/project_manager/__main__.py b/openpype/tools/project_manager/__main__.py index 3f29eb5a21..0855a0fc71 100644 --- a/openpype/tools/project_manager/__main__.py +++ b/openpype/tools/project_manager/__main__.py @@ -1,16 +1,4 @@ -import os import sys -paths = [ - r"C:\Users\iLLiCiT\PycharmProjects\pype3\.venv\Lib\site-packages", - r"C:\Users\iLLiCiT\PycharmProjects\pype3", - r"C:\Users\iLLiCiT\PycharmProjects\pype3\repos\avalon-core" -] -for path in paths: - sys.path.append(path) - -os.environ["OPENPYPE_DATABASE_NAME"] = "openpype" -os.environ["OPENPYPE_MONGO"] = "mongodb://localhost:2707" -os.environ["AVALON_TIMEOUT"] = "1000" from project_manager import Window From 3a139256acc1f18c5fe08f86519742afd7196ccc Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 22 Apr 2021 19:02:53 +0200 Subject: [PATCH 006/303] task item has basic implementation --- .../project_manager/project_manager/model.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index d8341df8c1..aa3380b87f 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -170,8 +170,19 @@ class HierarchyModel(QtCore.QAbstractItemModel): new_child = AssetItem(data) return self.add_item(new_child, parent) - def add_new_task(self, parent): - pass + def add_new_task(self, source_index): + item_id = source_index.data(IDENTIFIER_ROLE) + item = self.items_by_id[item_id] + + if not isinstance(item, AssetItem): + return None + + name = "task" + parent = item.parent() + + data = {"name": name} + new_child = TaskItem(data) + return self.add_item(new_child, parent) def add_new_item(self, parent): data = {"name": "Test {}".format(parent.childCount())} From 0ba22be07d380ebee17e681557eaa94078974369 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 22 Apr 2021 19:03:06 +0200 Subject: [PATCH 007/303] remove not needed debug --- .../project_manager/project_manager/model.py | 17 ----------------- 1 file changed, 17 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index aa3380b87f..63b833910e 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -14,23 +14,6 @@ from Qt import QtWidgets, QtCore class HierarchySelectionModel(QtCore.QItemSelectionModel): def setCurrentIndex(self, index, command): - # v = "" - # if command == QtCore.QItemSelectionModel.NoUpdate: - # v += "NoUpdate" - # if command & QtCore.QItemSelectionModel.Clear: - # v += "Clear" - # if command & QtCore.QItemSelectionModel.Select: - # v += "Select" - # if command & QtCore.QItemSelectionModel.Deselect: - # v += "Deselect" - # if command & QtCore.QItemSelectionModel.Toggle: - # v += "Toggle" - # if command & QtCore.QItemSelectionModel.Current: - # v += "Current" - # if command & QtCore.QItemSelectionModel.Rows: - # v += "Rows" - # if command & QtCore.QItemSelectionModel.Columns: - # v += "Columns" if index.column() > 0: if ( command & QtCore.QItemSelectionModel.Clear From 81958d7461ef45373c35722cde132ff59f82b92c Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 22 Apr 2021 19:03:30 +0200 Subject: [PATCH 008/303] added ability to create task with ctrl+shift+enter --- .../project_manager/project_manager/view.py | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index d49b5e3b52..62a2f22506 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -116,7 +116,10 @@ class HierarchyView(QtWidgets.QTreeView): self._delete_item() elif event.key() in (QtCore.Qt.Key_Return, QtCore.Qt.Key_Enter): - if event.modifiers() == QtCore.Qt.ShiftModifier: + mdfs = event.modifiers() + if mdfs == (QtCore.Qt.ShiftModifier | QtCore.Qt.ControlModifier): + self._on_ctrl_shift_enter_pressed() + elif mdfs == QtCore.Qt.ShiftModifier: self._on_shift_enter_pressed() else: if self.state() == HierarchyView.NoState: @@ -143,6 +146,24 @@ class HierarchyView(QtWidgets.QTreeView): index = self.currentIndex() self._source_model.remove_index(index) + def _on_ctrl_shift_enter_pressed(self): + index = self.currentIndex() + if not index.isValid(): + return + + new_index = self._source_model.add_new_task(index) + if new_index is None: + return + + # Stop editing + self.setState(HierarchyView.NoState) + QtWidgets.QApplication.processEvents() + + # Change current index + self.setCurrentIndex(new_index) + # Start editing + self.edit(new_index) + def _on_shift_enter_pressed(self): index = self.currentIndex() if not index.isValid(): From dcb51bfaf3a3ea891b78092b79c7258bc8b4c2fd Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 22 Apr 2021 19:04:02 +0200 Subject: [PATCH 009/303] columns definitions per line --- openpype/tools/project_manager/project_manager/view.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index 62a2f22506..e35663c24f 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -18,7 +18,11 @@ class HierarchyView(QtWidgets.QTreeView): "resolutionHeight": NumberDelegate } persistent_columns = [ - "frameStart", "frameEnd", "fps", "resolutionWidth", "resolutionHeight" + "frameStart", + "frameEnd", + "fps", + "resolutionWidth", + "resolutionHeight" ] def __init__(self, source_model, *args, **kwargs): From 57ba2a547d528204da3dc9d6acf826acb0d60e22 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 23 Apr 2021 09:55:24 +0200 Subject: [PATCH 010/303] replaced chidCount with rowCount --- .../project_manager/project_manager/model.py | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 63b833910e..bdb0da6492 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -9,7 +9,7 @@ from .constants import ( from avalon.api import AvalonMongoDB -from Qt import QtWidgets, QtCore +from Qt import QtCore class HierarchySelectionModel(QtCore.QItemSelectionModel): @@ -64,7 +64,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): parent_item = self._root_item else: parent_item = parent.internalPointer() - return parent_item.childCount() + return parent_item.rowCount() def columnCount(self, *args, **kwargs): return len(self.columns) @@ -168,7 +168,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): return self.add_item(new_child, parent) def add_new_item(self, parent): - data = {"name": "Test {}".format(parent.childCount())} + data = {"name": "Test {}".format(parent.rowCount())} new_child = AssetItem(data) return self.add_item(new_child, parent) @@ -177,7 +177,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): if parent is None: parent = self._root_item - idx = parent.childCount() + idx = parent.rowCount() parent_index = self.index_from_item(parent.row(), 0, parent.parent()) self.beginInsertRows(parent_index, idx, idx) @@ -216,7 +216,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): todo_queue.put(item) while not todo_queue.empty(): _item = todo_queue.get() - for row in range(_item.childCount()): + for row in range(_item.rowCount()): child_item = _item.child(row) all_descendants[_item.id][child_item.id] = child_item todo_queue.put(child_item) @@ -273,7 +273,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): # Move under parent before or after if before is None elif direction == 1: - if src_parent.childCount() == 1: + if src_parent.rowCount() == 1: return if item.row() == 0: @@ -281,7 +281,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): else: parent_row = item.row() - 1 dst_parent = src_parent.child(parent_row) - dst_row = dst_parent.childCount() + dst_row = dst_parent.rowCount() if src_parent is dst_parent: return @@ -329,7 +329,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): _destination_row = None # Down if direction == 1: - if source_row < src_parent.childCount() - 1: + if source_row < src_parent.rowCount() - 1: dst_parent_index = src_parent_index dst_parent = src_parent destination_row = source_row + 1 @@ -361,7 +361,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): if not previous_parent: return dst_parent = previous_parent - destination_row = previous_parent.childCount() + destination_row = previous_parent.rowCount() if dst_parent_index is None: dst_parent_index = self.index_from_item( @@ -468,11 +468,11 @@ class BaseItem: def id(self): return self._id - def childCount(self): + def rowCount(self): return len(self._children) def child(self, row): - if -1 < row < self.childCount(): + if -1 < row < self.rowCount(): return self._children[row] return None From e4282babb510a71225b25468d27cbe9b21f02047 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 23 Apr 2021 13:23:05 +0200 Subject: [PATCH 011/303] cleanup unused parts --- openpype/tools/project_manager/project_manager/constants.py | 2 +- openpype/tools/project_manager/project_manager/view.py | 6 ------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/constants.py b/openpype/tools/project_manager/project_manager/constants.py index 6c84ee6635..21db80e5f7 100644 --- a/openpype/tools/project_manager/project_manager/constants.py +++ b/openpype/tools/project_manager/project_manager/constants.py @@ -1,4 +1,4 @@ -from Qt import QtWidgets, QtCore +from Qt import QtCore IDENTIFIER_ROLE = QtCore.Qt.UserRole + 1 diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index e35663c24f..606edbda99 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -1,9 +1,5 @@ from Qt import QtWidgets, QtCore -from .constants import ( - IDENTIFIER_ROLE, - COLUMNS_ROLE -) from .delegates import NumberDelegate, StringDelegate @@ -92,8 +88,6 @@ class HierarchyView(QtWidgets.QTreeView): super(HierarchyView, self).rowsInserted(parent_index, start, end) for row in range(start, end + 1): - index = self._source_model.index(row, 0, parent_index) - columns = index.data(COLUMNS_ROLE) or [] for key, column in self._column_key_to_index.items(): if key not in self.persistent_columns: continue From 5e48a87531650562829e1f369405120f1bd78c36 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 23 Apr 2021 13:23:30 +0200 Subject: [PATCH 012/303] added basic icons --- .../project_manager/project_manager/model.py | 40 ++++++++++++++++--- 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index bdb0da6492..721a1c88e4 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -8,6 +8,7 @@ from .constants import ( ) from avalon.api import AvalonMongoDB +from avalon.vendor import qtawesome from Qt import QtCore @@ -407,6 +408,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): class BaseItem: columns = ["name"] + _name_icon = None def __init__(self, data=None): self._id = uuid4() @@ -423,6 +425,10 @@ class BaseItem: if key in self.columns: self._data[key] = value + @classmethod + def name_icon(cls): + return cls._name_icon + def model(self): return self._parent.model @@ -450,6 +456,8 @@ class BaseItem: value = self.parent().data(key, role) return value + if role == QtCore.Qt.DecorationRole and key == "name": + return self.name_icon() return None def setData(self, key, value, role): @@ -500,9 +508,23 @@ class BaseItem: return self._parent.child_row(self) return -1 - def add_child(self, item): - if item not in self._children: + def add_child(self, item, row=None): + if item in self._children: + return + + row_count = self.rowCount() + if row is None or row == row_count: self._children.append(item) + return + + if row > row_count or row < 0: + raise ValueError( + "Invalid row number {} expected range 0 - {}".format( + row, row_count + ) + ) + + self._children.insert(row, item) def remove_child(self, item): if item in self._children: @@ -547,10 +569,16 @@ class AssetItem(BaseItem): "resolutionHeight" ] - def __init__(self, data=None): - super(AssetItem, self).__init__(data) + @classmethod + def name_icon(cls): + if cls._name_icon is None: + cls._name_icon = qtawesome.icon("fa.folder", color="#333333") + return cls._name_icon class TaskItem(BaseItem): - def __init__(self, data=None): - super(TaskItem, self).__init__(data) + @classmethod + def name_icon(cls): + if cls._name_icon is None: + cls._name_icon = qtawesome.icon("fa.file-o", color="#333333") + return cls._name_icon From b224216cf55d76c34e0dd566835ad7a7cf38c958 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 23 Apr 2021 13:23:43 +0200 Subject: [PATCH 013/303] it is possible to add asset directly under current selection --- .../project_manager/project_manager/model.py | 34 +++++++++++-------- 1 file changed, 20 insertions(+), 14 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 721a1c88e4..7b54f2682f 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -143,28 +143,33 @@ class HierarchyModel(QtCore.QAbstractItemModel): item_id = source_index.data(IDENTIFIER_ROLE) item = self.items_by_id[item_id] + new_row = None if isinstance(item, (RootItem, ProjectItem)): name = "eq" parent = item else: name = source_index.data(QtCore.Qt.DisplayRole) parent = item.parent() + new_row = item.row() + 1 data = {"name": name} new_child = AssetItem(data) - return self.add_item(new_child, parent) - def add_new_task(self, source_index): - item_id = source_index.data(IDENTIFIER_ROLE) + return self.add_item(new_child, parent, new_row) + + def add_new_task(self, parent_index): + item_id = parent_index.data(IDENTIFIER_ROLE) item = self.items_by_id[item_id] - if not isinstance(item, AssetItem): + if isinstance(item, TaskItem): + parent = item.parent() + else: + parent = item + + if not isinstance(parent, AssetItem): return None - name = "task" - parent = item.parent() - - data = {"name": name} + data = {"name": "task"} new_child = TaskItem(data) return self.add_item(new_child, parent) @@ -174,28 +179,29 @@ class HierarchyModel(QtCore.QAbstractItemModel): return self.add_item(new_child, parent) - def add_item(self, item, parent=None): + def add_item(self, item, parent=None, row=None): if parent is None: parent = self._root_item - idx = parent.rowCount() + if row is None: + row = parent.rowCount() parent_index = self.index_from_item(parent.row(), 0, parent.parent()) - self.beginInsertRows(parent_index, idx, idx) + self.beginInsertRows(parent_index, row, row) if item.parent() is not parent: item.set_parent(parent) - parent.add_child(item) + parent.add_child(item, row) if item.id not in self._items_by_id: self._items_by_id[item.id] = item self.endInsertRows() - self.rowsInserted.emit(parent_index, idx, idx) + self.rowsInserted.emit(parent_index, row, row) - return self.index_from_item(idx, 0, parent) + return self.index_from_item(row, 0, parent) def remove_index(self, index): if not index.isValid(): From e88f7c3e333d4eb809516df1ef4b71cfb69d4e49 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 23 Apr 2021 13:26:19 +0200 Subject: [PATCH 014/303] avoid moving task under project --- openpype/tools/project_manager/project_manager/model.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 7b54f2682f..8c233564ff 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -293,6 +293,12 @@ class HierarchyModel(QtCore.QAbstractItemModel): if src_parent is dst_parent: return + if ( + isinstance(item, TaskItem) + and not isinstance(dst_parent, AssetItem) + ): + return + if dst_parent_index is None: dst_parent_index = self.index_from_item( dst_parent.row(), 0, dst_parent.parent() From f32cd5ced5fa11b94ea677f35ae6fe7ca3d21e82 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 23 Apr 2021 13:41:38 +0200 Subject: [PATCH 015/303] added some kind of check of editability --- openpype/tools/project_manager/project_manager/model.py | 4 ++++ openpype/tools/project_manager/project_manager/view.py | 6 +++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 8c233564ff..867adbaef3 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -589,6 +589,10 @@ class AssetItem(BaseItem): class TaskItem(BaseItem): + columns = [ + "name", + "type" + ] @classmethod def name_icon(cls): if cls._name_icon is None: diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index 606edbda99..7843254f87 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -92,7 +92,11 @@ class HierarchyView(QtWidgets.QTreeView): if key not in self.persistent_columns: continue col_index = self._source_model.index(row, column, parent_index) - self.openPersistentEditor(col_index) + if bool( + self._source_model.flags(col_index) + & QtCore.Qt.ItemIsEditable + ): + self.openPersistentEditor(col_index) # Expand parent on insert if not self.isExpanded(parent_index): From c45af6caebd6ad7463f97d9db3a56b1a2458dd64 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 23 Apr 2021 17:23:43 +0200 Subject: [PATCH 016/303] init commit asset name duplicity validation --- .../project_manager/constants.py | 2 +- .../project_manager/project_manager/model.py | 59 +++++++++++++++---- 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/constants.py b/openpype/tools/project_manager/project_manager/constants.py index 21db80e5f7..61d1944979 100644 --- a/openpype/tools/project_manager/project_manager/constants.py +++ b/openpype/tools/project_manager/project_manager/constants.py @@ -2,4 +2,4 @@ from Qt import QtCore IDENTIFIER_ROLE = QtCore.Qt.UserRole + 1 -COLUMNS_ROLE = QtCore.Qt.UserRole + 2 +DUPLICATED_ROLE = QtCore.Qt.UserRole + 2 diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 867adbaef3..47fc608112 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -4,7 +4,7 @@ from uuid import uuid4 from .constants import ( IDENTIFIER_ROLE, - COLUMNS_ROLE + DUPLICATED_ROLE ) from avalon.api import AvalonMongoDB @@ -39,6 +39,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): super(HierarchyModel, self).__init__(parent) self._root_item = None self._items_by_id = {} + self._asset_items_by_name = collections.defaultdict(list) self.dbcon = AvalonMongoDB() self._hierarchy_mode = True @@ -87,6 +88,12 @@ class HierarchyModel(QtCore.QAbstractItemModel): item = index.internalPointer() column = index.column() key = self.columns[column] + if ( + key == "name" + and role in (QtCore.Qt.EditRole, QtCore.Qt.DisplayRole) + ): + self._rename_asset(item, value) + result = item.setData(key, value, role) if result: self.dataChanged.emit(index, index, [role]) @@ -154,8 +161,13 @@ class HierarchyModel(QtCore.QAbstractItemModel): data = {"name": name} new_child = AssetItem(data) + self._asset_items_by_name[name].append(new_child) - return self.add_item(new_child, parent, new_row) + result = self.add_item(new_child, parent, new_row) + + self._validate_asset_duplicity(name) + + return result def add_new_task(self, parent_index): item_id = parent_index.data(IDENTIFIER_ROLE) @@ -173,12 +185,6 @@ class HierarchyModel(QtCore.QAbstractItemModel): new_child = TaskItem(data) return self.add_item(new_child, parent) - def add_new_item(self, parent): - data = {"name": "Test {}".format(parent.rowCount())} - new_child = AssetItem(data) - - return self.add_item(new_child, parent) - def add_item(self, item, parent=None, row=None): if parent is None: parent = self._root_item @@ -240,12 +246,46 @@ class HierarchyModel(QtCore.QAbstractItemModel): if child_id in all_descendants: continue + if isinstance(child_item, AssetItem): + self._rename_asset(child_item, None) children.pop(child_id) child_item.set_parent(None) self._items_by_id.pop(child_id) self.endRemoveRows() + def _rename_asset(self, asset_item, new_name): + if not isinstance(asset_item, AssetItem): + return + + prev_name = asset_item.data("name", QtCore.Qt.DisplayRole) + print(prev_name) + self._asset_items_by_name[prev_name].remove(asset_item) + + self._validate_asset_duplicity(prev_name) + + if new_name is None: + return + self._asset_items_by_name[new_name].append(asset_item) + + self._validate_asset_duplicity(new_name) + + def _validate_asset_duplicity(self, name): + if name not in self._asset_items_by_name: + return + + items = self._asset_items_by_name[name] + if not items: + self._asset_items_by_name.pop(name) + + elif len(items) == 1: + index = self.index_for_item(items[0]) + self.setData(index, False, DUPLICATED_ROLE) + else: + for item in items: + index = self.index_for_item(item) + self.setData(index, True, DUPLICATED_ROLE) + def move_vertical(self, index, direction): if not index.isValid(): return @@ -456,9 +496,6 @@ class BaseItem: if role == IDENTIFIER_ROLE: return self._id - if role == COLUMNS_ROLE: - return self.columns - if key not in self.columns: return None From 7ea8a6222e78ba202da3bcdb366cd58463d8b9b9 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 26 Apr 2021 09:10:36 +0200 Subject: [PATCH 017/303] asset names has duplicated information --- .../project_manager/project_manager/model.py | 24 +++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 47fc608112..5e497b755d 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -10,7 +10,7 @@ from .constants import ( from avalon.api import AvalonMongoDB from avalon.vendor import qtawesome -from Qt import QtCore +from Qt import QtCore, QtGui class HierarchySelectionModel(QtCore.QItemSelectionModel): @@ -259,7 +259,6 @@ class HierarchyModel(QtCore.QAbstractItemModel): return prev_name = asset_item.data("name", QtCore.Qt.DisplayRole) - print(prev_name) self._asset_items_by_name[prev_name].remove(asset_item) self._validate_asset_duplicity(prev_name) @@ -461,6 +460,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): class BaseItem: columns = ["name"] _name_icon = None + _is_duplicated = False def __init__(self, data=None): self._id = uuid4() @@ -496,6 +496,19 @@ class BaseItem: if role == IDENTIFIER_ROLE: return self._id + if role == DUPLICATED_ROLE: + return self._is_duplicated + + if role == QtCore.Qt.ForegroundRole: + if self._is_duplicated: + return QtGui.QColor(255, 0, 0) + + if role == QtCore.Qt.ToolTipRole: + if self._is_duplicated: + return "Asset with name \"{}\" already exists.".format( + self._data["name"] + ) + if key not in self.columns: return None @@ -510,6 +523,13 @@ class BaseItem: return None def setData(self, key, value, role): + if role == DUPLICATED_ROLE: + if value == self._is_duplicated: + return False + + self._is_duplicated = value + return True + if key not in self.columns: return False From eb6ece64222561bf9a0eaab2e5b56d268b6e0380 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 27 Apr 2021 11:54:17 +0200 Subject: [PATCH 018/303] avoid adding children to TaskItem --- .../project_manager/project_manager/model.py | 31 +++++++++++++++---- 1 file changed, 25 insertions(+), 6 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 5e497b755d..88ae8f841b 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -319,14 +319,30 @@ class HierarchyModel(QtCore.QAbstractItemModel): # Move under parent before or after if before is None elif direction == 1: - if src_parent.rowCount() == 1: + src_row_count = src_parent.rowCount() + if src_row_count == 1: return - if item.row() == 0: - parent_row = item.row() + 1 - else: - parent_row = item.row() - 1 - dst_parent = src_parent.child(parent_row) + item_row = item.row() + dst_parent = None + for row in reversed(range(item_row)): + if row == item_row: + continue + _item = src_parent.child(row) + if not isinstance(_item, TaskItem): + dst_parent = _item + break + + if dst_parent is None: + for row in range(item_row + 1, src_row_count + 2): + _item = src_parent.child(row) + if not isinstance(_item, TaskItem): + dst_parent = _item + break + + if dst_parent is None: + return + dst_row = dst_parent.rowCount() if src_parent is dst_parent: @@ -655,3 +671,6 @@ class TaskItem(BaseItem): if cls._name_icon is None: cls._name_icon = qtawesome.icon("fa.file-o", color="#333333") return cls._name_icon + + def add_child(self, item, row=None): + raise AssertionError("BUG: Can't add children to Task") From 7c78a9a584457252d7a201cda24c0a09d315121d Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 27 Apr 2021 11:59:36 +0200 Subject: [PATCH 019/303] always expand parent of moved item --- .../project_manager/project_manager/view.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index 7843254f87..78536d556d 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -183,16 +183,29 @@ class HierarchyView(QtWidgets.QTreeView): self.edit(new_index) def _on_up_ctrl_pressed(self): - self._source_model.move_horizontal(self.currentIndex(), -1) + index = self.currentIndex() + self._source_model.move_horizontal(index, -1) + parent_index = index.parent() + if not self.isExpanded(parent_index): + self.expand(parent_index) def _on_down_ctrl_pressed(self): - self._source_model.move_horizontal(self.currentIndex(), 1) + index = self.currentIndex() + self._source_model.move_horizontal(index, 1) + parent_index = index.parent() + if not self.isExpanded(parent_index): + self.expand(parent_index) def _on_left_ctrl_pressed(self): self._source_model.move_vertical(self.currentIndex(), -1) def _on_right_ctrl_pressed(self): - self._source_model.move_vertical(self.currentIndex(), 1) + index = self.currentIndex() + self._source_model.move_vertical(index, 1) + + parent_index = index.parent() + if not self.isExpanded(parent_index): + self.expand(parent_index) def _on_enter_pressed(self): index = self.currentIndex() From 31145de994857f23e7b8b4e5db1fa0cd20218ba8 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 27 Apr 2021 12:36:05 +0200 Subject: [PATCH 020/303] added index_moved signal to model --- openpype/tools/project_manager/project_manager/model.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 88ae8f841b..d426c15a03 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -34,6 +34,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): "resolutionWidth", "resolutionHeight" ] + index_moved = QtCore.Signal(QtCore.QModelIndex) def __init__(self, parent=None): super(HierarchyModel, self).__init__(parent) @@ -205,8 +206,6 @@ class HierarchyModel(QtCore.QAbstractItemModel): self.endInsertRows() - self.rowsInserted.emit(parent_index, row, row) - return self.index_from_item(row, 0, parent) def remove_index(self, index): @@ -373,6 +372,8 @@ class HierarchyModel(QtCore.QAbstractItemModel): self.endMoveRows() + self.index_moved.emit(index) + def move_horizontal(self, index, direction): if not index.isValid(): return @@ -458,6 +459,8 @@ class HierarchyModel(QtCore.QAbstractItemModel): self.endMoveRows() + self.index_moved.emit(index) + def child_removed(self, child): self._items_by_id.pop(child.id, None) From cdaa06706de32e47e705aa1cd43b96839bb6cd9e Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 27 Apr 2021 12:36:25 +0200 Subject: [PATCH 021/303] added row moved callback --- .../project_manager/project_manager/view.py | 26 +++++++------------ 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index 78536d556d..1f064ee17d 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -39,10 +39,17 @@ class HierarchyView(QtWidgets.QTreeView): column_delegates[key] = delegate column_key_to_index[key] = column + source_model.index_moved.connect(self._on_rows_moved) + self._delegate = main_delegate self._column_delegates = column_delegates self._column_key_to_index = column_key_to_index + def _on_rows_moved(self, index): + parent_index = index.parent() + if not self.isExpanded(parent_index): + self.expand(parent_index) + def commitData(self, editor): super(HierarchyView, self).commitData(editor) current_index = self.currentIndex() @@ -183,29 +190,16 @@ class HierarchyView(QtWidgets.QTreeView): self.edit(new_index) def _on_up_ctrl_pressed(self): - index = self.currentIndex() - self._source_model.move_horizontal(index, -1) - parent_index = index.parent() - if not self.isExpanded(parent_index): - self.expand(parent_index) + self._source_model.move_horizontal(self.currentIndex(), -1) def _on_down_ctrl_pressed(self): - index = self.currentIndex() - self._source_model.move_horizontal(index, 1) - parent_index = index.parent() - if not self.isExpanded(parent_index): - self.expand(parent_index) + self._source_model.move_horizontal(self.currentIndex(), 1) def _on_left_ctrl_pressed(self): self._source_model.move_vertical(self.currentIndex(), -1) def _on_right_ctrl_pressed(self): - index = self.currentIndex() - self._source_model.move_vertical(index, 1) - - parent_index = index.parent() - if not self.isExpanded(parent_index): - self.expand(parent_index) + self._source_model.move_vertical(self.currentIndex(), 1) def _on_enter_pressed(self): index = self.currentIndex() From a36048ca1340881b384b118df8ae0308aac10019 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 27 Apr 2021 12:57:17 +0200 Subject: [PATCH 022/303] move up/dowm works for multiselection --- .../project_manager/project_manager/model.py | 42 ++++++++++++++++++- .../project_manager/project_manager/view.py | 6 ++- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index d426c15a03..fc6442830a 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -374,7 +374,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): self.index_moved.emit(index) - def move_horizontal(self, index, direction): + def _move_horizontal_single(self, index, direction): if not index.isValid(): return @@ -461,6 +461,46 @@ class HierarchyModel(QtCore.QAbstractItemModel): self.index_moved.emit(index) + def move_horizontal(self, indexes, direction): + if not indexes: + return + + if isinstance(indexes, QtCore.QModelIndex): + indexes = [indexes] + + if len(indexes) == 1: + self._move_horizontal_single(indexes[0], direction) + return + + items_by_id = {} + for index in indexes: + item_id = index.data(IDENTIFIER_ROLE) + items_by_id[item_id] = self._items_by_id[item_id] + + skip_ids = set(items_by_id.keys()) + for item_id, item in tuple(items_by_id.items()): + parent = item.parent() + parent_ids = set() + skip_item = False + while parent is not None: + if parent.id in skip_ids: + skip_item = True + skip_ids |= parent_ids + break + parent_ids.add(parent.id) + parent = parent.parent() + + if skip_item: + items_by_id.pop(item_id) + + items = tuple(items_by_id.values()) + if direction == 1: + items = reversed(items) + + for item in items: + index = self.index_for_item(item) + self._move_horizontal_single(index, direction) + def child_removed(self, child): self._items_by_id.pop(child.id, None) diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index 1f064ee17d..59a380d6b6 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -190,10 +190,12 @@ class HierarchyView(QtWidgets.QTreeView): self.edit(new_index) def _on_up_ctrl_pressed(self): - self._source_model.move_horizontal(self.currentIndex(), -1) + indexes = self.selectedIndexes() + self._source_model.move_horizontal(indexes, -1) def _on_down_ctrl_pressed(self): - self._source_model.move_horizontal(self.currentIndex(), 1) + indexes = self.selectedIndexes() + self._source_model.move_horizontal(indexes, 1) def _on_left_ctrl_pressed(self): self._source_model.move_vertical(self.currentIndex(), -1) From 33c86aef37e281c4dd9c27b001926941c8540f6b Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 30 Apr 2021 09:58:24 +0200 Subject: [PATCH 023/303] use extended selection --- openpype/tools/project_manager/project_manager/view.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index 59a380d6b6..59eb968423 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -28,7 +28,7 @@ class HierarchyView(QtWidgets.QTreeView): main_delegate = QtWidgets.QStyledItemDelegate() self.setItemDelegate(main_delegate) self.setAlternatingRowColors(True) - self.setSelectionMode(HierarchyView.ContiguousSelection) + self.setSelectionMode(HierarchyView.ExtendedSelection) column_delegates = {} column_key_to_index = {} From 77fa75ec17544064d05a0fbc9adc3e45f39c7d67 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 30 Apr 2021 10:16:38 +0200 Subject: [PATCH 024/303] move vertically multiple items --- .../project_manager/project_manager/model.py | 69 ++++++++++++++++++- .../project_manager/project_manager/view.py | 6 +- 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index fc6442830a..04399c637e 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -284,7 +284,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): index = self.index_for_item(item) self.setData(index, True, DUPLICATED_ROLE) - def move_vertical(self, index, direction): + def _move_vertical_single(self, index, direction): if not index.isValid(): return @@ -374,6 +374,73 @@ class HierarchyModel(QtCore.QAbstractItemModel): self.index_moved.emit(index) + def move_vertical(self, indexes, direction): + if not indexes: + return + + if isinstance(indexes, QtCore.QModelIndex): + indexes = [indexes] + + if len(indexes) == 1: + self._move_vertical_single(indexes[0], direction) + return + + items_by_id = {} + for index in indexes: + item_id = index.data(IDENTIFIER_ROLE) + item = self._items_by_id[item_id] + if isinstance(item, (RootItem, ProjectItem)): + continue + + if ( + direction == -1 + and isinstance(item.parent(), (RootItem, ProjectItem)) + ): + continue + + items_by_id[item_id] = item + + if not items_by_id: + return + + parents_by_id = {} + items_ids_by_parent_id = collections.defaultdict(set) + skip_ids = set(items_by_id.keys()) + for item_id, item in tuple(items_by_id.items()): + item_parent = item.parent() + + parent_ids = set() + skip_item = False + parent = item_parent + while parent is not None: + if parent.id in skip_ids: + skip_item = True + skip_ids |= parent_ids + break + parent_ids.add(parent.id) + parent = parent.parent() + + if skip_item: + items_by_id.pop(item_id) + else: + parents_by_id[item_parent.id] = item_parent + items_ids_by_parent_id[item_parent.id].add(item_id) + + if direction == 1: + for parent_id, parent in parents_by_id.items(): + items_ids = items_ids_by_parent_id[parent_id] + if len(items_ids) == parent.rowCount(): + for item_id in items_ids: + items_by_id.pop(item_id) + + items = tuple(items_by_id.values()) + if direction == -1: + items = reversed(items) + + for item in items: + index = self.index_for_item(item) + self._move_vertical_single(index, direction) + def _move_horizontal_single(self, index, direction): if not index.isValid(): return diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index 59eb968423..b17736d7b0 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -198,10 +198,12 @@ class HierarchyView(QtWidgets.QTreeView): self._source_model.move_horizontal(indexes, 1) def _on_left_ctrl_pressed(self): - self._source_model.move_vertical(self.currentIndex(), -1) + indexes = self.selectedIndexes() + self._source_model.move_vertical(indexes, -1) def _on_right_ctrl_pressed(self): - self._source_model.move_vertical(self.currentIndex(), 1) + indexes = self.selectedIndexes() + self._source_model.move_vertical(indexes, 1) def _on_enter_pressed(self): index = self.currentIndex() From 77488bbd974a40cf39218ad342127e5c41f361f4 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 30 Apr 2021 10:21:07 +0200 Subject: [PATCH 025/303] items have editable columns definition --- .../project_manager/project_manager/model.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 04399c637e..6a899b3519 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -585,6 +585,9 @@ class HierarchyModel(QtCore.QAbstractItemModel): class BaseItem: columns = ["name"] + # Use `set` for faster result + editable_columns = {"name"} + _name_icon = None _is_duplicated = False @@ -727,7 +730,7 @@ class BaseItem: def flags(self, key): flags = QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable - if key in self.columns: + if key in self.editable_columns: flags |= QtCore.Qt.ItemIsEditable return flags @@ -763,6 +766,14 @@ class AssetItem(BaseItem): "resolutionWidth", "resolutionHeight" ] + editable_columns = { + "name", + "frameStart", + "frameEnd", + "fps", + "resolutionWidth", + "resolutionHeight" + } @classmethod def name_icon(cls): @@ -776,6 +787,11 @@ class TaskItem(BaseItem): "name", "type" ] + editable_columns = { + "name", + "type" + } + @classmethod def name_icon(cls): if cls._name_icon is None: From 21620f715fa094e6923a134156ede71c27b76063 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 30 Apr 2021 10:23:49 +0200 Subject: [PATCH 026/303] small tweaks of columns and editable columns --- .../project_manager/project_manager/model.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 6a899b3519..e53f3fe405 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -160,7 +160,10 @@ class HierarchyModel(QtCore.QAbstractItemModel): parent = item.parent() new_row = item.row() + 1 - data = {"name": name} + data = { + "name": name, + "type": "asset" + } new_child = AssetItem(data) self._asset_items_by_name[name].append(new_child) @@ -584,9 +587,9 @@ class HierarchyModel(QtCore.QAbstractItemModel): class BaseItem: - columns = ["name"] + columns = [] # Use `set` for faster result - editable_columns = {"name"} + editable_columns = set() _name_icon = None _is_duplicated = False @@ -748,9 +751,15 @@ class RootItem(BaseItem): class ProjectItem(BaseItem): + columns = [ + "name", + "type" + ] + def __init__(self, data=None): super(ProjectItem, self).__init__(data) self._data["name"] = "project" + self._data["type"] = "project" def flags(self, *args, **kwargs): return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable From f79d497313b134d3308428f80c0f5551de4dea90 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 30 Apr 2021 10:24:01 +0200 Subject: [PATCH 027/303] red color only for name column --- openpype/tools/project_manager/project_manager/model.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index e53f3fe405..14950e09c6 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -631,10 +631,6 @@ class BaseItem: if role == DUPLICATED_ROLE: return self._is_duplicated - if role == QtCore.Qt.ForegroundRole: - if self._is_duplicated: - return QtGui.QColor(255, 0, 0) - if role == QtCore.Qt.ToolTipRole: if self._is_duplicated: return "Asset with name \"{}\" already exists.".format( @@ -644,6 +640,11 @@ class BaseItem: if key not in self.columns: return None + if role == QtCore.Qt.ForegroundRole: + if self._is_duplicated and key == "name": + return QtGui.QColor(255, 0, 0) + return None + if role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole): value = self._data[key] if value is None: From 8ebf7f9fb32553cfd955f6c2f7692cafc3c26910 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 30 Apr 2021 11:07:15 +0200 Subject: [PATCH 028/303] added project model --- .../project_manager/project_manager/model.py | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 14950e09c6..08621075c0 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -13,6 +13,43 @@ from avalon.vendor import qtawesome from Qt import QtCore, QtGui +class ProjectModel(QtGui.QStandardItemModel): + project_changed = QtCore.Signal() + + def __init__(self, dbcon, *args, **kwargs): + self.dbcon = dbcon + + self._project_names = set() + + super(ProjectModel, self).__init__(*args, **kwargs) + + def refresh(self): + self.dbcon.Session["AVALON_PROJECT"] = None + + project_items = [] + database = self.dbcon.database + project_names = set() + for project_name in database.collection_names(): + # Each collection will have exactly one project document + project_doc = database[project_name].find_one( + {"type": "project"}, + {"name": 1} + ) + if not project_doc: + continue + + project_name = project_doc.get("name") + if project_name: + project_names.add(project_name) + project_items.append(QtGui.QStandardItem(project_name)) + + self.clear() + + self._project_names = project_names + + self.invisibleRootItem().appendRows(project_items) + + class HierarchySelectionModel(QtCore.QItemSelectionModel): def setCurrentIndex(self, index, command): if index.column() > 0: From f13d8c7cd9fe0032fdb0533fc526540de1f83853 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 30 Apr 2021 11:09:15 +0200 Subject: [PATCH 029/303] dbconection is passed from window --- openpype/tools/project_manager/project_manager/model.py | 6 ++---- openpype/tools/project_manager/project_manager/window.py | 6 +++++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 08621075c0..a0f75b5121 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -7,9 +7,7 @@ from .constants import ( DUPLICATED_ROLE ) -from avalon.api import AvalonMongoDB from avalon.vendor import qtawesome - from Qt import QtCore, QtGui @@ -73,12 +71,12 @@ class HierarchyModel(QtCore.QAbstractItemModel): ] index_moved = QtCore.Signal(QtCore.QModelIndex) - def __init__(self, parent=None): + def __init__(self, dbcon, parent=None): super(HierarchyModel, self).__init__(parent) self._root_item = None self._items_by_id = {} self._asset_items_by_name = collections.defaultdict(list) - self.dbcon = AvalonMongoDB() + self.dbcon = dbcon self._hierarchy_mode = True self._reset_root_item() diff --git a/openpype/tools/project_manager/project_manager/window.py b/openpype/tools/project_manager/project_manager/window.py index db5527b4ac..257850c014 100644 --- a/openpype/tools/project_manager/project_manager/window.py +++ b/openpype/tools/project_manager/project_manager/window.py @@ -6,12 +6,16 @@ from . import ( HierarchyView ) +from avalon.api import AvalonMongoDB + class Window(QtWidgets.QWidget): def __init__(self, parent=None): super(Window, self).__init__(parent) - model = HierarchyModel() + dbcon = AvalonMongoDB() + + model = HierarchyModel(dbcon) view = HierarchyView(model, self) view.setModel(model) _selection_model = HierarchySelectionModel() From 00b1f35526da3784f691cc75a09b41239321636b Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 30 Apr 2021 11:11:08 +0200 Subject: [PATCH 030/303] renmamed variables in window --- .../project_manager/project_manager/window.py | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/window.py b/openpype/tools/project_manager/project_manager/window.py index 257850c014..77cff6759c 100644 --- a/openpype/tools/project_manager/project_manager/window.py +++ b/openpype/tools/project_manager/project_manager/window.py @@ -15,14 +15,15 @@ class Window(QtWidgets.QWidget): dbcon = AvalonMongoDB() - model = HierarchyModel(dbcon) - view = HierarchyView(model, self) - view.setModel(model) - _selection_model = HierarchySelectionModel() - _selection_model.setModel(view.model()) - view.setSelectionModel(_selection_model) + hierarchy_model = HierarchyModel(dbcon) - header = view.header() + hierarchy_view = HierarchyView(hierarchy_model, self) + hierarchy_view.setModel(hierarchy_model) + _selection_model = HierarchySelectionModel() + _selection_model.setModel(hierarchy_view.model()) + hierarchy_view.setSelectionModel(_selection_model) + + header = hierarchy_view.header() header.setStretchLastSection(False) header.setSectionResizeMode( header.logicalIndex(0), QtWidgets.QHeaderView.Stretch @@ -31,27 +32,26 @@ class Window(QtWidgets.QWidget): # btn = QtWidgets.QPushButton("Refresh") main_layout = QtWidgets.QVBoxLayout(self) - main_layout.addWidget(view) + main_layout.addWidget(hierarchy_view) main_layout.addWidget(checkbox) # main_layout.addWidget(btn) # btn.clicked.connect(self._on_refresh) checkbox.toggled.connect(self._on_checkbox) - self.view = view - self.model = model # self.btn = btn + self.hierarchy_view = hierarchy_view + self.hierarchy_model = hierarchy_model self.checkbox = checkbox self.change_edit_mode() self.resize(1200, 600) - model.set_project({"name": "test"}) def change_edit_mode(self, value=None): if value is None: value = self.checkbox.isChecked() - self.model.change_edit_mode(value) + self.hierarchy_model.change_edit_mode(value) def _on_checkbox(self, value): self.change_edit_mode(value) From 64e4d3be4af122aaa1822d0b32298d4b943dbdef Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 30 Apr 2021 11:11:40 +0200 Subject: [PATCH 031/303] added project combobox to window --- .../tools/project_manager/project_manager/__init__.py | 4 ++++ openpype/tools/project_manager/project_manager/window.py | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/openpype/tools/project_manager/project_manager/__init__.py b/openpype/tools/project_manager/project_manager/__init__.py index 1a538baa20..a652e950c4 100644 --- a/openpype/tools/project_manager/project_manager/__init__.py +++ b/openpype/tools/project_manager/project_manager/__init__.py @@ -3,6 +3,8 @@ from .constants import ( ) from .view import HierarchyView from .model import ( + ProjectModel, + HierarchyModel, HierarchySelectionModel, BaseItem, @@ -18,6 +20,8 @@ __all__ = ( "HierarchyView", + "ProjectModel", + "HierarchyModel", "HierarchySelectionModel", "BaseItem", diff --git a/openpype/tools/project_manager/project_manager/window.py b/openpype/tools/project_manager/project_manager/window.py index 77cff6759c..61db70a6d7 100644 --- a/openpype/tools/project_manager/project_manager/window.py +++ b/openpype/tools/project_manager/project_manager/window.py @@ -1,6 +1,8 @@ from Qt import QtWidgets, QtCore from . import ( + ProjectModel, + HierarchyModel, HierarchySelectionModel, HierarchyView @@ -15,6 +17,12 @@ class Window(QtWidgets.QWidget): dbcon = AvalonMongoDB() + project_model = ProjectModel(dbcon) + + project_combobox = QtWidgets.QComboBox() + project_combobox.setModel(project_model) + project_combobox.setRootModelIndex(QtCore.QModelIndex()) + hierarchy_model = HierarchyModel(dbcon) hierarchy_view = HierarchyView(hierarchy_model, self) @@ -32,6 +40,7 @@ class Window(QtWidgets.QWidget): # btn = QtWidgets.QPushButton("Refresh") main_layout = QtWidgets.QVBoxLayout(self) + main_layout.addWidget(project_combobox) main_layout.addWidget(hierarchy_view) main_layout.addWidget(checkbox) # main_layout.addWidget(btn) From 9250939bdd36b3ac531c6c695debeb7871f4d7d5 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 30 Apr 2021 11:12:46 +0200 Subject: [PATCH 032/303] project item has more columns and has convertor from project document --- .../project_manager/project_manager/model.py | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index a0f75b5121..5f1f1d5d0a 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -789,13 +789,33 @@ class RootItem(BaseItem): class ProjectItem(BaseItem): columns = [ "name", - "type" + "type", + "frameStart", + "frameEnd", + "fps", + "resolutionWidth", + "resolutionHeight" ] - def __init__(self, data=None): + def __init__(self, project_doc): + + data = self.data_from_doc(project_doc) super(ProjectItem, self).__init__(data) - self._data["name"] = "project" - self._data["type"] = "project" + + @classmethod + def data_from_doc(cls, project_doc): + data = { + "name": project_doc["name"], + "type": project_doc["type"] + } + doc_data = project_doc.get("data") or {} + for key in cls.columns: + if key in data: + continue + + data[key] = doc_data.get(key) + + return data def flags(self, *args, **kwargs): return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable From eb8380aeb25ee6db25a3198c0a232193bf113e23 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 30 Apr 2021 11:13:21 +0200 Subject: [PATCH 033/303] model expect project name in set_project method --- .../project_manager/project_manager/model.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 5f1f1d5d0a..9c307063c0 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -73,6 +73,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): def __init__(self, dbcon, parent=None): super(HierarchyModel, self).__init__(parent) + self._current_project = None self._root_item = None self._items_by_id = {} self._asset_items_by_name = collections.defaultdict(list) @@ -91,11 +92,22 @@ class HierarchyModel(QtCore.QAbstractItemModel): def _reset_root_item(self): self._root_item = RootItem(self) - def set_project(self, project_doc): + def set_project(self, project_name): + if self._current_project == project_name: + return + self.clear() - item = ProjectItem(project_doc) - self.add_item(item) + self._current_project = project_name + if not project_name: + return + + project_doc = self.dbcon.database[project_name].find_one({ + "type": "project" + }) + if project_doc: + item = ProjectItem(project_doc) + self.add_item(item) def rowCount(self, parent=None): if parent is None or not parent.isValid(): From 135d811654874e0becf5c369392a75f35bd66626 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 30 Apr 2021 11:13:35 +0200 Subject: [PATCH 034/303] window can load and change projects --- .../project_manager/project_manager/window.py | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/openpype/tools/project_manager/project_manager/window.py b/openpype/tools/project_manager/project_manager/window.py index 61db70a6d7..fa47bfd1bb 100644 --- a/openpype/tools/project_manager/project_manager/window.py +++ b/openpype/tools/project_manager/project_manager/window.py @@ -57,6 +57,31 @@ class Window(QtWidgets.QWidget): self.resize(1200, 600) + self.refresh_projects() + + def refresh_projects(self): + current_project = None + if self.project_combobox.count() > 0: + current_project = self.project_combobox.currentText() + + self.project_model.refresh() + + if self.project_combobox.count() == 0: + return self._set_project() + + if current_project: + row = self.project_combobox.findText(current_project) + if row >= 0: + self._set_project(current_project) + index = self.project_combobox.model().index(row, 0) + self.project_combobox.setCurrentIndex(index) + return + + self._set_project(self.project_combobox.currentText()) + + def _set_project(self, project_name=None): + self.hierarchy_model.set_project(project_name) + def change_edit_mode(self, value=None): if value is None: value = self.checkbox.isChecked() From f8ef837a0cbdfe8552c98172c449896e3df2b6c7 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 30 Apr 2021 11:19:55 +0200 Subject: [PATCH 035/303] added simple refresh button for projects --- .../tools/project_manager/project_manager/window.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/openpype/tools/project_manager/project_manager/window.py b/openpype/tools/project_manager/project_manager/window.py index fa47bfd1bb..0dee861f59 100644 --- a/openpype/tools/project_manager/project_manager/window.py +++ b/openpype/tools/project_manager/project_manager/window.py @@ -17,12 +17,23 @@ class Window(QtWidgets.QWidget): dbcon = AvalonMongoDB() + # TOP Project selection + project_widget = QtWidgets.QWidget(self) + project_model = ProjectModel(dbcon) - project_combobox = QtWidgets.QComboBox() + project_combobox = QtWidgets.QComboBox(project_widget) project_combobox.setModel(project_model) project_combobox.setRootModelIndex(QtCore.QModelIndex()) + refresh_projects_btn = QtWidgets.QPushButton("Refresh", project_widget) + + project_layout = QtWidgets.QHBoxLayout(project_widget) + project_layout.setContentsMargins(0, 0, 0, 0) + project_layout.addWidget(refresh_projects_btn, 0) + project_layout.addWidget(project_combobox, 0) + project_layout.addStretch(1) + hierarchy_model = HierarchyModel(dbcon) hierarchy_view = HierarchyView(hierarchy_model, self) From 533adaf44db4c22f1a9c71ac29ee914772758778 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 30 Apr 2021 11:20:49 +0200 Subject: [PATCH 036/303] removed unused parts --- openpype/tools/project_manager/project_manager/window.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/window.py b/openpype/tools/project_manager/project_manager/window.py index 0dee861f59..2f7d4d472e 100644 --- a/openpype/tools/project_manager/project_manager/window.py +++ b/openpype/tools/project_manager/project_manager/window.py @@ -48,20 +48,17 @@ class Window(QtWidgets.QWidget): header.logicalIndex(0), QtWidgets.QHeaderView.Stretch ) checkbox = QtWidgets.QCheckBox(self) - # btn = QtWidgets.QPushButton("Refresh") main_layout = QtWidgets.QVBoxLayout(self) main_layout.addWidget(project_combobox) main_layout.addWidget(hierarchy_view) main_layout.addWidget(checkbox) - # main_layout.addWidget(btn) - # btn.clicked.connect(self._on_refresh) checkbox.toggled.connect(self._on_checkbox) - # self.btn = btn self.hierarchy_view = hierarchy_view self.hierarchy_model = hierarchy_model + self.checkbox = checkbox self.change_edit_mode() @@ -100,6 +97,3 @@ class Window(QtWidgets.QWidget): def _on_checkbox(self, value): self.change_edit_mode(value) - - def _on_refresh(self): - self.model.clear() From 27eb318274c3f274eb198c12a27e93bf9654704e Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 30 Apr 2021 11:21:09 +0200 Subject: [PATCH 037/303] project is changed on combobox change --- .../project_manager/project_manager/window.py | 27 +++++++++++++------ 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/window.py b/openpype/tools/project_manager/project_manager/window.py index 2f7d4d472e..3f9a54442b 100644 --- a/openpype/tools/project_manager/project_manager/window.py +++ b/openpype/tools/project_manager/project_manager/window.py @@ -50,11 +50,16 @@ class Window(QtWidgets.QWidget): checkbox = QtWidgets.QCheckBox(self) main_layout = QtWidgets.QVBoxLayout(self) - main_layout.addWidget(project_combobox) + main_layout.addWidget(project_widget) main_layout.addWidget(hierarchy_view) main_layout.addWidget(checkbox) + refresh_projects_btn.clicked.connect(self._on_project_refresh) checkbox.toggled.connect(self._on_checkbox) + project_combobox.currentIndexChanged.connect(self._on_project_change) + + self.project_model = project_model + self.project_combobox = project_combobox self.hierarchy_view = hierarchy_view self.hierarchy_model = hierarchy_model @@ -67,6 +72,14 @@ class Window(QtWidgets.QWidget): self.refresh_projects() + def _change_edit_mode(self, value=None): + if value is None: + value = self.checkbox.isChecked() + self.hierarchy_model.change_edit_mode(value) + + def _set_project(self, project_name=None): + self.hierarchy_model.set_project(project_name) + def refresh_projects(self): current_project = None if self.project_combobox.count() > 0: @@ -87,13 +100,11 @@ class Window(QtWidgets.QWidget): self._set_project(self.project_combobox.currentText()) - def _set_project(self, project_name=None): - self.hierarchy_model.set_project(project_name) + def _on_project_change(self): + self._set_project(self.project_combobox.currentText()) - def change_edit_mode(self, value=None): - if value is None: - value = self.checkbox.isChecked() - self.hierarchy_model.change_edit_mode(value) + def _on_project_refresh(self): + self.refresh_projects() def _on_checkbox(self, value): - self.change_edit_mode(value) + self._change_edit_mode(value) From b7efe9b9f61a21082113918fd23bd4bb5b6e6993 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 30 Apr 2021 11:24:13 +0200 Subject: [PATCH 038/303] removed checkbox without logic --- .../project_manager/project_manager/model.py | 4 ---- .../project_manager/project_manager/window.py | 15 --------------- 2 files changed, 19 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 9c307063c0..f7973563ad 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -79,12 +79,8 @@ class HierarchyModel(QtCore.QAbstractItemModel): self._asset_items_by_name = collections.defaultdict(list) self.dbcon = dbcon - self._hierarchy_mode = True self._reset_root_item() - def change_edit_mode(self, hiearchy_mode): - self._hierarchy_mode = hiearchy_mode - @property def items_by_id(self): return self._items_by_id diff --git a/openpype/tools/project_manager/project_manager/window.py b/openpype/tools/project_manager/project_manager/window.py index 3f9a54442b..7f9b4e7331 100644 --- a/openpype/tools/project_manager/project_manager/window.py +++ b/openpype/tools/project_manager/project_manager/window.py @@ -47,15 +47,12 @@ class Window(QtWidgets.QWidget): header.setSectionResizeMode( header.logicalIndex(0), QtWidgets.QHeaderView.Stretch ) - checkbox = QtWidgets.QCheckBox(self) main_layout = QtWidgets.QVBoxLayout(self) main_layout.addWidget(project_widget) main_layout.addWidget(hierarchy_view) - main_layout.addWidget(checkbox) refresh_projects_btn.clicked.connect(self._on_project_refresh) - checkbox.toggled.connect(self._on_checkbox) project_combobox.currentIndexChanged.connect(self._on_project_change) self.project_model = project_model @@ -64,19 +61,10 @@ class Window(QtWidgets.QWidget): self.hierarchy_view = hierarchy_view self.hierarchy_model = hierarchy_model - self.checkbox = checkbox - - self.change_edit_mode() - self.resize(1200, 600) self.refresh_projects() - def _change_edit_mode(self, value=None): - if value is None: - value = self.checkbox.isChecked() - self.hierarchy_model.change_edit_mode(value) - def _set_project(self, project_name=None): self.hierarchy_model.set_project(project_name) @@ -105,6 +93,3 @@ class Window(QtWidgets.QWidget): def _on_project_refresh(self): self.refresh_projects() - - def _on_checkbox(self, value): - self._change_edit_mode(value) From 108666cd3ffd4dbc4c9c08d1043b76be10dd17f0 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 30 Apr 2021 12:05:28 +0200 Subject: [PATCH 039/303] added hardcoded min/max for number delegate --- openpype/tools/project_manager/project_manager/delegates.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/tools/project_manager/project_manager/delegates.py b/openpype/tools/project_manager/project_manager/delegates.py index 605d0cbbab..1102242199 100644 --- a/openpype/tools/project_manager/project_manager/delegates.py +++ b/openpype/tools/project_manager/project_manager/delegates.py @@ -4,6 +4,8 @@ from Qt import QtWidgets, QtCore class NumberDelegate(QtWidgets.QStyledItemDelegate): def createEditor(self, parent, option, index): editor = QtWidgets.QSpinBox(parent) + editor.setMaximum(999999) + editor.setMinimum(0) value = index.data(QtCore.Qt.DisplayRole) if value is not None: editor.setValue(value) From 6e578ee63c99a2f7afb8360e2e30613567312194 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 30 Apr 2021 12:06:12 +0200 Subject: [PATCH 040/303] items have defined query projections --- .../project_manager/project_manager/model.py | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index f7973563ad..dea14d021d 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -804,9 +804,18 @@ class ProjectItem(BaseItem): "resolutionWidth", "resolutionHeight" ] + query_projection = { + "_id": 1, + "name": 1, + "type": 1, + "data.frameStart": 1, + "data.frameEnd": 1, + "data.fps": 1, + "data.resolutionWidth": 1, + "data.resolutionHeight": 1 + } def __init__(self, project_doc): - data = self.data_from_doc(project_doc) super(ProjectItem, self).__init__(data) @@ -847,6 +856,17 @@ class AssetItem(BaseItem): "resolutionWidth", "resolutionHeight" } + query_projection = { + "_id": 1, + "name": 1, + "type": 1, + "data.frameStart": 1, + "data.frameEnd": 1, + "data.fps": 1, + "data.resolutionWidth": 1, + "data.resolutionHeight": 1, + "data.visualParent": 1 + } @classmethod def name_icon(cls): From 7d3132f058f89c7f3c631bee523a4a89c5e5b0db Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 30 Apr 2021 12:06:24 +0200 Subject: [PATCH 041/303] add existing asset docs to hierarchy --- .../project_manager/project_manager/model.py | 37 ++++++++++++++++--- 1 file changed, 31 insertions(+), 6 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index dea14d021d..8cafc9019a 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -98,12 +98,37 @@ class HierarchyModel(QtCore.QAbstractItemModel): if not project_name: return - project_doc = self.dbcon.database[project_name].find_one({ - "type": "project" - }) - if project_doc: - item = ProjectItem(project_doc) - self.add_item(item) + project_doc = self.dbcon.database[project_name].find_one( + {"type": "project"}, + ProjectItem.query_projection + ) + if not project_doc: + return + + project_item = ProjectItem(project_doc) + self.add_item(project_item) + + asset_docs = self.dbcon.database[project_name].find( + {"type": "asset"}, + AssetItem.query_projection + ) + asset_docs_by_parent_id = collections.defaultdict(list) + for asset_doc in asset_docs: + parent_id = asset_doc["data"]["visualParent"] + asset_docs_by_parent_id[parent_id].append(asset_doc) + + appending_queue = Queue() + appending_queue.put((None, project_item)) + + while not appending_queue.empty(): + parent_id, parent_item = appending_queue.get() + if parent_id not in asset_docs_by_parent_id: + continue + + for asset_doc in asset_docs_by_parent_id[parent_id]: + new_item = AssetItem(asset_doc) + self.add_item(new_item, parent_item) + appending_queue.put((asset_doc["_id"], new_item)) def rowCount(self, parent=None): if parent is None or not parent.isValid(): From df179af335a3a6dd44ce1c64fa3bc25b536c928f Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 30 Apr 2021 12:08:04 +0200 Subject: [PATCH 042/303] AssetItem has convertor from document to data --- .../project_manager/project_manager/model.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 8cafc9019a..bf00c3f865 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -893,6 +893,25 @@ class AssetItem(BaseItem): "data.visualParent": 1 } + def __init__(self, asset_doc): + data = self.data_from_doc(asset_doc) + super(AssetItem, self).__init__(data) + + @classmethod + def data_from_doc(cls, asset_doc): + data = { + "name": asset_doc["name"], + "type": asset_doc["type"] + } + doc_data = asset_doc.get("data") or {} + for key in cls.columns: + if key in data: + continue + + data[key] = doc_data.get(key) + + return data + @classmethod def name_icon(cls): if cls._name_icon is None: From d08fbcc02f0f3395137e483e23078f7273f4902d Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 30 Apr 2021 12:15:32 +0200 Subject: [PATCH 043/303] added ability to add multiple items under same parent --- .../project_manager/project_manager/model.py | 37 +++++++++++++------ 1 file changed, 26 insertions(+), 11 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index bf00c3f865..55dd23c76a 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -125,11 +125,14 @@ class HierarchyModel(QtCore.QAbstractItemModel): if parent_id not in asset_docs_by_parent_id: continue + new_items = [] for asset_doc in asset_docs_by_parent_id[parent_id]: new_item = AssetItem(asset_doc) - self.add_item(new_item, parent_item) + new_items.append(new_item) appending_queue.put((asset_doc["_id"], new_item)) + self.add_items(new_items, parent_item) + def rowCount(self, parent=None): if parent is None or not parent.isValid(): parent_item = self._root_item @@ -257,27 +260,39 @@ class HierarchyModel(QtCore.QAbstractItemModel): new_child = TaskItem(data) return self.add_item(new_child, parent) - def add_item(self, item, parent=None, row=None): + def add_items(self, items, parent=None, start_row=None): if parent is None: parent = self._root_item - if row is None: - row = parent.rowCount() + if start_row is None: + start_row = parent.rowCount() + + end_row = start_row + len(items) - 1 parent_index = self.index_from_item(parent.row(), 0, parent.parent()) - self.beginInsertRows(parent_index, row, row) + self.beginInsertRows(parent_index, start_row, end_row) - if item.parent() is not parent: - item.set_parent(parent) + for idx, item in enumerate(items): + row = start_row + idx + if item.parent() is not parent: + item.set_parent(parent) - parent.add_child(item, row) + parent.add_child(item, row) - if item.id not in self._items_by_id: - self._items_by_id[item.id] = item + if item.id not in self._items_by_id: + self._items_by_id[item.id] = item self.endInsertRows() - return self.index_from_item(row, 0, parent) + indexes = [] + for row in range(start_row, end_row + 1): + indexes.append( + self.index_from_item(row, 0, parent) + ) + return indexes + + def add_item(self, item, parent=None, row=None): + return self.add_items([item], parent, row)[0] def remove_index(self, index): if not index.isValid(): From b8e500cb6809f831dc50f33fdff9cd7ad5efa823 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 30 Apr 2021 12:22:05 +0200 Subject: [PATCH 044/303] fix project combobox refresh --- openpype/tools/project_manager/project_manager/window.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/window.py b/openpype/tools/project_manager/project_manager/window.py index 7f9b4e7331..56c496346d 100644 --- a/openpype/tools/project_manager/project_manager/window.py +++ b/openpype/tools/project_manager/project_manager/window.py @@ -81,10 +81,7 @@ class Window(QtWidgets.QWidget): if current_project: row = self.project_combobox.findText(current_project) if row >= 0: - self._set_project(current_project) - index = self.project_combobox.model().index(row, 0) - self.project_combobox.setCurrentIndex(index) - return + self.project_combobox.setCurrentIndex(row) self._set_project(self.project_combobox.currentText()) From ab54913a435dd7a0319e94a3cf4a36eedf5e8e35 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 30 Apr 2021 12:24:09 +0200 Subject: [PATCH 045/303] modified asset query projection --- openpype/tools/project_manager/project_manager/model.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 55dd23c76a..f25b187b6c 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -898,14 +898,16 @@ class AssetItem(BaseItem): } query_projection = { "_id": 1, + "data.tasks": 1, + "data.visualParent": 1, + "name": 1, "type": 1, "data.frameStart": 1, "data.frameEnd": 1, "data.fps": 1, "data.resolutionWidth": 1, - "data.resolutionHeight": 1, - "data.visualParent": 1 + "data.resolutionHeight": 1 } def __init__(self, asset_doc): From 558ca50ed18c69127729b66d95f4c61fee17da30 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 30 Apr 2021 12:27:20 +0200 Subject: [PATCH 046/303] load also task items --- .../project_manager/project_manager/model.py | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index f25b187b6c..947dd5087f 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -1,4 +1,5 @@ import collections +import copy from queue import Queue from uuid import uuid4 @@ -112,14 +113,20 @@ class HierarchyModel(QtCore.QAbstractItemModel): {"type": "asset"}, AssetItem.query_projection ) + asset_docs_by_id = { + asset_doc["_id"]: asset_doc + for asset_doc in asset_docs + } asset_docs_by_parent_id = collections.defaultdict(list) - for asset_doc in asset_docs: + for asset_doc in asset_docs_by_id.values(): parent_id = asset_doc["data"]["visualParent"] asset_docs_by_parent_id[parent_id].append(asset_doc) appending_queue = Queue() appending_queue.put((None, project_item)) + asset_items_by_id = {} + while not appending_queue.empty(): parent_id, parent_item = appending_queue.get() if parent_id not in asset_docs_by_parent_id: @@ -127,12 +134,33 @@ class HierarchyModel(QtCore.QAbstractItemModel): new_items = [] for asset_doc in asset_docs_by_parent_id[parent_id]: + # Create new Item new_item = AssetItem(asset_doc) + # Store item to be added under parent in bulk new_items.append(new_item) - appending_queue.put((asset_doc["_id"], new_item)) + + # Store item by id for task processing + asset_id = asset_doc["_id"] + asset_items_by_id[asset_id] = new_item + # Add item to appending queue + appending_queue.put((asset_id, new_item)) self.add_items(new_items, parent_item) + for asset_id, asset_item in asset_items_by_id.items(): + asset_doc = asset_docs_by_id[asset_id] + asset_tasks = asset_doc["data"]["tasks"] + if not asset_tasks: + continue + + task_items = [] + for task_name, task_data in asset_tasks.items(): + _task_data = copy.deepcopy(task_data) + _task_data["name"] = task_name + task_item = TaskItem(_task_data) + task_items.append(task_item) + self.add_items(task_items, asset_item) + def rowCount(self, parent=None): if parent is None or not parent.isValid(): parent_item = self._root_item From e771d4b887d5657c282e70eb99e1e7b667023c29 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 30 Apr 2021 12:40:41 +0200 Subject: [PATCH 047/303] different way how to define delegates with more abilities --- .../project_manager/delegates.py | 17 +++++++-- .../project_manager/project_manager/view.py | 36 ++++++++++++++----- 2 files changed, 42 insertions(+), 11 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/delegates.py b/openpype/tools/project_manager/project_manager/delegates.py index 1102242199..a26fb17b29 100644 --- a/openpype/tools/project_manager/project_manager/delegates.py +++ b/openpype/tools/project_manager/project_manager/delegates.py @@ -2,10 +2,21 @@ from Qt import QtWidgets, QtCore class NumberDelegate(QtWidgets.QStyledItemDelegate): + def __init__(self, minimum, maximum, decimals, *args, **kwargs): + super(NumberDelegate, self).__init__(*args, **kwargs) + self.minimum = minimum + self.maximum = maximum + self.decimals = decimals + def createEditor(self, parent, option, index): - editor = QtWidgets.QSpinBox(parent) - editor.setMaximum(999999) - editor.setMinimum(0) + print(option.rect) + if self.decimals > 0: + editor = QtWidgets.QDoubleSpinBox(parent) + else: + editor = QtWidgets.QSpinBox(parent) + editor.setMinimum(self.minimum) + editor.setMaximum(self.maximum) + value = index.data(QtCore.Qt.DisplayRole) if value is not None: editor.setValue(value) diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index b17736d7b0..90e0e5289e 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -3,15 +3,27 @@ from Qt import QtWidgets, QtCore from .delegates import NumberDelegate, StringDelegate +class StringDef: + def __init__(self, regex=None): + self.regex = regex + + +class NumberDef: + def __init__(self, minimum=None, maximum=None, decimals=None): + self.minimum = 0 if minimum is None else minimum + self.maximum = 999999 if maximum is None else maximum + self.decimals = 0 if decimals is None else decimals + + class HierarchyView(QtWidgets.QTreeView): """A tree view that deselects on clicking on an empty area in the view""" column_delegate_defs = { - "name": StringDelegate, - "frameStart": NumberDelegate, - "frameEnd": NumberDelegate, - "fps": NumberDelegate, - "resolutionWidth": NumberDelegate, - "resolutionHeight": NumberDelegate + "name": StringDef(), + "frameStart": NumberDef(1), + "frameEnd": NumberDef(1), + "fps": NumberDef(1, decimals=2), + "resolutionWidth": NumberDef(0), + "resolutionHeight": NumberDef(0) } persistent_columns = [ "frameStart", @@ -32,8 +44,16 @@ class HierarchyView(QtWidgets.QTreeView): column_delegates = {} column_key_to_index = {} - for key, delegate_klass in self.column_delegate_defs.items(): - delegate = delegate_klass() + for key, item_type in self.column_delegate_defs.items(): + if isinstance(item_type, StringDef): + delegate = StringDelegate() + elif isinstance(item_type, NumberDef): + delegate = NumberDelegate( + item_type.minimum, + item_type.maximum, + item_type.decimals + ) + column = self._source_model.columns.index(key) self.setItemDelegateForColumn(column, delegate) column_delegates[key] = delegate From f5bbe2a3d4265e20e5e4bd249b1c73aca6b9bd42 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 30 Apr 2021 12:41:24 +0200 Subject: [PATCH 048/303] remove debug print --- openpype/tools/project_manager/project_manager/delegates.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/tools/project_manager/project_manager/delegates.py b/openpype/tools/project_manager/project_manager/delegates.py index a26fb17b29..51bf6515ad 100644 --- a/openpype/tools/project_manager/project_manager/delegates.py +++ b/openpype/tools/project_manager/project_manager/delegates.py @@ -9,7 +9,6 @@ class NumberDelegate(QtWidgets.QStyledItemDelegate): self.decimals = decimals def createEditor(self, parent, option, index): - print(option.rect) if self.decimals > 0: editor = QtWidgets.QDoubleSpinBox(parent) else: From 550945972ad41102887bfc220803837f7801b546 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 30 Apr 2021 13:40:29 +0200 Subject: [PATCH 049/303] added more columns --- .../project_manager/project_manager/model.py | 94 ++++++++++++++----- .../project_manager/project_manager/view.py | 19 +++- 2 files changed, 86 insertions(+), 27 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 947dd5087f..a6446b22bb 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -61,15 +61,30 @@ class HierarchySelectionModel(QtCore.QItemSelectionModel): class HierarchyModel(QtCore.QAbstractItemModel): - columns = [ - "name", - "type", - "frameStart", - "frameEnd", - "fps", - "resolutionWidth", - "resolutionHeight" + _columns_def = [ + ("name", "Name"), + ("type", "Type"), + ("fps", "FPS"), + ("frameStart", "Frame start"), + ("frameEnd", "Frame end"), + ("handleStart", "Handle start"), + ("handleEnd", "Handle end"), + ("resolutionWidth", "Width"), + ("resolutionHeight", "Height"), + ("clipIn", "Clip in"), + ("clipOut", "Clip out"), + ("pixelAspect", "Pixel aspect"), + ("tools_env", "Tools") ] + columns = [ + item[0] + for item in _columns_def + ] + columns_len = len(columns) + column_labels = { + idx: item[1] + for idx, item in enumerate(_columns_def) + } index_moved = QtCore.Signal(QtCore.QModelIndex) def __init__(self, dbcon, parent=None): @@ -169,7 +184,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): return parent_item.rowCount() def columnCount(self, *args, **kwargs): - return len(self.columns) + return self.columns_len def data(self, index, role): if not index.isValid(): @@ -202,8 +217,8 @@ class HierarchyModel(QtCore.QAbstractItemModel): def headerData(self, section, orientation, role): if role == QtCore.Qt.DisplayRole: - if section < len(self.columns): - return self.columns[section] + if section < self.columnCount(): + return self.column_labels[section] super(HierarchyModel, self).headerData(section, orientation, role) @@ -252,7 +267,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): new_row = None if isinstance(item, (RootItem, ProjectItem)): - name = "eq" + name = "ep" parent = item else: name = source_index.data(QtCore.Qt.DisplayRole) @@ -863,24 +878,37 @@ class RootItem(BaseItem): class ProjectItem(BaseItem): - columns = [ + columns = { "name", "type", "frameStart", "frameEnd", "fps", "resolutionWidth", - "resolutionHeight" - ] + "resolutionHeight", + "handleStart", + "handleEnd", + "clipIn", + "clipOut", + "pixelAspect", + "tools_env", + } query_projection = { "_id": 1, "name": 1, "type": 1, + "data.frameStart": 1, "data.frameEnd": 1, "data.fps": 1, "data.resolutionWidth": 1, - "data.resolutionHeight": 1 + "data.resolutionHeight": 1, + "data.handleStart": 1, + "data.handleEnd": 1, + "data.clipIn": 1, + "data.clipOut": 1, + "data.pixelAspect": 1, + "data.tools_env": 1 } def __init__(self, project_doc): @@ -907,22 +935,34 @@ class ProjectItem(BaseItem): class AssetItem(BaseItem): - columns = [ + columns = { "name", "type", + "fps", "frameStart", "frameEnd", - "fps", "resolutionWidth", - "resolutionHeight" - ] + "resolutionHeight", + "handleStart", + "handleEnd", + "clipIn", + "clipOut", + "pixelAspect", + "tools_env" + } editable_columns = { "name", "frameStart", "frameEnd", "fps", "resolutionWidth", - "resolutionHeight" + "resolutionHeight", + "handleStart", + "handleEnd", + "clipIn", + "clipOut", + "pixelAspect", + "tools_env" } query_projection = { "_id": 1, @@ -935,7 +975,13 @@ class AssetItem(BaseItem): "data.frameEnd": 1, "data.fps": 1, "data.resolutionWidth": 1, - "data.resolutionHeight": 1 + "data.resolutionHeight": 1, + "data.handleStart": 1, + "data.handleEnd": 1, + "data.clipIn": 1, + "data.clipOut": 1, + "data.pixelAspect": 1, + "data.tools_env": 1 } def __init__(self, asset_doc): @@ -965,10 +1011,10 @@ class AssetItem(BaseItem): class TaskItem(BaseItem): - columns = [ + columns = { "name", "type" - ] + } editable_columns = { "name", "type" diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index 90e0e5289e..7131687612 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -23,14 +23,25 @@ class HierarchyView(QtWidgets.QTreeView): "frameEnd": NumberDef(1), "fps": NumberDef(1, decimals=2), "resolutionWidth": NumberDef(0), - "resolutionHeight": NumberDef(0) + "resolutionHeight": NumberDef(0), + "handleStart": NumberDef(0), + "handleEnd": NumberDef(0), + "clipIn": NumberDef(1), + "clipOut": NumberDef(1), + "pixelAspect": NumberDef(0, decimals=2), + # "tools_env": NumberDef(0) } persistent_columns = [ "frameStart", "frameEnd", "fps", "resolutionWidth", - "resolutionHeight" + "resolutionHeight", + "handleStart", + "handleEnd", + "clipIn", + "clipOut", + "pixelAspect" ] def __init__(self, source_model, *args, **kwargs): @@ -95,7 +106,9 @@ class HierarchyView(QtWidgets.QTreeView): def _deselect_editor(self, editor): if editor: - if isinstance(editor, QtWidgets.QSpinBox): + if isinstance( + editor, (QtWidgets.QSpinBox, QtWidgets.QDoubleSpinBox) + ): line_edit = editor.findChild(QtWidgets.QLineEdit) line_edit.deselect() From 80da5af4f9e1ee60375c1b859d2a4392cf8b26cb Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 5 May 2021 15:34:26 +0200 Subject: [PATCH 050/303] Sync Server beginning of documentation --- website/README.md | 28 ++++++++ website/docs/artist_tools.md | 48 ++++++++++++++ website/docs/assets/site_sync_gdrive_user.png | Bin 0 -> 63598 bytes website/docs/assets/site_sync_loader.png | Bin 0 -> 22983 bytes .../docs/assets/site_sync_local_setting.png | Bin 0 -> 26004 bytes .../assets/site_sync_project_settings.png | Bin 0 -> 40179 bytes website/docs/assets/site_sync_sync_queue.png | Bin 0 -> 66171 bytes website/docs/assets/site_sync_system.png | Bin 0 -> 15171 bytes website/docs/module_site_sync.md | 60 ++++++++++++++++++ 9 files changed, 136 insertions(+) create mode 100644 website/README.md create mode 100644 website/docs/assets/site_sync_gdrive_user.png create mode 100644 website/docs/assets/site_sync_loader.png create mode 100644 website/docs/assets/site_sync_local_setting.png create mode 100644 website/docs/assets/site_sync_project_settings.png create mode 100644 website/docs/assets/site_sync_sync_queue.png create mode 100644 website/docs/assets/site_sync_system.png diff --git a/website/README.md b/website/README.md new file mode 100644 index 0000000000..c6f13273e8 --- /dev/null +++ b/website/README.md @@ -0,0 +1,28 @@ +When developing on Windows make sure `start.sh` has the correct line endings (`LF`). + +Start via yarn: +--------------- +Clone repository + +Install yarn if not already installed (https://classic.yarnpkg.com/en/docs/install) +For example via npm (but could be installed differently too) + + ```npm install --global yarn``` + +Then go to `website` folder + + ```yarn install``` (takes a while) + +To start local test server: + + ```yarn start``` + +Server is accessible by default on http://localhost:3000 + +Start via docker: +----------------- +Setting for docker container: +```bash +docker build . -t pype-docs +docker run --rm -p 3000:3000 -v /c/Users/admin/openpype.io:/app pype-docs +``` diff --git a/website/docs/artist_tools.md b/website/docs/artist_tools.md index f03ea8e249..5bc3f4c1fd 100644 --- a/website/docs/artist_tools.md +++ b/website/docs/artist_tools.md @@ -142,6 +142,22 @@ You can set group of selected subsets with shortcut `Ctrl + G`. You'll set the group in Avalon database so your changes will take effect for all users. ::: +### Site Sync support + +If **Site Sync** is enabled additional widget is shown in right bottom corner. +It contains list of all representations of selected version(s). It also shows availability of representation files +on particular site (*active* - mine, *remote* - theirs). + +![site_sync_support](assets/site_sync_loader.png) + +On this picture you see that representation files are available only on remote site (could be GDrive or other). +If artist wants to work with the file(s) they need to be downloaded first. That could be done by right mouse click on +particular representation (or multiselect all) and select *Download*. + +This will mark representation to be download which will happen in the background if OpenPype Tray is running. + +For more details of progress, state or possible error details artist should open **[Sync Queue](#Sync-Queue)** item in Tray app. + Work in progress... ## Library Loader @@ -412,3 +428,35 @@ It might also happen that user deletes underlying host item(for example layer in This could result in phantom issues during publishing. Use Subset Manager to purge workfile from abandoned items. Please check behaviour in host of your choice. + +## Sync Queue + +### Details + +If **Site Sync** is configured for a project, each asset is marked to be synchronized to a remote site during publishing. +Each artist's OpenPype Tray application handles synchronization in background, it looks for all representation which +are marked with the site of the user (unique site name per artist) and remote site. + +Artists then can see progress of synchronization via **Sync Queue** link in the Tray application. + +Artists can see all synced representation in this dialog with helpful information such as when representation was created, when it was synched, +status of synchronization (OK or Fail) etc. + +### Usage + +With this app artists can modify synchronized representation, for example mark failed representation for re-sync etc. + +![Sync Queue](assets/site_sync_sync_queue.png) + +Actions accessible by context menu on single (or multiple representations): +- *Open in Explorer* - if site is locally accessible, open folder with it with OS based explorer +- *Re-sync Active Site* - mark artist own side for re-download (repre must be accessible on remote side) +- *Re-sync Remote Site* - mark representation for re-upload +- *Completely remove from local* - removes tag of synchronization to artist's local site, removes files from disk (available only for personal sites) +- *Change priority* - mark representations with higher priority for faster synchronization run + +Double click on any of the representation open Detail dialog with information about all files for particular representation. +In this dialog error details could be accessed in the context menu. + +Artists can also Pause whole server or specific project for synchronization. In that state no download/upload is being run. +This might be helpful if the artist is not interested in a particular project for a while or wants to save bandwidth data limit for a bit. \ No newline at end of file diff --git a/website/docs/assets/site_sync_gdrive_user.png b/website/docs/assets/site_sync_gdrive_user.png new file mode 100644 index 0000000000000000000000000000000000000000..cfffcc644cc8ffea4ecf75874f1db9ce3e639d70 GIT binary patch literal 63598 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 literal 0 HcmV?d00001 diff --git a/website/docs/assets/site_sync_loader.png b/website/docs/assets/site_sync_loader.png new file mode 100644 index 0000000000000000000000000000000000000000..8792f6c9d9d4cb2b236583b731e01f9196d1fcbf GIT binary patch literal 22983 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%0APHTZ9;c% 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 F{{xr{$x#3R literal 0 HcmV?d00001 diff --git a/website/docs/assets/site_sync_local_setting.png b/website/docs/assets/site_sync_local_setting.png new file mode 100644 index 0000000000000000000000000000000000000000..6af1b8aaca808ca6bccaade975cf3b2608bb161d GIT binary patch literal 26004 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 literal 0 HcmV?d00001 diff --git a/website/docs/assets/site_sync_project_settings.png b/website/docs/assets/site_sync_project_settings.png new file mode 100644 index 0000000000000000000000000000000000000000..3b3156661654f96a44c4e0dcc5141cb536ba0162 GIT binary patch literal 40179 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%0AyKzb(M@l>H5#dvz=7Tjg7U&@xBJ|3*AfX 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 X2&>LUw8c1czFzS~p^q?rh`RD$xeus| literal 0 HcmV?d00001 diff --git a/website/docs/assets/site_sync_sync_queue.png b/website/docs/assets/site_sync_sync_queue.png new file mode 100644 index 0000000000000000000000000000000000000000..0fc7d0491d7017ae145a772b1375c7b4d3bff7d2 GIT binary patch literal 66171 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 literal 0 HcmV?d00001 diff --git a/website/docs/assets/site_sync_system.png b/website/docs/assets/site_sync_system.png new file mode 100644 index 0000000000000000000000000000000000000000..0c455eca37282f2a4abba7932ad57d0511bbccbf GIT binary patch literal 15171 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%0Dxqwukb8ey#KVkchod!Z^d z-Lk6Cdiks1@S0)$9gBma2{D*7pWM)056HY@S!bw#m8DcJ_9eL$a1T}^qKcs1@~REE zW_m7ImNt?Sl7u;f?c(?_&4h+&NDgKvg}KJ_8+|tX#oeI>B);)m>HR?RhP0xstBkN{w&_B#>2$k!GJO<^=mjd)h@d;Y~Tz&8lbWHDg963C|+V)ViguO&0cNzF&d2; z<_zduH}ca(ovE-b%^hi0mg}!ci6^YrqPuL@Q8gtUBXqYfrSTdQQplF_%YEP;ly$OL zyQTE)TL+uU9)E7hxE@ih!HM=eoGyL4r(~#XzIk$bKEpGc=H*R**SB7%PNyg*#Z3GV&Z!YvDWiMFckO%1Gwp%?= z5H{7vYgXKvz;uGW+DC#{GQw65HFBsNl}(ttBOY&!4F_01eRv;O-VphyBa`0;-N6^az@|;oUc> zY){PWpndy<^nyFk{r7yDhtmVC8kOCS2m|YUEF5?as+LG>eEerjU3>eRaFTR|B`7<} z$qdQtzs-E2(JdFc_;oSd1-2SQ4vEZPu^UYh81^3br!2gy7jIb8$M~^`cMi$S?EaD1 zUOB=ImtO$R|E;t65r`Tu(NYPsG*}095LRhE@YlWbL;=vWY*LvX>D$YO>Cw>L)X@Fl z`g!p4X!M9mVPPBRvlxOb`Aev(YB8rGmKQ{wcTC?p=3 zwSd(f0l_*b637fXegj9wf*UiZ&fQ=bij1V_lEp7s-87SV@=dnoglH5m<5UUG>p3s+ zVP$1?UAG^Vk`_5&ZNG&!)vl|7k>zgJb^j>?o8v`2aMRd`oArY9T!5ZE&wwKbN>rQnIbC_9kl#IlyqHCWn`fS81h)&^vgdn*neGt z|6zz>2`I5MO#oDSGuK8m-?Ud<@s(OZKQ+1k&Bmp`fc0%%z{clZ`bUY4jVaTew=7z; zU2j}8ueh(@LiBI)G!PB@WT5oD?A=!2Wu6n9QJlQq`9!!8{5h=cD2)&6Jc{RiRLx^>svI_fE7b4K8s2O2QqE~{5?(Y!V5P|NP}xv%Cr?@ z<)Iinrs<_}`=5h9zZ+L2Z+PlxmmsCoO<+J$>8N%wNr!(vc4L3gqFZI6s{74j1+83b zv?wcRN=95rq~I{y)4Qn&Cm=dN^dE@sWl<$CJo3TWx#Tz?+wF}lHM+1z6`Tn8i3!@j zzb6VB`?zipX-WS(caD|6Vh<q@M56Z)hc3P55Sm?%oj|#9QxM|Uon41zFV8s zNCM{bmS1*aV##|v?^bB_(35mi9=ETn=|Ug8#3P4B_iy35z$&< z`*KPD(c1zH@7I33O_&`+2YzsvX8aE;4y&}Dnnxx5JyIQldo)^QNF;~fG+mqC8a*p_vOdXXXT2IpHpm`fGr z%&-(5KIj`h$yx*g_KC3g_>}VZTy&Ai`z~+EmDxh=-LK!SXiEof#E!)vT)9q}zL8V# zYQng-TcQo$wV&%T%xyc`ga?N=cUd1@Tz3_)T;{j_2acmA;%pc6BVuRARiK}h9{HYu zS!#0kqW5Usv1#}>^N$jjJ8~CiXRT)4!><#2h>sNPgS4g{N^4C8TL`Qp@nyaQjQNze z`USPuby2Cv82Ywc1(|4eo4F?ZOhwgDL|hgv-?x|^;-jWRP_U@GC1iO*#dhqk$=hwY zgD1gPn#GMrQq60n<52c0DAQc$U+>&vc`eV1jR=~%QJk%pR8pfH9t~AId*|9xh{0^Y zkD+Z5nnB(U)oJiFlmnyyqrqFJnjgZ?81qi{l7E^+{oybWj8r~oR{ZaBu%4@!Ur?@= zaWehYWt{o(XsT}2hdz1f69(ZwHXsbWf*0A+pD%qBxubGY{G!V zKPg=Rv2vx9MZatk6@82EeliG34BQkZ`x13Ci70PBXV=bQQ+)+MsB;@hI1!v8>Ni&S z{vObg954O1>uU+;UjXww-97z}D&BvK)&3Li%6P9}3*^=ZEB`9D-T~+#pCoq0zV}O0 z=U+mI+mU~f!vB-h_+KRIj)YLFq1;rLv~wKj8~kQ*Nbg${&ZN+tZF28W-Xyqw&kQhX zoL>#-==mL&&DQzGx{<)T*g1D9xEVlv(i4miS(nJ08z|L8Kbn({VrhZ2zr>kkiHkbu zEo`T&vUB*iXO?!y)8h^xpPNa1n%wXw*M|OD&Gf92P3~BW=?l=E%BdR!xowhN6}B$0 z>I`4*C4E9zrpxm%0D$)3#o;kIgS7P* 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 Date: Thu, 6 May 2021 14:07:34 +0200 Subject: [PATCH 051/303] adde ProjectHelper which helps to keep track with project document --- .../project_manager/project_manager/view.py | 25 ++++++++++++++++++- .../project_manager/project_manager/window.py | 4 +-- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index 7131687612..7f8abd82c9 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -15,6 +15,22 @@ class NumberDef: self.decimals = 0 if decimals is None else decimals +class ProjectHelper: + def __init__(self, dbcon): + self.dbcon = dbcon + self.project_doc = None + + def set_project(self, project_name): + self.project_doc = None + + if not project_name: + return + + self.project_doc = self.dbcon.database[project_name].find_one( + {"type": "project"} + ) + + class HierarchyView(QtWidgets.QTreeView): """A tree view that deselects on clicking on an empty area in the view""" column_delegate_defs = { @@ -44,10 +60,12 @@ class HierarchyView(QtWidgets.QTreeView): "pixelAspect" ] - def __init__(self, source_model, *args, **kwargs): + def __init__(self, dbcon, source_model, *args, **kwargs): super(HierarchyView, self).__init__(*args, **kwargs) self._source_model = source_model + project_helper = ProjectHelper(dbcon) + main_delegate = QtWidgets.QStyledItemDelegate() self.setItemDelegate(main_delegate) self.setAlternatingRowColors(True) @@ -72,10 +90,15 @@ class HierarchyView(QtWidgets.QTreeView): source_model.index_moved.connect(self._on_rows_moved) + self._project_helper = project_helper self._delegate = main_delegate self._column_delegates = column_delegates self._column_key_to_index = column_key_to_index + def set_project(self, project_name): + self._source_model.set_project(project_name) + self._project_helper.set_project(project_name) + def _on_rows_moved(self, index): parent_index = index.parent() if not self.isExpanded(parent_index): diff --git a/openpype/tools/project_manager/project_manager/window.py b/openpype/tools/project_manager/project_manager/window.py index 56c496346d..790df4557e 100644 --- a/openpype/tools/project_manager/project_manager/window.py +++ b/openpype/tools/project_manager/project_manager/window.py @@ -36,7 +36,7 @@ class Window(QtWidgets.QWidget): hierarchy_model = HierarchyModel(dbcon) - hierarchy_view = HierarchyView(hierarchy_model, self) + hierarchy_view = HierarchyView(dbcon, hierarchy_model, self) hierarchy_view.setModel(hierarchy_model) _selection_model = HierarchySelectionModel() _selection_model.setModel(hierarchy_view.model()) @@ -66,7 +66,7 @@ class Window(QtWidgets.QWidget): self.refresh_projects() def _set_project(self, project_name=None): - self.hierarchy_model.set_project(project_name) + self.hierarchy_view.set_project(project_name) def refresh_projects(self): current_project = None From 00b7b4514ef10acf74bf35ae35a5bd538aa5f79b Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 6 May 2021 14:07:48 +0200 Subject: [PATCH 052/303] adde type delegate for task types --- .../project_manager/delegates.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/openpype/tools/project_manager/project_manager/delegates.py b/openpype/tools/project_manager/project_manager/delegates.py index 51bf6515ad..e660cd05f4 100644 --- a/openpype/tools/project_manager/project_manager/delegates.py +++ b/openpype/tools/project_manager/project_manager/delegates.py @@ -33,3 +33,20 @@ class StringDelegate(QtWidgets.QStyledItemDelegate): if value is not None: editor.setText(str(value)) return editor + + +class TypeDelegate(QtWidgets.QStyledItemDelegate): + def __init__(self, project_helper, *args, **kwargs): + self.project_helper = project_helper + super(TypeDelegate, self).__init__(*args, **kwargs) + + def createEditor(self, parent, option, index): + editor = QtWidgets.QComboBox(parent) + task_type_defs = self.project_helper.project_doc["config"]["tasks"] + items = list(task_type_defs.keys()) + + value = index.data(QtCore.Qt.DisplayRole) + + editor.addItems(items) + + return editor From 3028e61c0010d4012b539da9f15d8c3ee0b8b119 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 6 May 2021 14:08:02 +0200 Subject: [PATCH 053/303] use task type delegate creating combobox --- .../tools/project_manager/project_manager/view.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index 7f8abd82c9..88836aa969 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -1,6 +1,10 @@ from Qt import QtWidgets, QtCore -from .delegates import NumberDelegate, StringDelegate +from .delegates import ( + NumberDelegate, + StringDelegate, + TypeDelegate +) class StringDef: @@ -15,6 +19,10 @@ class NumberDef: self.decimals = 0 if decimals is None else decimals +class TypeDef: + pass + + class ProjectHelper: def __init__(self, dbcon): self.dbcon = dbcon @@ -35,6 +43,7 @@ class HierarchyView(QtWidgets.QTreeView): """A tree view that deselects on clicking on an empty area in the view""" column_delegate_defs = { "name": StringDef(), + "type": TypeDef(), "frameStart": NumberDef(1), "frameEnd": NumberDef(1), "fps": NumberDef(1, decimals=2), @@ -82,6 +91,8 @@ class HierarchyView(QtWidgets.QTreeView): item_type.maximum, item_type.decimals ) + elif isinstance(item_type, TypeDef): + delegate = TypeDelegate(project_helper) column = self._source_model.columns.index(key) self.setItemDelegateForColumn(column, delegate) From 72c2d66c029c61fe767099296bc2027505d24c96 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 6 May 2021 14:56:48 +0200 Subject: [PATCH 054/303] fixed TypeDelegate --- .../project_manager/project_manager/delegates.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/delegates.py b/openpype/tools/project_manager/project_manager/delegates.py index e660cd05f4..22b8427dd1 100644 --- a/openpype/tools/project_manager/project_manager/delegates.py +++ b/openpype/tools/project_manager/project_manager/delegates.py @@ -36,17 +36,17 @@ class StringDelegate(QtWidgets.QStyledItemDelegate): class TypeDelegate(QtWidgets.QStyledItemDelegate): - def __init__(self, project_helper, *args, **kwargs): - self.project_helper = project_helper + def __init__(self, project_doc_cache, *args, **kwargs): + self._project_doc_cache = project_doc_cache super(TypeDelegate, self).__init__(*args, **kwargs) def createEditor(self, parent, option, index): editor = QtWidgets.QComboBox(parent) - task_type_defs = self.project_helper.project_doc["config"]["tasks"] - items = list(task_type_defs.keys()) + if not self._project_doc_cache.project_doc: + return editor - value = index.data(QtCore.Qt.DisplayRole) + task_type_defs = self._project_doc_cache.project_doc["config"]["tasks"] + editor.addItems(list(task_type_defs.keys())) - editor.addItems(items) return editor From 3e2507598f5e2780314c78c945e95488c563e450 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 6 May 2021 14:57:06 +0200 Subject: [PATCH 055/303] added multiselection combobox --- .../multiselection_combobox.py | 215 ++++++++++++++++++ 1 file changed, 215 insertions(+) create mode 100644 openpype/tools/project_manager/project_manager/multiselection_combobox.py diff --git a/openpype/tools/project_manager/project_manager/multiselection_combobox.py b/openpype/tools/project_manager/project_manager/multiselection_combobox.py new file mode 100644 index 0000000000..b26976d3c6 --- /dev/null +++ b/openpype/tools/project_manager/project_manager/multiselection_combobox.py @@ -0,0 +1,215 @@ +from Qt import QtCore, QtGui, QtWidgets + + +class ComboItemDelegate(QtWidgets.QStyledItemDelegate): + """ + Helper styled delegate (mostly based on existing private Qt's + delegate used by the QtWidgets.QComboBox). Used to style the popup like a + list view (e.g windows style). + """ + + def paint(self, painter, option, index): + option = QtWidgets.QStyleOptionViewItem(option) + option.showDecorationSelected = True + + # option.state &= ( + # ~QtWidgets.QStyle.State_HasFocus + # & ~QtWidgets.QStyle.State_MouseOver + # ) + super(ComboItemDelegate, self).paint(painter, option, index) + + +class MultiSelectionComboBox(QtWidgets.QComboBox): + value_changed = QtCore.Signal() + ignored_keys = { + QtCore.Qt.Key_Up, + QtCore.Qt.Key_Down, + QtCore.Qt.Key_PageDown, + QtCore.Qt.Key_PageUp, + QtCore.Qt.Key_Home, + QtCore.Qt.Key_End + } + + def __init__(self, parent=None, **kwargs): + super(MultiSelectionComboBox, self).__init__(parent=parent, **kwargs) + self.setObjectName("MultiSelectionComboBox") + self.setFocusPolicy(QtCore.Qt.StrongFocus) + + self._popup_is_shown = False + self._block_mouse_release_timer = QtCore.QTimer(self, singleShot=True) + self._initial_mouse_pos = None + self._delegate = ComboItemDelegate(self) + self.setItemDelegate(self._delegate) + + def mousePressEvent(self, event): + """Reimplemented.""" + self._popup_is_shown = False + super(MultiSelectionComboBox, self).mousePressEvent(event) + if self._popup_is_shown: + self._initial_mouse_pos = self.mapToGlobal(event.pos()) + self._block_mouse_release_timer.start( + QtWidgets.QApplication.doubleClickInterval() + ) + + def showPopup(self): + """Reimplemented.""" + super(MultiSelectionComboBox, self).showPopup() + view = self.view() + view.installEventFilter(self) + view.viewport().installEventFilter(self) + self._popup_is_shown = True + + def hidePopup(self): + """Reimplemented.""" + self.view().removeEventFilter(self) + self.view().viewport().removeEventFilter(self) + self._popup_is_shown = False + self._initial_mouse_pos = None + super(MultiSelectionComboBox, self).hidePopup() + self.view().clearFocus() + + def _event_popup_shown(self, obj, event): + if not self._popup_is_shown: + return + + current_index = self.view().currentIndex() + model = self.model() + + if event.type() == QtCore.QEvent.MouseMove: + if ( + self.view().isVisible() + and self._initial_mouse_pos is not None + and self._block_mouse_release_timer.isActive() + ): + diff = obj.mapToGlobal(event.pos()) - self._initial_mouse_pos + if diff.manhattanLength() > 9: + self._block_mouse_release_timer.stop() + return + + index_flags = current_index.flags() + state = current_index.data(QtCore.Qt.CheckStateRole) + new_state = None + + if event.type() == QtCore.QEvent.MouseButtonRelease: + if ( + self._block_mouse_release_timer.isActive() + or not current_index.isValid() + or not self.view().isVisible() + or not self.view().rect().contains(event.pos()) + or not index_flags & QtCore.Qt.ItemIsSelectable + or not index_flags & QtCore.Qt.ItemIsEnabled + or not index_flags & QtCore.Qt.ItemIsUserCheckable + ): + return + + if state == QtCore.Qt.Unchecked: + new_state = QtCore.Qt.Checked + else: + new_state = QtCore.Qt.Unchecked + + elif event.type() == QtCore.QEvent.KeyPress: + # TODO: handle QtCore.Qt.Key_Enter, Key_Return? + if event.key() == QtCore.Qt.Key_Space: + # toogle the current items check state + if ( + index_flags & QtCore.Qt.ItemIsUserCheckable + and index_flags & QtCore.Qt.ItemIsTristate + ): + new_state = QtCore.Qt.CheckState((int(state) + 1) % 3) + + elif index_flags & QtCore.Qt.ItemIsUserCheckable: + if state != QtCore.Qt.Checked: + new_state = QtCore.Qt.Checked + else: + new_state = QtCore.Qt.Unchecked + + if new_state is not None: + model.setData(current_index, new_state, QtCore.Qt.CheckStateRole) + self.view().update(current_index) + self.value_changed.emit() + return True + + def eventFilter(self, obj, event): + """Reimplemented.""" + result = self._event_popup_shown(obj, event) + if result is not None: + return result + + return super(MultiSelectionComboBox, self).eventFilter(obj, event) + + def addItem(self, *args, **kwargs): + idx = self.count() + super(MultiSelectionComboBox, self).addItem(*args, **kwargs) + self.model().item(idx).setCheckable(True) + + def paintEvent(self, event): + """Reimplemented.""" + painter = QtWidgets.QStylePainter(self) + option = QtWidgets.QStyleOptionComboBox() + self.initStyleOption(option) + painter.drawComplexControl(QtWidgets.QStyle.CC_ComboBox, option) + + # draw the icon and text + items = self.checked_items_text() + if not items: + return + + text_rect = self.style().subControlRect( + QtWidgets.QStyle.CC_ComboBox, + option, + QtWidgets.QStyle.SC_ComboBoxEditField + ) + text = ", ".join(items) + new_text = self.fontMetrics().elidedText( + text, QtCore.Qt.ElideRight, text_rect.width() + ) + painter.drawText( + text_rect, + QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter, + new_text + ) + + def setItemCheckState(self, index, state): + self.setItemData(index, state, QtCore.Qt.CheckStateRole) + + def set_value(self, values): + for idx in range(self.count()): + value = self.itemData(idx, role=QtCore.Qt.UserRole) + if value in values: + check_state = QtCore.Qt.Checked + else: + check_state = QtCore.Qt.Unchecked + self.setItemData(idx, check_state, QtCore.Qt.CheckStateRole) + + def value(self): + items = list() + for idx in range(self.count()): + state = self.itemData(idx, role=QtCore.Qt.CheckStateRole) + if state == QtCore.Qt.Checked: + items.append( + self.itemData(idx, role=QtCore.Qt.UserRole) + ) + return items + + def checked_items_text(self): + items = list() + for idx in range(self.count()): + state = self.itemData(idx, role=QtCore.Qt.CheckStateRole) + if state == QtCore.Qt.Checked: + items.append(self.itemText(idx)) + return items + + def wheelEvent(self, event): + event.ignore() + + def keyPressEvent(self, event): + if ( + event.key() == QtCore.Qt.Key_Down + and event.modifiers() & QtCore.Qt.AltModifier + ): + return self.showPopup() + + if event.key() in self.ignored_keys: + return event.ignore() + + return super(MultiSelectionComboBox, self).keyPressEvent(event) From 948212c658475ab3d9a3d61e48cc85aac3c83a63 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 6 May 2021 14:57:34 +0200 Subject: [PATCH 056/303] implemented ToolsDelegate --- .../project_manager/delegates.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/openpype/tools/project_manager/project_manager/delegates.py b/openpype/tools/project_manager/project_manager/delegates.py index 22b8427dd1..67e462bf4a 100644 --- a/openpype/tools/project_manager/project_manager/delegates.py +++ b/openpype/tools/project_manager/project_manager/delegates.py @@ -1,5 +1,7 @@ from Qt import QtWidgets, QtCore +from .multiselection_combobox import MultiSelectionComboBox + class NumberDelegate(QtWidgets.QStyledItemDelegate): def __init__(self, minimum, maximum, decimals, *args, **kwargs): @@ -48,5 +50,20 @@ class TypeDelegate(QtWidgets.QStyledItemDelegate): task_type_defs = self._project_doc_cache.project_doc["config"]["tasks"] editor.addItems(list(task_type_defs.keys())) + return editor + + +class ToolsDelegate(QtWidgets.QStyledItemDelegate): + def __init__(self, tools_cache, *args, **kwargs): + self._tools_cache = tools_cache + super(ToolsDelegate, self).__init__(*args, **kwargs) + + def createEditor(self, parent, option, index): + editor = MultiSelectionComboBox(parent) + if not self._tools_cache.tools_data: + return editor + + for key, label in self._tools_cache.tools_data: + editor.addItem(label, key) return editor From e802fd240c5a72c7c4dd391afa6d2c65df84bd8a Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 6 May 2021 14:58:36 +0200 Subject: [PATCH 057/303] changed variable name --- .../tools/project_manager/project_manager/view.py | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index 88836aa969..2543d061ea 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -23,7 +23,7 @@ class TypeDef: pass -class ProjectHelper: +class ProjectDocCache: def __init__(self, dbcon): self.dbcon = dbcon self.project_doc = None @@ -73,7 +73,7 @@ class HierarchyView(QtWidgets.QTreeView): super(HierarchyView, self).__init__(*args, **kwargs) self._source_model = source_model - project_helper = ProjectHelper(dbcon) + project_doc_cache = ProjectDocCache(dbcon) main_delegate = QtWidgets.QStyledItemDelegate() self.setItemDelegate(main_delegate) @@ -101,14 +101,17 @@ class HierarchyView(QtWidgets.QTreeView): source_model.index_moved.connect(self._on_rows_moved) - self._project_helper = project_helper + self._project_doc_cache = project_doc_cache self._delegate = main_delegate self._column_delegates = column_delegates self._column_key_to_index = column_key_to_index def set_project(self, project_name): + # Trigger helpers first + self._project_doc_cache.set_project(project_name) + + # Trigger update of model after all data for delegates are filled self._source_model.set_project(project_name) - self._project_helper.set_project(project_name) def _on_rows_moved(self, index): parent_index = index.parent() From 09a92474f8fd1cc54c3d24cfc4b3f24f8710b302 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 6 May 2021 14:58:56 +0200 Subject: [PATCH 058/303] added tools delegate --- .../project_manager/project_manager/view.py | 39 +++++++++++++++++-- 1 file changed, 35 insertions(+), 4 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index 2543d061ea..82275a4557 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -3,9 +3,12 @@ from Qt import QtWidgets, QtCore from .delegates import ( NumberDelegate, StringDelegate, - TypeDelegate + TypeDelegate, + ToolsDelegate ) +from openpype.lib import ApplicationManager + class StringDef: def __init__(self, regex=None): @@ -23,6 +26,10 @@ class TypeDef: pass +class ToolsDef: + pass + + class ProjectDocCache: def __init__(self, dbcon): self.dbcon = dbcon @@ -39,6 +46,20 @@ class ProjectDocCache: ) +class ToolsCache: + def __init__(self): + self.tools_data = [] + + def refresh(self): + app_manager = ApplicationManager() + tools_data = [] + for tool_name, tool in app_manager.tools.items(): + tools_data.append( + (tool_name, tool.label) + ) + self.tools_data = tools_data + + class HierarchyView(QtWidgets.QTreeView): """A tree view that deselects on clicking on an empty area in the view""" column_delegate_defs = { @@ -54,7 +75,7 @@ class HierarchyView(QtWidgets.QTreeView): "clipIn": NumberDef(1), "clipOut": NumberDef(1), "pixelAspect": NumberDef(0, decimals=2), - # "tools_env": NumberDef(0) + "tools_env": ToolsDef() } persistent_columns = [ "frameStart", @@ -66,7 +87,8 @@ class HierarchyView(QtWidgets.QTreeView): "handleEnd", "clipIn", "clipOut", - "pixelAspect" + "pixelAspect", + "tools_env" ] def __init__(self, dbcon, source_model, *args, **kwargs): @@ -74,6 +96,7 @@ class HierarchyView(QtWidgets.QTreeView): self._source_model = source_model project_doc_cache = ProjectDocCache(dbcon) + tools_cache = ToolsCache() main_delegate = QtWidgets.QStyledItemDelegate() self.setItemDelegate(main_delegate) @@ -85,14 +108,19 @@ class HierarchyView(QtWidgets.QTreeView): for key, item_type in self.column_delegate_defs.items(): if isinstance(item_type, StringDef): delegate = StringDelegate() + elif isinstance(item_type, NumberDef): delegate = NumberDelegate( item_type.minimum, item_type.maximum, item_type.decimals ) + elif isinstance(item_type, TypeDef): - delegate = TypeDelegate(project_helper) + delegate = TypeDelegate(project_doc_cache) + + elif isinstance(item_type, ToolsDef): + delegate = ToolsDelegate(tools_cache) column = self._source_model.columns.index(key) self.setItemDelegateForColumn(column, delegate) @@ -102,6 +130,8 @@ class HierarchyView(QtWidgets.QTreeView): source_model.index_moved.connect(self._on_rows_moved) self._project_doc_cache = project_doc_cache + self._tools_cache = tools_cache + self._delegate = main_delegate self._column_delegates = column_delegates self._column_key_to_index = column_key_to_index @@ -109,6 +139,7 @@ class HierarchyView(QtWidgets.QTreeView): def set_project(self, project_name): # Trigger helpers first self._project_doc_cache.set_project(project_name) + self._tools_cache.refresh() # Trigger update of model after all data for delegates are filled self._source_model.set_project(project_name) From 4d737e47d00fd4e4a1ef68944d2b57384ad18d5d Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 6 May 2021 15:19:13 +0200 Subject: [PATCH 059/303] separated global data getter --- .../project_manager/project_manager/model.py | 29 +++++++++++++++---- 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index a6446b22bb..7907966951 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -720,6 +720,8 @@ class BaseItem: _name_icon = None _is_duplicated = False + _None = object() + def __init__(self, data=None): self._id = uuid4() self._children = list() @@ -750,18 +752,19 @@ class BaseItem: self._children.pop(idx) self._children.insert(row, item) - def data(self, key, role): + def _global_data(self, role): if role == IDENTIFIER_ROLE: return self._id if role == DUPLICATED_ROLE: return self._is_duplicated - if role == QtCore.Qt.ToolTipRole: - if self._is_duplicated: - return "Asset with name \"{}\" already exists.".format( - self._data["name"] - ) + return self._None + + def data(self, key, role): + value = self._global_data(role) + if value is not self._None: + return value if key not in self.columns: return None @@ -1009,6 +1012,13 @@ class AssetItem(BaseItem): cls._name_icon = qtawesome.icon("fa.folder", color="#333333") return cls._name_icon + def _global_data(self, role): + if role == QtCore.Qt.ToolTipRole and self._is_duplicated: + return "Asset with name \"{}\" already exists.".format( + self._data["name"] + ) + return super(AssetItem, self)._global_data(role) + class TaskItem(BaseItem): columns = { @@ -1028,3 +1038,10 @@ class TaskItem(BaseItem): def add_child(self, item, row=None): raise AssertionError("BUG: Can't add children to Task") + + def _global_data(self, role): + if role == QtCore.Qt.ToolTipRole and self._is_duplicated: + return "Duplicated Task name \"{}\".".format( + self._data["name"] + ) + return super(TaskItem, self)._global_data(role) From 1d0058dc6145deef13e2ba552093911dee1ef4b9 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 6 May 2021 16:09:14 +0200 Subject: [PATCH 060/303] fix asset name duplications --- openpype/tools/project_manager/project_manager/model.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 7907966951..e6344fc5dc 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -279,7 +279,6 @@ class HierarchyModel(QtCore.QAbstractItemModel): "type": "asset" } new_child = AssetItem(data) - self._asset_items_by_name[name].append(new_child) result = self.add_item(new_child, parent, new_row) @@ -322,6 +321,10 @@ class HierarchyModel(QtCore.QAbstractItemModel): parent.add_child(item, row) + if isinstance(item, AssetItem): + name = item.data("name", QtCore.Qt.DisplayRole) + self._asset_items_by_name[name].append(item) + if item.id not in self._items_by_id: self._items_by_id[item.id] = item @@ -387,6 +390,9 @@ class HierarchyModel(QtCore.QAbstractItemModel): return prev_name = asset_item.data("name", QtCore.Qt.DisplayRole) + if prev_name == new_name: + return + self._asset_items_by_name[prev_name].remove(asset_item) self._validate_asset_duplicity(prev_name) From b5bd9e57131271380e63243f852a1d3d51ce9494 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 6 May 2021 16:09:32 +0200 Subject: [PATCH 061/303] tools_env data can be changed --- .../project_manager/project_manager/delegates.py | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/delegates.py b/openpype/tools/project_manager/project_manager/delegates.py index 67e462bf4a..3d8451bde6 100644 --- a/openpype/tools/project_manager/project_manager/delegates.py +++ b/openpype/tools/project_manager/project_manager/delegates.py @@ -18,7 +18,7 @@ class NumberDelegate(QtWidgets.QStyledItemDelegate): editor.setMinimum(self.minimum) editor.setMaximum(self.maximum) - value = index.data(QtCore.Qt.DisplayRole) + value = index.data(QtCore.Qt.EditRole) if value is not None: editor.setValue(value) return editor @@ -31,7 +31,7 @@ class NumberDelegate(QtWidgets.QStyledItemDelegate): class StringDelegate(QtWidgets.QStyledItemDelegate): def createEditor(self, parent, option, index): editor = QtWidgets.QLineEdit(parent) - value = index.data(QtCore.Qt.DisplayRole) + value = index.data(QtCore.Qt.EditRole) if value is not None: editor.setText(str(value)) return editor @@ -67,3 +67,10 @@ class ToolsDelegate(QtWidgets.QStyledItemDelegate): editor.addItem(label, key) return editor + + def setEditorData(self, editor, index): + value = index.data(QtCore.Qt.EditRole) + editor.set_value(value) + + def setModelData(self, editor, model, index): + model.setData(index, editor.value(), QtCore.Qt.EditRole) From 9813597feb4ecf76e4405ea9e5ff8edba07eda84 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 6 May 2021 20:45:00 +0200 Subject: [PATCH 062/303] added base of save mechanism --- .../tools/project_manager/project_manager/model.py | 3 +++ .../project_manager/project_manager/window.py | 14 ++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index e6344fc5dc..db016b38c9 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -717,6 +717,9 @@ class HierarchyModel(QtCore.QAbstractItemModel): self._reset_root_item() self.endResetModel() + def save(self): + print("Saving (They said)") + class BaseItem: columns = [] diff --git a/openpype/tools/project_manager/project_manager/window.py b/openpype/tools/project_manager/project_manager/window.py index 790df4557e..0be69c29c8 100644 --- a/openpype/tools/project_manager/project_manager/window.py +++ b/openpype/tools/project_manager/project_manager/window.py @@ -38,6 +38,7 @@ class Window(QtWidgets.QWidget): hierarchy_view = HierarchyView(dbcon, hierarchy_model, self) hierarchy_view.setModel(hierarchy_model) + _selection_model = HierarchySelectionModel() _selection_model.setModel(hierarchy_view.model()) hierarchy_view.setSelectionModel(_selection_model) @@ -47,13 +48,23 @@ class Window(QtWidgets.QWidget): header.setSectionResizeMode( header.logicalIndex(0), QtWidgets.QHeaderView.Stretch ) + buttons_widget = QtWidgets.QWidget(self) + + save_btn = QtWidgets.QPushButton("Save", buttons_widget) + + buttons_layout = QtWidgets.QHBoxLayout(buttons_widget) + buttons_layout.setContentsMargins(0, 0, 0, 0) + buttons_layout.addStretch(1) + buttons_layout.addWidget(save_btn) main_layout = QtWidgets.QVBoxLayout(self) main_layout.addWidget(project_widget) main_layout.addWidget(hierarchy_view) + main_layout.addWidget(buttons_widget) refresh_projects_btn.clicked.connect(self._on_project_refresh) project_combobox.currentIndexChanged.connect(self._on_project_change) + save_btn.clicked.connect(self._on_save_click) self.project_model = project_model self.project_combobox = project_combobox @@ -90,3 +101,6 @@ class Window(QtWidgets.QWidget): def _on_project_refresh(self): self.refresh_projects() + + def _on_save_click(self): + self.hierarchy_model.save() From ce95ee27de1262d527e67c645af9f954932112c4 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 6 May 2021 20:49:01 +0200 Subject: [PATCH 063/303] implemented few necessary properties --- .../project_manager/project_manager/model.py | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index db016b38c9..d7abca47f0 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -924,9 +924,22 @@ class ProjectItem(BaseItem): } def __init__(self, project_doc): + self._mongo_id = project_doc["_id"] + data = self.data_from_doc(project_doc) super(ProjectItem, self).__init__(data) + @property + def project_id(self): + return self._mongo_id + + @property + def asset_id(self): + return None + + @property + def name(self): + return self._data["name"] @classmethod def data_from_doc(cls, project_doc): data = { @@ -997,9 +1010,25 @@ class AssetItem(BaseItem): } def __init__(self, asset_doc): + self.mongo_id = asset_doc.get("_id") + self._project_id = None + data = self.data_from_doc(asset_doc) super(AssetItem, self).__init__(data) + @property + def project_id(self): + if self._project_id is None: + self._project_id = self.parent().project_id + return self._project_id + + @property + def asset_id(self): + return self.mongo_id + + @property + def name(self): + return self._data["name"] @classmethod def data_from_doc(cls, asset_doc): data = { From c2e6c974b979f6c5d3992286a78a1a1044c975d2 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 6 May 2021 20:51:13 +0200 Subject: [PATCH 064/303] task can be converted to data --- openpype/tools/project_manager/project_manager/model.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index d7abca47f0..b346dbba07 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -1083,3 +1083,10 @@ class TaskItem(BaseItem): self._data["name"] ) return super(TaskItem, self)._global_data(role) + + def to_doc_data(self): + data = copy.deepcopy(self._data) + name = data.pop("name") + return { + name: data + } From df75e05e7d4dd835dbca2072d756f02afe4c7328 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 6 May 2021 20:59:41 +0200 Subject: [PATCH 065/303] added conversion of asset item to doc data --- .../project_manager/project_manager/model.py | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index b346dbba07..5ed09bc677 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -1029,6 +1029,35 @@ class AssetItem(BaseItem): @property def name(self): return self._data["name"] + + def to_doc(self): + tasks = {} + for item in self.children(): + if isinstance(item, TaskItem): + tasks.update(item.to_doc_data()) + + doc_data = { + "parents": self.parent().asset_parents(), + "visualParent": self.parent().asset_id, + "tasks": tasks + } + schema_name = ( + self._origin_asset_doc.get("schema") or "openpype:asset-3.0" + ) + + doc = { + "name": self._data["name"], + "type": self._data["type"], + "schema": schema_name, + "data": doc_data, + "parent": self.project_id + } + for key, value in self._data.items(): + if key in doc: + continue + doc_data[key] = value + + return doc @classmethod def data_from_doc(cls, asset_doc): data = { From 7d56929ffff8655a673dc35bd6332b13e6b616a2 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 6 May 2021 21:00:05 +0200 Subject: [PATCH 066/303] it is possible to get update changes for mongo from asset item --- .../project_manager/project_manager/model.py | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 5ed09bc677..9d26a1c96e 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -1058,6 +1058,42 @@ class AssetItem(BaseItem): doc_data[key] = value return doc + + def update_data(self): + if not self.mongo_id: + return {} + + document = self.to_doc() + + changes = {} + + for key, value in document.items(): + if key in ("data", "_id"): + continue + + if ( + key in self._origin_asset_doc + and self._origin_asset_doc[key] == value + ): + continue + + changes[key] = value + + if "data" not in self._origin_asset_doc: + changes["data"] = document["data"] + else: + origin_data = self._origin_asset_doc["data"] + + for key, value in document["data"].items(): + if key in origin_data and origin_data[key] == value: + continue + _key = "data.{}".format(key) + changes[_key] = value + + if changes: + return {"$set": changes} + return {} + @classmethod def data_from_doc(cls, asset_doc): data = { From feb64e776e4fff1cdf6ccc879dc94763b2a17b13 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 6 May 2021 21:03:28 +0200 Subject: [PATCH 067/303] added child_parents method --- .../tools/project_manager/project_manager/model.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 9d26a1c96e..cc52e67c47 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -940,6 +940,10 @@ class ProjectItem(BaseItem): @property def name(self): return self._data["name"] + + def child_parents(self): + return [] + @classmethod def data_from_doc(cls, project_doc): data = { @@ -1030,6 +1034,11 @@ class AssetItem(BaseItem): def name(self): return self._data["name"] + def child_parents(self): + parents = self.parent().child_parents() + parents.append(self.name) + return parents + def to_doc(self): tasks = {} for item in self.children(): @@ -1037,7 +1046,7 @@ class AssetItem(BaseItem): tasks.update(item.to_doc_data()) doc_data = { - "parents": self.parent().asset_parents(), + "parents": self.parent().child_parents(), "visualParent": self.parent().asset_id, "tasks": tasks } From e60aa9e80de3d03e69ead12e3c9f9a63b56f5f45 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 6 May 2021 21:08:01 +0200 Subject: [PATCH 068/303] modified how AssetItem is created --- .../project_manager/project_manager/model.py | 21 ++++++++++++------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index cc52e67c47..9690f52ed5 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -274,11 +274,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): parent = item.parent() new_row = item.row() + 1 - data = { - "name": name, - "type": "asset" - } - new_child = AssetItem(data) + new_child = AssetItem() result = self.add_item(new_child, parent, new_row) @@ -1013,10 +1009,14 @@ class AssetItem(BaseItem): "data.tools_env": 1 } - def __init__(self, asset_doc): + def __init__(self, asset_doc=None): + if not asset_doc: + asset_doc = {} self.mongo_id = asset_doc.get("_id") self._project_id = None + self._origin_asset_doc = copy.deepcopy(asset_doc) + data = self.data_from_doc(asset_doc) super(AssetItem, self).__init__(data) @@ -1106,9 +1106,14 @@ class AssetItem(BaseItem): @classmethod def data_from_doc(cls, asset_doc): data = { - "name": asset_doc["name"], - "type": asset_doc["type"] + "name": None, + "type": "asset" } + if asset_doc: + for key in data.keys(): + if key in asset_doc: + data[key] = asset_doc[key] + doc_data = asset_doc.get("data") or {} for key in cls.columns: if key in data: From dc76e91f41c3b87c96abc950e059d6864777d349 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 6 May 2021 21:08:32 +0200 Subject: [PATCH 069/303] add id to doc data --- openpype/tools/project_manager/project_manager/model.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 9690f52ed5..9eb7a7656c 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -1061,6 +1061,9 @@ class AssetItem(BaseItem): "data": doc_data, "parent": self.project_id } + if self.mongo_id: + doc["_id"] = self.mongo_id + for key, value in self._data.items(): if key in doc: continue From bc532fa6cb330907d1ee578c2efb306e157090e6 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 6 May 2021 21:10:15 +0200 Subject: [PATCH 070/303] AssetItem requires at least empty dictionary --- .../tools/project_manager/project_manager/model.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 9eb7a7656c..dd9bfdc561 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -266,15 +266,19 @@ class HierarchyModel(QtCore.QAbstractItemModel): item = self.items_by_id[item_id] new_row = None + name = None + asset_data = {} if isinstance(item, (RootItem, ProjectItem)): name = "ep" parent = item else: - name = source_index.data(QtCore.Qt.DisplayRole) parent = item.parent() new_row = item.row() + 1 - new_child = AssetItem() + if name: + asset_data["name"] = name + + new_child = AssetItem(asset_data) result = self.add_item(new_child, parent, new_row) @@ -1009,7 +1013,7 @@ class AssetItem(BaseItem): "data.tools_env": 1 } - def __init__(self, asset_doc=None): + def __init__(self, asset_doc): if not asset_doc: asset_doc = {} self.mongo_id = asset_doc.get("_id") From 1c1c11625aef236fe6250b3c1123159fecb43e15 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 6 May 2021 21:11:09 +0200 Subject: [PATCH 071/303] added schema to query projection --- openpype/tools/project_manager/project_manager/model.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index dd9bfdc561..12ad49086b 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -997,6 +997,7 @@ class AssetItem(BaseItem): "_id": 1, "data.tasks": 1, "data.visualParent": 1, + "schema": 1, "name": 1, "type": 1, From e124d084aa2795ac9e13c4feba2654b127d4c7c6 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 6 May 2021 21:11:20 +0200 Subject: [PATCH 072/303] implemented save method --- .../project_manager/project_manager/model.py | 48 ++++++++++++++++++- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 12ad49086b..83a8b2d7f8 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -7,7 +7,7 @@ from .constants import ( IDENTIFIER_ROLE, DUPLICATED_ROLE ) - +from pymongo import UpdateOne from avalon.vendor import qtawesome from Qt import QtCore, QtGui @@ -718,7 +718,51 @@ class HierarchyModel(QtCore.QAbstractItemModel): self.endResetModel() def save(self): - print("Saving (They said)") + project_item = None + for _project_item in self._root_item.children(): + project_item = _project_item + + if not project_item: + return + + project_name = project_item.name + project_col = self.dbcon.database[project_name] + + to_process = Queue() + to_process.put(project_item) + + update_list = [] + while not to_process.empty(): + parent = to_process.get() + insert_list = [] + for item in parent.children(): + if not isinstance(item, AssetItem): + continue + + to_process.put(item) + + if item.asset_id is None: + insert_list.append(item) + continue + + update_data = item.update_data() + if update_data: + update_list.append(UpdateOne( + {"_id": item.asset_id}, + update_data + )) + + if insert_list: + new_docs = [] + for item in insert_list: + new_docs.append(item.to_doc()) + + result = project_col.insert_many(new_docs) + for idx, mongo_id in enumerate(result.inserted_ids): + insert_list[idx].mongo_id = mongo_id + + if update_list: + project_col.bulk_write(update_list) class BaseItem: From 5eb439b3b32653b8f24bdb667e100b8cdf5285e4 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 7 May 2021 14:05:11 +0200 Subject: [PATCH 073/303] changed imports and main definition location --- openpype/tools/project_manager/__init__.py | 6 ++- openpype/tools/project_manager/__main__.py | 15 +------ .../project_manager/__init__.py | 40 +++++++++++++------ 3 files changed, 33 insertions(+), 28 deletions(-) diff --git a/openpype/tools/project_manager/__init__.py b/openpype/tools/project_manager/__init__.py index 7d8f8bf432..880fc253cf 100644 --- a/openpype/tools/project_manager/__init__.py +++ b/openpype/tools/project_manager/__init__.py @@ -1,6 +1,10 @@ -from .project_manager import Window +from .project_manager import ( + Window, + main +) __all__ = ( "Window", + "main" ) diff --git a/openpype/tools/project_manager/__main__.py b/openpype/tools/project_manager/__main__.py index 0855a0fc71..2e57af5f11 100644 --- a/openpype/tools/project_manager/__main__.py +++ b/openpype/tools/project_manager/__main__.py @@ -1,17 +1,4 @@ -import sys - -from project_manager import Window - -from Qt import QtWidgets - - -def main(): - app = QtWidgets.QApplication([]) - - window = Window() - window.show() - - sys.exit(app.exec_()) +from project_manager import main if __name__ == "__main__": diff --git a/openpype/tools/project_manager/project_manager/__init__.py b/openpype/tools/project_manager/project_manager/__init__.py index a652e950c4..dccc46f771 100644 --- a/openpype/tools/project_manager/project_manager/__init__.py +++ b/openpype/tools/project_manager/project_manager/__init__.py @@ -1,3 +1,23 @@ +__all__ = ( + "IDENTIFIER_ROLE", + + "HierarchyView", + + "ProjectModel", + + "HierarchyModel", + "HierarchySelectionModel", + "BaseItem", + "RootItem", + "ProjectItem", + "AssetItem", + "TaskItem", + + "Window", + "main" +) + + from .constants import ( IDENTIFIER_ROLE ) @@ -15,20 +35,14 @@ from .model import ( ) from .window import Window -__all__ = ( - "IDENTIFIER_ROLE", - "HierarchyView", +def main(): + import sys + from Qt import QtWidgets - "ProjectModel", + app = QtWidgets.QApplication([]) - "HierarchyModel", - "HierarchySelectionModel", - "BaseItem", - "RootItem", - "ProjectItem", - "AssetItem", - "TaskItem", + window = Window() + window.show() - "Window" -) + sys.exit(app.exec_()) From 70ed7abeb5d4201965570bbc88ee53e68587c4d2 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 7 May 2021 15:13:50 +0200 Subject: [PATCH 074/303] added projectmanager to openpype cli commands --- openpype/cli.py | 5 +++++ openpype/pype_commands.py | 6 ++++++ tools/run_project_manager.ps1 | 18 ++++++++++++++++++ 3 files changed, 29 insertions(+) create mode 100644 tools/run_project_manager.ps1 diff --git a/openpype/cli.py b/openpype/cli.py index 9c49825721..df38c74a21 100644 --- a/openpype/cli.py +++ b/openpype/cli.py @@ -224,6 +224,11 @@ def launch(app, project, asset, task, PypeCommands().run_application(app, project, asset, task, tools, arguments) +@main.command(context_settings={"ignore_unknown_options": True}) +def projectmanager(): + PypeCommands().launch_project_manager() + + @main.command( context_settings=dict( ignore_unknown_options=True, diff --git a/openpype/pype_commands.py b/openpype/pype_commands.py index 981cca82dc..326ca8349a 100644 --- a/openpype/pype_commands.py +++ b/openpype/pype_commands.py @@ -110,6 +110,12 @@ class PypeCommands: with open(output_json_path, "w") as file_stream: json.dump(env, file_stream, indent=4) + @staticmethod + def launch_project_manager(): + from openpype.tools import project_manager + + project_manager.main() + def texture_copy(self, project, asset, path): pass diff --git a/tools/run_project_manager.ps1 b/tools/run_project_manager.ps1 new file mode 100644 index 0000000000..78dce19df1 --- /dev/null +++ b/tools/run_project_manager.ps1 @@ -0,0 +1,18 @@ +<# +.SYNOPSIS + Helper script OpenPype Tray. + +.DESCRIPTION + + +.EXAMPLE + +PS> .\run_tray.ps1 + +#> +$current_dir = Get-Location +$script_dir = Split-Path -Path $MyInvocation.MyCommand.Definition -Parent +$openpype_root = (Get-Item $script_dir).parent.FullName +Set-Location -Path $openpype_root +& poetry run python "$($openpype_root)\start.py" projectmanager +Set-Location -Path $current_dir From f561e1a864f04d5d1389403fd0a0668d61c3b8b2 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 7 May 2021 15:14:05 +0200 Subject: [PATCH 075/303] added validation of number values in number delegate --- .../tools/project_manager/project_manager/delegates.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/openpype/tools/project_manager/project_manager/delegates.py b/openpype/tools/project_manager/project_manager/delegates.py index 3d8451bde6..88b366a7d0 100644 --- a/openpype/tools/project_manager/project_manager/delegates.py +++ b/openpype/tools/project_manager/project_manager/delegates.py @@ -20,7 +20,14 @@ class NumberDelegate(QtWidgets.QStyledItemDelegate): value = index.data(QtCore.Qt.EditRole) if value is not None: - editor.setValue(value) + try: + if isinstance(value, str): + value = float(value) + editor.setValue(value) + + except Exception: + print("Couldn't set invalid value \"{}\"".format(str(value))) + return editor # def updateEditorGeometry(self, editor, options, index): From 46dc52c4ea38ca0e9b85b927c06d5911e4c8e901 Mon Sep 17 00:00:00 2001 From: antirotor Date: Fri, 7 May 2021 16:07:56 +0000 Subject: [PATCH 076/303] Create draft PR for #1159 From 16f3d9c38987ac4b53c884d6550e9be7b04cef82 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 7 May 2021 18:34:17 +0200 Subject: [PATCH 077/303] create sane render default and validation wip --- .../maya/plugins/create/create_render.py | 93 +++++++++++++++++-- .../publish/validate_rendersettings.py | 39 ++++++-- 2 files changed, 117 insertions(+), 15 deletions(-) diff --git a/openpype/hosts/maya/plugins/create/create_render.py b/openpype/hosts/maya/plugins/create/create_render.py index 907f9cf781..cbca091365 100644 --- a/openpype/hosts/maya/plugins/create/create_render.py +++ b/openpype/hosts/maya/plugins/create/create_render.py @@ -12,7 +12,7 @@ from openpype.hosts.maya.api import ( lib, plugin ) -from openpype.api import get_system_settings +from openpype.api import (get_system_settings, get_asset) class CreateRender(plugin.Creator): @@ -104,7 +104,7 @@ class CreateRender(plugin.Creator): # namespace is not empty, so we leave it untouched pass - while(cmds.namespace(exists=namespace_name)): + while cmds.namespace(exists=namespace_name): namespace_name = "_{}{}".format(str(instance), index) index += 1 @@ -125,7 +125,7 @@ class CreateRender(plugin.Creator): cmds.sets(sets, forceElement=instance) # if no render layers are present, create default one with - # asterix selector + # asterisk selector if not layers: render_layer = self._rs.createRenderLayer('Main') collection = render_layer.createCollection("defaultCollection") @@ -137,9 +137,7 @@ class CreateRender(plugin.Creator): if renderer.startswith('renderman'): renderer = 'renderman' - cmds.setAttr(self._image_prefix_nodes[renderer], - self._image_prefixes[renderer], - type="string") + self._set_default_renderer_settings(renderer) def _create_render_settings(self): # get pools @@ -318,3 +316,86 @@ class CreateRender(plugin.Creator): False if os.getenv("OPENPYPE_DONT_VERIFY_SSL", True) else True ) # noqa return requests.get(*args, **kwargs) + + def _set_default_renderer_settings(self, renderer): + """Set basic settings based on renderer. + + Args: + renderer (str): Renderer name. + + """ + cmds.setAttr(self._image_prefix_nodes[renderer], + self._image_prefixes[renderer], + type="string") + + asset = get_asset() + + if renderer == "arnold": + # set format to exr + cmds.setAttr( + "defaultArnoldDriver.ai_translator", "exr", type="string") + # enable animation + cmds.setAttr("defaultRenderGlobals.outFormatControl", 0) + cmds.setAttr("defaultRenderGlobals.animation", 1) + cmds.setAttr("defaultRenderGlobals.putFrameBeforeExt", 1) + cmds.setAttr("defaultRenderGlobals.extensionPadding", 4) + + # resolution + cmds.setAttr( + "defaultResolution.width", + asset["data"].get("resolutionWidth")) + cmds.setAttr( + "defaultResolution.height", + asset["data"].get("resolutionHeight")) + + if renderer == "vray": + vray_settings = cmds.ls(type="VRaySettingsNode") + if not vray_settings: + node = cmds.createNode("VRaySettingsNode") + else: + node = vray_settings[0] + + # set underscore as element separator instead of default `.` + cmds.setAttr( + "{}.fileNameRenderElementSeparator".format( + node), + "_" + ) + # set format to exr + cmds.setAttr( + "{}.imageFormatStr".format(node), 5) + + # animType + cmds.setAttr( + "{}.animType".format(node), 1) + + # resolution + cmds.setAttr( + "{}.width".format(node), + asset["data"].get("resolutionWidth")) + cmds.setAttr( + "{}.height".format(node), + asset["data"].get("resolutionHeight")) + + if renderer == "redshift": + redshift_settings = cmds.ls(type="RedshiftOptions") + if not redshift_settings: + node = cmds.createNode("RedshiftOptions") + else: + node = redshift_settings[0] + + # set exr + cmds.setAttr("{}.imageFormat".format(node), 1) + # resolution + cmds.setAttr( + "defaultResolution.width", + asset["data"].get("resolutionWidth")) + cmds.setAttr( + "defaultResolution.height", + asset["data"].get("resolutionHeight")) + + # enable animation + cmds.setAttr("defaultRenderGlobals.outFormatControl", 0) + cmds.setAttr("defaultRenderGlobals.animation", 1) + cmds.setAttr("defaultRenderGlobals.putFrameBeforeExt", 1) + cmds.setAttr("defaultRenderGlobals.extensionPadding", 4) diff --git a/openpype/hosts/maya/plugins/publish/validate_rendersettings.py b/openpype/hosts/maya/plugins/publish/validate_rendersettings.py index ba676bee83..afaae9cd89 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rendersettings.py +++ b/openpype/hosts/maya/plugins/publish/validate_rendersettings.py @@ -1,8 +1,8 @@ -import os +# -*- coding: utf-8 -*- +"""Maya validator for render settings.""" import re from maya import cmds, mel -import pymel.core as pm import pyblish.api import openpype.api @@ -120,16 +120,24 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): "doesn't have: '' or " "'' token".format(prefix)) - if len(cameras) > 1: - if not re.search(cls.R_CAMERA_TOKEN, prefix): - invalid = True - cls.log.error("Wrong image prefix [ {} ] - " - "doesn't have: '' token".format(prefix)) + if len(cameras) > 1 and not re.search(cls.R_CAMERA_TOKEN, prefix): + invalid = True + cls.log.error("Wrong image prefix [ {} ] - " + "doesn't have: '' token".format(prefix)) # renderer specific checks if renderer == "vray": - # no vray checks implemented yet - pass + vray_settings = cmds.ls(type="VRaySettingsNode") + if not vray_settings: + node = cmds.createNode("VRaySettingsNode") + else: + node = vray_settings[0] + + if cmds.getAttr( + "{}.fileNameRenderElementSeparator".format(node)) != "_": + invalid = False + cls.log.error("AOV separator is not set correctly.") + elif renderer == "redshift": if re.search(cls.R_AOV_TOKEN, prefix): invalid = True @@ -210,3 +218,16 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): cmds.setAttr("rmanGlobals.imageOutputDir", cls.RendermanDirPrefix, type="string") + + if renderer == "vray": + vray_settings = cmds.ls(type="VRaySettingsNode") + if not vray_settings: + node = cmds.createNode("VRaySettingsNode") + else: + node = vray_settings[0] + + cmds.setAttr( + "{}.fileNameRenderElementSeparator".format( + node), + "_" + ) \ No newline at end of file From 46fd091aec409d41e87e5c710ac6c496f7c42c2b Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 10 May 2021 09:38:08 +0200 Subject: [PATCH 078/303] SyncServer - support for additional settings and local settings WIP --- .../providers/abstract_provider.py | 34 ++++++++++++++ .../modules/sync_server/providers/gdrive.py | 21 ++++++++- openpype/modules/sync_server/providers/lib.py | 4 +- .../sync_server/providers/local_drive.py | 15 ++++++ .../modules/sync_server/sync_server_module.py | 38 ++++++++++----- openpype/modules/sync_server/utils.py | 6 +++ openpype/settings/entities/__init__.py | 4 +- openpype/settings/entities/enum_entity.py | 46 +++++++++++++++++++ .../schemas/system_schema/schema_modules.json | 31 +++++++++++-- 9 files changed, 177 insertions(+), 22 deletions(-) diff --git a/openpype/modules/sync_server/providers/abstract_provider.py b/openpype/modules/sync_server/providers/abstract_provider.py index a60595ba93..7cd42fb4fa 100644 --- a/openpype/modules/sync_server/providers/abstract_provider.py +++ b/openpype/modules/sync_server/providers/abstract_provider.py @@ -7,6 +7,8 @@ log = Logger().get_logger("SyncServer") @six.add_metaclass(abc.ABCMeta) class AbstractProvider: + CODE = '' + LABEL = '' def __init__(self, project_name, site_name, tree=None, presets=None): self.presets = None @@ -25,6 +27,38 @@ class AbstractProvider: (boolean) """ + @abc.abstractmethod + def set_editable_properties(self): + """ + Sets dictionary of editable properties with scopes. + + Example: + { 'credentials_url': {'scopes': [utils.EditableScopes.SYSTEM], + 'type': 'text'}} + """ + + @abc.abstractmethod + def get_editable_properties(self, scopes): + """ + Returns filtered list of editable properties + + Args: + scopes (list) of utils.EditableScopes (optional - filter on) + + Returns: + (dict) + """ + if not scopes: + return self._editable_properties + + editable = {} + for scope in scopes: + for key, properties in self._editable_properties.items(): + if scope in properties['scope']: + editable[key] = properties + + return editable + @abc.abstractmethod def upload_file(self, source_path, path, server, collection, file, representation, site, diff --git a/openpype/modules/sync_server/providers/gdrive.py b/openpype/modules/sync_server/providers/gdrive.py index b67e5a6cfa..f2c18a04a9 100644 --- a/openpype/modules/sync_server/providers/gdrive.py +++ b/openpype/modules/sync_server/providers/gdrive.py @@ -7,7 +7,7 @@ from .abstract_provider import AbstractProvider from googleapiclient.http import MediaFileUpload, MediaIoBaseDownload from openpype.api import Logger from openpype.api import get_system_settings -from ..utils import time_function, ResumableError +from ..utils import time_function, ResumableError, EditableScopes import time @@ -42,9 +42,12 @@ class GDriveHandler(AbstractProvider): } } """ + CODE = 'gdrive' + LABEL = 'Google Drive' + FOLDER_STR = 'application/vnd.google-apps.folder' MY_DRIVE_STR = 'My Drive' # name of root folder of regular Google drive - CHUNK_SIZE = 2097152 # must be divisible by 256! + CHUNK_SIZE = 2097152 # must be divisible by 256! used for upload chunks def __init__(self, project_name, site_name, tree=None, presets=None): self.presets = None @@ -52,6 +55,8 @@ class GDriveHandler(AbstractProvider): self.project_name = project_name self.site_name = site_name + self._editable_properties = {} + self.presets = presets if not self.presets: log.info("Sync Server: There are no presets for {}.". @@ -73,6 +78,7 @@ class GDriveHandler(AbstractProvider): self._tree = tree self.active = True + self.set_editable_properties() def is_active(self): """ @@ -82,6 +88,17 @@ class GDriveHandler(AbstractProvider): """ return self.active + def set_editable_properties(self): + editable = { + 'credential_url': {'scope': [EditableScopes.PROJECT, + EditableScopes.LOCAL], + 'type': 'text'}, + + 'roots': {'scope': [EditableScopes.PROJECT], + 'type': 'dict'} + } + self._editable_properties = editable + def get_roots_config(self, anatomy=None): """ Returns root values for path resolving diff --git a/openpype/modules/sync_server/providers/lib.py b/openpype/modules/sync_server/providers/lib.py index 01a5d50ba5..f9c4309724 100644 --- a/openpype/modules/sync_server/providers/lib.py +++ b/openpype/modules/sync_server/providers/lib.py @@ -91,5 +91,5 @@ factory = ProviderFactory() # there is implementing 'GDriveHandler' class # 7 denotes number of files that could be synced in single loop - learned by # trial and error -factory.register_provider('gdrive', GDriveHandler, 7) -factory.register_provider('local_drive', LocalDriveHandler, 50) +factory.register_provider(GDriveHandler.CODE, GDriveHandler, 7) +factory.register_provider(LocalDriveHandler.CODE, LocalDriveHandler, 50) diff --git a/openpype/modules/sync_server/providers/local_drive.py b/openpype/modules/sync_server/providers/local_drive.py index 1f4fca80eb..2a96094f22 100644 --- a/openpype/modules/sync_server/providers/local_drive.py +++ b/openpype/modules/sync_server/providers/local_drive.py @@ -7,22 +7,37 @@ import time from openpype.api import Logger, Anatomy from .abstract_provider import AbstractProvider +from ..utils import EditableScopes + log = Logger().get_logger("SyncServer") class LocalDriveHandler(AbstractProvider): + CODE = 'local_drive' + LABEL = 'Local drive' + """ Handles required operations on mounted disks with OS """ def __init__(self, project_name, site_name, tree=None, presets=None): self.presets = None self.active = False self.project_name = project_name self.site_name = site_name + self._editable_properties = {} self.active = self.is_active() + self.set_editable_properties() def is_active(self): return True + def set_editable_properties(self): + editable = { + 'roots': {'scope': [EditableScopes.PROJECT, + EditableScopes.LOCAL], + 'type': 'dict'} + } + self._editable_properties = editable + def upload_file(self, source_path, target_path, server, collection, file, representation, site, overwrite=False, direction="Upload"): diff --git a/openpype/modules/sync_server/sync_server_module.py b/openpype/modules/sync_server/sync_server_module.py index b50bf19dca..e29861c20c 100644 --- a/openpype/modules/sync_server/sync_server_module.py +++ b/openpype/modules/sync_server/sync_server_module.py @@ -340,18 +340,6 @@ class SyncServerModule(PypeModule, ITrayModule): return self._get_enabled_sites_from_settings(sync_settings) - def get_configurable_items_for_site(self, project_name, site_name): - """ - Returns list of items that should be configurable by User - - Returns: - (list of dict) - [{key:"root", label:"root", value:"valueFromSettings"}] - """ - # if project_name is None: ..for get_default_project_settings - # return handler.get_configurable_items() - pass - def get_active_site(self, project_name): """ Returns active (mine) site for 'project_name' from settings @@ -402,6 +390,32 @@ class SyncServerModule(PypeModule, ITrayModule): return remote_site + def get_configurable_items(self): + pass + + def get_configurable_items_for_site(self, project_name, site_name): + """ + Returns list of items that should be configurable by User + + Returns: + (list of dict) + [{key:"root", label:"root", value:"valueFromSettings"}] + """ + # sites = set(self.get_active_sites(project_name), + # self.get_remote_sites(project_name)) + # for site in sites: + # if site_name + + def _get_configurable_items_for_project(self, project_name): + from .providers import lib + sites = set(self.get_active_sites(project_name), + self.get_remote_sites(project_name)) + editable = {} + for site in sites: + provider_name = self.get_provider_for_site(project_name, site) + + + def reset_timer(self): """ Called when waiting for next loop should be skipped. diff --git a/openpype/modules/sync_server/utils.py b/openpype/modules/sync_server/utils.py index fa6e63b029..d4fc29ff8a 100644 --- a/openpype/modules/sync_server/utils.py +++ b/openpype/modules/sync_server/utils.py @@ -33,3 +33,9 @@ def time_function(method): return result return timed + + +class EditableScopes: + SYSTEM = 0 + PROJECT = 1 + LOCAL = 2 diff --git a/openpype/settings/entities/__init__.py b/openpype/settings/entities/__init__.py index f76a915225..2c71b622ee 100644 --- a/openpype/settings/entities/__init__.py +++ b/openpype/settings/entities/__init__.py @@ -101,7 +101,8 @@ from .enum_entity import ( BaseEnumEntity, EnumEntity, AppsEnumEntity, - ToolsEnumEntity + ToolsEnumEntity, + ProvidersEnum ) from .list_entity import ListEntity @@ -149,6 +150,7 @@ __all__ = ( "EnumEntity", "AppsEnumEntity", "ToolsEnumEntity", + "ProvidersEnum", "ListEntity", diff --git a/openpype/settings/entities/enum_entity.py b/openpype/settings/entities/enum_entity.py index 693305cb1e..a5492cd727 100644 --- a/openpype/settings/entities/enum_entity.py +++ b/openpype/settings/entities/enum_entity.py @@ -217,3 +217,49 @@ class ToolsEnumEntity(BaseEnumEntity): if key in self.valid_keys: new_value.append(key) self._current_value = new_value + + +class ProvidersEnum(BaseEnumEntity): + schema_types = ["providers-enum"] + + def _item_initalization(self): + self.multiselection = False + self.value_on_not_set = "" + self.enum_items = [] + self.valid_keys = set() + self.valid_value_types = (str, ) + self.placeholder = None + + def _get_enum_values(self): + # from openpype.modules.sync_server.providers import lib as lib_providers + # + # providers = lib_providers.factory.providers + # + # valid_keys = set() + # enum_items = [] + # for provider_code, provider_info in providers.items(): + # provider, _ = provider_info + # enum_items.append({provider_code: provider.LABEL}) + # valid_keys.add(provider_code) + valid_keys = set() + enum_items = [] + if not valid_keys: + enum_items.append({'': 'N/A'}) + valid_keys.add('') + + return enum_items, valid_keys + + def set_override_state(self, *args, **kwargs): + super(ProvidersEnum, self).set_override_state(*args, **kwargs) + + self.enum_items, self.valid_keys = self._get_enum_values() + + value_on_not_set = list(self.valid_keys)[0] + if self._current_value is NOT_SET: + self._current_value = value_on_not_set + + self.value_on_not_set = value_on_not_set + + +# class ActiveSiteEnum +# class RemoteSiteEnum \ No newline at end of file diff --git a/openpype/settings/entities/schemas/system_schema/schema_modules.json b/openpype/settings/entities/schemas/system_schema/schema_modules.json index 878958b12d..d1b498bb86 100644 --- a/openpype/settings/entities/schemas/system_schema/schema_modules.json +++ b/openpype/settings/entities/schemas/system_schema/schema_modules.json @@ -85,11 +85,32 @@ "label": "Site Sync", "collapsible": true, "checkbox_key": "enabled", - "children": [{ - "type": "boolean", - "key": "enabled", - "label": "Enabled" - }] + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "dict-modifiable", + "collapsible": true, + "key": "sites", + "label": "Sites", + "collapsible_key": false, + "is_file": true, + "object_type": + { + "type": "dict", + "children": [ + { + "type": "providers-enum", + "key": "provider", + "label": "Provider" + } + ] + } + } + ] },{ "type": "dict", "key": "deadline", From 0a273b23049c7bef207c4b539c9ef4da1e7a3a41 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 May 2021 18:03:39 +0200 Subject: [PATCH 079/303] added _add_task to hierarchy view --- .../tools/project_manager/project_manager/view.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index 82275a4557..30a9ec6eb1 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -257,11 +257,16 @@ class HierarchyView(QtWidgets.QTreeView): self._source_model.remove_index(index) def _on_ctrl_shift_enter_pressed(self): - index = self.currentIndex() - if not index.isValid(): + self._add_task() + + def _add_task(self, parent_index=None): + if parent_index is None: + parent_index = self.currentIndex() + + if not parent_index.isValid(): return - new_index = self._source_model.add_new_task(index) + new_index = self._source_model.add_new_task(parent_index) if new_index is None: return From 96d8079a54431a83c9c4063fc7f0c7055debb01f Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 May 2021 18:03:57 +0200 Subject: [PATCH 080/303] type is edited as first thing on task creation --- openpype/tools/project_manager/project_manager/view.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index 30a9ec6eb1..66051fe030 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -274,10 +274,14 @@ class HierarchyView(QtWidgets.QTreeView): self.setState(HierarchyView.NoState) QtWidgets.QApplication.processEvents() + # TODO change hardcoded column index to coded + task_type_index = self._source_model.index( + new_index.row(), 1, new_index.parent() + ) # Change current index - self.setCurrentIndex(new_index) + self.setCurrentIndex(task_type_index) # Start editing - self.edit(new_index) + self.edit(task_type_index) def _on_shift_enter_pressed(self): index = self.currentIndex() From 1e79141b24d946fc9e0e42efde0275b0eb903777 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 May 2021 18:09:51 +0200 Subject: [PATCH 081/303] added type to persisten columns --- openpype/tools/project_manager/project_manager/view.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index 66051fe030..b2ca27e77c 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -78,6 +78,7 @@ class HierarchyView(QtWidgets.QTreeView): "tools_env": ToolsDef() } persistent_columns = [ + "type", "frameStart", "frameEnd", "fps", From acb7ea347a37f9a4510c0ad3160bb22497672121 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 May 2021 18:10:29 +0200 Subject: [PATCH 082/303] TaskItem does not have default values prefilled --- .../project_manager/project_manager/model.py | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 83a8b2d7f8..b70426640b 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -298,8 +298,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): if not isinstance(parent, AssetItem): return None - data = {"name": "task"} - new_child = TaskItem(data) + new_child = TaskItem() return self.add_item(new_child, parent) def add_items(self, items, parent=None, start_row=None): @@ -1199,6 +1198,11 @@ class TaskItem(BaseItem): "type" } + def __init__(self, data=None): + if data is None: + data = {} + super(TaskItem, self).__init__(data) + @classmethod def name_icon(cls): if cls._name_icon is None: @@ -1217,7 +1221,16 @@ class TaskItem(BaseItem): def to_doc_data(self): data = copy.deepcopy(self._data) - name = data.pop("name") + data.pop("name") + name = self.data("name", QtCore.Qt.DisplayRole) return { name: data } + + def data(self, key, role): + if ( + role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole) + and key == "name" + ): + return self._data[key] or self._data["type"] or "< Select Type >" + return super(TaskItem, self).data(key, role) From 048f856f6317496b23aec92c1da4de91e72ac068 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 May 2021 18:12:13 +0200 Subject: [PATCH 083/303] do not set keys that are not in editable keys --- openpype/tools/project_manager/project_manager/model.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index b70426640b..3f400fc8ee 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -844,12 +844,11 @@ class BaseItem: self._is_duplicated = value return True - if key not in self.columns: - return False - if role == QtCore.Qt.EditRole: - self._data[key] = value + if key not in self.editable_columns: + return False + self._data[key] = value # must return true if successful return True From 35e7cb7b46e18c41ac0543e1c3f749906fd1aedd Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 May 2021 18:14:02 +0200 Subject: [PATCH 084/303] defined new role defying if asset item is modifiable --- openpype/tools/project_manager/project_manager/constants.py | 1 + openpype/tools/project_manager/project_manager/model.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/tools/project_manager/project_manager/constants.py b/openpype/tools/project_manager/project_manager/constants.py index 61d1944979..49ae2f7883 100644 --- a/openpype/tools/project_manager/project_manager/constants.py +++ b/openpype/tools/project_manager/project_manager/constants.py @@ -3,3 +3,4 @@ from Qt import QtCore IDENTIFIER_ROLE = QtCore.Qt.UserRole + 1 DUPLICATED_ROLE = QtCore.Qt.UserRole + 2 +HIERARCHY_CHANGE_ABLE_ROLE = QtCore.Qt.UserRole + 3 diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 3f400fc8ee..c397906e71 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -5,7 +5,8 @@ from uuid import uuid4 from .constants import ( IDENTIFIER_ROLE, - DUPLICATED_ROLE + DUPLICATED_ROLE, + HIERARCHY_CHANGE_ABLE_ROLE ) from pymongo import UpdateOne from avalon.vendor import qtawesome From c9202e2c029044b0df60d4d7a238c5164ecb47a2 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 May 2021 18:14:48 +0200 Subject: [PATCH 085/303] asset item can handle new role defying if is editable --- .../project_manager/project_manager/model.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index c397906e71..6744d51113 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -1062,6 +1062,7 @@ class AssetItem(BaseItem): asset_doc = {} self.mongo_id = asset_doc.get("_id") self._project_id = None + self._hierarchy_changes_enabled = True self._origin_asset_doc = copy.deepcopy(asset_doc) @@ -1181,12 +1182,38 @@ class AssetItem(BaseItem): return cls._name_icon def _global_data(self, role): + if role == HIERARCHY_CHANGE_ABLE_ROLE: + return self._hierarchy_changes_enabled + if role == QtCore.Qt.ToolTipRole and self._is_duplicated: return "Asset with name \"{}\" already exists.".format( self._data["name"] ) return super(AssetItem, self)._global_data(role) + def setData(self, key, value, role): + if role == HIERARCHY_CHANGE_ABLE_ROLE: + if self._hierarchy_changes_enabled == value: + return False + self._hierarchy_changes_enabled = value + return True + + if ( + role == QtCore.Qt.EditRole + and key == "name" + and not self._hierarchy_changes_enabled + ): + return False + return super(AssetItem, self).setData(key, value, role) + + def flags(self, key): + if key == "name": + flags = QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable + if self._hierarchy_changes_enabled: + flags |= QtCore.Qt.ItemIsEditable + return flags + return super(AssetItem, self).flags(key) + class TaskItem(BaseItem): columns = { From 9a0203bd9e45ca6820030bb805a49186231c7e9d Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 May 2021 18:15:15 +0200 Subject: [PATCH 086/303] store information about loaded asset documents form database if can be modified --- .../project_manager/project_manager/model.py | 55 ++++++++++++++++++- 1 file changed, 54 insertions(+), 1 deletion(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 6744d51113..90a45b9f48 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -133,6 +133,36 @@ class HierarchyModel(QtCore.QAbstractItemModel): asset_doc["_id"]: asset_doc for asset_doc in asset_docs } + + # Prepare booleans if asset item can be modified (name or hierarchy) + # - the same must be applied to all it's parents + asset_ids = list(asset_docs_by_id.keys()) + result = [] + if asset_ids: + result = self.dbcon.database[project_name].aggregate([ + { + "$match": { + "type": "subset", + "parent": {"$in": asset_ids} + } + }, + { + "$group": { + "_id": "$parent", + "count": {"$sum": 1} + } + } + ]) + + asset_modifiable = { + asset_id: True + for asset_id in asset_docs_by_id.keys() + } + for item in result: + asset_id = item["_id"] + count = item["count"] + asset_modifiable[asset_id] = count < 1 + asset_docs_by_parent_id = collections.defaultdict(list) for asset_doc in asset_docs_by_id.values(): parent_id = asset_doc["data"]["visualParent"] @@ -142,7 +172,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): appending_queue.put((None, project_item)) asset_items_by_id = {} - + non_modifiable_items = set() while not appending_queue.empty(): parent_id, parent_item = appending_queue.get() if parent_id not in asset_docs_by_parent_id: @@ -157,12 +187,35 @@ class HierarchyModel(QtCore.QAbstractItemModel): # Store item by id for task processing asset_id = asset_doc["_id"] + if not asset_modifiable[asset_id]: + non_modifiable_items.add(new_item.id) + asset_items_by_id[asset_id] = new_item # Add item to appending queue appending_queue.put((asset_id, new_item)) self.add_items(new_items, parent_item) + # Handle Asset's that are not modifiable + # - pass the information to all it's parents + non_modifiable_queue = Queue() + for item_id in non_modifiable_items: + non_modifiable_queue.put(item_id) + + while not non_modifiable_queue.empty(): + item_id = non_modifiable_queue.get() + item = self._items_by_id[item_id] + item.setData(None, False, HIERARCHY_CHANGE_ABLE_ROLE) + + parent = item.parent() + if ( + isinstance(parent, AssetItem) + and parent.id not in non_modifiable_items + ): + non_modifiable_items.add(parent.id) + non_modifiable_queue.put(parent.id) + + # Add task items for asset_id, asset_item in asset_items_by_id.items(): asset_doc = asset_docs_by_id[asset_id] asset_tasks = asset_doc["data"]["tasks"] From 28f37046b141d7b13e9cc40a3ae9c9c99a0f5fca Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Mon, 10 May 2021 18:43:41 +0200 Subject: [PATCH 087/303] handle redshift formats and separator --- openpype/hosts/maya/api/expected_files.py | 15 ++--- .../publish/validate_rendersettings.py | 59 ++++++++++++++++--- 2 files changed, 54 insertions(+), 20 deletions(-) diff --git a/openpype/hosts/maya/api/expected_files.py b/openpype/hosts/maya/api/expected_files.py index 186b199796..c6232f6ca4 100644 --- a/openpype/hosts/maya/api/expected_files.py +++ b/openpype/hosts/maya/api/expected_files.py @@ -184,7 +184,7 @@ class AExpectedFiles: (str): sanitized camera name Example: - >>> sanizite_camera_name('test:camera_01') + >>> AExpectedFiles.sanizite_camera_name('test:camera_01') test_camera_01 """ @@ -230,7 +230,7 @@ class AExpectedFiles: if self.layer.startswith("rs_"): layer_name = self.layer[3:] - scene_data = { + return { "frameStart": int(self.get_render_attribute("startFrame")), "frameEnd": int(self.get_render_attribute("endFrame")), "frameStep": int(self.get_render_attribute("byFrameStep")), @@ -245,7 +245,6 @@ class AExpectedFiles: "filePrefix": file_prefix, "enabledAOVs": self.get_aovs(), } - return scene_data def _generate_single_file_sequence( self, layer_data, force_aov_name=None): @@ -685,8 +684,6 @@ class ExpectedFilesRedshift(AExpectedFiles): """Expected files for Redshift renderer. Attributes: - ext_mapping (list): Mapping redshift extension dropdown values - to strings. unmerged_aovs (list): Name of aovs that are not merged into resulting exr and we need them specified in expectedFiles output. @@ -695,8 +692,6 @@ class ExpectedFilesRedshift(AExpectedFiles): unmerged_aovs = ["Cryptomatte"] - ext_mapping = ["iff", "exr", "tif", "png", "tga", "jpg"] - def __init__(self, layer, render_instance): """Construtor.""" super(ExpectedFilesRedshift, self).__init__(layer, render_instance) @@ -785,12 +780,10 @@ class ExpectedFilesRedshift(AExpectedFiles): # anyway. return enabled_aovs - default_ext = self.ext_mapping[ - cmds.getAttr("redshiftOptions.imageFormat") - ] + default_ext = cmds.getAttr( + "redshiftOptions.imageFormat", asString=True) rs_aovs = cmds.ls(type="RedshiftAOV", referencedNodes=False) - # todo: find out how to detect multichannel exr for redshift for aov in rs_aovs: enabled = self.maya_is_true(cmds.getAttr("{}.enabled".format(aov))) for override in self.get_layer_overrides( diff --git a/openpype/hosts/maya/plugins/publish/validate_rendersettings.py b/openpype/hosts/maya/plugins/publish/validate_rendersettings.py index afaae9cd89..c2ed1eeaf0 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rendersettings.py +++ b/openpype/hosts/maya/plugins/publish/validate_rendersettings.py @@ -60,6 +60,8 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): 'renderman': '_..' } + redshift_AOV_prefix = "/_" + # WARNING: There is bug? in renderman, translating token # to something left behind mayas default image prefix. So instead # `SceneName_v01` it translates to: @@ -138,15 +140,41 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): invalid = False cls.log.error("AOV separator is not set correctly.") - elif renderer == "redshift": + if renderer == "redshift": if re.search(cls.R_AOV_TOKEN, prefix): invalid = True - cls.log.error("Do not use AOV token [ {} ] - " - "Redshift automatically append AOV name and " - "it doesn't make much sense with " - "Multipart EXR".format(prefix)) + cls.log.error(("Do not use AOV token [ {} ] - " + "Redshift is using image prefixes per AOV so " + "it doesn't make much sense using it in global" + "image prefix").format(prefix)) + # get redshift AOVs + rs_aovs = cmds.ls(type="RedshiftAOV", referencedNodes=False) + for aov in rs_aovs: + aov_prefix = cmds.getAttr("{}.filePrefix".format(aov)) + # check their image prefix + if aov_prefix != cls.redshift_AOV_prefix: + cls.log.error(("AOV ({}) image prefix is not set " + "correctly {} != {}").format( + cmds.getAttr("{}.name".format(aov)), + cmds.getAttr("{}.filePrefix".format(aov)), + aov_prefix + )) + invalid = True + # get aov format + aov_ext = cmds.getAttr( + "{}.fileFormat".format(aov), asString=True) - elif renderer == "renderman": + default_ext = cmds.getAttr( + "redshiftOptions.imageFormat", asString=True) + + if default_ext != aov_ext: + cls.log.error(("AOV file format is not the same " + "as the one set globally " + "{} != {}").format(default_ext, + aov_ext)) + invalid = True + + if renderer == "renderman": file_prefix = cmds.getAttr("rmanGlobals.imageFileFormat") dir_prefix = cmds.getAttr("rmanGlobals.imageOutputDir") @@ -159,7 +187,7 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): cls.log.error("Wrong directory prefix [ {} ]".format( dir_prefix)) - else: + if renderer == "arnold": multipart = cmds.getAttr("defaultArnoldDriver.mergeAOVs") if multipart: if re.search(cls.R_AOV_TOKEN, prefix): @@ -225,9 +253,22 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): node = cmds.createNode("VRaySettingsNode") else: node = vray_settings[0] - + cmds.setAttr( "{}.fileNameRenderElementSeparator".format( node), "_" - ) \ No newline at end of file + ) + + if renderer == "redshift": + # get redshift AOVs + rs_aovs = cmds.ls(type="RedshiftAOV", referencedNodes=False) + for aov in rs_aovs: + # fix AOV prefixes + cmds.setAttr( + "{}.filePrefix".format(aov), cls.redshift_AOV_prefix) + # fix AOV file format + default_ext = cmds.getAttr( + "redshiftOptions.imageFormat", asString=True) + cmds.setAttr( + "{}.fileFormat".format(aov), default_ext) From 6b940ae6bff1a1036deb4efef9a28ed1a8a6d313 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 May 2021 22:24:23 +0200 Subject: [PATCH 088/303] added few checks --- openpype/tools/project_manager/project_manager/view.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index b2ca27e77c..dee694a59c 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -253,8 +253,9 @@ class HierarchyView(QtWidgets.QTreeView): else: event.accept() - def _delete_item(self): - index = self.currentIndex() + def _delete_item(self, index=None): + if index is None: + index = self.currentIndex() self._source_model.remove_index(index) def _on_ctrl_shift_enter_pressed(self): @@ -294,6 +295,8 @@ class HierarchyView(QtWidgets.QTreeView): QtWidgets.QApplication.processEvents() new_index = self._source_model.add_new_asset(index) + if new_index is None: + return # Change current index self.setCurrentIndex(new_index) From 41f21bc0669394b22c2d1105ec7f2e546e8707f1 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 May 2021 22:24:33 +0200 Subject: [PATCH 089/303] added REMOVED_ROLE --- openpype/tools/project_manager/project_manager/constants.py | 1 + openpype/tools/project_manager/project_manager/model.py | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/tools/project_manager/project_manager/constants.py b/openpype/tools/project_manager/project_manager/constants.py index 49ae2f7883..7f9a859ac1 100644 --- a/openpype/tools/project_manager/project_manager/constants.py +++ b/openpype/tools/project_manager/project_manager/constants.py @@ -4,3 +4,4 @@ from Qt import QtCore IDENTIFIER_ROLE = QtCore.Qt.UserRole + 1 DUPLICATED_ROLE = QtCore.Qt.UserRole + 2 HIERARCHY_CHANGE_ABLE_ROLE = QtCore.Qt.UserRole + 3 +REMOVED_ROLE = QtCore.Qt.UserRole + 4 diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 90a45b9f48..c06be9e219 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -6,7 +6,8 @@ from uuid import uuid4 from .constants import ( IDENTIFIER_ROLE, DUPLICATED_ROLE, - HIERARCHY_CHANGE_ABLE_ROLE + HIERARCHY_CHANGE_ABLE_ROLE, + REMOVED_ROLE ) from pymongo import UpdateOne from avalon.vendor import qtawesome From b8775392f190c4958a06ca6eec7a7da80f7d9fb5 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 May 2021 22:26:13 +0200 Subject: [PATCH 090/303] minor changes and fixes --- .../tools/project_manager/project_manager/model.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index c06be9e219..750cb52661 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -91,6 +91,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): def __init__(self, dbcon, parent=None): super(HierarchyModel, self).__init__(parent) + # TODO Reset them on project change self._current_project = None self._root_item = None self._items_by_id = {} @@ -275,7 +276,9 @@ class HierarchyModel(QtCore.QAbstractItemModel): if section < self.columnCount(): return self.column_labels[section] - super(HierarchyModel, self).headerData(section, orientation, role) + return super(HierarchyModel, self).headerData( + section, orientation, role + ) def flags(self, index): item = index.internalPointer() @@ -283,7 +286,10 @@ class HierarchyModel(QtCore.QAbstractItemModel): key = self.columns[column] return item.flags(key) - def parent(self, index): + def parent(self, index=None): + if not index.isValid(): + return QtCore.QModelIndex() + item = index.internalPointer() parent_item = item.parent() From 72508c738f151a251f5b9816c8d1dd439d045675 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 May 2021 22:26:43 +0200 Subject: [PATCH 091/303] added validations on add item which may not be successfull --- openpype/tools/project_manager/project_manager/model.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 750cb52661..05e7b1831b 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -342,8 +342,8 @@ class HierarchyModel(QtCore.QAbstractItemModel): new_child = AssetItem(asset_data) result = self.add_item(new_child, parent, new_row) - - self._validate_asset_duplicity(name) + if result is not None: + self._validate_asset_duplicity(name) return result @@ -398,7 +398,10 @@ class HierarchyModel(QtCore.QAbstractItemModel): return indexes def add_item(self, item, parent=None, row=None): - return self.add_items([item], parent, row)[0] + result = self.add_items([item], parent, row) + if result: + return result[0] + return None def remove_index(self, index): if not index.isValid(): From 0a764aca055c22cfb176d4b3881e1cd1b7585427 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 May 2021 22:27:22 +0200 Subject: [PATCH 092/303] items have is_new properties --- .../tools/project_manager/project_manager/model.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 05e7b1831b..ae47fc3168 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -922,6 +922,10 @@ class BaseItem: def id(self): return self._id + @property + def is_new(self): + return False + def rowCount(self): return len(self._children) @@ -1142,6 +1146,10 @@ class AssetItem(BaseItem): def asset_id(self): return self.mongo_id + @property + def is_new(self): + return self.asset_id is None + @property def name(self): return self._data["name"] @@ -1293,6 +1301,10 @@ class TaskItem(BaseItem): data = {} super(TaskItem, self).__init__(data) + @property + def is_new(self): + return self._origin_data is None + @classmethod def name_icon(cls): if cls._name_icon is None: From 146a99bd8658849bc8e0bdb81655ce15b701fda0 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 May 2021 22:29:05 +0200 Subject: [PATCH 093/303] items can be tagged as removed --- .../project_manager/project_manager/model.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index ae47fc3168..c060a49037 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -1130,6 +1130,7 @@ class AssetItem(BaseItem): self.mongo_id = asset_doc.get("_id") self._project_id = None self._hierarchy_changes_enabled = True + self._removed = False self._origin_asset_doc = copy.deepcopy(asset_doc) @@ -1256,6 +1257,9 @@ class AssetItem(BaseItem): if role == HIERARCHY_CHANGE_ABLE_ROLE: return self._hierarchy_changes_enabled + if role == REMOVED_ROLE: + return self._removed + if role == QtCore.Qt.ToolTipRole and self._is_duplicated: return "Asset with name \"{}\" already exists.".format( self._data["name"] @@ -1263,6 +1267,10 @@ class AssetItem(BaseItem): return super(AssetItem, self)._global_data(role) def setData(self, key, value, role): + if role == REMOVED_ROLE: + self._removed = value + return True + if role == HIERARCHY_CHANGE_ABLE_ROLE: if self._hierarchy_changes_enabled == value: return False @@ -1297,6 +1305,7 @@ class TaskItem(BaseItem): } def __init__(self, data=None): + self._removed = False if data is None: data = {} super(TaskItem, self).__init__(data) @@ -1315,6 +1324,9 @@ class TaskItem(BaseItem): raise AssertionError("BUG: Can't add children to Task") def _global_data(self, role): + if role == REMOVED_ROLE: + return self._removed + if role == QtCore.Qt.ToolTipRole and self._is_duplicated: return "Duplicated Task name \"{}\".".format( self._data["name"] @@ -1336,3 +1348,9 @@ class TaskItem(BaseItem): ): return self._data[key] or self._data["type"] or "< Select Type >" return super(TaskItem, self).data(key, role) + + def setData(self, key, value, role): + if role == REMOVED_ROLE: + self._removed = value + return True + return super(TaskItem, self).setData(key, value, role) From 656c0f87c910ea77d2e7671d563540d57488d500 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 May 2021 22:29:25 +0200 Subject: [PATCH 094/303] set red background on removed items --- openpype/tools/project_manager/project_manager/model.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index c060a49037..d8dfe5287e 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -1266,6 +1266,12 @@ class AssetItem(BaseItem): ) return super(AssetItem, self)._global_data(role) + def data(self, key, role): + if self._removed and role == QtCore.Qt.BackgroundRole: + return QtGui.QColor(255, 0, 0, 127) + + return super(AssetItem, self).data(key, role) + def setData(self, key, value, role): if role == REMOVED_ROLE: self._removed = value @@ -1342,6 +1348,9 @@ class TaskItem(BaseItem): } def data(self, key, role): + if self._removed and role == QtCore.Qt.BackgroundRole: + return QtGui.QColor(255, 0, 0, 127) + if ( role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole) and key == "name" From 15c5361733b780dd420ed17900bcff2c907ec0aa Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 May 2021 22:30:04 +0200 Subject: [PATCH 095/303] use is_new attribute --- openpype/tools/project_manager/project_manager/model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index d8dfe5287e..aa429d9fbe 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -804,7 +804,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): to_process.put(item) - if item.asset_id is None: + if item.is_new: insert_list.append(item) continue From 7d225a2ab461b5b5bbd7cfce911bdc96df255384 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 May 2021 22:33:31 +0200 Subject: [PATCH 096/303] added default value for removed role --- openpype/tools/project_manager/project_manager/model.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index aa429d9fbe..0c999974f6 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -875,6 +875,9 @@ class BaseItem: if role == DUPLICATED_ROLE: return self._is_duplicated + if role == REMOVED_ROLE: + return False + return self._None def data(self, key, role): From 84887088f908d05e94c78a0fe1d11e9740ecd2b9 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 May 2021 22:33:48 +0200 Subject: [PATCH 097/303] don't allow adding items under removed items --- openpype/tools/project_manager/project_manager/model.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 0c999974f6..ea3fd85496 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -366,6 +366,9 @@ class HierarchyModel(QtCore.QAbstractItemModel): if parent is None: parent = self._root_item + if parent.data(None, REMOVED_ROLE): + return [] + if start_row is None: start_row = parent.rowCount() From c84e37c4bb7c009bd22c9876a5dc9791ba153d31 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 May 2021 22:34:05 +0200 Subject: [PATCH 098/303] store origin data of task item --- openpype/tools/project_manager/project_manager/model.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index ea3fd85496..20cce4fd24 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -1317,6 +1317,7 @@ class TaskItem(BaseItem): } def __init__(self, data=None): + self._origin_data = copy.deepcopy(data) self._removed = False if data is None: data = {} From 335c1073dffd6374434abfeacfaef73fab705d84 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 May 2021 22:34:27 +0200 Subject: [PATCH 099/303] modified delete index method --- .../project_manager/project_manager/model.py | 94 +++++++++++++++---- 1 file changed, 75 insertions(+), 19 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 20cce4fd24..cdc46b4383 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -410,27 +410,38 @@ class HierarchyModel(QtCore.QAbstractItemModel): if not index.isValid(): return - item_id = index.data(IDENTIFIER_ROLE) - item = self._items_by_id[item_id] + item = index.internalPointer() + if isinstance(item, (RootItem, ProjectItem)): return parent = item.parent() + all_descendants = collections.defaultdict(dict) all_descendants[parent.id][item.id] = item - row = item.row() - self.beginRemoveRows(self.index_for_item(parent), row, row) + def _fill_children(_all_descendants, cur_item, parent_item=None): + if parent_item is not None: + _all_descendants[parent_item.id][cur_item.id] = cur_item - todo_queue = Queue() - todo_queue.put(item) - while not todo_queue.empty(): - _item = todo_queue.get() - for row in range(_item.rowCount()): - child_item = _item.child(row) - all_descendants[_item.id][child_item.id] = child_item - todo_queue.put(child_item) + if isinstance(cur_item, TaskItem): + task_removed = True + cur_item.setData(None, task_removed, REMOVED_ROLE) + return task_removed + remove_item = cur_item.data(None, HIERARCHY_CHANGE_ABLE_ROLE) + for row in range(cur_item.rowCount()): + child_item = cur_item.child(row) + if not _fill_children(_all_descendants, child_item, cur_item): + remove_item = False + + if remove_item: + cur_item.setData(None, True, REMOVED_ROLE) + return remove_item + + _fill_children(all_descendants, item) + + modified_children = [] while all_descendants: for parent_id in tuple(all_descendants.keys()): children = all_descendants[parent_id] @@ -438,18 +449,63 @@ class HierarchyModel(QtCore.QAbstractItemModel): all_descendants.pop(parent_id) continue + parent_children = {} + all_without_children = True for child_id in tuple(children.keys()): - child_item = children[child_id] if child_id in all_descendants: + all_without_children = False + break + parent_children[child_id] = children[child_id] + + if not all_without_children: + continue + + parent_item = self._items_by_id[parent_id] + row_ranges = [] + start_row = end_row = None + chilren_by_row = {} + for row in range(parent_item.rowCount()): + child_item = parent_item.child(row) + child_id = child_item.id + if child_id not in children: continue - if isinstance(child_item, AssetItem): - self._rename_asset(child_item, None) - children.pop(child_id) - child_item.set_parent(None) - self._items_by_id.pop(child_id) + chilren_by_row[row] = child_item + children.pop(child_item.id) - self.endRemoveRows() + remove_item = child_item.data(None, REMOVED_ROLE) + if not remove_item or not child_item.is_new: + modified_children.append(child_item) + if end_row is not None: + row_ranges.append((start_row, end_row)) + start_row = end_row = None + continue + + end_row = row + if start_row is None: + start_row = row + + if end_row is not None: + row_ranges.append((start_row, end_row)) + + parent_index = None + for start, end in row_ranges: + if parent_index is None: + parent_index = self.index_for_item(parent_item) + + self.beginRemoveRows(parent_index, start, end) + + for idx in range(start, end + 1): + child_item = chilren_by_row[idx] + child_item.set_parent(None) + self._items_by_id.pop(child_item.id) + + self.endRemoveRows() + + for item in modified_children: + s_index = self.index_for_item(item) + e_index = self.index_for_item(item, column=self.columns_len - 1) + self.dataChanged.emit(s_index, e_index, [QtCore.Qt.BackgroundRole]) def _rename_asset(self, asset_item, new_name): if not isinstance(asset_item, AssetItem): From a000c51dd4a882b3a6e44b7c277c98d78a688693 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 May 2021 22:36:44 +0200 Subject: [PATCH 100/303] force name validation on remove --- openpype/tools/project_manager/project_manager/model.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index cdc46b4383..986bc7f38a 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -497,6 +497,9 @@ class HierarchyModel(QtCore.QAbstractItemModel): for idx in range(start, end + 1): child_item = chilren_by_row[idx] + # Force name validation + if isinstance(child_item, AssetItem): + self._rename_asset(child_item, None) child_item.set_parent(None) self._items_by_id.pop(child_item.id) From 7b31f7f00088f2be105f70a6a8415dbfa34c0c92 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Mon, 10 May 2021 22:37:34 +0200 Subject: [PATCH 101/303] add render settings to settings :grin: --- .../schemas/schema_maya_publish.json | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json index 95b02a7936..b737dcda70 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json @@ -72,6 +72,52 @@ } ] }, + + { + "type": "dict", + "collapsible": true, + "key": "ValidateRenderSettings", + "label": "ValidateRenderSettings", + "children": [ + { + "type": "dict-modifiable", + "key": "arnold_render_attributes", + "label": "Arnold Render Attributes", + "use_label_wrap": true, + "object_type": { + "type": "text" + } + }, + { + "type": "dict-modifiable", + "key": "vray_render_attributes", + "label": "Vray Render Attributes", + "use_label_wrap": true, + "object_type": { + "type": "text" + } + }, + { + "type": "dict-modifiable", + "key": "redshift_render_attributes", + "label": "Redshift Render Attributes", + "use_label_wrap": true, + "object_type": { + "type": "text" + } + }, + { + "type": "dict-modifiable", + "key": "renderman_render_attributes", + "label": "Renderman Render Attributes", + "use_label_wrap": true, + "object_type": { + "type": "text" + } + } + ] + }, + { "type": "collapsible-wrap", "label": "Model", From b27116102bd609eab54607f0ab96f3d2381b4830 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 10 May 2021 22:38:28 +0200 Subject: [PATCH 102/303] added bg color for new items --- .../project_manager/project_manager/model.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 986bc7f38a..baea086d87 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -1332,8 +1332,11 @@ class AssetItem(BaseItem): return super(AssetItem, self)._global_data(role) def data(self, key, role): - if self._removed and role == QtCore.Qt.BackgroundRole: - return QtGui.QColor(255, 0, 0, 127) + if role == QtCore.Qt.BackgroundRole: + if self._removed: + return QtGui.QColor(255, 0, 0, 127) + elif self.is_new: + return QtGui.QColor(0, 255, 0, 127) return super(AssetItem, self).data(key, role) @@ -1414,8 +1417,12 @@ class TaskItem(BaseItem): } def data(self, key, role): - if self._removed and role == QtCore.Qt.BackgroundRole: - return QtGui.QColor(255, 0, 0, 127) + if role == QtCore.Qt.BackgroundRole: + if self._removed: + return QtGui.QColor(255, 0, 0, 127) + + elif self.is_new: + return QtGui.QColor(0, 255, 0, 127) if ( role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole) From d76fae0096aeada27b2eb46dec21b1cf1376780e Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 11 May 2021 09:29:33 +0200 Subject: [PATCH 103/303] added filtrable combobox widget --- .../project_manager/widgets.py | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 openpype/tools/project_manager/project_manager/widgets.py diff --git a/openpype/tools/project_manager/project_manager/widgets.py b/openpype/tools/project_manager/project_manager/widgets.py new file mode 100644 index 0000000000..03f43fa489 --- /dev/null +++ b/openpype/tools/project_manager/project_manager/widgets.py @@ -0,0 +1,46 @@ +from Qt import QtWidgets, QtCore + + +class FilterComboBox(QtWidgets.QComboBox): + def __init__(self, parent=None): + super(FilterComboBox, self).__init__(parent) + + self.setFocusPolicy(QtCore.Qt.StrongFocus) + self.setEditable(True) + + filter_proxy_model = QtCore.QSortFilterProxyModel(self) + filter_proxy_model.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive) + filter_proxy_model.setSourceModel(self.model()) + + completer = QtWidgets.QCompleter(filter_proxy_model, self) + completer.setCompletionMode( + QtWidgets.QCompleter.UnfilteredPopupCompletion + ) + self.setCompleter(completer) + + self.lineEdit().textEdited.connect( + filter_proxy_model.setFilterFixedString + ) + completer.activated.connect(self.on_completer_activated) + + self._completer = completer + self._filter_proxy_model = filter_proxy_model + + def focusInEvent(self, event): + super(FilterComboBox, self).focusInEvent(event) + self.lineEdit().selectAll() + + def on_completer_activated(self, text): + if text: + index = self.findText(text) + self.setCurrentIndex(index) + + def setModel(self, model): + super(FilterComboBox, self).setModel(model) + self._filter_proxy_model.setSourceModel(model) + self._completer.setModel(self._filter_proxy_model) + + def setModelColumn(self, column): + self._completer.setCompletionColumn(column) + self._filter_proxy_model.setFilterKeyColumn(column) + super(FilterComboBox, self).setModelColumn(column) From 342fd59eb28a34e0c75a4d87fd6c1da00c8f4964 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 11 May 2021 09:30:29 +0200 Subject: [PATCH 104/303] use filter combobox for type editor --- .../tools/project_manager/project_manager/delegates.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/openpype/tools/project_manager/project_manager/delegates.py b/openpype/tools/project_manager/project_manager/delegates.py index 88b366a7d0..c00cd37956 100644 --- a/openpype/tools/project_manager/project_manager/delegates.py +++ b/openpype/tools/project_manager/project_manager/delegates.py @@ -1,5 +1,6 @@ from Qt import QtWidgets, QtCore +from .widgets import FilterComboBox from .multiselection_combobox import MultiSelectionComboBox @@ -50,7 +51,7 @@ class TypeDelegate(QtWidgets.QStyledItemDelegate): super(TypeDelegate, self).__init__(*args, **kwargs) def createEditor(self, parent, option, index): - editor = QtWidgets.QComboBox(parent) + editor = FilterComboBox(parent) if not self._project_doc_cache.project_doc: return editor @@ -59,6 +60,12 @@ class TypeDelegate(QtWidgets.QStyledItemDelegate): return editor + def setEditorData(self, editor, index): + value = index.data(QtCore.Qt.EditRole) + index = editor.findText(value) + if index >= 0: + editor.setCurrentIndex(index) + class ToolsDelegate(QtWidgets.QStyledItemDelegate): def __init__(self, tools_cache, *args, **kwargs): From 6b8002f4a0d354b01d6c6459c9e9ff3bb973a38a Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 11 May 2021 09:45:12 +0200 Subject: [PATCH 105/303] Text input may have set text regex --- .../project_manager/project_manager/delegates.py | 14 ++++++++++++-- .../tools/project_manager/project_manager/view.py | 2 +- .../project_manager/project_manager/widgets.py | 11 +++++++++++ 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/delegates.py b/openpype/tools/project_manager/project_manager/delegates.py index c00cd37956..bfd6cfaabb 100644 --- a/openpype/tools/project_manager/project_manager/delegates.py +++ b/openpype/tools/project_manager/project_manager/delegates.py @@ -1,6 +1,9 @@ from Qt import QtWidgets, QtCore -from .widgets import FilterComboBox +from .widgets import ( + RegexTextEdit, + FilterComboBox +) from .multiselection_combobox import MultiSelectionComboBox @@ -37,8 +40,15 @@ class NumberDelegate(QtWidgets.QStyledItemDelegate): class StringDelegate(QtWidgets.QStyledItemDelegate): + def __init__(self, regex, *args, **kwargs): + super(StringDelegate, self).__init__(*args, **kwargs) + self._regex = regex + def createEditor(self, parent, option, index): - editor = QtWidgets.QLineEdit(parent) + if self._regex: + editor = RegexTextEdit(self._regex, parent) + else: + editor = QtWidgets.QLineEdit(parent) value = index.data(QtCore.Qt.EditRole) if value is not None: editor.setText(str(value)) diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index dee694a59c..be7513de25 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -108,7 +108,7 @@ class HierarchyView(QtWidgets.QTreeView): column_key_to_index = {} for key, item_type in self.column_delegate_defs.items(): if isinstance(item_type, StringDef): - delegate = StringDelegate() + delegate = StringDelegate(item_type.regex) elif isinstance(item_type, NumberDef): delegate = NumberDelegate( diff --git a/openpype/tools/project_manager/project_manager/widgets.py b/openpype/tools/project_manager/project_manager/widgets.py index 03f43fa489..9ef5dfaf85 100644 --- a/openpype/tools/project_manager/project_manager/widgets.py +++ b/openpype/tools/project_manager/project_manager/widgets.py @@ -1,6 +1,17 @@ from Qt import QtWidgets, QtCore +class RegexTextEdit(QtWidgets.QLineEdit): + def __init__(self, regex, *args, **kwargs): + super(RegexTextEdit, self).__init__(*args, **kwargs) + self._regex = regex + + self.textChanged.connect(self._on_text_change) + + def _on_text_change(self, text): + print(text) + + class FilterComboBox(QtWidgets.QComboBox): def __init__(self, parent=None): super(FilterComboBox, self).__init__(parent) From 586f8faa1047b176009c2c4b2f29b012f2a73de3 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 11 May 2021 10:08:14 +0200 Subject: [PATCH 106/303] changed string delegate to name delegate --- .../project_manager/project_manager/constants.py | 4 ++++ .../project_manager/project_manager/delegates.py | 13 +++---------- .../tools/project_manager/project_manager/view.py | 13 ++++++------- .../project_manager/project_manager/widgets.py | 12 ++++++++---- 4 files changed, 21 insertions(+), 21 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/constants.py b/openpype/tools/project_manager/project_manager/constants.py index 7f9a859ac1..76d3d3cda1 100644 --- a/openpype/tools/project_manager/project_manager/constants.py +++ b/openpype/tools/project_manager/project_manager/constants.py @@ -1,3 +1,4 @@ +import re from Qt import QtCore @@ -5,3 +6,6 @@ IDENTIFIER_ROLE = QtCore.Qt.UserRole + 1 DUPLICATED_ROLE = QtCore.Qt.UserRole + 2 HIERARCHY_CHANGE_ABLE_ROLE = QtCore.Qt.UserRole + 3 REMOVED_ROLE = QtCore.Qt.UserRole + 4 + +NAME_ALLOWED_SYMBOLS = "a-zA-Z0-9_" +NAME_REGEX = re.compile("^[" + NAME_ALLOWED_SYMBOLS + "]*$") diff --git a/openpype/tools/project_manager/project_manager/delegates.py b/openpype/tools/project_manager/project_manager/delegates.py index bfd6cfaabb..18e3e2d81b 100644 --- a/openpype/tools/project_manager/project_manager/delegates.py +++ b/openpype/tools/project_manager/project_manager/delegates.py @@ -1,7 +1,7 @@ from Qt import QtWidgets, QtCore from .widgets import ( - RegexTextEdit, + NameTextEdit, FilterComboBox ) from .multiselection_combobox import MultiSelectionComboBox @@ -39,16 +39,9 @@ class NumberDelegate(QtWidgets.QStyledItemDelegate): # return super().updateEditorGeometry(editor, options, index) -class StringDelegate(QtWidgets.QStyledItemDelegate): - def __init__(self, regex, *args, **kwargs): - super(StringDelegate, self).__init__(*args, **kwargs) - self._regex = regex - +class NameDelegate(QtWidgets.QStyledItemDelegate): def createEditor(self, parent, option, index): - if self._regex: - editor = RegexTextEdit(self._regex, parent) - else: - editor = QtWidgets.QLineEdit(parent) + editor = NameTextEdit(parent) value = index.data(QtCore.Qt.EditRole) if value is not None: editor.setText(str(value)) diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index be7513de25..b782d27d47 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -2,7 +2,7 @@ from Qt import QtWidgets, QtCore from .delegates import ( NumberDelegate, - StringDelegate, + NameDelegate, TypeDelegate, ToolsDelegate ) @@ -10,9 +10,8 @@ from .delegates import ( from openpype.lib import ApplicationManager -class StringDef: - def __init__(self, regex=None): - self.regex = regex +class NameDef: + pass class NumberDef: @@ -63,7 +62,7 @@ class ToolsCache: class HierarchyView(QtWidgets.QTreeView): """A tree view that deselects on clicking on an empty area in the view""" column_delegate_defs = { - "name": StringDef(), + "name": NameDef(), "type": TypeDef(), "frameStart": NumberDef(1), "frameEnd": NumberDef(1), @@ -107,8 +106,8 @@ class HierarchyView(QtWidgets.QTreeView): column_delegates = {} column_key_to_index = {} for key, item_type in self.column_delegate_defs.items(): - if isinstance(item_type, StringDef): - delegate = StringDelegate(item_type.regex) + if isinstance(item_type, NameDef): + delegate = NameDelegate() elif isinstance(item_type, NumberDef): delegate = NumberDelegate( diff --git a/openpype/tools/project_manager/project_manager/widgets.py b/openpype/tools/project_manager/project_manager/widgets.py index 9ef5dfaf85..878033de27 100644 --- a/openpype/tools/project_manager/project_manager/widgets.py +++ b/openpype/tools/project_manager/project_manager/widgets.py @@ -1,10 +1,14 @@ +import re +from .constants import ( + NAME_ALLOWED_SYMBOLS, + NAME_REGEX +) from Qt import QtWidgets, QtCore -class RegexTextEdit(QtWidgets.QLineEdit): - def __init__(self, regex, *args, **kwargs): - super(RegexTextEdit, self).__init__(*args, **kwargs) - self._regex = regex +class NameTextEdit(QtWidgets.QLineEdit): + def __init__(self, *args, **kwargs): + super(NameTextEdit, self).__init__(*args, **kwargs) self.textChanged.connect(self._on_text_change) From 6daec25886aa7fe6585ff538eb6fb3e1e4915af2 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 11 May 2021 10:08:29 +0200 Subject: [PATCH 107/303] skip not allowed symbols and keep position --- .../project_manager/project_manager/widgets.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/openpype/tools/project_manager/project_manager/widgets.py b/openpype/tools/project_manager/project_manager/widgets.py index 878033de27..1da4da3c24 100644 --- a/openpype/tools/project_manager/project_manager/widgets.py +++ b/openpype/tools/project_manager/project_manager/widgets.py @@ -13,7 +13,19 @@ class NameTextEdit(QtWidgets.QLineEdit): self.textChanged.connect(self._on_text_change) def _on_text_change(self, text): - print(text) + if NAME_REGEX.match(text): + return + + idx = self.cursorPosition() + before_text = text[0:idx] + after_text = text[idx:len(text)] + sub_regex = "[^{}]+".format(NAME_ALLOWED_SYMBOLS) + new_before_text = re.sub(sub_regex, "", before_text) + new_after_text = re.sub(sub_regex, "", after_text) + idx -= (len(before_text) - len(new_before_text)) + + self.setText(new_before_text + new_after_text) + self.setCursorPosition(idx) class FilterComboBox(QtWidgets.QComboBox): From fb09eb8ef75ffa454a09cb0a5f600069a51ac46b Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 11 May 2021 10:35:09 +0200 Subject: [PATCH 108/303] AssetItem cares about children task duplications --- .../project_manager/project_manager/model.py | 81 ++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index baea086d87..2ed9f04e4f 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -1197,6 +1197,9 @@ class AssetItem(BaseItem): self._hierarchy_changes_enabled = True self._removed = False + self._task_items_by_name = collections.defaultdict(list) + self._task_name_by_item_id = {} + self._origin_asset_doc = copy.deepcopy(asset_doc) data = self.data_from_doc(asset_doc) @@ -1367,6 +1370,76 @@ class AssetItem(BaseItem): return flags return super(AssetItem, self).flags(key) + def _add_task(self, item): + name = item.data("name", QtCore.Qt.DisplayRole).lower() + item_id = item.data(None, IDENTIFIER_ROLE) + + self._task_name_by_item_id[item_id] = name + self._task_items_by_name[name].append(item) + if len(self._task_items_by_name[name]) > 1: + for _item in self._task_items_by_name[name]: + _item.setData(None, True, DUPLICATED_ROLE) + + def _remove_task(self, item): + item_id = item.data(None, IDENTIFIER_ROLE) + name = self._task_name_by_item_id[item_id] + + self._task_name_by_item_id.pop(item_id) + self._task_items_by_name[name].append(item) + if not self._task_items_by_name[name]: + self._task_items_by_name.pop(name) + + elif len(self._task_items_by_name[name]) == 1: + for _item in self._task_items_by_name[name]: + _item.setData(None, False, DUPLICATED_ROLE) + + def _rename_task(self, item): + new_name = item.data("name", QtCore.Qt.DisplayRole).lower() + item_id = item.data(None, IDENTIFIER_ROLE) + prev_name = self._task_name_by_item_id[item_id] + if new_name == prev_name: + return + + # Remove from previous name mapping + self._task_items_by_name[prev_name].remove(item) + if not self._task_items_by_name[prev_name]: + self._task_items_by_name.pop(prev_name) + + elif len(self._task_items_by_name[prev_name]) == 1: + for _item in self._task_items_by_name[prev_name]: + _item.setData(None, False, DUPLICATED_ROLE) + + # Add to new name mapping + self._task_items_by_name[new_name].append(item) + if len(self._task_items_by_name[new_name]) > 1: + for _item in self._task_items_by_name[new_name]: + _item.setData(None, True, DUPLICATED_ROLE) + else: + item.setData(None, False, DUPLICATED_ROLE) + + self._task_name_by_item_id[item_id] = new_name + + def on_task_name_change(self, task_item): + self._rename_task(task_item) + + def add_child(self, item, row=None): + if item in self._children: + return + + super(AssetItem, self).add_child(item, row) + + if isinstance(item, TaskItem): + self._add_task(item) + + def remove_child(self, item): + if item not in self._children: + return + + if isinstance(item, TaskItem): + self._remove_task(item) + + super(AssetItem).remove_child(item) + class TaskItem(BaseItem): columns = { @@ -1435,4 +1508,10 @@ class TaskItem(BaseItem): if role == REMOVED_ROLE: self._removed = value return True - return super(TaskItem, self).setData(key, value, role) + + result = super(TaskItem, self).setData(key, value, role) + + if key == "name": + self.parent().on_task_name_change(self) + + return result From 94dedde1dc865ca711d1f9a95902ce04be2c4bb8 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 11 May 2021 10:41:06 +0200 Subject: [PATCH 109/303] check for type changes if name is not set --- openpype/tools/project_manager/project_manager/model.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 2ed9f04e4f..e26d213297 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -1511,7 +1511,10 @@ class TaskItem(BaseItem): result = super(TaskItem, self).setData(key, value, role) - if key == "name": + if ( + key == "name" + or (key == "type" and self._data["name"] is None) + ): self.parent().on_task_name_change(self) return result From 964f7b2b290e6876559921726235f4b2dc685624 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 11 May 2021 10:41:21 +0200 Subject: [PATCH 110/303] store duplicated task names --- openpype/tools/project_manager/project_manager/model.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index e26d213297..8f7e86dbb0 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -1194,12 +1194,17 @@ class AssetItem(BaseItem): asset_doc = {} self.mongo_id = asset_doc.get("_id") self._project_id = None + + # Item data self._hierarchy_changes_enabled = True self._removed = False + # Task children duplication variables self._task_items_by_name = collections.defaultdict(list) self._task_name_by_item_id = {} + self._duplicated_task_names = set() + # Copy of original document self._origin_asset_doc = copy.deepcopy(asset_doc) data = self.data_from_doc(asset_doc) @@ -1377,6 +1382,7 @@ class AssetItem(BaseItem): self._task_name_by_item_id[item_id] = name self._task_items_by_name[name].append(item) if len(self._task_items_by_name[name]) > 1: + self._duplicated_task_names.add(name) for _item in self._task_items_by_name[name]: _item.setData(None, True, DUPLICATED_ROLE) @@ -1390,6 +1396,7 @@ class AssetItem(BaseItem): self._task_items_by_name.pop(name) elif len(self._task_items_by_name[name]) == 1: + self._duplicated_task_names.remove(name) for _item in self._task_items_by_name[name]: _item.setData(None, False, DUPLICATED_ROLE) @@ -1406,12 +1413,14 @@ class AssetItem(BaseItem): self._task_items_by_name.pop(prev_name) elif len(self._task_items_by_name[prev_name]) == 1: + self._duplicated_task_names.remove(prev_name) for _item in self._task_items_by_name[prev_name]: _item.setData(None, False, DUPLICATED_ROLE) # Add to new name mapping self._task_items_by_name[new_name].append(item) if len(self._task_items_by_name[new_name]) > 1: + self._duplicated_task_names.add(new_name) for _item in self._task_items_by_name[new_name]: _item.setData(None, True, DUPLICATED_ROLE) else: From ad50d5b38d09b73db6edda66ceb8112a67d85486 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 11 May 2021 10:46:58 +0200 Subject: [PATCH 111/303] added base of item validations --- .../tools/project_manager/project_manager/model.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 8f7e86dbb0..e6df0ea5ab 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -843,6 +843,15 @@ class HierarchyModel(QtCore.QAbstractItemModel): self.endResetModel() def save(self): + all_valid = True + for item in self._items_by_id.values(): + if not item.is_valid: + all_valid = False + break + + if not all_valid: + return + project_item = None for _project_item in self._root_item.children(): project_item = _project_item @@ -919,6 +928,10 @@ class BaseItem: def name_icon(cls): return cls._name_icon + @property + def is_valid(self): + return not self._is_duplicated + def model(self): return self._parent.model From 5fa0bb524aa2766961b8705b71a63b887a7dc8b5 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 11 May 2021 10:47:12 +0200 Subject: [PATCH 112/303] use data method to get asset values --- openpype/tools/project_manager/project_manager/model.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index e6df0ea5ab..bf1f0eda49 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -1262,8 +1262,8 @@ class AssetItem(BaseItem): ) doc = { - "name": self._data["name"], - "type": self._data["type"], + "name": self.data("name", QtCore.Qt.DisplayRole), + "type": self.data("type", QtCore.Qt.DisplayRole), "schema": schema_name, "data": doc_data, "parent": self.project_id @@ -1271,10 +1271,11 @@ class AssetItem(BaseItem): if self.mongo_id: doc["_id"] = self.mongo_id - for key, value in self._data.items(): + for key in self._data.keys(): if key in doc: continue - doc_data[key] = value + # Use `data` method to get inherited values + doc_data[key] = self.data(key, QtCore.Qt.DisplayRole) return doc From c26c141343685ec8815d01df03247fa19664e637 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 11 May 2021 10:49:31 +0200 Subject: [PATCH 113/303] fix super call --- openpype/tools/project_manager/project_manager/model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index bf1f0eda49..86c7f79aab 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -1461,7 +1461,7 @@ class AssetItem(BaseItem): if isinstance(item, TaskItem): self._remove_task(item) - super(AssetItem).remove_child(item) + super(AssetItem, self).remove_child(item) class TaskItem(BaseItem): From 53027f6916392ca851bf730397d99443cdf8ec14 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 11 May 2021 11:01:08 +0200 Subject: [PATCH 114/303] store origin data of asset and task --- openpype/tools/project_manager/project_manager/model.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 86c7f79aab..9c8021f974 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -1221,6 +1221,9 @@ class AssetItem(BaseItem): self._origin_asset_doc = copy.deepcopy(asset_doc) data = self.data_from_doc(asset_doc) + + self._origin_data = copy.deepcopy(data) + super(AssetItem, self).__init__(data) @property @@ -1475,10 +1478,11 @@ class TaskItem(BaseItem): } def __init__(self, data=None): - self._origin_data = copy.deepcopy(data) self._removed = False if data is None: data = {} + self._origin_data = copy.deepcopy(data) + super(TaskItem, self).__init__(data) @property From 2bc795197e07f53a15078435e888aa859695030b Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 11 May 2021 11:25:49 +0200 Subject: [PATCH 115/303] implemented delegate that can resize editor to required sizes --- .../project_manager/delegates.py | 63 +++++++++++++++++-- 1 file changed, 59 insertions(+), 4 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/delegates.py b/openpype/tools/project_manager/project_manager/delegates.py index 18e3e2d81b..4292224b09 100644 --- a/openpype/tools/project_manager/project_manager/delegates.py +++ b/openpype/tools/project_manager/project_manager/delegates.py @@ -7,6 +7,65 @@ from .widgets import ( from .multiselection_combobox import MultiSelectionComboBox +class ResizeEditorDelegate(QtWidgets.QStyledItemDelegate): + @staticmethod + def _q_smart_min_size(editor): + min_size_hint = editor.minimumSizeHint() + size_policy = editor.sizePolicy() + width = 0 + height = 0 + if size_policy.horizontalPolicy() != QtWidgets.QSizePolicy.Ignored: + if ( + size_policy.horizontalPolicy() + & QtWidgets.QSizePolicy.ShrinkFlag + ): + width = min_size_hint.width() + else: + width = max( + editor.sizeHint().width(), + min_size_hint.width() + ) + + if size_policy.verticalPolicy() != QtWidgets.QSizePolicy.Ignored: + if size_policy.verticalPolicy() & QtWidgets.QSizePolicy.ShrinkFlag: + height = min_size_hint.height() + else: + height = max( + editor.sizeHint().height(), + min_size_hint.height() + ) + + output = QtCore.QSize(width, height).boundedTo(editor.maximumSize()) + min_size = editor.minimumSize() + if min_size.width() > 0: + output.setWidth(min_size.width()) + if min_size.height() > 0: + output.setHeight(min_size.height()) + + return output.expandedTo(QtCore.QSize(0, 0)) + + def updateEditorGeometry(self, editor, option, index): + self.initStyleOption(option, index) + + option.showDecorationSelected = editor.style().styleHint( + QtWidgets.QStyle.SH_ItemView_ShowDecorationSelected, None, editor + ) + + widget = option.widget + + style = widget.style() if widget else QtWidgets.QApplication.style() + geo = style.subElementRect( + QtWidgets.QStyle.SE_ItemViewItemText, option, widget + ) + delta = self._q_smart_min_size(editor).width() - geo.width() + if delta > 0: + if editor.layoutDirection() == QtCore.Qt.RightToLeft: + geo.adjust(-delta, 0, 0, 0) + else: + geo.adjust(0, 0, delta, 0) + editor.setGeometry(geo) + + class NumberDelegate(QtWidgets.QStyledItemDelegate): def __init__(self, minimum, maximum, decimals, *args, **kwargs): super(NumberDelegate, self).__init__(*args, **kwargs) @@ -34,10 +93,6 @@ class NumberDelegate(QtWidgets.QStyledItemDelegate): return editor - # def updateEditorGeometry(self, editor, options, index): - # print(editor) - # return super().updateEditorGeometry(editor, options, index) - class NameDelegate(QtWidgets.QStyledItemDelegate): def createEditor(self, parent, option, index): From 1c9c6164a62d6bd9ec9c2b6558ca1ac63a49ba3e Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 11 May 2021 11:38:44 +0200 Subject: [PATCH 116/303] fix TaskItem new item detection --- openpype/tools/project_manager/project_manager/model.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 9c8021f974..cf5f3cf9f1 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -1479,15 +1479,16 @@ class TaskItem(BaseItem): def __init__(self, data=None): self._removed = False + self._is_new = data is None if data is None: data = {} - self._origin_data = copy.deepcopy(data) + self._origin_data = copy.deepcopy(data) super(TaskItem, self).__init__(data) @property def is_new(self): - return self._origin_data is None + return self._is_new @classmethod def name_icon(cls): From e4b51604ad9002e04138dbc59a36f5f481428d7b Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 11 May 2021 12:12:39 +0200 Subject: [PATCH 117/303] SyncServer - sync_project_settings now contains all projects, must skip disabled explicitly --- openpype/modules/sync_server/sync_server.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/openpype/modules/sync_server/sync_server.py b/openpype/modules/sync_server/sync_server.py index 9b305a1b2e..28169ca8b3 100644 --- a/openpype/modules/sync_server/sync_server.py +++ b/openpype/modules/sync_server/sync_server.py @@ -274,6 +274,9 @@ class SyncServerThread(threading.Thread): self.module.set_sync_project_settings() # clean cache for collection, preset in self.module.sync_project_settings.\ items(): + if collection not in self.module.get_enabled_projects(): + continue + start_time = time.time() local_site, remote_site = self._working_sites(collection) if not all([local_site, remote_site]): From 675aac19322b345094a2864f18c568c96d7b9707 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 11 May 2021 12:15:52 +0200 Subject: [PATCH 118/303] model has defined multiselection columns --- .../project_manager/project_manager/model.py | 26 ++++++++++++++++++- .../project_manager/project_manager/view.py | 4 +-- .../project_manager/project_manager/window.py | 4 ++- 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index cf5f3cf9f1..f34b9eb39c 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -52,8 +52,12 @@ class ProjectModel(QtGui.QStandardItemModel): class HierarchySelectionModel(QtCore.QItemSelectionModel): + def __init__(self, multiselection_columns, *args, **kwargs): + super(HierarchySelectionModel, self).__init__(*args, **kwargs) + self.multiselection_columns = multiselection_columns + def setCurrentIndex(self, index, command): - if index.column() > 0: + if index.column() in self.multiselection_columns: if ( command & QtCore.QItemSelectionModel.Clear and command & QtCore.QItemSelectionModel.Select @@ -78,6 +82,19 @@ class HierarchyModel(QtCore.QAbstractItemModel): ("pixelAspect", "Pixel aspect"), ("tools_env", "Tools") ] + multiselection_columns = { + "frameStart", + "frameEnd", + "fps", + "resolutionWidth", + "resolutionHeight", + "handleStart", + "handleEnd", + "clipIn", + "clipOut", + "pixelAspect", + "tools_env" + } columns = [ item[0] for item in _columns_def @@ -87,10 +104,17 @@ class HierarchyModel(QtCore.QAbstractItemModel): idx: item[1] for idx, item in enumerate(_columns_def) } + index_moved = QtCore.Signal(QtCore.QModelIndex) def __init__(self, dbcon, parent=None): super(HierarchyModel, self).__init__(parent) + + self.multiselection_column_indexes = { + self.columns.index(key) + for key in self.multiselection_columns + } + # TODO Reset them on project change self._current_project = None self._root_item = None diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index b782d27d47..04c0e6b08b 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -76,7 +76,7 @@ class HierarchyView(QtWidgets.QTreeView): "pixelAspect": NumberDef(0, decimals=2), "tools_env": ToolsDef() } - persistent_columns = [ + persistent_columns = { "type", "frameStart", "frameEnd", @@ -89,7 +89,7 @@ class HierarchyView(QtWidgets.QTreeView): "clipOut", "pixelAspect", "tools_env" - ] + } def __init__(self, dbcon, source_model, *args, **kwargs): super(HierarchyView, self).__init__(*args, **kwargs) diff --git a/openpype/tools/project_manager/project_manager/window.py b/openpype/tools/project_manager/project_manager/window.py index 0be69c29c8..f2ad399ab5 100644 --- a/openpype/tools/project_manager/project_manager/window.py +++ b/openpype/tools/project_manager/project_manager/window.py @@ -39,7 +39,9 @@ class Window(QtWidgets.QWidget): hierarchy_view = HierarchyView(dbcon, hierarchy_model, self) hierarchy_view.setModel(hierarchy_model) - _selection_model = HierarchySelectionModel() + _selection_model = HierarchySelectionModel( + hierarchy_model.multiselection_column_indexes + ) _selection_model.setModel(hierarchy_view.model()) hierarchy_view.setSelectionModel(_selection_model) From db10512a305f66b61e97c87b6047b920601d56b5 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 11 May 2021 12:16:14 +0200 Subject: [PATCH 119/303] clear selection on adding new item --- .../tools/project_manager/project_manager/view.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index 04c0e6b08b..4d3557eb9e 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -280,7 +280,11 @@ class HierarchyView(QtWidgets.QTreeView): new_index.row(), 1, new_index.parent() ) # Change current index - self.setCurrentIndex(task_type_index) + self.selectionModel().setCurrentIndex( + task_type_index, + QtCore.QItemSelectionModel.Clear + | QtCore.QItemSelectionModel.Select + ) # Start editing self.edit(task_type_index) @@ -298,7 +302,11 @@ class HierarchyView(QtWidgets.QTreeView): return # Change current index - self.setCurrentIndex(new_index) + self.selectionModel().setCurrentIndex( + new_index, + QtCore.QItemSelectionModel.Clear + | QtCore.QItemSelectionModel.Select + ) # Start editing self.edit(new_index) From 8b30c7a5edcc170c469704b7fb9f3118acaca1d7 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 11 May 2021 12:16:23 +0200 Subject: [PATCH 120/303] added `_add_asset` method --- openpype/tools/project_manager/project_manager/view.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index 4d3557eb9e..1db8a12c23 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -289,7 +289,12 @@ class HierarchyView(QtWidgets.QTreeView): self.edit(task_type_index) def _on_shift_enter_pressed(self): - index = self.currentIndex() + self._add_asset() + + def _add_asset(self, index=None): + if index is None: + index = self.currentIndex() + if not index.isValid(): return From 49e44f0d70af849d2133d33fdffcbbd05cbb8cb4 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 11 May 2021 14:43:26 +0200 Subject: [PATCH 121/303] SyncServer - check configured sites more effectively --- openpype/modules/sync_server/sync_server.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/openpype/modules/sync_server/sync_server.py b/openpype/modules/sync_server/sync_server.py index 28169ca8b3..b89eeebf19 100644 --- a/openpype/modules/sync_server/sync_server.py +++ b/openpype/modules/sync_server/sync_server.py @@ -206,10 +206,10 @@ def _get_configured_sites_from_setting(module, project_name, project_setting): all_sites = module._get_default_site_configs() all_sites.update(project_setting.get("sites")) for site_name, config in all_sites.items(): - handler = initiated_handlers. \ - get((config["provider"], site_name)) + provider = module.get_provider_for_site(site=site_name) + handler = initiated_handlers.get((provider, site_name)) if not handler: - handler = lib.factory.get_provider(config["provider"], + handler = lib.factory.get_provider(provider, project_name, site_name, presets=config) @@ -454,8 +454,9 @@ class SyncServerThread(threading.Thread): remote_site)) return None, None - if not all([site_is_working(self.module, collection, local_site), - site_is_working(self.module, collection, remote_site)]): + configured_sites = _get_configured_sites(self.module, collection) + if not all([local_site in configured_sites, + remote_site in configured_sites]): log.debug("Some of the sites {} - {} is not ".format(local_site, remote_site) + "working properly") From 1aafb697e44888074de1abad59bde216429d459e Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 11 May 2021 14:49:13 +0200 Subject: [PATCH 122/303] SyncServer - change settings to new format Sites are configured in System Schemas and defaults were modified All providers carry dict of modifiable properties for Local Settings --- .../providers/abstract_provider.py | 27 +-- .../modules/sync_server/providers/gdrive.py | 55 ++++-- openpype/modules/sync_server/providers/lib.py | 11 ++ .../sync_server/providers/local_drive.py | 19 +- .../modules/sync_server/sync_server_module.py | 180 +++++++++++++----- openpype/modules/sync_server/tray/lib.py | 2 +- openpype/modules/sync_server/tray/widgets.py | 2 +- .../defaults/project_settings/global.json | 7 - .../defaults/system_settings/modules.json | 3 +- openpype/settings/entities/enum_entity.py | 28 +-- .../schema_project_syncserver.json | 5 - 11 files changed, 212 insertions(+), 127 deletions(-) diff --git a/openpype/modules/sync_server/providers/abstract_provider.py b/openpype/modules/sync_server/providers/abstract_provider.py index 7cd42fb4fa..2e9632134c 100644 --- a/openpype/modules/sync_server/providers/abstract_provider.py +++ b/openpype/modules/sync_server/providers/abstract_provider.py @@ -27,37 +27,16 @@ class AbstractProvider: (boolean) """ + @classmethod @abc.abstractmethod - def set_editable_properties(self): + def get_configurable_items(cls): """ - Sets dictionary of editable properties with scopes. + Returns filtered dict of editable properties - Example: - { 'credentials_url': {'scopes': [utils.EditableScopes.SYSTEM], - 'type': 'text'}} - """ - - @abc.abstractmethod - def get_editable_properties(self, scopes): - """ - Returns filtered list of editable properties - - Args: - scopes (list) of utils.EditableScopes (optional - filter on) Returns: (dict) """ - if not scopes: - return self._editable_properties - - editable = {} - for scope in scopes: - for key, properties in self._editable_properties.items(): - if scope in properties['scope']: - editable[key] = properties - - return editable @abc.abstractmethod def upload_file(self, source_path, path, diff --git a/openpype/modules/sync_server/providers/gdrive.py b/openpype/modules/sync_server/providers/gdrive.py index f2c18a04a9..e79927590b 100644 --- a/openpype/modules/sync_server/providers/gdrive.py +++ b/openpype/modules/sync_server/providers/gdrive.py @@ -1,22 +1,32 @@ from __future__ import print_function import os.path -from googleapiclient.discovery import build -import google.oauth2.service_account as service_account -from googleapiclient import errors -from .abstract_provider import AbstractProvider -from googleapiclient.http import MediaFileUpload, MediaIoBaseDownload +import time +import sys +from setuptools.extern import six + from openpype.api import Logger from openpype.api import get_system_settings +from .abstract_provider import AbstractProvider from ..utils import time_function, ResumableError, EditableScopes -import time +log = Logger().get_logger("SyncServer") + +try: + from googleapiclient.discovery import build + import google.oauth2.service_account as service_account + from googleapiclient import errors + from googleapiclient.http import MediaFileUpload, MediaIoBaseDownload +except (ImportError, SyntaxError): + if six.PY3: + six.reraise(*sys.exc_info()) + + # handle imports from Python 2 hosts - in those only basic methods are used + log.warning("Import failed, imported from Python 2, operations will fail.") SCOPES = ['https://www.googleapis.com/auth/drive.metadata.readonly', 'https://www.googleapis.com/auth/drive.file', 'https://www.googleapis.com/auth/drive.readonly'] # for write|delete -log = Logger().get_logger("SyncServer") - class GDriveHandler(AbstractProvider): """ @@ -54,8 +64,7 @@ class GDriveHandler(AbstractProvider): self.active = False self.project_name = project_name self.site_name = site_name - - self._editable_properties = {} + self.service = None self.presets = presets if not self.presets: @@ -63,7 +72,7 @@ class GDriveHandler(AbstractProvider): format(site_name)) return - if not os.path.exists(self.presets["credentials_url"]): + if not os.path.exists(self.presets.get("credentials_url", "")): log.info("Sync Server: No credentials for Gdrive provider! ") return @@ -78,7 +87,6 @@ class GDriveHandler(AbstractProvider): self._tree = tree self.active = True - self.set_editable_properties() def is_active(self): """ @@ -86,18 +94,29 @@ class GDriveHandler(AbstractProvider): Returns: (boolean) """ - return self.active + return self.service is not None - def set_editable_properties(self): + @classmethod + def get_configurable_items(cls): + """ + Returns filtered dict of editable properties. + + + Returns: + (dict) + """ editable = { + # credentials could be override on Project or User level 'credential_url': {'scope': [EditableScopes.PROJECT, EditableScopes.LOCAL], + 'label': "Credentials url", 'type': 'text'}, - - 'roots': {'scope': [EditableScopes.PROJECT], - 'type': 'dict'} + # roots could be override only on Project leve, User cannot + 'root': {'scope': [EditableScopes.PROJECT], + 'label': "Roots", + 'type': 'dict'} } - self._editable_properties = editable + return editable def get_roots_config(self, anatomy=None): """ diff --git a/openpype/modules/sync_server/providers/lib.py b/openpype/modules/sync_server/providers/lib.py index f9c4309724..816ccca981 100644 --- a/openpype/modules/sync_server/providers/lib.py +++ b/openpype/modules/sync_server/providers/lib.py @@ -65,6 +65,17 @@ class ProviderFactory: info = self._get_creator_info(provider) return info[1] + def get_provider_configurable_items(self, provider): + """ + Returns dict of modifiable properties for 'provider'. + + Provider contains information which its properties and on what + level could be override + """ + provider_info = self._get_creator_info(provider) + + return provider_info[0].get_configurable_items() + def _get_creator_info(self, provider): """ Collect all necessary info for provider. Currently only creator diff --git a/openpype/modules/sync_server/providers/local_drive.py b/openpype/modules/sync_server/providers/local_drive.py index 2a96094f22..2d37d0e1c4 100644 --- a/openpype/modules/sync_server/providers/local_drive.py +++ b/openpype/modules/sync_server/providers/local_drive.py @@ -25,18 +25,25 @@ class LocalDriveHandler(AbstractProvider): self._editable_properties = {} self.active = self.is_active() - self.set_editable_properties() def is_active(self): return True - def set_editable_properties(self): + @classmethod + def get_configurable_items(cls): + """ + Returns filtered dict of editable properties + + Returns: + (dict) + """ editable = { - 'roots': {'scope': [EditableScopes.PROJECT, - EditableScopes.LOCAL], - 'type': 'dict'} + 'root': {'scope': [EditableScopes.PROJECT, + EditableScopes.LOCAL], + 'label': "Roots", + 'type': 'dict'} } - self._editable_properties = editable + return editable def upload_file(self, source_path, target_path, server, collection, file, representation, site, diff --git a/openpype/modules/sync_server/sync_server_module.py b/openpype/modules/sync_server/sync_server_module.py index e29861c20c..1e12db84a1 100644 --- a/openpype/modules/sync_server/sync_server_module.py +++ b/openpype/modules/sync_server/sync_server_module.py @@ -9,10 +9,12 @@ from .. import PypeModule, ITrayModule from openpype.api import ( Anatomy, get_project_settings, + get_system_settings, get_local_site_id) from openpype.lib import PypeLogger from .providers.local_drive import LocalDriveHandler +from .providers import lib from .utils import time_function, SyncStatus @@ -390,31 +392,99 @@ class SyncServerModule(PypeModule, ITrayModule): return remote_site - def get_configurable_items(self): - pass - - def get_configurable_items_for_site(self, project_name, site_name): + def get_configurable_items(self, scope=None): """ - Returns list of items that should be configurable by User + Returns list of items that could be configurable for all projects. + + Could be filtered by 'scope' argument (list) + + Args: + scope (list of utils.EditableScope) (optional) Returns: - (list of dict) - [{key:"root", label:"root", value:"valueFromSettings"}] + (dict of dict) + {projectA: { + siteA : { + key:"root", label:"root", value:"valueFromSettings" + } + } """ - # sites = set(self.get_active_sites(project_name), - # self.get_remote_sites(project_name)) - # for site in sites: - # if site_name - - def _get_configurable_items_for_project(self, project_name): - from .providers import lib - sites = set(self.get_active_sites(project_name), - self.get_remote_sites(project_name)) editable = {} - for site in sites: - provider_name = self.get_provider_for_site(project_name, site) + for project in self.connection.projects(): + project_name = project["name"] + items = self.get_configurable_items_for_project(project_name, + scope) + editable.update(items) + return editable + def get_configurable_items_for_project(self, project_name, scope=None): + """ + Returns list of items that could be configurable for specific + 'project_name' + + Args: + project_name (str) + scope (list of utils.EditableScope) (optional) + + Returns: + (dict of dict) + {projectA: { + siteA : { + key:"root", label:"root", value:"valueFromSettings" + } + } + """ + sites = set(self.get_active_sites(project_name)) | \ + set(self.get_remote_sites(project_name)) + editable = {} + for site_name in sites: + items = self.get_configurable_items_for_site(project_name, + site_name, + scope) + editable.update(items) + + return editable + + def get_configurable_items_for_site(self, project_name, site_name, + scope=None): + """ + Returns list of items that could be configurable. + + Args: + project_name (str) + site_name (str) + scope (list of utils.EditableScope) (optional) + + Returns: + (dict of dict) + {projectA: { + siteA : { + key:"root", label:"root", value:"valueFromSettings" + } + } + """ + provider_name = self.get_provider_for_site(site=site_name) + items = lib.factory.get_provider_configurable_items(provider_name, + scope) + + if not scope: + return {project_name: {site_name: items}} + + editable = {} + sync_s = self.get_sync_project_setting(project_name, True) + for scope in set([scope]): + for key, properties in items.items(): + if scope in properties['scope']: + val = sync_s.get("sites", {}).get(site_name, {}).get(key) + editable = { + "key": key, + "value": val, + "label": properties.get("label"), + "type": properties.get("type"), + } + + return {project_name: {site_name: editable}} def reset_timer(self): """ @@ -432,7 +502,7 @@ class SyncServerModule(PypeModule, ITrayModule): for project in self.connection.projects(): project_name = project["name"] project_settings = self.get_sync_project_setting(project_name) - if project_settings: + if project_settings and project_settings.get("enabled"): enabled_projects.append(project_name) return enabled_projects @@ -584,55 +654,60 @@ class SyncServerModule(PypeModule, ITrayModule): return self._sync_project_settings - def set_sync_project_settings(self): + def set_sync_project_settings(self, exclude_locals=False): """ Set sync_project_settings for all projects (caching) - + Args: + exclude_locals (bool): ignore overrides from Local Settings For performance """ sync_project_settings = {} + # sites are now configured system wide + sys_sett = get_system_settings() + sync_sett = sys_sett["modules"].get("sync_server") + system_sites = {} + for site, detail in sync_sett.get("sites", {}).items(): + system_sites[site] = detail + for collection in self.connection.database.collection_names(False): sync_settings = self._parse_sync_settings_from_settings( - get_project_settings(collection)) - if sync_settings: - default_sites = self._get_default_site_configs() - sync_settings['sites'].update(default_sites) - sync_project_settings[collection] = sync_settings + get_project_settings(collection, + exclude_locals=exclude_locals)) + + default_sites = self._get_default_site_configs() + sync_settings['sites'].update(default_sites) + sync_settings['sites'].update(system_sites) + sync_project_settings[collection] = sync_settings if not sync_project_settings: log.info("No enabled and configured projects for sync.") self._sync_project_settings = sync_project_settings - def get_sync_project_setting(self, project_name): + def get_sync_project_setting(self, project_name, exclude_locals=False): """ Handles pulling sync_server's settings for enabled 'project_name' Args: project_name (str): used in project settings + exclude_locals (bool): ignore overrides from Local Settings Returns: (dict): settings dictionary for the enabled project, empty if no settings or sync is disabled """ # presets set already, do not call again and again # self.log.debug("project preset {}".format(self.presets)) - if self.sync_project_settings and \ - self.sync_project_settings.get(project_name): - return self.sync_project_settings.get(project_name) + if not self.sync_project_settings or \ + not self.sync_project_settings.get(project_name): + self.set_sync_project_settings(project_name, exclude_locals) - settings = get_project_settings(project_name) - return self._parse_sync_settings_from_settings(settings) + return self.sync_project_settings.get(project_name) def _parse_sync_settings_from_settings(self, settings): """ settings from api.get_project_settings, TOOD rename """ sync_settings = settings.get("global").get("sync_server") - if not sync_settings: - log.info("No project setting not syncing.") - return {} - if sync_settings.get("enabled"): - return sync_settings - return {} + return sync_settings def _get_default_site_configs(self): """ @@ -643,16 +718,29 @@ class SyncServerModule(PypeModule, ITrayModule): get_local_site_id(): default_config} return all_sites - def get_provider_for_site(self, project_name, site): + def get_provider_for_site(self, project_name=None, site=None): """ - Return provider name for site. + Return provider name for site (unique name across all projects. """ - site_preset = self.get_sync_project_setting(project_name)["sites"].\ - get(site) - if site_preset: - return site_preset["provider"] + sites = {self.DEFAULT_SITE: "local_drive", + self.LOCAL_SITE: "local_drive", + get_local_site_id(): "local_drive"} - return "NA" + if site in sites.keys(): + return sites[site] + + if project_name: # backward compatibility + proj_settings = self.get_sync_project_setting(project_name) + provider = proj_settings.get("sites", {}).get(site, {}).\ + get("provider") + return provider + + sys_sett = get_system_settings() + sync_sett = sys_sett["modules"].get("sync_server") + for site, detail in sync_sett.get("sites", {}).items(): + sites[site] = detail.get("provider") + + return sites.get(site, 'N/A') @time_function def get_sync_representations(self, collection, active_site, remote_site): @@ -1130,7 +1218,7 @@ class SyncServerModule(PypeModule, ITrayModule): format(site_name)) return - provider_name = self.get_provider_for_site(collection, site_name) + provider_name = self.get_provider_for_site(site=site_name) if provider_name == 'local_drive': query = { diff --git a/openpype/modules/sync_server/tray/lib.py b/openpype/modules/sync_server/tray/lib.py index c1f8eaf629..25c600abd2 100644 --- a/openpype/modules/sync_server/tray/lib.py +++ b/openpype/modules/sync_server/tray/lib.py @@ -158,7 +158,7 @@ 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) + return sync_server.get_provider_for_site(site=site) def get_item_by_id(model, object_id): diff --git a/openpype/modules/sync_server/tray/widgets.py b/openpype/modules/sync_server/tray/widgets.py index e80f91e09f..eae912206e 100644 --- a/openpype/modules/sync_server/tray/widgets.py +++ b/openpype/modules/sync_server/tray/widgets.py @@ -236,7 +236,7 @@ class _SyncRepresentationWidget(QtWidgets.QWidget): for site, progress in {active_site: local_progress, remote_site: remote_progress}.items(): - provider = self.sync_server.get_provider_for_site(project, site) + provider = self.sync_server.get_provider_for_site(site=site) if provider == 'local_drive': if 'studio' in site: txt = " studio version" diff --git a/openpype/settings/defaults/project_settings/global.json b/openpype/settings/defaults/project_settings/global.json index 48b7a24b0d..1f54bed03c 100644 --- a/openpype/settings/defaults/project_settings/global.json +++ b/openpype/settings/defaults/project_settings/global.json @@ -267,13 +267,6 @@ "remote_site": "studio" }, "sites": { - "gdrive": { - "provider": "gdrive", - "credentials_url": "", - "root": { - "work": "" - } - } } }, "project_plugins": { diff --git a/openpype/settings/defaults/system_settings/modules.json b/openpype/settings/defaults/system_settings/modules.json index 6e4b493116..5c4aa6c485 100644 --- a/openpype/settings/defaults/system_settings/modules.json +++ b/openpype/settings/defaults/system_settings/modules.json @@ -135,7 +135,8 @@ "workspace_name": "" }, "sync_server": { - "enabled": false + "enabled": false, + "sites": {} }, "deadline": { "enabled": true, diff --git a/openpype/settings/entities/enum_entity.py b/openpype/settings/entities/enum_entity.py index a5492cd727..c6021b68de 100644 --- a/openpype/settings/entities/enum_entity.py +++ b/openpype/settings/entities/enum_entity.py @@ -231,21 +231,17 @@ class ProvidersEnum(BaseEnumEntity): self.placeholder = None def _get_enum_values(self): - # from openpype.modules.sync_server.providers import lib as lib_providers - # - # providers = lib_providers.factory.providers - # - # valid_keys = set() - # enum_items = [] - # for provider_code, provider_info in providers.items(): - # provider, _ = provider_info - # enum_items.append({provider_code: provider.LABEL}) - # valid_keys.add(provider_code) + from openpype.modules.sync_server.providers import lib as lib_providers + + providers = lib_providers.factory.providers + valid_keys = set() - enum_items = [] - if not valid_keys: - enum_items.append({'': 'N/A'}) - valid_keys.add('') + valid_keys.add('') + enum_items = [{'': 'Choose Provider'}] + for provider_code, provider_info in providers.items(): + provider, _ = provider_info + enum_items.append({provider_code: provider.LABEL}) + valid_keys.add(provider_code) return enum_items, valid_keys @@ -259,7 +255,3 @@ class ProvidersEnum(BaseEnumEntity): self._current_value = value_on_not_set self.value_on_not_set = value_on_not_set - - -# class ActiveSiteEnum -# class RemoteSiteEnum \ No newline at end of file diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_syncserver.json b/openpype/settings/entities/schemas/projects_schema/schema_project_syncserver.json index ea1b8fc9da..bb5ebea45a 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_syncserver.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_syncserver.json @@ -49,11 +49,6 @@ { "type": "dict", "children": [ - { - "type": "text", - "key": "provider", - "label": "Provider" - }, { "type": "text", "key": "credentials_url", From 0b47e921af0c11317d0dbcc8f8b6420199db8285 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 12 May 2021 12:06:10 +0200 Subject: [PATCH 123/303] SyncServer - fixes for overrode settings --- .../modules/sync_server/providers/gdrive.py | 8 +- openpype/modules/sync_server/sync_server.py | 2 +- .../modules/sync_server/sync_server_module.py | 99 +++++++++++++------ 3 files changed, 72 insertions(+), 37 deletions(-) diff --git a/openpype/modules/sync_server/providers/gdrive.py b/openpype/modules/sync_server/providers/gdrive.py index e79927590b..5578a130b4 100644 --- a/openpype/modules/sync_server/providers/gdrive.py +++ b/openpype/modules/sync_server/providers/gdrive.py @@ -107,10 +107,10 @@ class GDriveHandler(AbstractProvider): """ editable = { # credentials could be override on Project or User level - 'credential_url': {'scope': [EditableScopes.PROJECT, - EditableScopes.LOCAL], - 'label': "Credentials url", - 'type': 'text'}, + 'credentials_url': {'scope': [EditableScopes.PROJECT, + EditableScopes.LOCAL], + 'label': "Credentials url", + 'type': 'text'}, # roots could be override only on Project leve, User cannot 'root': {'scope': [EditableScopes.PROJECT], 'label': "Roots", diff --git a/openpype/modules/sync_server/sync_server.py b/openpype/modules/sync_server/sync_server.py index b89eeebf19..262986fb12 100644 --- a/openpype/modules/sync_server/sync_server.py +++ b/openpype/modules/sync_server/sync_server.py @@ -213,7 +213,7 @@ def _get_configured_sites_from_setting(module, project_name, project_setting): project_name, site_name, presets=config) - initiated_handlers[(config["provider"], site_name)] = \ + initiated_handlers[(provider, site_name)] = \ handler if handler.is_active(): diff --git a/openpype/modules/sync_server/sync_server_module.py b/openpype/modules/sync_server/sync_server_module.py index 1e12db84a1..6e54025cdf 100644 --- a/openpype/modules/sync_server/sync_server_module.py +++ b/openpype/modules/sync_server/sync_server_module.py @@ -2,6 +2,7 @@ import os from bson.objectid import ObjectId from datetime import datetime import threading +import platform from avalon.api import AvalonMongoDB @@ -12,6 +13,9 @@ from openpype.api import ( get_system_settings, get_local_site_id) from openpype.lib import PypeLogger +from openpype.settings.lib import ( + get_default_project_settings, + get_default_anatomy_settings) from .providers.local_drive import LocalDriveHandler from .providers import lib @@ -410,15 +414,21 @@ class SyncServerModule(PypeModule, ITrayModule): } """ editable = {} - for project in self.connection.projects(): - project_name = project["name"] + applicable_projects = list(self.connection.projects()) + applicable_projects.append(None) + for project in applicable_projects: + project_name = None + if project: + project_name = project["name"] + items = self.get_configurable_items_for_project(project_name, scope) editable.update(items) return editable - def get_configurable_items_for_project(self, project_name, scope=None): + def get_configurable_items_for_project(self, project_name=None, + scope=None): """ Returns list of items that could be configurable for specific 'project_name' @@ -435,10 +445,9 @@ class SyncServerModule(PypeModule, ITrayModule): } } """ - sites = set(self.get_active_sites(project_name)) | \ - set(self.get_remote_sites(project_name)) + sites = self.get_all_sites() editable = {} - for site_name in sites: + for site_name in sites.keys(): items = self.get_configurable_items_for_site(project_name, site_name, scope) @@ -446,7 +455,8 @@ class SyncServerModule(PypeModule, ITrayModule): return editable - def get_configurable_items_for_site(self, project_name, site_name, + def get_configurable_items_for_site(self, project_name=None, + site_name=None, scope=None): """ Returns list of items that could be configurable. @@ -465,26 +475,31 @@ class SyncServerModule(PypeModule, ITrayModule): } """ provider_name = self.get_provider_for_site(site=site_name) - items = lib.factory.get_provider_configurable_items(provider_name, - scope) + items = lib.factory.get_provider_configurable_items(provider_name) if not scope: - return {project_name: {site_name: items}} + return {site_name: items} - editable = {} - sync_s = self.get_sync_project_setting(project_name, True) + editable = [] + if project_name: + sync_s = self.get_sync_project_setting(project_name, + exclude_locals=True) + else: + sync_s = get_default_project_settings(exclude_locals=True) + sync_s = sync_s["global"]["sync_server"] + sync_s["sites"].update(self._get_default_site_configs()) for scope in set([scope]): for key, properties in items.items(): if scope in properties['scope']: val = sync_s.get("sites", {}).get(site_name, {}).get(key) - editable = { + editable.append({ "key": key, "value": val, - "label": properties.get("label"), - "type": properties.get("type"), - } + "label": properties["label"], + "type": properties["type"], + }) - return {project_name: {site_name: editable}} + return {site_name: editable} def reset_timer(self): """ @@ -663,22 +678,17 @@ class SyncServerModule(PypeModule, ITrayModule): """ sync_project_settings = {} - # sites are now configured system wide - sys_sett = get_system_settings() - sync_sett = sys_sett["modules"].get("sync_server") - system_sites = {} - for site, detail in sync_sett.get("sites", {}).items(): - system_sites[site] = detail + system_sites = self.get_all_sites() for collection in self.connection.database.collection_names(False): - sync_settings = self._parse_sync_settings_from_settings( + sites = dict(system_sites) # get all configured sites + proj_settings = self._parse_sync_settings_from_settings( get_project_settings(collection, exclude_locals=exclude_locals)) + sites.update(proj_settings["sites"]) # apply project overrides + proj_settings["sites"] = sites - default_sites = self._get_default_site_configs() - sync_settings['sites'].update(default_sites) - sync_settings['sites'].update(system_sites) - sync_project_settings[collection] = sync_settings + sync_project_settings[collection] = proj_settings if not sync_project_settings: log.info("No enabled and configured projects for sync.") @@ -699,7 +709,7 @@ class SyncServerModule(PypeModule, ITrayModule): # self.log.debug("project preset {}".format(self.presets)) if not self.sync_project_settings or \ not self.sync_project_settings.get(project_name): - self.set_sync_project_settings(project_name, exclude_locals) + self.set_sync_project_settings(exclude_locals) return self.sync_project_settings.get(project_name) @@ -713,11 +723,36 @@ class SyncServerModule(PypeModule, ITrayModule): """ Returns skeleton settings for 'studio' and user's local site """ - default_config = {'provider': 'local_drive'} - all_sites = {self.DEFAULT_SITE: default_config, - get_local_site_id(): default_config} + anatomy_sett = get_default_anatomy_settings() + roots = {} + for root, config in anatomy_sett["roots"].items(): + roots[root] = config[platform.system().lower()] + studio_config = { + 'provider': 'local_drive', + "root": roots + } + all_sites = {self.DEFAULT_SITE: studio_config, + get_local_site_id(): {'provider': 'local_drive'}} return all_sites + def get_all_sites(self): + """ + Returns (dict) with all sites configured system wide. + + Returns: + (dict): {'studio': {'provider':'local_drive'...}, + 'MY_LOCAL': {'provider':....}} + """ + sys_sett = get_system_settings() + sync_sett = sys_sett["modules"].get("sync_server") + system_sites = {} + for site, detail in sync_sett.get("sites", {}).items(): + system_sites[site] = detail + + system_sites.update(self._get_default_site_configs()) + + return system_sites + def get_provider_for_site(self, project_name=None, site=None): """ Return provider name for site (unique name across all projects. From 06a341d276aa1b2947d9f72e8a06965436c10965 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 12 May 2021 12:39:53 +0200 Subject: [PATCH 124/303] SyncServer - fixed return types, added documentation --- .../modules/sync_server/sync_server_module.py | 94 +++++++++++-------- 1 file changed, 53 insertions(+), 41 deletions(-) diff --git a/openpype/modules/sync_server/sync_server_module.py b/openpype/modules/sync_server/sync_server_module.py index 6e54025cdf..c06c7a0f15 100644 --- a/openpype/modules/sync_server/sync_server_module.py +++ b/openpype/modules/sync_server/sync_server_module.py @@ -20,7 +20,7 @@ from openpype.settings.lib import ( from .providers.local_drive import LocalDriveHandler from .providers import lib -from .utils import time_function, SyncStatus +from .utils import time_function, SyncStatus, EditableScopes log = PypeLogger().get_logger("SyncServer") @@ -396,21 +396,28 @@ class SyncServerModule(PypeModule, ITrayModule): return remote_site - def get_configurable_items(self, scope=None): + def get_configurable_items(self, scope=[EditableScopes.LOCAL]): """ Returns list of items that could be configurable for all projects. Could be filtered by 'scope' argument (list) Args: - scope (list of utils.EditableScope) (optional) + scope (list of utils.EditableScope) Returns: - (dict of dict) - {projectA: { - siteA : { - key:"root", label:"root", value:"valueFromSettings" - } + (dict of list of dict) + { + siteA : [ + { + key:"root", label:"root", + "value":"valueFromSettings", "type": "text" + }, + { + key:"credentials_url", label:"Credentials url", + ... + } + ] } """ editable = {} @@ -428,21 +435,26 @@ class SyncServerModule(PypeModule, ITrayModule): return editable def get_configurable_items_for_project(self, project_name=None, - scope=None): + scope=[EditableScopes.LOCAL]): """ Returns list of items that could be configurable for specific 'project_name' Args: - project_name (str) - scope (list of utils.EditableScope) (optional) + project_name (str) - None > default project, + scope (list of utils.EditableScope) + (optional, None is all scopes, default is LOCAL) Returns: - (dict of dict) - {projectA: { - siteA : { - key:"root", label:"root", value:"valueFromSettings" - } + (dict of list of dict) + { + siteA : [ + { + key:"root", label:"root", + "value":"valueFromSettings", "type": "text" + }, + studio: {...} + ] } """ sites = self.get_all_sites() @@ -451,36 +463,34 @@ class SyncServerModule(PypeModule, ITrayModule): items = self.get_configurable_items_for_site(project_name, site_name, scope) - editable.update(items) + editable[site_name] = items return editable def get_configurable_items_for_site(self, project_name=None, site_name=None, - scope=None): + scope=[EditableScopes.LOCAL]): """ Returns list of items that could be configurable. Args: - project_name (str) + project_name (str) - None > default project site_name (str) - scope (list of utils.EditableScope) (optional) + scope (list of utils.EditableScope) + (optional, None is all scopes) Returns: - (dict of dict) - {projectA: { - siteA : { - key:"root", label:"root", value:"valueFromSettings" - } - } + (list) + [ + { + key:"root", label:"root", + "value":"valueFromSettings", "type": "text" + }, ... + ] """ provider_name = self.get_provider_for_site(site=site_name) items = lib.factory.get_provider_configurable_items(provider_name) - if not scope: - return {site_name: items} - - editable = [] if project_name: sync_s = self.get_sync_project_setting(project_name, exclude_locals=True) @@ -488,18 +498,20 @@ class SyncServerModule(PypeModule, ITrayModule): sync_s = get_default_project_settings(exclude_locals=True) sync_s = sync_s["global"]["sync_server"] sync_s["sites"].update(self._get_default_site_configs()) - for scope in set([scope]): - for key, properties in items.items(): - if scope in properties['scope']: - val = sync_s.get("sites", {}).get(site_name, {}).get(key) - editable.append({ - "key": key, - "value": val, - "label": properties["label"], - "type": properties["type"], - }) - return {site_name: editable} + editable = [] + scope = set([scope]) + for key, properties in items.items(): + if scope is None or scope.intersection(set(properties["scope"])): + val = sync_s.get("sites", {}).get(site_name, {}).get(key) + editable.append({ + "key": key, + "value": val, + "label": properties["label"], + "type": properties["type"], + }) + + return editable def reset_timer(self): """ From 5461ee461ed5e67bb34586462e0f1b69d59e9ef5 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 12 May 2021 12:54:47 +0200 Subject: [PATCH 125/303] SyncServer - fixed documentation --- openpype/modules/sync_server/sync_server_module.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/openpype/modules/sync_server/sync_server_module.py b/openpype/modules/sync_server/sync_server_module.py index c06c7a0f15..b77d19a9c3 100644 --- a/openpype/modules/sync_server/sync_server_module.py +++ b/openpype/modules/sync_server/sync_server_module.py @@ -411,11 +411,11 @@ class SyncServerModule(PypeModule, ITrayModule): siteA : [ { key:"root", label:"root", - "value":"valueFromSettings", "type": "text" + "value":"{'work': 'c:/projects'}", "type": "dict" }, { key:"credentials_url", label:"Credentials url", - ... + "value":"'c:/projects/cred.json'", "type": "text" } ] } @@ -451,9 +451,12 @@ class SyncServerModule(PypeModule, ITrayModule): siteA : [ { key:"root", label:"root", - "value":"valueFromSettings", "type": "text" + "value":"{'work': 'c:/projects'}", "type": "dict" }, - studio: {...} + { + key:"credentials_url", label:"Credentials url", + "value":"'c:/projects/cred.json'", "type": "text" + } ] } """ @@ -484,7 +487,7 @@ class SyncServerModule(PypeModule, ITrayModule): [ { key:"root", label:"root", - "value":"valueFromSettings", "type": "text" + "value":"{'work': 'c:/projects'}", "type": "dict" }, ... ] """ From 895d7ac18a58aa51f6b9534de4db265ceb8b203e Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 12 May 2021 13:04:51 +0200 Subject: [PATCH 126/303] SyncServer - added explicit wrappers Changed back method signature --- .../modules/sync_server/sync_server_module.py | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/openpype/modules/sync_server/sync_server_module.py b/openpype/modules/sync_server/sync_server_module.py index b77d19a9c3..bcd2271eaa 100644 --- a/openpype/modules/sync_server/sync_server_module.py +++ b/openpype/modules/sync_server/sync_server_module.py @@ -396,7 +396,7 @@ class SyncServerModule(PypeModule, ITrayModule): return remote_site - def get_configurable_items(self, scope=[EditableScopes.LOCAL]): + def get_configurable_items(self, scope=None): """ Returns list of items that could be configurable for all projects. @@ -434,8 +434,12 @@ class SyncServerModule(PypeModule, ITrayModule): return editable + def get_local_settings_schema_for_project(self, project_name): + """Wrapper for Local settings""" + return self.get_configurable_items(project_name, EditableScopes.LOCAL) + def get_configurable_items_for_project(self, project_name=None, - scope=[EditableScopes.LOCAL]): + scope=None): """ Returns list of items that could be configurable for specific 'project_name' @@ -470,9 +474,15 @@ class SyncServerModule(PypeModule, ITrayModule): return editable + def get_local_settings_schema_for_site(self, project_name, site_name): + """Wrapper for Local settings""" + return self.get_configurable_items(project_name, + site_name, + EditableScopes.LOCAL) + def get_configurable_items_for_site(self, project_name=None, site_name=None, - scope=[EditableScopes.LOCAL]): + scope=None): """ Returns list of items that could be configurable. @@ -503,7 +513,9 @@ class SyncServerModule(PypeModule, ITrayModule): sync_s["sites"].update(self._get_default_site_configs()) editable = [] - scope = set([scope]) + if type(scope) is not list: + scope = [scope] + scope = set(scope) for key, properties in items.items(): if scope is None or scope.intersection(set(properties["scope"])): val = sync_s.get("sites", {}).get(site_name, {}).get(key) From 54e7a479c3096f73010332b1cc2f57874686382e Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 12 May 2021 13:06:02 +0200 Subject: [PATCH 127/303] dont remove tasks if it's parent was not removed and task is not selected --- openpype/tools/project_manager/project_manager/model.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index f34b9eb39c..03e28717af 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -449,8 +449,12 @@ class HierarchyModel(QtCore.QAbstractItemModel): _all_descendants[parent_item.id][cur_item.id] = cur_item if isinstance(cur_item, TaskItem): + was_removed = cur_item.data(None, REMOVED_ROLE) task_removed = True - cur_item.setData(None, task_removed, REMOVED_ROLE) + if not was_removed and parent_item is not None: + task_removed = parent_item.data(None, REMOVED_ROLE) + if not was_removed: + cur_item.setData(None, task_removed, REMOVED_ROLE) return task_removed remove_item = cur_item.data(None, HIERARCHY_CHANGE_ABLE_ROLE) From e31a2687c88801dbd1db85d384700e0357df3494 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 12 May 2021 13:08:01 +0200 Subject: [PATCH 128/303] added method remove multiple indexes at once --- .../project_manager/project_manager/model.py | 108 +----------------- 1 file changed, 3 insertions(+), 105 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 03e28717af..c0a96058e3 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -431,112 +431,10 @@ class HierarchyModel(QtCore.QAbstractItemModel): return None def remove_index(self, index): - if not index.isValid(): - return + return self.remove_indexes([index]) - item = index.internalPointer() - - if isinstance(item, (RootItem, ProjectItem)): - return - - parent = item.parent() - - all_descendants = collections.defaultdict(dict) - all_descendants[parent.id][item.id] = item - - def _fill_children(_all_descendants, cur_item, parent_item=None): - if parent_item is not None: - _all_descendants[parent_item.id][cur_item.id] = cur_item - - if isinstance(cur_item, TaskItem): - was_removed = cur_item.data(None, REMOVED_ROLE) - task_removed = True - if not was_removed and parent_item is not None: - task_removed = parent_item.data(None, REMOVED_ROLE) - if not was_removed: - cur_item.setData(None, task_removed, REMOVED_ROLE) - return task_removed - - remove_item = cur_item.data(None, HIERARCHY_CHANGE_ABLE_ROLE) - for row in range(cur_item.rowCount()): - child_item = cur_item.child(row) - if not _fill_children(_all_descendants, child_item, cur_item): - remove_item = False - - if remove_item: - cur_item.setData(None, True, REMOVED_ROLE) - return remove_item - - _fill_children(all_descendants, item) - - modified_children = [] - while all_descendants: - for parent_id in tuple(all_descendants.keys()): - children = all_descendants[parent_id] - if not children: - all_descendants.pop(parent_id) - continue - - parent_children = {} - all_without_children = True - for child_id in tuple(children.keys()): - if child_id in all_descendants: - all_without_children = False - break - parent_children[child_id] = children[child_id] - - if not all_without_children: - continue - - parent_item = self._items_by_id[parent_id] - row_ranges = [] - start_row = end_row = None - chilren_by_row = {} - for row in range(parent_item.rowCount()): - child_item = parent_item.child(row) - child_id = child_item.id - if child_id not in children: - continue - - chilren_by_row[row] = child_item - children.pop(child_item.id) - - remove_item = child_item.data(None, REMOVED_ROLE) - if not remove_item or not child_item.is_new: - modified_children.append(child_item) - if end_row is not None: - row_ranges.append((start_row, end_row)) - start_row = end_row = None - continue - - end_row = row - if start_row is None: - start_row = row - - if end_row is not None: - row_ranges.append((start_row, end_row)) - - parent_index = None - for start, end in row_ranges: - if parent_index is None: - parent_index = self.index_for_item(parent_item) - - self.beginRemoveRows(parent_index, start, end) - - for idx in range(start, end + 1): - child_item = chilren_by_row[idx] - # Force name validation - if isinstance(child_item, AssetItem): - self._rename_asset(child_item, None) - child_item.set_parent(None) - self._items_by_id.pop(child_item.id) - - self.endRemoveRows() - - for item in modified_children: - s_index = self.index_for_item(item) - e_index = self.index_for_item(item, column=self.columns_len - 1) - self.dataChanged.emit(s_index, e_index, [QtCore.Qt.BackgroundRole]) + def remove_indexes(self, indexes): + items_by_id = {} def _rename_asset(self, asset_item, new_name): if not isinstance(asset_item, AssetItem): From 44cc3d40113d2110d0f274e9b2f1da61fe7db018 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 12 May 2021 13:08:32 +0200 Subject: [PATCH 129/303] implemeted method that cares about removing single item --- .../project_manager/project_manager/model.py | 104 ++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index c0a96058e3..cb51323975 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -436,6 +436,110 @@ class HierarchyModel(QtCore.QAbstractItemModel): def remove_indexes(self, indexes): items_by_id = {} + def _remove_item(self, item): + is_removed = item.data(None, REMOVED_ROLE) + if is_removed: + return + + parent = item.parent() + + all_descendants = collections.defaultdict(dict) + all_descendants[parent.id][item.id] = item + + def _fill_children(_all_descendants, cur_item, parent_item=None): + if parent_item is not None: + _all_descendants[parent_item.id][cur_item.id] = cur_item + + if isinstance(cur_item, TaskItem): + was_removed = cur_item.data(None, REMOVED_ROLE) + task_removed = True + if not was_removed and parent_item is not None: + task_removed = parent_item.data(None, REMOVED_ROLE) + if not was_removed: + cur_item.setData(None, task_removed, REMOVED_ROLE) + return task_removed + + remove_item = cur_item.data(None, HIERARCHY_CHANGE_ABLE_ROLE) + for row in range(cur_item.rowCount()): + child_item = cur_item.child(row) + if not _fill_children(_all_descendants, child_item, cur_item): + remove_item = False + + if remove_item: + cur_item.setData(None, True, REMOVED_ROLE) + return remove_item + + _fill_children(all_descendants, item) + + modified_children = [] + while all_descendants: + for parent_id in tuple(all_descendants.keys()): + children = all_descendants[parent_id] + if not children: + all_descendants.pop(parent_id) + continue + + parent_children = {} + all_without_children = True + for child_id in tuple(children.keys()): + if child_id in all_descendants: + all_without_children = False + break + parent_children[child_id] = children[child_id] + + if not all_without_children: + continue + + parent_item = self._items_by_id[parent_id] + row_ranges = [] + start_row = end_row = None + chilren_by_row = {} + for row in range(parent_item.rowCount()): + child_item = parent_item.child(row) + child_id = child_item.id + if child_id not in children: + continue + + chilren_by_row[row] = child_item + children.pop(child_item.id) + + remove_item = child_item.data(None, REMOVED_ROLE) + if not remove_item or not child_item.is_new: + modified_children.append(child_item) + if end_row is not None: + row_ranges.append((start_row, end_row)) + start_row = end_row = None + continue + + end_row = row + if start_row is None: + start_row = row + + if end_row is not None: + row_ranges.append((start_row, end_row)) + + parent_index = None + for start, end in row_ranges: + if parent_index is None: + parent_index = self.index_for_item(parent_item) + + self.beginRemoveRows(parent_index, start, end) + + for idx in range(start, end + 1): + child_item = chilren_by_row[idx] + # Force name validation + if isinstance(child_item, AssetItem): + self._rename_asset(child_item, None) + child_item.set_parent(None) + self._items_by_id.pop(child_item.id) + + self.endRemoveRows() + + for item in modified_children: + s_index = self.index_for_item(item) + e_index = self.index_for_item(item, column=self.columns_len - 1) + self.dataChanged.emit(s_index, e_index, [QtCore.Qt.BackgroundRole]) + def _rename_asset(self, asset_item, new_name): if not isinstance(asset_item, AssetItem): return From 188cf1e57a37d70d228f433d8a183132f35da271 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 12 May 2021 13:10:45 +0200 Subject: [PATCH 130/303] remove indexes implemented remove_indexes content --- .../project_manager/project_manager/model.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index cb51323975..0b3bf2fa9e 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -435,6 +435,25 @@ class HierarchyModel(QtCore.QAbstractItemModel): def remove_indexes(self, indexes): items_by_id = {} + processed_ids = set() + for index in indexes: + if not index.isValid(): + continue + + item_id = index.data(IDENTIFIER_ROLE) + # There may be indexes for multiple columns + if item_id not in processed_ids: + processed_ids.add(item_id) + + item = self._items_by_id[item_id] + if isinstance(item, (TaskItem, AssetItem)): + items_by_id[item_id] = item + + if not items_by_id: + return + + for item in items_by_id.values(): + self._remove_item(item) def _remove_item(self, item): is_removed = item.data(None, REMOVED_ROLE) From c1a8d59ad125c7eff5517d934569a68e9785383e Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 12 May 2021 13:17:10 +0200 Subject: [PATCH 131/303] view send all selected indexes to model on remove --- openpype/tools/project_manager/project_manager/view.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index 1db8a12c23..680601587b 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -252,10 +252,10 @@ class HierarchyView(QtWidgets.QTreeView): else: event.accept() - def _delete_item(self, index=None): - if index is None: - index = self.currentIndex() - self._source_model.remove_index(index) + def _delete_item(self, indexes=None): + if indexes is None: + indexes = self.selectedIndexes() + self._source_model.remove_indexes(indexes) def _on_ctrl_shift_enter_pressed(self): self._add_task() From 15a15336b6659daf1d78035692a97b0eea2849d9 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 12 May 2021 14:56:27 +0200 Subject: [PATCH 132/303] added ability to unset delete flags by item ids --- .../project_manager/project_manager/model.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 0b3bf2fa9e..c672cdc053 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -430,6 +430,33 @@ class HierarchyModel(QtCore.QAbstractItemModel): return result[0] return None + def remove_delete_flag(self, item_ids): + remove_tag_items_by_id = {} + for item_id in item_ids: + item = self.items_by_id[item_id] + if not isinstance(item, (AssetItem, TaskItem)): + continue + + if item.data(None, REMOVED_ROLE): + remove_tag_items_by_id[item_id] = item + + for item in remove_tag_items_by_id.values(): + parent = item.parent() + while True: + if not isinstance(parent, (AssetItem, TaskItem)): + break + + if parent.id in remove_tag_items_by_id: + continue + + if parent.data(None, REMOVED_ROLE): + remove_tag_items_by_id[parent.id] = parent + + parent = parent.parent() + + for item in remove_tag_items_by_id.values(): + item.setData(None, False, REMOVED_ROLE) + def remove_index(self, index): return self.remove_indexes([index]) From 439876d64b76c80cd7b2c5901c196dc7653e1e5f Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 12 May 2021 14:57:04 +0200 Subject: [PATCH 133/303] hierarchy view has ability to show context menu --- .../project_manager/project_manager/view.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index 680601587b..598d55270a 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -102,6 +102,7 @@ class HierarchyView(QtWidgets.QTreeView): self.setItemDelegate(main_delegate) self.setAlternatingRowColors(True) self.setSelectionMode(HierarchyView.ExtendedSelection) + self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) column_delegates = {} column_key_to_index = {} @@ -128,6 +129,7 @@ class HierarchyView(QtWidgets.QTreeView): column_key_to_index[key] = column source_model.index_moved.connect(self._on_rows_moved) + self.customContextMenuRequested.connect(self._on_context_menu) self._project_doc_cache = project_doc_cache self._tools_cache = tools_cache @@ -338,3 +340,21 @@ class HierarchyView(QtWidgets.QTreeView): and index.flags() & QtCore.Qt.ItemIsEditable ): self.edit(index) + + def _on_context_menu(self, point): + index = self.indexAt(point) + if index.column() != 0: + return + + actions = [] + + context_menu = QtWidgets.QMenu(self) + if not actions: + return + + for action in actions: + context_menu.addAction(action) + + global_point = self.viewport().mapToGlobal(point) + context_menu.exec_(global_point) + From 196c2bc175cc1cf723fbadf36b80268654fc32e0 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 12 May 2021 14:57:16 +0200 Subject: [PATCH 134/303] added action to unsed remove tag --- .../project_manager/project_manager/view.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index 598d55270a..a91f02a590 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -8,6 +8,10 @@ from .delegates import ( ) from openpype.lib import ApplicationManager +from .constants import ( + REMOVED_ROLE, + IDENTIFIER_ROLE +) class NameDef: @@ -349,6 +353,28 @@ class HierarchyView(QtWidgets.QTreeView): actions = [] context_menu = QtWidgets.QMenu(self) + + indexes = self.selectedIndexes() + + items_by_id = {} + for index in indexes: + item_id = index.data(IDENTIFIER_ROLE) + if item_id in items_by_id: + continue + items_by_id[item_id] = self._source_model.items_by_id[item_id] + + removed_item_ids = [] + for item_id, item in items_by_id.items(): + if item.data(None, REMOVED_ROLE): + removed_item_ids.append(item_id) + + if removed_item_ids: + action = QtWidgets.QAction("Keep items", context_menu) + action.triggered.connect( + lambda: self._remove_delete_flag(removed_item_ids) + ) + actions.append(action) + if not actions: return @@ -358,3 +384,5 @@ class HierarchyView(QtWidgets.QTreeView): global_point = self.viewport().mapToGlobal(point) context_menu.exec_(global_point) + def _remove_delete_flag(self, item_ids): + self._source_model.remove_delete_flag(item_ids) From 40ac5afb28bb0275cd0de89a0fc71f5842d9387c Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 12 May 2021 15:08:58 +0200 Subject: [PATCH 135/303] fix task removement validation order --- openpype/tools/project_manager/project_manager/model.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index c672cdc053..dd514369ea 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -506,13 +506,21 @@ class HierarchyModel(QtCore.QAbstractItemModel): return task_removed remove_item = cur_item.data(None, HIERARCHY_CHANGE_ABLE_ROLE) + task_children = [] for row in range(cur_item.rowCount()): child_item = cur_item.child(row) + if isinstance(child_item, TaskItem): + task_children.append(child_item) + continue + if not _fill_children(_all_descendants, child_item, cur_item): remove_item = False if remove_item: cur_item.setData(None, True, REMOVED_ROLE) + + for task_item in task_children: + _fill_children(_all_descendants, task_item, cur_item) return remove_item _fill_children(all_descendants, item) From 85f0117bec9540b6ff85d74d706c4ea8253780ac Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 12 May 2021 15:20:55 +0200 Subject: [PATCH 136/303] fix remove_delete_flag method --- .../project_manager/project_manager/model.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index dd514369ea..ed84ab631b 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -430,7 +430,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): return result[0] return None - def remove_delete_flag(self, item_ids): + def remove_delete_flag(self, item_ids, with_children=True): remove_tag_items_by_id = {} for item_id in item_ids: item = self.items_by_id[item_id] @@ -440,7 +440,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): if item.data(None, REMOVED_ROLE): remove_tag_items_by_id[item_id] = item - for item in remove_tag_items_by_id.values(): + for item in tuple(remove_tag_items_by_id.values()): parent = item.parent() while True: if not isinstance(parent, (AssetItem, TaskItem)): @@ -454,6 +454,18 @@ class HierarchyModel(QtCore.QAbstractItemModel): parent = parent.parent() + if not with_children: + continue + + def _children_recursion(_item, store_obj): + if isinstance(_item, AssetItem): + for row in range(_item.rowCount()): + _child_item = _item.child(row) + if _child_item.id not in store_obj: + store_obj[_child_item.id] = _child_item + _children_recursion(_child_item, store_obj) + _children_recursion(item, remove_tag_items_by_id) + for item in remove_tag_items_by_id.values(): item.setData(None, False, REMOVED_ROLE) From 0739ee5a3a98f85ee761ba81541687f3738fbc32 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 12 May 2021 15:29:25 +0200 Subject: [PATCH 137/303] save can remove tasks and change type of asset to archived_asset --- .../project_manager/project_manager/model.py | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index ed84ab631b..e9f517b7f1 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -961,7 +961,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): to_process = Queue() to_process.put(project_item) - update_list = [] + bulk_writes = [] while not to_process.empty(): parent = to_process.get() insert_list = [] @@ -973,15 +973,21 @@ class HierarchyModel(QtCore.QAbstractItemModel): if item.is_new: insert_list.append(item) - continue - update_data = item.update_data() - if update_data: - update_list.append(UpdateOne( + elif item.data(None, REMOVED_ROLE): + bulk_writes.append(UpdateOne( {"_id": item.asset_id}, - update_data + {"$set": {"type": "archived_asset"}} )) + else: + update_data = item.update_data() + if update_data: + bulk_writes.append(UpdateOne( + {"_id": item.asset_id}, + update_data + )) + if insert_list: new_docs = [] for item in insert_list: @@ -991,8 +997,8 @@ class HierarchyModel(QtCore.QAbstractItemModel): for idx, mongo_id in enumerate(result.inserted_ids): insert_list[idx].mongo_id = mongo_id - if update_list: - project_col.bulk_write(update_list) + if bulk_writes: + project_col.bulk_write(bulk_writes) class BaseItem: @@ -1606,6 +1612,8 @@ class TaskItem(BaseItem): return super(TaskItem, self)._global_data(role) def to_doc_data(self): + if self._removed: + return {} data = copy.deepcopy(self._data) data.pop("name") name = self.data("name", QtCore.Qt.DisplayRole) From 1d4dd107d30c73355caacd7a72666a1a02e2240f Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 12 May 2021 15:30:22 +0200 Subject: [PATCH 138/303] refresh project after save --- openpype/tools/project_manager/project_manager/model.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index e9f517b7f1..1cca1d1544 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -131,6 +131,11 @@ class HierarchyModel(QtCore.QAbstractItemModel): def _reset_root_item(self): self._root_item = RootItem(self) + def refresh_project(self): + project_name = self._current_project + self._current_project = None + self.set_project(project_name) + def set_project(self, project_name): if self._current_project == project_name: return @@ -1000,6 +1005,8 @@ class HierarchyModel(QtCore.QAbstractItemModel): if bulk_writes: project_col.bulk_write(bulk_writes) + self.refresh_project() + class BaseItem: columns = [] From 1d24e2fc32d9b53453bf90b2116ad9726888c783 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 12 May 2021 15:42:05 +0200 Subject: [PATCH 139/303] SyncServer - modify which sites return when disabled --- .../modules/sync_server/sync_server_module.py | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/openpype/modules/sync_server/sync_server_module.py b/openpype/modules/sync_server/sync_server_module.py index bcd2271eaa..6a97b66e63 100644 --- a/openpype/modules/sync_server/sync_server_module.py +++ b/openpype/modules/sync_server/sync_server_module.py @@ -464,7 +464,7 @@ class SyncServerModule(PypeModule, ITrayModule): ] } """ - sites = self.get_all_sites() + sites = self.get_all_sites(project_name) editable = {} for site_name in sites.keys(): items = self.get_configurable_items_for_site(project_name, @@ -510,7 +510,8 @@ class SyncServerModule(PypeModule, ITrayModule): else: sync_s = get_default_project_settings(exclude_locals=True) sync_s = sync_s["global"]["sync_server"] - sync_s["sites"].update(self._get_default_site_configs()) + sync_s["sites"].update( + self._get_default_site_configs(self.enabled)) editable = [] if type(scope) is not list: @@ -746,7 +747,7 @@ class SyncServerModule(PypeModule, ITrayModule): return sync_settings - def _get_default_site_configs(self): + def _get_default_site_configs(self, sync_enabled=True): """ Returns skeleton settings for 'studio' and user's local site """ @@ -758,25 +759,36 @@ class SyncServerModule(PypeModule, ITrayModule): 'provider': 'local_drive', "root": roots } - all_sites = {self.DEFAULT_SITE: studio_config, - get_local_site_id(): {'provider': 'local_drive'}} + all_sites = {self.DEFAULT_SITE: studio_config} + if sync_enabled: + all_sites[get_local_site_id()] = {'provider': 'local_drive'} return all_sites - def get_all_sites(self): + def get_all_sites(self, project_name=None): """ Returns (dict) with all sites configured system wide. + Args: + project_name (str)(optional): if present, check if not disabled + Returns: (dict): {'studio': {'provider':'local_drive'...}, 'MY_LOCAL': {'provider':....}} """ sys_sett = get_system_settings() sync_sett = sys_sett["modules"].get("sync_server") - system_sites = {} - for site, detail in sync_sett.get("sites", {}).items(): - system_sites[site] = detail - system_sites.update(self._get_default_site_configs()) + project_enabled = True + if project_name: + project_enabled = project_name in self.get_enabled_projects() + sync_enabled = sync_sett["enabled"] and project_enabled + + system_sites = {} + if sync_enabled: + for site, detail in sync_sett.get("sites", {}).items(): + system_sites[site] = detail + + system_sites.update(self._get_default_site_configs(sync_enabled)) return system_sites From 30bc0c8fabc28e1b94de4015e20dc42b528c5c3e Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 12 May 2021 15:46:28 +0200 Subject: [PATCH 140/303] items have item_type --- .../project_manager/constants.py | 1 + .../project_manager/project_manager/model.py | 44 +++++++++++++------ 2 files changed, 31 insertions(+), 14 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/constants.py b/openpype/tools/project_manager/project_manager/constants.py index 76d3d3cda1..703cc0d003 100644 --- a/openpype/tools/project_manager/project_manager/constants.py +++ b/openpype/tools/project_manager/project_manager/constants.py @@ -6,6 +6,7 @@ IDENTIFIER_ROLE = QtCore.Qt.UserRole + 1 DUPLICATED_ROLE = QtCore.Qt.UserRole + 2 HIERARCHY_CHANGE_ABLE_ROLE = QtCore.Qt.UserRole + 3 REMOVED_ROLE = QtCore.Qt.UserRole + 4 +ITEM_TYPE_ROLE = QtCore.Qt.UserRole + 5 NAME_ALLOWED_SYMBOLS = "a-zA-Z0-9_" NAME_REGEX = re.compile("^[" + NAME_ALLOWED_SYMBOLS + "]*$") diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 1cca1d1544..145dacd740 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -5,6 +5,7 @@ from uuid import uuid4 from .constants import ( IDENTIFIER_ROLE, + ITEM_TYPE_ROLE, DUPLICATED_ROLE, HIERARCHY_CHANGE_ABLE_ROLE, REMOVED_ROLE @@ -1015,6 +1016,7 @@ class BaseItem: _name_icon = None _is_duplicated = False + item_type = "base" _None = object() @@ -1027,6 +1029,7 @@ class BaseItem: key: None for key in self.columns } + self._global_data = {} self._source_data = data if data: for key, value in data.items(): @@ -1052,7 +1055,10 @@ class BaseItem: self._children.pop(idx) self._children.insert(row, item) - def _global_data(self, role): + def _get_global_data(self, role): + if role == ITEM_TYPE_ROLE: + return self.item_type + if role == IDENTIFIER_ROLE: return self._id @@ -1062,10 +1068,14 @@ class BaseItem: if role == REMOVED_ROLE: return False - return self._None + return self._global_data.get(role, self._None) + + def _set_global_data(self, value, role): + self._global_data[role] = value + return True def data(self, key, role): - value = self._global_data(role) + value = self._get_global_data(role) if value is not self._None: return value @@ -1096,14 +1106,12 @@ class BaseItem: return True if role == QtCore.Qt.EditRole: - if key not in self.editable_columns: - return False + if key in self.editable_columns: + self._data[key] = value + # must return true if successful + return True - self._data[key] = value - # must return true if successful - return True - - return False + return self._set_global_data(value, role) @property def id(self): @@ -1175,6 +1183,8 @@ class BaseItem: class RootItem(BaseItem): + item_type = "root" + def __init__(self, model): super(RootItem, self).__init__() self._model = model @@ -1187,6 +1197,8 @@ class RootItem(BaseItem): class ProjectItem(BaseItem): + item_type = "project" + columns = { "name", "type", @@ -1261,6 +1273,8 @@ class ProjectItem(BaseItem): class AssetItem(BaseItem): + item_type = "asset" + columns = { "name", "type", @@ -1452,7 +1466,7 @@ class AssetItem(BaseItem): cls._name_icon = qtawesome.icon("fa.folder", color="#333333") return cls._name_icon - def _global_data(self, role): + def _get_global_data(self, role): if role == HIERARCHY_CHANGE_ABLE_ROLE: return self._hierarchy_changes_enabled @@ -1463,7 +1477,7 @@ class AssetItem(BaseItem): return "Asset with name \"{}\" already exists.".format( self._data["name"] ) - return super(AssetItem, self)._global_data(role) + return super(AssetItem, self)._get_global_data(role) def data(self, key, role): if role == QtCore.Qt.BackgroundRole: @@ -1577,6 +1591,8 @@ class AssetItem(BaseItem): class TaskItem(BaseItem): + item_type = "task" + columns = { "name", "type" @@ -1608,7 +1624,7 @@ class TaskItem(BaseItem): def add_child(self, item, row=None): raise AssertionError("BUG: Can't add children to Task") - def _global_data(self, role): + def _get_global_data(self, role): if role == REMOVED_ROLE: return self._removed @@ -1616,7 +1632,7 @@ class TaskItem(BaseItem): return "Duplicated Task name \"{}\".".format( self._data["name"] ) - return super(TaskItem, self)._global_data(role) + return super(TaskItem, self)._get_global_data(role) def to_doc_data(self): if self._removed: From 6f8a57daaf26e170075cf12a27bc7ed4428eb386 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 12 May 2021 15:48:21 +0200 Subject: [PATCH 141/303] context menu has add task and add asset actions --- .../project_manager/project_manager/view.py | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index a91f02a590..c8ce7219ca 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -10,7 +10,8 @@ from .delegates import ( from openpype.lib import ApplicationManager from .constants import ( REMOVED_ROLE, - IDENTIFIER_ROLE + IDENTIFIER_ROLE, + ITEM_TYPE_ROLE ) @@ -347,7 +348,8 @@ class HierarchyView(QtWidgets.QTreeView): def _on_context_menu(self, point): index = self.indexAt(point) - if index.column() != 0: + column = index.column() + if column != 0: return actions = [] @@ -358,11 +360,31 @@ class HierarchyView(QtWidgets.QTreeView): items_by_id = {} for index in indexes: - item_id = index.data(IDENTIFIER_ROLE) - if item_id in items_by_id: + if index.column() != column: continue + + item_id = index.data(IDENTIFIER_ROLE) items_by_id[item_id] = self._source_model.items_by_id[item_id] + item_ids = tuple(items_by_id.keys()) + if len(item_ids) == 1: + item = items_by_id[item_ids[0]] + item_type = item.data(None, ITEM_TYPE_ROLE) + if item_type in ("asset", "project"): + add_asset_action = QtWidgets.QAction("Add Asset", context_menu) + add_asset_action.triggered.connect( + lambda: self._add_asset() + ) + actions.append(add_asset_action) + + if item_type in ("asset", "task"): + add_task_action = QtWidgets.QAction("Add Task", context_menu) + add_task_action.triggered.connect( + lambda: self._add_task() + ) + actions.append(add_task_action) + + # Remove delete tag on items removed_item_ids = [] for item_id, item in items_by_id.items(): if item.data(None, REMOVED_ROLE): From 4319136dd8bb3ae9a4fdc2d421a3a8d6a3aa6f46 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 12 May 2021 15:51:18 +0200 Subject: [PATCH 142/303] assets and task are loaded sorted by name --- openpype/tools/project_manager/project_manager/model.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 145dacd740..e0af188ec6 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -211,7 +211,8 @@ class HierarchyModel(QtCore.QAbstractItemModel): continue new_items = [] - for asset_doc in asset_docs_by_parent_id[parent_id]: + asset_docs = asset_docs_by_parent_id[parent_id] + for asset_doc in sorted(asset_docs, key=lambda item: item["name"]): # Create new Item new_item = AssetItem(asset_doc) # Store item to be added under parent in bulk @@ -255,8 +256,8 @@ class HierarchyModel(QtCore.QAbstractItemModel): continue task_items = [] - for task_name, task_data in asset_tasks.items(): - _task_data = copy.deepcopy(task_data) + for task_name in sorted(asset_tasks.keys()): + _task_data = copy.deepcopy(asset_tasks[task_name]) _task_data["name"] = task_name task_item = TaskItem(_task_data) task_items.append(task_item) From c4d5baae6588630a7424d22c526bdb10d594b6a7 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 12 May 2021 15:52:16 +0200 Subject: [PATCH 143/303] SyncServer - changed output format, added children list --- .../modules/sync_server/sync_server_module.py | 25 ++++++++++++++++--- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/openpype/modules/sync_server/sync_server_module.py b/openpype/modules/sync_server/sync_server_module.py index 6a97b66e63..f834caeb38 100644 --- a/openpype/modules/sync_server/sync_server_module.py +++ b/openpype/modules/sync_server/sync_server_module.py @@ -520,12 +520,29 @@ class SyncServerModule(PypeModule, ITrayModule): for key, properties in items.items(): if scope is None or scope.intersection(set(properties["scope"])): val = sync_s.get("sites", {}).get(site_name, {}).get(key) - editable.append({ + + children = [] + if properties["type"] == "dict": + if val: + for val_key, val_val in val.items(): + child = { + "type": "text", + "key": val_key, + "value": val_val + } + children.append(child) + + item = { "key": key, - "value": val, "label": properties["label"], - "type": properties["type"], - }) + "type": properties["type"] + } + if properties["type"] == "dict": + item["children"] = children + else: + item["value"] = val + + editable.append(item) return editable From fca039884aad17e204d8c863d59edf78fe5a7a55 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 12 May 2021 15:54:19 +0200 Subject: [PATCH 144/303] SyncServer - local_drive sites (studio, local) only configurable in Local Settings In Project settings only configured via Anatomy --- openpype/modules/sync_server/providers/local_drive.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/openpype/modules/sync_server/providers/local_drive.py b/openpype/modules/sync_server/providers/local_drive.py index 2d37d0e1c4..3b3e699d00 100644 --- a/openpype/modules/sync_server/providers/local_drive.py +++ b/openpype/modules/sync_server/providers/local_drive.py @@ -38,8 +38,7 @@ class LocalDriveHandler(AbstractProvider): (dict) """ editable = { - 'root': {'scope': [EditableScopes.PROJECT, - EditableScopes.LOCAL], + 'root': {'scope': [EditableScopes.LOCAL], 'label': "Roots", 'type': 'dict'} } From 97c2a47024236629ba59046063e5ad154a3ffa39 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 12 May 2021 15:54:45 +0200 Subject: [PATCH 145/303] SyncServer - credentials_url is multiplatform path --- .../schemas/projects_schema/schema_project_syncserver.json | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_syncserver.json b/openpype/settings/entities/schemas/projects_schema/schema_project_syncserver.json index bb5ebea45a..9428ce2db0 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_syncserver.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_syncserver.json @@ -50,9 +50,10 @@ "type": "dict", "children": [ { - "type": "text", + "type": "path", "key": "credentials_url", - "label": "Credentials url" + "label": "Credentials url", + "multiplatform": true }, { "type": "dict-modifiable", From bb8452f44eca71c87bb6b24e8fa766bd15cfa801 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 12 May 2021 15:57:13 +0200 Subject: [PATCH 146/303] SyncServer - add namespace for gdrive Namespace points Local Settings where to overwrite value. Namespace values should be unique paths, placeholders could be used. --- openpype/modules/sync_server/providers/gdrive.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/openpype/modules/sync_server/providers/gdrive.py b/openpype/modules/sync_server/providers/gdrive.py index 5578a130b4..851c190094 100644 --- a/openpype/modules/sync_server/providers/gdrive.py +++ b/openpype/modules/sync_server/providers/gdrive.py @@ -107,10 +107,12 @@ class GDriveHandler(AbstractProvider): """ editable = { # credentials could be override on Project or User level - 'credentials_url': {'scope': [EditableScopes.PROJECT, - EditableScopes.LOCAL], - 'label': "Credentials url", - 'type': 'text'}, + 'credentials_url': { + 'scope': [EditableScopes.PROJECT, + EditableScopes.LOCAL], + 'label': "Credentials url", + 'type': 'text', + 'namespace': '{project_setting}/global/sync_server/sites'}, # roots could be override only on Project leve, User cannot 'root': {'scope': [EditableScopes.PROJECT], 'label': "Roots", From 6e16479d22d2f2baaf858787388eb295b71833d5 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 12 May 2021 15:58:15 +0200 Subject: [PATCH 147/303] auto refill selected value of filter combobox on focus out --- .../tools/project_manager/project_manager/widgets.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/openpype/tools/project_manager/project_manager/widgets.py b/openpype/tools/project_manager/project_manager/widgets.py index 1da4da3c24..566e17ea86 100644 --- a/openpype/tools/project_manager/project_manager/widgets.py +++ b/openpype/tools/project_manager/project_manager/widgets.py @@ -57,6 +57,15 @@ class FilterComboBox(QtWidgets.QComboBox): super(FilterComboBox, self).focusInEvent(event) self.lineEdit().selectAll() + def focusOutEvent(self, event): + idx = self.currentIndex() + if idx > -1: + index = self.model().index(idx, 0) + text = index.data(QtCore.Qt.DisplayRole) + if text != self.lineEdit().text(): + self.lineEdit().setText(text) + super(FilterComboBox, self).focusOutEvent(event) + def on_completer_activated(self, text): if text: index = self.findText(text) From 9b65ef84eb7b6f4925928c56f4b7eb44273ac154 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 12 May 2021 16:00:07 +0200 Subject: [PATCH 148/303] shuffle methods order --- .../tools/project_manager/project_manager/view.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index c8ce7219ca..a2a9fc1666 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -295,9 +295,6 @@ class HierarchyView(QtWidgets.QTreeView): # Start editing self.edit(task_type_index) - def _on_shift_enter_pressed(self): - self._add_asset() - def _add_asset(self, index=None): if index is None: index = self.currentIndex() @@ -322,6 +319,9 @@ class HierarchyView(QtWidgets.QTreeView): # Start editing self.edit(new_index) + def _on_shift_enter_pressed(self): + self._add_asset() + def _on_up_ctrl_pressed(self): indexes = self.selectedIndexes() self._source_model.move_horizontal(indexes, -1) @@ -346,6 +346,9 @@ class HierarchyView(QtWidgets.QTreeView): ): self.edit(index) + def _remove_delete_flag(self, item_ids): + self._source_model.remove_delete_flag(item_ids) + def _on_context_menu(self, point): index = self.indexAt(point) column = index.column() @@ -405,6 +408,3 @@ class HierarchyView(QtWidgets.QTreeView): global_point = self.viewport().mapToGlobal(point) context_menu.exec_(global_point) - - def _remove_delete_flag(self, item_ids): - self._source_model.remove_delete_flag(item_ids) From abfb7589227b40b65ed791253c9fa298a7676721 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 12 May 2021 16:22:33 +0200 Subject: [PATCH 149/303] SyncServer - limit return only configured sites to Local Settings --- openpype/modules/sync_server/sync_server_module.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/openpype/modules/sync_server/sync_server_module.py b/openpype/modules/sync_server/sync_server_module.py index f834caeb38..29db74555a 100644 --- a/openpype/modules/sync_server/sync_server_module.py +++ b/openpype/modules/sync_server/sync_server_module.py @@ -464,9 +464,18 @@ class SyncServerModule(PypeModule, ITrayModule): ] } """ + allowed_sites = set() sites = self.get_all_sites(project_name) + if project_name: + # Local Settings can select only from allowed sites for project + allowed_sites.update(set(self.get_active_sites(project_name))) + allowed_sites.update(set(self.get_remote_sites(project_name))) + editable = {} for site_name in sites.keys(): + if allowed_sites and site_name not in allowed_sites: + continue + items = self.get_configurable_items_for_site(project_name, site_name, scope) From 798f67ff64ac7886540d3ba4407eb3319c21e514 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 12 May 2021 16:41:29 +0200 Subject: [PATCH 150/303] SyncServer - added namespace field --- .../modules/sync_server/sync_server_module.py | 54 +++++++++++++------ 1 file changed, 38 insertions(+), 16 deletions(-) diff --git a/openpype/modules/sync_server/sync_server_module.py b/openpype/modules/sync_server/sync_server_module.py index 29db74555a..08810dbad1 100644 --- a/openpype/modules/sync_server/sync_server_module.py +++ b/openpype/modules/sync_server/sync_server_module.py @@ -411,11 +411,19 @@ class SyncServerModule(PypeModule, ITrayModule): siteA : [ { key:"root", label:"root", - "value":"{'work': 'c:/projects'}", "type": "dict" + "value":"{'work': 'c:/projects'}", + "type": "dict", + "children":[ + { "key": "work", + "type": "text", + "value": "c:/projects"} + ] }, { key:"credentials_url", label:"Credentials url", - "value":"'c:/projects/cred.json'", "type": "text" + "value":"'c:/projects/cred.json'", "type": "text", + "namespace": "{project_setting}/global/sync_server/ + sites" } ] } @@ -451,18 +459,25 @@ class SyncServerModule(PypeModule, ITrayModule): Returns: (dict of list of dict) - { - siteA : [ - { - key:"root", label:"root", - "value":"{'work': 'c:/projects'}", "type": "dict" - }, - { - key:"credentials_url", label:"Credentials url", - "value":"'c:/projects/cred.json'", "type": "text" - } - ] - } + { + siteA : [ + { + key:"root", label:"root", + "type": "dict", + "children":[ + { "key": "work", + "type": "text", + "value": "c:/projects"} + ] + }, + { + key:"credentials_url", label:"Credentials url", + "value":"'c:/projects/cred.json'", "type": "text", + "namespace": "{project_setting}/global/sync_server/ + sites" + } + ] + } """ allowed_sites = set() sites = self.get_all_sites(project_name) @@ -505,8 +520,12 @@ class SyncServerModule(PypeModule, ITrayModule): (list) [ { - key:"root", label:"root", - "value":"{'work': 'c:/projects'}", "type": "dict" + key:"root", label:"root", type:"dict", + "children":[ + { "key": "work", + "type": "text", + "value": "c:/projects"} + ] }, ... ] """ @@ -551,6 +570,9 @@ class SyncServerModule(PypeModule, ITrayModule): else: item["value"] = val + if properties.get("namespace"): + item["namespace"] = properties.get("namespace") + editable.append(item) return editable From 434db48bc8e27a1c6e306cdb1accab5884cd96d8 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 12 May 2021 16:52:41 +0200 Subject: [PATCH 151/303] fix horizontal/vertical naming --- .../project_manager/project_manager/model.py | 16 ++++++++-------- .../project_manager/project_manager/view.py | 8 ++++---- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index e0af188ec6..435b6e2538 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -647,7 +647,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): index = self.index_for_item(item) self.setData(index, True, DUPLICATED_ROLE) - def _move_vertical_single(self, index, direction): + def _move_horizontal_single(self, index, direction): if not index.isValid(): return @@ -737,7 +737,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): self.index_moved.emit(index) - def move_vertical(self, indexes, direction): + def move_horizontal(self, indexes, direction): if not indexes: return @@ -745,7 +745,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): indexes = [indexes] if len(indexes) == 1: - self._move_vertical_single(indexes[0], direction) + self._move_horizontal_single(indexes[0], direction) return items_by_id = {} @@ -802,9 +802,9 @@ class HierarchyModel(QtCore.QAbstractItemModel): for item in items: index = self.index_for_item(item) - self._move_vertical_single(index, direction) + self._move_horizontal_single(index, direction) - def _move_horizontal_single(self, index, direction): + def _move_vertical_single(self, index, direction): if not index.isValid(): return @@ -891,7 +891,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): self.index_moved.emit(index) - def move_horizontal(self, indexes, direction): + def move_vertical(self, indexes, direction): if not indexes: return @@ -899,7 +899,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): indexes = [indexes] if len(indexes) == 1: - self._move_horizontal_single(indexes[0], direction) + self._move_vertical_single(indexes[0], direction) return items_by_id = {} @@ -929,7 +929,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): for item in items: index = self.index_for_item(item) - self._move_horizontal_single(index, direction) + self._move_vertical_single(index, direction) def child_removed(self, child): self._items_by_id.pop(child.id, None) diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index a2a9fc1666..4a1f4998a8 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -324,19 +324,19 @@ class HierarchyView(QtWidgets.QTreeView): def _on_up_ctrl_pressed(self): indexes = self.selectedIndexes() - self._source_model.move_horizontal(indexes, -1) + self._source_model.move_vertical(indexes, -1) def _on_down_ctrl_pressed(self): indexes = self.selectedIndexes() - self._source_model.move_horizontal(indexes, 1) + self._source_model.move_vertical(indexes, 1) def _on_left_ctrl_pressed(self): indexes = self.selectedIndexes() - self._source_model.move_vertical(indexes, -1) + self._source_model.move_horizontal(indexes, -1) def _on_right_ctrl_pressed(self): indexes = self.selectedIndexes() - self._source_model.move_vertical(indexes, 1) + self._source_model.move_horizontal(indexes, 1) def _on_enter_pressed(self): index = self.currentIndex() From d01cd88a1be997c8a52a1ea029aa74f443b93cc1 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 12 May 2021 17:00:48 +0200 Subject: [PATCH 152/303] change `key` argument order for `data` and `setData` methods --- .../project_manager/project_manager/model.py | 84 +++++++++---------- .../project_manager/project_manager/view.py | 4 +- 2 files changed, 44 insertions(+), 44 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 435b6e2538..b44e8786aa 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -238,7 +238,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): while not non_modifiable_queue.empty(): item_id = non_modifiable_queue.get() item = self._items_by_id[item_id] - item.setData(None, False, HIERARCHY_CHANGE_ABLE_ROLE) + item.setData(False, HIERARCHY_CHANGE_ABLE_ROLE) parent = item.parent() if ( @@ -281,7 +281,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): key = self.columns[column] item = index.internalPointer() - return item.data(key, role) + return item.data(role, key) def setData(self, index, value, role=QtCore.Qt.EditRole): if not index.isValid(): @@ -296,7 +296,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): ): self._rename_asset(item, value) - result = item.setData(key, value, role) + result = item.setData(value, role, key) if result: self.dataChanged.emit(index, index, [role]) @@ -397,7 +397,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): if parent is None: parent = self._root_item - if parent.data(None, REMOVED_ROLE): + if parent.data(REMOVED_ROLE): return [] if start_row is None: @@ -416,7 +416,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): parent.add_child(item, row) if isinstance(item, AssetItem): - name = item.data("name", QtCore.Qt.DisplayRole) + name = item.data(QtCore.Qt.DisplayRole, "name") self._asset_items_by_name[name].append(item) if item.id not in self._items_by_id: @@ -444,7 +444,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): if not isinstance(item, (AssetItem, TaskItem)): continue - if item.data(None, REMOVED_ROLE): + if item.data(REMOVED_ROLE): remove_tag_items_by_id[item_id] = item for item in tuple(remove_tag_items_by_id.values()): @@ -456,7 +456,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): if parent.id in remove_tag_items_by_id: continue - if parent.data(None, REMOVED_ROLE): + if parent.data(REMOVED_ROLE): remove_tag_items_by_id[parent.id] = parent parent = parent.parent() @@ -474,7 +474,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): _children_recursion(item, remove_tag_items_by_id) for item in remove_tag_items_by_id.values(): - item.setData(None, False, REMOVED_ROLE) + item.setData(False, REMOVED_ROLE) def remove_index(self, index): return self.remove_indexes([index]) @@ -502,7 +502,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): self._remove_item(item) def _remove_item(self, item): - is_removed = item.data(None, REMOVED_ROLE) + is_removed = item.data(REMOVED_ROLE) if is_removed: return @@ -516,15 +516,15 @@ class HierarchyModel(QtCore.QAbstractItemModel): _all_descendants[parent_item.id][cur_item.id] = cur_item if isinstance(cur_item, TaskItem): - was_removed = cur_item.data(None, REMOVED_ROLE) + was_removed = cur_item.data(REMOVED_ROLE) task_removed = True if not was_removed and parent_item is not None: - task_removed = parent_item.data(None, REMOVED_ROLE) + task_removed = parent_item.data(REMOVED_ROLE) if not was_removed: - cur_item.setData(None, task_removed, REMOVED_ROLE) + cur_item.setData(task_removed, REMOVED_ROLE) return task_removed - remove_item = cur_item.data(None, HIERARCHY_CHANGE_ABLE_ROLE) + remove_item = cur_item.data(HIERARCHY_CHANGE_ABLE_ROLE) task_children = [] for row in range(cur_item.rowCount()): child_item = cur_item.child(row) @@ -536,7 +536,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): remove_item = False if remove_item: - cur_item.setData(None, True, REMOVED_ROLE) + cur_item.setData(True, REMOVED_ROLE) for task_item in task_children: _fill_children(_all_descendants, task_item, cur_item) @@ -576,7 +576,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): chilren_by_row[row] = child_item children.pop(child_item.id) - remove_item = child_item.data(None, REMOVED_ROLE) + remove_item = child_item.data(REMOVED_ROLE) if not remove_item or not child_item.is_new: modified_children.append(child_item) if end_row is not None: @@ -617,7 +617,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): if not isinstance(asset_item, AssetItem): return - prev_name = asset_item.data("name", QtCore.Qt.DisplayRole) + prev_name = asset_item.data(QtCore.Qt.DisplayRole, "name") if prev_name == new_name: return @@ -981,7 +981,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): if item.is_new: insert_list.append(item) - elif item.data(None, REMOVED_ROLE): + elif item.data(REMOVED_ROLE): bulk_writes.append(UpdateOne( {"_id": item.asset_id}, {"$set": {"type": "archived_asset"}} @@ -1075,7 +1075,7 @@ class BaseItem: self._global_data[role] = value return True - def data(self, key, role): + def data(self, role, key=None): value = self._get_global_data(role) if value is not self._None: return value @@ -1091,14 +1091,14 @@ class BaseItem: if role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole): value = self._data[key] if value is None: - value = self.parent().data(key, role) + value = self.parent().data(role, key) return value if role == QtCore.Qt.DecorationRole and key == "name": return self.name_icon() return None - def setData(self, key, value, role): + def setData(self, value, role, key=None): if role == DUPLICATED_ROLE: if value == self._is_duplicated: return False @@ -1389,8 +1389,8 @@ class AssetItem(BaseItem): ) doc = { - "name": self.data("name", QtCore.Qt.DisplayRole), - "type": self.data("type", QtCore.Qt.DisplayRole), + "name": self.data(QtCore.Qt.DisplayRole, "name"), + "type": self.data(QtCore.Qt.DisplayRole, "type"), "schema": schema_name, "data": doc_data, "parent": self.project_id @@ -1402,7 +1402,7 @@ class AssetItem(BaseItem): if key in doc: continue # Use `data` method to get inherited values - doc_data[key] = self.data(key, QtCore.Qt.DisplayRole) + doc_data[key] = self.data(QtCore.Qt.DisplayRole, key) return doc @@ -1480,16 +1480,16 @@ class AssetItem(BaseItem): ) return super(AssetItem, self)._get_global_data(role) - def data(self, key, role): + def data(self, role, key=None): if role == QtCore.Qt.BackgroundRole: if self._removed: return QtGui.QColor(255, 0, 0, 127) elif self.is_new: return QtGui.QColor(0, 255, 0, 127) - return super(AssetItem, self).data(key, role) + return super(AssetItem, self).data(role, key) - def setData(self, key, value, role): + def setData(self, value, role, key=None): if role == REMOVED_ROLE: self._removed = value return True @@ -1506,7 +1506,7 @@ class AssetItem(BaseItem): and not self._hierarchy_changes_enabled ): return False - return super(AssetItem, self).setData(key, value, role) + return super(AssetItem, self).setData(value, role, key) def flags(self, key): if key == "name": @@ -1517,18 +1517,18 @@ class AssetItem(BaseItem): return super(AssetItem, self).flags(key) def _add_task(self, item): - name = item.data("name", QtCore.Qt.DisplayRole).lower() - item_id = item.data(None, IDENTIFIER_ROLE) + name = item.data(QtCore.Qt.DisplayRole, "name").lower() + item_id = item.data(IDENTIFIER_ROLE) self._task_name_by_item_id[item_id] = name self._task_items_by_name[name].append(item) if len(self._task_items_by_name[name]) > 1: self._duplicated_task_names.add(name) for _item in self._task_items_by_name[name]: - _item.setData(None, True, DUPLICATED_ROLE) + _item.setData(True, DUPLICATED_ROLE) def _remove_task(self, item): - item_id = item.data(None, IDENTIFIER_ROLE) + item_id = item.data(IDENTIFIER_ROLE) name = self._task_name_by_item_id[item_id] self._task_name_by_item_id.pop(item_id) @@ -1539,11 +1539,11 @@ class AssetItem(BaseItem): elif len(self._task_items_by_name[name]) == 1: self._duplicated_task_names.remove(name) for _item in self._task_items_by_name[name]: - _item.setData(None, False, DUPLICATED_ROLE) + _item.setData(False, DUPLICATED_ROLE) def _rename_task(self, item): - new_name = item.data("name", QtCore.Qt.DisplayRole).lower() - item_id = item.data(None, IDENTIFIER_ROLE) + new_name = item.data(QtCore.Qt.DisplayRole, "name").lower() + item_id = item.data(IDENTIFIER_ROLE) prev_name = self._task_name_by_item_id[item_id] if new_name == prev_name: return @@ -1556,16 +1556,16 @@ class AssetItem(BaseItem): elif len(self._task_items_by_name[prev_name]) == 1: self._duplicated_task_names.remove(prev_name) for _item in self._task_items_by_name[prev_name]: - _item.setData(None, False, DUPLICATED_ROLE) + _item.setData(False, DUPLICATED_ROLE) # Add to new name mapping self._task_items_by_name[new_name].append(item) if len(self._task_items_by_name[new_name]) > 1: self._duplicated_task_names.add(new_name) for _item in self._task_items_by_name[new_name]: - _item.setData(None, True, DUPLICATED_ROLE) + _item.setData(True, DUPLICATED_ROLE) else: - item.setData(None, False, DUPLICATED_ROLE) + item.setData(False, DUPLICATED_ROLE) self._task_name_by_item_id[item_id] = new_name @@ -1640,12 +1640,12 @@ class TaskItem(BaseItem): return {} data = copy.deepcopy(self._data) data.pop("name") - name = self.data("name", QtCore.Qt.DisplayRole) + name = self.data(QtCore.Qt.DisplayRole, "name") return { name: data } - def data(self, key, role): + def data(self, role, key=None): if role == QtCore.Qt.BackgroundRole: if self._removed: return QtGui.QColor(255, 0, 0, 127) @@ -1658,14 +1658,14 @@ class TaskItem(BaseItem): and key == "name" ): return self._data[key] or self._data["type"] or "< Select Type >" - return super(TaskItem, self).data(key, role) + return super(TaskItem, self).data(role, key) - def setData(self, key, value, role): + def setData(self, value, role, key=None): if role == REMOVED_ROLE: self._removed = value return True - result = super(TaskItem, self).setData(key, value, role) + result = super(TaskItem, self).setData(value, role, key) if ( key == "name" diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index 4a1f4998a8..32eff23209 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -372,7 +372,7 @@ class HierarchyView(QtWidgets.QTreeView): item_ids = tuple(items_by_id.keys()) if len(item_ids) == 1: item = items_by_id[item_ids[0]] - item_type = item.data(None, ITEM_TYPE_ROLE) + item_type = item.data(ITEM_TYPE_ROLE) if item_type in ("asset", "project"): add_asset_action = QtWidgets.QAction("Add Asset", context_menu) add_asset_action.triggered.connect( @@ -390,7 +390,7 @@ class HierarchyView(QtWidgets.QTreeView): # Remove delete tag on items removed_item_ids = [] for item_id, item in items_by_id.items(): - if item.data(None, REMOVED_ROLE): + if item.data(REMOVED_ROLE): removed_item_ids.append(item_id) if removed_item_ids: From eeb9722652e238725dd6f905a7b10ccb7418a574 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 12 May 2021 17:07:12 +0200 Subject: [PATCH 153/303] SyncServer - fixed resolving of credentials_url Moved root config creation before its totally necessary --- .../modules/sync_server/providers/gdrive.py | 84 ++++++++++--------- 1 file changed, 46 insertions(+), 38 deletions(-) diff --git a/openpype/modules/sync_server/providers/gdrive.py b/openpype/modules/sync_server/providers/gdrive.py index 851c190094..d3ae0477e2 100644 --- a/openpype/modules/sync_server/providers/gdrive.py +++ b/openpype/modules/sync_server/providers/gdrive.py @@ -3,6 +3,7 @@ import os.path import time import sys from setuptools.extern import six +import platform from openpype.api import Logger from openpype.api import get_system_settings @@ -65,6 +66,7 @@ class GDriveHandler(AbstractProvider): self.project_name = project_name self.site_name = site_name self.service = None + self.root = None self.presets = presets if not self.presets: @@ -72,18 +74,13 @@ class GDriveHandler(AbstractProvider): format(site_name)) return - if not os.path.exists(self.presets.get("credentials_url", "")): + cred_path = self.presets.get("credentials_url", {}).\ + get(platform.system().lower()) or '' + if not os.path.exists(cred_path): log.info("Sync Server: No credentials for Gdrive provider! ") return - self.service = self._get_gd_service() - try: - self.root = self._prepare_root_info() - except errors.HttpError: - log.warning("HttpError in sync loop, " - "trying next loop", - exc_info=True) - raise ResumableError + self.service = self._get_gd_service(cred_path) self._tree = tree self.active = True @@ -575,7 +572,7 @@ class GDriveHandler(AbstractProvider): return return provider_presets - def _get_gd_service(self): + def _get_gd_service(self, credentials_path): """ Authorize client with 'credentials.json', uses service account. Service account needs to have target folder shared with. @@ -585,7 +582,7 @@ class GDriveHandler(AbstractProvider): None """ creds = service_account.Credentials.from_service_account_file( - self.presets["credentials_url"], + credentials_path, scopes=SCOPES) service = build('drive', 'v3', credentials=creds, cache_discovery=False) @@ -600,39 +597,47 @@ class GDriveHandler(AbstractProvider): Returns: (dicts) of dicts where root folders are keys + throws ResumableError in case of errors.HttpError """ roots = {} config_roots = self.get_roots_config() - for path in config_roots.values(): - if self.MY_DRIVE_STR in path: - roots[self.MY_DRIVE_STR] = self.service.files()\ - .get(fileId='root').execute() - else: - shared_drives = [] - page_token = None + try: + for path in config_roots.values(): + if self.MY_DRIVE_STR in path: + roots[self.MY_DRIVE_STR] = self.service.files()\ + .get(fileId='root')\ + .execute() + else: + shared_drives = [] + page_token = None - while True: - response = self.service.drives().list( - pageSize=100, - pageToken=page_token).execute() - shared_drives.extend(response.get('drives', [])) - page_token = response.get('nextPageToken', None) - if page_token is None: - break + while True: + response = self.service.drives().list( + pageSize=100, + pageToken=page_token).execute() + shared_drives.extend(response.get('drives', [])) + page_token = response.get('nextPageToken', None) + if page_token is None: + break - folders = path.split('/') - if len(folders) < 2: - raise ValueError("Wrong root folder definition {}". - format(path)) + folders = path.split('/') + if len(folders) < 2: + raise ValueError("Wrong root folder definition {}". + format(path)) - for shared_drive in shared_drives: - if folders[1] in shared_drive["name"]: - roots[shared_drive["name"]] = { - "name": shared_drive["name"], - "id": shared_drive["id"]} - if self.MY_DRIVE_STR not in roots: # add My Drive always - roots[self.MY_DRIVE_STR] = self.service.files() \ - .get(fileId='root').execute() + for shared_drive in shared_drives: + if folders[1] in shared_drive["name"]: + roots[shared_drive["name"]] = { + "name": shared_drive["name"], + "id": shared_drive["id"]} + if self.MY_DRIVE_STR not in roots: # add My Drive always + roots[self.MY_DRIVE_STR] = self.service.files() \ + .get(fileId='root').execute() + except errors.HttpError: + log.warning("HttpError in sync loop, " + "trying next loop", + exc_info=True) + raise ResumableError return roots @@ -653,6 +658,9 @@ class GDriveHandler(AbstractProvider): (dictionary) path as a key, folder id as a value """ log.debug("build_tree len {}".format(len(folders))) + if not self.root: # build only when necessary, could be expensive + self.root = self._prepare_root_info() + root_ids = [] default_root_id = None tree = {} From 0c779b7ed2e068cfc91ae073a4162ac7212fa157 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 12 May 2021 17:28:48 +0200 Subject: [PATCH 154/303] not possible to move with deleted or not hierachy changeable items --- .../project_manager/project_manager/model.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index b44e8786aa..0ca958f83f 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -659,6 +659,15 @@ class HierarchyModel(QtCore.QAbstractItemModel): if isinstance(item, (RootItem, ProjectItem)): return + if item.data(REMOVED_ROLE): + return + + if ( + isinstance(item, AssetItem) + and not item.data(HIERARCHY_CHANGE_ABLE_ROLE) + ): + return + if abs(direction) != 1: return @@ -813,6 +822,15 @@ class HierarchyModel(QtCore.QAbstractItemModel): if isinstance(item, (RootItem, ProjectItem)): return + if item.data(REMOVED_ROLE): + return + + if ( + isinstance(item, AssetItem) + and not item.data(HIERARCHY_CHANGE_ABLE_ROLE) + ): + return + if abs(direction) != 1: return From 31077bdc5a63cfd8ba6b303dbb91163baa4fddda Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Wed, 12 May 2021 17:29:29 +0200 Subject: [PATCH 155/303] added restrictions to vertical movement into removed items --- .../project_manager/project_manager/model.py | 31 ++++++++++++------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 0ca958f83f..b375bba22f 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -697,22 +697,31 @@ class HierarchyModel(QtCore.QAbstractItemModel): item_row = item.row() dst_parent = None for row in reversed(range(item_row)): - if row == item_row: - continue _item = src_parent.child(row) - if not isinstance(_item, TaskItem): + if not isinstance(_item, AssetItem): + continue + + if _item.data(REMOVED_ROLE): + continue + + dst_parent = _item + break + + _next_row = item_row + 1 + if dst_parent is None and _next_row < src_row_count: + for row in range(_next_row, src_row_count): + _item = src_parent.child(row) + if not isinstance(_item, AssetItem): + continue + + if _item.data(REMOVED_ROLE): + continue + dst_parent = _item break if dst_parent is None: - for row in range(item_row + 1, src_row_count + 2): - _item = src_parent.child(row) - if not isinstance(_item, TaskItem): - dst_parent = _item - break - - if dst_parent is None: - return + return dst_row = dst_parent.rowCount() From 4da285cf8f7f21ea411a9343ba01466189384582 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 12 May 2021 17:30:59 +0200 Subject: [PATCH 156/303] SyncServer - fix multiplatform properties --- .../modules/sync_server/providers/gdrive.py | 5 +++- .../modules/sync_server/sync_server_module.py | 27 ++++++++++++++----- 2 files changed, 24 insertions(+), 8 deletions(-) diff --git a/openpype/modules/sync_server/providers/gdrive.py b/openpype/modules/sync_server/providers/gdrive.py index d3ae0477e2..eaa2f5643b 100644 --- a/openpype/modules/sync_server/providers/gdrive.py +++ b/openpype/modules/sync_server/providers/gdrive.py @@ -102,6 +102,8 @@ class GDriveHandler(AbstractProvider): Returns: (dict) """ + # {platform} tells that value is multiplatform and only specific OS + # should be returned editable = { # credentials could be override on Project or User level 'credentials_url': { @@ -109,7 +111,8 @@ class GDriveHandler(AbstractProvider): EditableScopes.LOCAL], 'label': "Credentials url", 'type': 'text', - 'namespace': '{project_setting}/global/sync_server/sites'}, + 'namespace': '{project_setting}/global/sync_server/sites/{site}/{platform}' # noqa: E501 + }, # roots could be override only on Project leve, User cannot 'root': {'scope': [EditableScopes.PROJECT], 'label': "Roots", diff --git a/openpype/modules/sync_server/sync_server_module.py b/openpype/modules/sync_server/sync_server_module.py index 08810dbad1..69f9131b6f 100644 --- a/openpype/modules/sync_server/sync_server_module.py +++ b/openpype/modules/sync_server/sync_server_module.py @@ -485,6 +485,9 @@ class SyncServerModule(PypeModule, ITrayModule): # Local Settings can select only from allowed sites for project allowed_sites.update(set(self.get_active_sites(project_name))) allowed_sites.update(set(self.get_remote_sites(project_name))) + # Settings allow use of 'local' site, user's site is not 'local' + if 'local' in allowed_sites: + allowed_sites.add(get_local_site_id()) editable = {} for site_name in sites.keys(): @@ -549,6 +552,22 @@ class SyncServerModule(PypeModule, ITrayModule): if scope is None or scope.intersection(set(properties["scope"])): val = sync_s.get("sites", {}).get(site_name, {}).get(key) + item = { + "key": key, + "label": properties["label"], + "type": properties["type"] + } + + if properties.get("namespace"): + item["namespace"] = properties.get("namespace") + if "platform" in item["namespace"]: + try: + if val: + val = val[platform.system().lower()] + except KeyError: + st = "{}'s field value {} should be".format(key, val) # noqa: E501 + log.error(st + " multiplatform dict") + children = [] if properties["type"] == "dict": if val: @@ -560,18 +579,12 @@ class SyncServerModule(PypeModule, ITrayModule): } children.append(child) - item = { - "key": key, - "label": properties["label"], - "type": properties["type"] - } if properties["type"] == "dict": item["children"] = children else: item["value"] = val - if properties.get("namespace"): - item["namespace"] = properties.get("namespace") + editable.append(item) From 10c0bd279b068b55c41a4a3a099363984262ca14 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 12 May 2021 17:46:46 +0200 Subject: [PATCH 157/303] SyncServer - fixed broken studio <> remote use case User should only upload/download when active or remote site is his. --- .../modules/sync_server/sync_server_module.py | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/openpype/modules/sync_server/sync_server_module.py b/openpype/modules/sync_server/sync_server_module.py index 69f9131b6f..0c9a12fcbf 100644 --- a/openpype/modules/sync_server/sync_server_module.py +++ b/openpype/modules/sync_server/sync_server_module.py @@ -994,6 +994,15 @@ class SyncServerModule(PypeModule, ITrayModule): Always is comparing local record, eg. site with 'name' == self.presets[PROJECT_NAME]['config']["active_site"] + This leads to trigger actual upload or download, there is + a use case 'studio' <> 'remote' where user should publish + to 'studio', see progress in Tray GUI, but do not do + physical upload/download + (as multiple user would be doing that). + + Do physical U/D only when any of the sites is user's local, in that + case only user has the data and must U/D. + Args: file (dictionary): of file from representation in Mongo local_site (string): - local side of compare (usually 'studio') @@ -1003,8 +1012,12 @@ class SyncServerModule(PypeModule, ITrayModule): (string) - one of SyncStatus """ sites = file.get("sites") or [] - # if isinstance(sites, list): # temporary, old format of 'sites' - # return SyncStatus.DO_NOTHING + + if get_local_site_id() not in (local_site, remote_site): + # don't do upload/download for studio sites + log.debug("No local site {} - {}".format(local_site, remote_site)) + return SyncStatus.DO_NOTHING + _, remote_rec = self._get_site_rec(sites, remote_site) or {} if remote_rec: # sync remote target created_dt = remote_rec.get("created_dt") From 5f79df6dd601d6d14fc199034700df770e96f6cc Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 12 May 2021 17:52:14 +0200 Subject: [PATCH 158/303] validate list of node types --- .../publish/validate_rendersettings.py | 39 +++++++++++++++++++ .../defaults/project_settings/maya.json | 6 +++ .../schemas/schema_maya_publish.json | 4 ++ 3 files changed, 49 insertions(+) diff --git a/openpype/hosts/maya/plugins/publish/validate_rendersettings.py b/openpype/hosts/maya/plugins/publish/validate_rendersettings.py index c2ed1eeaf0..308612e42e 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rendersettings.py +++ b/openpype/hosts/maya/plugins/publish/validate_rendersettings.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- """Maya validator for render settings.""" import re +from collections import OrderedDict from maya import cmds, mel @@ -213,6 +214,44 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): cls.log.error("Expecting padding of {} ( {} )".format( cls.DEFAULT_PADDING, "0" * cls.DEFAULT_PADDING)) + # load validation definitions from settings + validation_settings = ( + instance.context.data["project_settings"]["maya"]["publish"]["ValidateRenderSettings"].get( + "{}_render_attributes".format(renderer)) + ) + from pprint import pprint + pprint(validation_settings) + # go through definitions and test if such node.attribute exists. + # if so, compare its value from the one required. + for attr, value in OrderedDict(validation_settings).items(): + # first get node of that type + cls.log.debug("{}: {}".format(attr, value)) + node_type = attr.split(".")[0] + attribute_name = ".".join(attr.split(".")[1:]) + nodes = cmds.ls(type=node_type) + + if not isinstance(nodes, list): + cls.log.warning("No nodes of '{}' found.".format(node_type)) + continue + + for node in nodes: + try: + render_value = cmds.getAttr( + "{}.{}".format(node, attribute_name)) + except RuntimeError as e: + invalid = True + cls.log.error( + "Cannot get value of {}.{}".format( + node, attribute_name)) + else: + if value != render_value: + invalid = True + cls.log.error( + ("Invalid value {} set on {}.{}. " + "Expecting {}").format( + render_value, node, attribute_name, value) + ) + return invalid @classmethod diff --git a/openpype/settings/defaults/project_settings/maya.json b/openpype/settings/defaults/project_settings/maya.json index 8600e49518..779b8bb3f3 100644 --- a/openpype/settings/defaults/project_settings/maya.json +++ b/openpype/settings/defaults/project_settings/maya.json @@ -135,6 +135,12 @@ "enabled": false, "attributes": {} }, + "ValidateRenderSettings": { + "arnold_render_attributes": [], + "vray_render_attributes": [], + "redshift_render_attributes": [], + "renderman_render_attributes": [] + }, "ValidateModelName": { "enabled": false, "material_file": { diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json index b737dcda70..4cabf5bb74 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_maya_publish.json @@ -81,6 +81,7 @@ "children": [ { "type": "dict-modifiable", + "store_as_list": true, "key": "arnold_render_attributes", "label": "Arnold Render Attributes", "use_label_wrap": true, @@ -90,6 +91,7 @@ }, { "type": "dict-modifiable", + "store_as_list": true, "key": "vray_render_attributes", "label": "Vray Render Attributes", "use_label_wrap": true, @@ -99,6 +101,7 @@ }, { "type": "dict-modifiable", + "store_as_list": true, "key": "redshift_render_attributes", "label": "Redshift Render Attributes", "use_label_wrap": true, @@ -108,6 +111,7 @@ }, { "type": "dict-modifiable", + "store_as_list": true, "key": "renderman_render_attributes", "label": "Renderman Render Attributes", "use_label_wrap": true, From b835282ad7254444ebf586f9f606efa4ace5ae35 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 12 May 2021 17:55:25 +0200 Subject: [PATCH 159/303] hound fixes --- .../hosts/maya/plugins/publish/validate_rendersettings.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/validate_rendersettings.py b/openpype/hosts/maya/plugins/publish/validate_rendersettings.py index 308612e42e..db835da29f 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rendersettings.py +++ b/openpype/hosts/maya/plugins/publish/validate_rendersettings.py @@ -216,7 +216,7 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): # load validation definitions from settings validation_settings = ( - instance.context.data["project_settings"]["maya"]["publish"]["ValidateRenderSettings"].get( + instance.context.data["project_settings"]["maya"]["publish"]["ValidateRenderSettings"].get( # noqa: E501 "{}_render_attributes".format(renderer)) ) from pprint import pprint @@ -238,7 +238,7 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): try: render_value = cmds.getAttr( "{}.{}".format(node, attribute_name)) - except RuntimeError as e: + except RuntimeError: invalid = True cls.log.error( "Cannot get value of {}.{}".format( From 7d792899bc4b0d00e89b987614eeab653a607630 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Wed, 12 May 2021 18:38:22 +0200 Subject: [PATCH 160/303] add documentation for validator --- .../publish/validate_rendersettings.py | 3 +- website/docs/admin_hosts_maya.md | 52 ++++++++++++++++++ .../maya-admin_render_settings_validator.png | Bin 0 -> 11220 bytes website/sidebars.js | 3 +- 4 files changed, 55 insertions(+), 3 deletions(-) create mode 100644 website/docs/admin_hosts_maya.md create mode 100644 website/docs/assets/maya-admin_render_settings_validator.png diff --git a/openpype/hosts/maya/plugins/publish/validate_rendersettings.py b/openpype/hosts/maya/plugins/publish/validate_rendersettings.py index db835da29f..9aeaad7ff1 100644 --- a/openpype/hosts/maya/plugins/publish/validate_rendersettings.py +++ b/openpype/hosts/maya/plugins/publish/validate_rendersettings.py @@ -219,8 +219,7 @@ class ValidateRenderSettings(pyblish.api.InstancePlugin): instance.context.data["project_settings"]["maya"]["publish"]["ValidateRenderSettings"].get( # noqa: E501 "{}_render_attributes".format(renderer)) ) - from pprint import pprint - pprint(validation_settings) + # go through definitions and test if such node.attribute exists. # if so, compare its value from the one required. for attr, value in OrderedDict(validation_settings).items(): diff --git a/website/docs/admin_hosts_maya.md b/website/docs/admin_hosts_maya.md new file mode 100644 index 0000000000..83c4121be9 --- /dev/null +++ b/website/docs/admin_hosts_maya.md @@ -0,0 +1,52 @@ +--- +id: admin_hosts_maya +title: Maya +sidebar_label: Maya +--- + +## Maya + +### Publish Plugins + +#### Render Settings Validator (`ValidateRenderSettings`) + +Render Settings Validator is here to make sure artists will submit renders +we correct settings. Some of these settings are needed by OpenPype but some +can be defined by TD using [OpenPype Settings UI](admin_settings). + +OpenPype enforced settings include: + +- animation must be enabled in output +- render prefix must start with `maya/` to make sure renders are in +correct directory +- there must be `` or its equivalent in different renderers in +file prefix +- if multiple cameras are to be rendered, `` token must be in file prefix + +For **Vray**: +- AOV separator must be set to `_` (underscore) + +For **Redshift**: +- all AOVs must follow `/_` image file prefix +- AOV image format must be same as the one set in Output settings + +For **Renderman**: +- both image and directory prefixes must comply to `_..` and `/renders/maya//` respectively + +For **Arnold**: +- there shouldn't be `` token when merge AOVs option is turned on + + +Additional check can be added via Settings - **Project Settings > Maya > Publish plugin > ValidateRenderSettings**. +You can add as many options as you want for every supported renderer. In first field put node type and attribute +and in the second required value. + +![Settings example](assets/maya-admin_render_settings_validator.png) + +In this example we've put `aiOptions.AA_samples` in first one and `6` to second to enforce +Arnolds Camera (AA) samples to 6. + +Note that `aiOptions` is not the name of node but rather its type. For renderers there is usually +just one instance of this node type but if that is not so, validator will go through all its +instances and check the value there. Node type for **VRay** settings is `VRaySettingsNode`, for **Renderman** +it is `rmanGlobals`, for **Redshift** it is `RedshiftOptions`. \ No newline at end of file diff --git a/website/docs/assets/maya-admin_render_settings_validator.png b/website/docs/assets/maya-admin_render_settings_validator.png new file mode 100644 index 0000000000000000000000000000000000000000..8687b538b124c6857534d9033a46c70deb875fde GIT binary patch literal 11220 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 Date: Wed, 12 May 2021 19:24:19 +0200 Subject: [PATCH 161/303] SyncServer - fix broken provider access --- openpype/modules/sync_server/sync_server.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/modules/sync_server/sync_server.py b/openpype/modules/sync_server/sync_server.py index 262986fb12..638a4a367f 100644 --- a/openpype/modules/sync_server/sync_server.py +++ b/openpype/modules/sync_server/sync_server.py @@ -298,13 +298,14 @@ class SyncServerThread(threading.Thread): processed_file_path = set() site_preset = preset.get('sites')[remote_site] - remote_provider = site_preset['provider'] + remote_provider = \ + self.module.get_provider_for_site(site=remote_site) handler = lib.factory.get_provider(remote_provider, collection, remote_site, presets=site_preset) limit = lib.factory.get_provider_batch_limit( - site_preset['provider']) + remote_provider) # first call to get_provider could be expensive, its # building folder tree structure in memory # call only if needed, eg. DO_UPLOAD or DO_DOWNLOAD From 0e944f121e8355f9606cad1a6c402919e0ab25b2 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 12 May 2021 19:28:46 +0200 Subject: [PATCH 162/303] SyncServer - fix broken provider access in module --- openpype/modules/sync_server/sync_server_module.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/modules/sync_server/sync_server_module.py b/openpype/modules/sync_server/sync_server_module.py index 0c9a12fcbf..aefe8195c4 100644 --- a/openpype/modules/sync_server/sync_server_module.py +++ b/openpype/modules/sync_server/sync_server_module.py @@ -868,7 +868,8 @@ class SyncServerModule(PypeModule, ITrayModule): proj_settings = self.get_sync_project_setting(project_name) provider = proj_settings.get("sites", {}).get(site, {}).\ get("provider") - return provider + if provider: + return provider sys_sett = get_system_settings() sync_sett = sys_sett["modules"].get("sync_server") From 86cf197d07d89f36550a093f823d0798a0e62126 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 12 May 2021 20:09:05 +0200 Subject: [PATCH 163/303] SyncServer - added better error for not working credentials file --- .../modules/sync_server/providers/gdrive.py | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/openpype/modules/sync_server/providers/gdrive.py b/openpype/modules/sync_server/providers/gdrive.py index eaa2f5643b..65fccb3215 100644 --- a/openpype/modules/sync_server/providers/gdrive.py +++ b/openpype/modules/sync_server/providers/gdrive.py @@ -4,6 +4,7 @@ import time import sys from setuptools.extern import six import platform +from json.decoder import JSONDecodeError from openpype.api import Logger from openpype.api import get_system_settings @@ -77,7 +78,9 @@ class GDriveHandler(AbstractProvider): cred_path = self.presets.get("credentials_url", {}).\ get(platform.system().lower()) or '' if not os.path.exists(cred_path): - log.info("Sync Server: No credentials for Gdrive provider! ") + msg = "Sync Server: No credentials for gdrive provider " + \ + "for '{}' on path '{}'!".format(site_name, cred_path) + log.info(msg) return self.service = self._get_gd_service(cred_path) @@ -584,11 +587,18 @@ class GDriveHandler(AbstractProvider): Returns: None """ - creds = service_account.Credentials.from_service_account_file( - credentials_path, - scopes=SCOPES) - service = build('drive', 'v3', - credentials=creds, cache_discovery=False) + service = None + try: + creds = service_account.Credentials.from_service_account_file( + credentials_path, + scopes=SCOPES) + service = build('drive', 'v3', + credentials=creds, cache_discovery=False) + except Exception: + log.error("Connection failed, " + + "check '{}' credentials file".format(credentials_path), + exc_info=True) + return service def _prepare_root_info(self): From dab7a53fb48c97fc42894935c723ff23fbc7e286 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 May 2021 11:42:41 +0200 Subject: [PATCH 164/303] vertical movement can go to next parents across the board --- .../project_manager/project_manager/model.py | 89 ++++++++++++++++--- 1 file changed, 77 insertions(+), 12 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index b375bba22f..7626ef4199 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -844,11 +844,22 @@ class HierarchyModel(QtCore.QAbstractItemModel): return src_parent = item.parent() + if not isinstance(src_parent, AssetItem): + return + src_parent_index = self.index_from_item( src_parent.row(), 0, src_parent.parent() ) source_row = item.row() + parent_items = [] + parent = src_parent + while True: + parent = parent.parent() + parent_items.insert(0, parent) + if isinstance(parent, ProjectItem): + break + dst_parent = None dst_parent_index = None destination_row = None @@ -874,20 +885,71 @@ class HierarchyModel(QtCore.QAbstractItemModel): # Up elif direction == -1: - if source_row > 0: - dst_parent_index = src_parent_index - dst_parent = src_parent - destination_row = source_row - 1 - else: - parent_parent = src_parent.parent() - if not parent_parent: + current_idxs = [] + for parent_item in parent_items: + if not isinstance(parent_item, ProjectItem): + current_idxs.append(parent_item.row()) + current_idxs.append(src_parent.row()) + + max_idxs = [0 for _ in current_idxs] + indexes_len = len(current_idxs) + + while True: + if current_idxs == max_idxs: return - previous_parent = parent_parent.child(src_parent.row() - 1) - if not previous_parent: + def _update_parents( + _current_idx, _parent_items, _current_idxs, top=True + ): + if _current_idx < 0: + return False + + if _current_idxs[_current_idx] == 0: + if not _update_parents( + _current_idx - 1, _parent_items, _current_idxs, False + ): + return False + + parent = _parent_items[_current_idx] + row_count = 0 + if parent is not None: + row_count = parent.rowCount() + _current_idxs[_current_idx] = row_count + return True + if top: + return True + + _current_idxs[_current_idx] -= 1 + parent_item = _parent_items[_current_idx] + new_item = parent_item.child(_current_idxs[_current_idx]) + _parent_items[_current_idx + 1] = new_item + + return True + + updated = _update_parents( + indexes_len - 1, parent_items, current_idxs + ) + if not updated: return - dst_parent = previous_parent - destination_row = previous_parent.rowCount() + + parent_item = parent_items[-1] + row_count = current_idxs[-1] + current_idxs[-1] = 0 + for row in reversed(range(row_count)): + child_item = parent_item.child(row) + if ( + child_item is src_parent + or child_item.data(REMOVED_ROLE) + or not isinstance(child_item, AssetItem) + ): + continue + + dst_parent = child_item + destination_row = dst_parent.rowCount() + break + + if dst_parent is not None: + break if dst_parent_index is None: dst_parent_index = self.index_from_item( @@ -916,7 +978,10 @@ class HierarchyModel(QtCore.QAbstractItemModel): self.endMoveRows() - self.index_moved.emit(index) + new_index = self.index( + _destination_row, index.column(), dst_parent_index + ) + self.index_moved.emit(new_index) def move_vertical(self, indexes, direction): if not indexes: From 1bdae76a96a40b5199cd220cf5389c1d98945f97 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 May 2021 11:50:42 +0200 Subject: [PATCH 165/303] simplified parent resolving function --- .../project_manager/project_manager/model.py | 26 +++++++------------ 1 file changed, 10 insertions(+), 16 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 7626ef4199..4e0139a8d1 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -898,37 +898,31 @@ class HierarchyModel(QtCore.QAbstractItemModel): if current_idxs == max_idxs: return - def _update_parents( - _current_idx, _parent_items, _current_idxs, top=True - ): + def _update_parents(_current_idx, top=True): if _current_idx < 0: return False - if _current_idxs[_current_idx] == 0: - if not _update_parents( - _current_idx - 1, _parent_items, _current_idxs, False - ): + if current_idxs[_current_idx] == 0: + if not _update_parents(_current_idx - 1, False): return False - parent = _parent_items[_current_idx] + parent = parent_items[_current_idx] row_count = 0 if parent is not None: row_count = parent.rowCount() - _current_idxs[_current_idx] = row_count + current_idxs[_current_idx] = row_count return True if top: return True - _current_idxs[_current_idx] -= 1 - parent_item = _parent_items[_current_idx] - new_item = parent_item.child(_current_idxs[_current_idx]) - _parent_items[_current_idx + 1] = new_item + current_idxs[_current_idx] -= 1 + parent_item = parent_items[_current_idx] + new_item = parent_item.child(current_idxs[_current_idx]) + parent_items[_current_idx + 1] = new_item return True - updated = _update_parents( - indexes_len - 1, parent_items, current_idxs - ) + updated = _update_parents(indexes_len - 1) if not updated: return From 1e59f2f91903fa5a61c86848f1ba2c59d940c47a Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 May 2021 12:10:07 +0200 Subject: [PATCH 166/303] it is possible to jump with items in both ways --- .../project_manager/project_manager/model.py | 89 +++++++++++++------ 1 file changed, 64 insertions(+), 25 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 4e0139a8d1..86f889ae95 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -861,27 +861,67 @@ class HierarchyModel(QtCore.QAbstractItemModel): break dst_parent = None - dst_parent_index = None - destination_row = None - _destination_row = None # Down if direction == 1: - if source_row < src_parent.rowCount() - 1: - dst_parent_index = src_parent_index - dst_parent = src_parent - destination_row = source_row + 1 - # This row is not row number after moving but before moving - _destination_row = destination_row + 1 - else: - destination_row = 0 - parent_parent = src_parent.parent() - if not parent_parent: + current_idxs = [] + current_max_idxs = [] + for parent_item in parent_items: + current_max_idxs.append(parent_item.rowCount()) + if not isinstance(parent_item, ProjectItem): + current_idxs.append(parent_item.row()) + current_idxs.append(src_parent.row()) + indexes_len = len(current_idxs) + + while True: + def _update_parents(idx, top=True): + if idx < 0: + return False + + if current_max_idxs[idx] == current_idxs[idx]: + if not _update_parents(idx - 1, False): + return False + + parent = parent_items[idx] + row_count = 0 + if parent is not None: + row_count = parent.rowCount() + current_max_idxs[idx] = row_count + current_idxs[idx] = 0 + return True + + if top: + return True + + current_idxs[idx] += 1 + parent_item = parent_items[idx] + new_item = parent_item.child(current_idxs[idx]) + parent_items[idx + 1] = new_item + + return True + + updated = _update_parents(indexes_len - 1) + if not updated: return - new_parent = parent_parent.child(src_parent.row() + 1) - if not new_parent: - return - dst_parent = new_parent + start = current_idxs[-1] + end = current_max_idxs[-1] + current_idxs[-1] = current_max_idxs[-1] + parent = parent_items[-1] + for row in range(start, end): + child_item = parent.child(row) + if ( + child_item is src_parent + or child_item.data(REMOVED_ROLE) + or not isinstance(child_item, AssetItem) + ): + continue + + dst_parent = child_item + destination_row = 0 + break + + if dst_parent is not None: + break # Up elif direction == -1: @@ -945,20 +985,19 @@ class HierarchyModel(QtCore.QAbstractItemModel): if dst_parent is not None: break - if dst_parent_index is None: - dst_parent_index = self.index_from_item( - dst_parent.row(), 0, dst_parent.parent() - ) + if dst_parent is None: + return - if _destination_row is None: - _destination_row = destination_row + dst_parent_index = self.index_from_item( + dst_parent.row(), 0, dst_parent.parent() + ) self.beginMoveRows( src_parent_index, source_row, source_row, dst_parent_index, - _destination_row + destination_row ) if src_parent is dst_parent: @@ -973,7 +1012,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): self.endMoveRows() new_index = self.index( - _destination_row, index.column(), dst_parent_index + destination_row, index.column(), dst_parent_index ) self.index_moved.emit(new_index) From d3f66d28d6b352347d809c801c86b8e1a9315067 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 May 2021 12:10:24 +0200 Subject: [PATCH 167/303] easier horizontal movement --- .../tools/project_manager/project_manager/model.py | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 86f889ae95..98952c8b73 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -680,7 +680,6 @@ class HierarchyModel(QtCore.QAbstractItemModel): dst_row = None dst_parent = None - dst_parent_index = None if direction == -1: if isinstance(src_parent, (RootItem, ProjectItem)): @@ -734,10 +733,9 @@ class HierarchyModel(QtCore.QAbstractItemModel): ): return - if dst_parent_index is None: - dst_parent_index = self.index_from_item( - dst_parent.row(), 0, dst_parent.parent() - ) + dst_parent_index = self.index_from_item( + dst_parent.row(), 0, dst_parent.parent() + ) self.beginMoveRows( src_parent_index, @@ -753,7 +751,8 @@ class HierarchyModel(QtCore.QAbstractItemModel): self.endMoveRows() - self.index_moved.emit(index) + new_index = self.index(dst_row, index.column(), dst_parent_index) + self.index_moved.emit(new_index) def move_horizontal(self, indexes, direction): if not indexes: From e999b1a8c4c2e854f6f0a382aef5e25f69c93542 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 May 2021 12:10:37 +0200 Subject: [PATCH 168/303] fix task remove and name validations --- openpype/tools/project_manager/project_manager/model.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 98952c8b73..8c70c4e9fe 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -1650,13 +1650,14 @@ class AssetItem(BaseItem): self._duplicated_task_names.add(name) for _item in self._task_items_by_name[name]: _item.setData(True, DUPLICATED_ROLE) + elif item.data(DUPLICATED_ROLE): + item.setData(False, DUPLICATED_ROLE) def _remove_task(self, item): item_id = item.data(IDENTIFIER_ROLE) - name = self._task_name_by_item_id[item_id] - self._task_name_by_item_id.pop(item_id) - self._task_items_by_name[name].append(item) + name = self._task_name_by_item_id.pop(item_id) + self._task_items_by_name[name].remove(item) if not self._task_items_by_name[name]: self._task_items_by_name.pop(name) From e80488f5820fe90c9ff8630f3ebeb6fa548fcdc0 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 13 May 2021 12:12:07 +0200 Subject: [PATCH 169/303] Harmony - added documentation for Project Settings --- website/docs/admin_hosts_harmony.md | 51 ++++++++++++++++++ .../assets/admin_hosts_harmony_settings.png | Bin 0 -> 39384 bytes website/sidebars.js | 3 +- 3 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 website/docs/admin_hosts_harmony.md create mode 100644 website/docs/assets/admin_hosts_harmony_settings.png diff --git a/website/docs/admin_hosts_harmony.md b/website/docs/admin_hosts_harmony.md new file mode 100644 index 0000000000..756ca1c27f --- /dev/null +++ b/website/docs/admin_hosts_harmony.md @@ -0,0 +1,51 @@ +--- +id: admin_hosts_harmony +title: ToonBoom Harmony Settings +sidebar_label: ToonBoom Harmony +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +## ToonBoom Harmony settings + +There is a couple of settings that could configure publishing process for **ToonBoom Harmony**. +All of them are Project based, eg. each project could have different configuration. + +Location: Settings > Project > Harmony + +![Harmony Project Settings](assets/admin_hosts_harmony_settings.png) + +## Publish plugins + +### Collect Palettes + +#### Allowed tasks + +Set regex pattern(s) only for task names when publishing of Palettes should occur. + +Use ".*" for publish Palettes for ALL tasks. + +### Validate Scene Settings + +#### Skip Frame check for Assets with + +Set regex pattern(s) to find in Asset name to skip checks of `frameEnd` value from DB. + +#### Skip Resolution Check for Tasks + +Set regex pattern(s) to find in Task name to skip resolution check against values from DB. + +#### Skip Timeline Check for Tasks + +Set regex pattern(s) to find in Task name to skip `frameStart`, `frameEnd` check against values from DB. + +### Harmony Submit to Deadline + +* `Use Published scene` - Set to True (green) when Deadline should take published scene as a source instead of uploaded local one. +* `Priority` - priority of job on farm +* `Primary Pool` - here is list of pool fetched from server you can select from. +* `Secondary Pool` +* `Frames Per Task` - number of sequence division between individual tasks (chunks) +making one job on farm. + diff --git a/website/docs/assets/admin_hosts_harmony_settings.png b/website/docs/assets/admin_hosts_harmony_settings.png new file mode 100644 index 0000000000000000000000000000000000000000..800a64e98664aa1f947a5f770319c36c2a4dfc38 GIT binary patch literal 39384 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 Date: Thu, 13 May 2021 12:17:31 +0200 Subject: [PATCH 170/303] Harmony - cleaned up settings location Standardized to use regex when possible Removed 'General' sections as everything is in ValidateSceneSettings --- openpype/hosts/harmony/api/__init__.py | 22 +++++--- .../publish/validate_scene_settings.py | 24 +++++---- .../defaults/project_settings/harmony.json | 16 +++--- .../schema_project_harmony.json | 50 +++++++++++-------- 4 files changed, 66 insertions(+), 46 deletions(-) diff --git a/openpype/hosts/harmony/api/__init__.py b/openpype/hosts/harmony/api/__init__.py index 705ccef892..523f947be5 100644 --- a/openpype/hosts/harmony/api/__init__.py +++ b/openpype/hosts/harmony/api/__init__.py @@ -3,6 +3,7 @@ import os from pathlib import Path import logging +import re from openpype import lib from openpype.api import (get_current_project_settings) @@ -68,19 +69,28 @@ def get_asset_settings(): settings = get_current_project_settings() try: - skip_resolution_check = \ - settings["harmony"]["general"]["skip_resolution_check"] - skip_timelines_check = \ - settings["harmony"]["general"]["skip_timelines_check"] + skip_resolution_check = (settings["plugins"] + ["harmony"] + ["publish"] + ["ValidateSceneSettings"] + ["skip_resolution_check"]) + + skip_timelines_check = (settings["plugins"] + ["harmony"] + ["publish"] + ["ValidateSceneSettings"] + ["skip_timelines_check"]) except KeyError: skip_resolution_check = [] skip_timelines_check = [] - if os.getenv('AVALON_TASK') in skip_resolution_check: + if (any(re.search(pattern, os.getenv('AVALON_TASK')) + for pattern in skip_resolution_check)): scene_data.pop("resolutionWidth") scene_data.pop("resolutionHeight") - if entity_type in skip_timelines_check: + if (any(re.search(pattern, entity_type) + for pattern in skip_timelines_check)): scene_data.pop('frameStart', None) scene_data.pop('frameEnd', None) diff --git a/openpype/hosts/harmony/plugins/publish/validate_scene_settings.py b/openpype/hosts/harmony/plugins/publish/validate_scene_settings.py index b3e7f49268..c5cd141636 100644 --- a/openpype/hosts/harmony/plugins/publish/validate_scene_settings.py +++ b/openpype/hosts/harmony/plugins/publish/validate_scene_settings.py @@ -2,6 +2,7 @@ """Validate scene settings.""" import os import json +import re import pyblish.api @@ -41,10 +42,16 @@ class ValidateSceneSettings(pyblish.api.InstancePlugin): families = ["workfile"] hosts = ["harmony"] actions = [ValidateSceneSettingsRepair] + optional = True - frame_check_filter = ["_ch_", "_pr_", "_intd_", "_extd_"] - # used for skipping resolution validation for render tasks - render_check_filter = ["render", "Render"] + # skip frameEnd check if asset contains any of: + frame_check_filter = ["_ch_", "_pr_", "_intd_", "_extd_"] # regex + + # skip resolution check if Task name matches any of regex patterns + skip_resolution_check = ["render", "Render"] # regex + + # skip frameStart, frameEnd check if Task name matches any of regex patt. + skip_timelines_check = [] # regex def process(self, instance): """Plugin entry point.""" @@ -55,8 +62,9 @@ class ValidateSceneSettings(pyblish.api.InstancePlugin): expected_settings["frameEndHandle"] = expected_settings["frameEnd"] +\ expected_settings["handleEnd"] - if any(string in instance.context.data['anatomyData']['asset'] - for string in self.frame_check_filter): + asset_name = instance.context.data['anatomyData']['asset'] + if any(re.search(pattern, asset_name) + for pattern in self.frame_check_filter): expected_settings.pop("frameEnd") # handle case where ftrack uses only two decimal places @@ -66,12 +74,6 @@ class ValidateSceneSettings(pyblish.api.InstancePlugin): fps = float( "{:.2f}".format(instance.context.data.get("frameRate"))) - if any(string in instance.context.data['anatomyData']['task'] - for string in self.render_check_filter): - self.log.debug("Render task detected, resolution check skipped") - expected_settings.pop("resolutionWidth") - expected_settings.pop("resolutionHeight") - self.log.debug(expected_settings) current_settings = { diff --git a/openpype/settings/defaults/project_settings/harmony.json b/openpype/settings/defaults/project_settings/harmony.json index f5f084dd44..2131516805 100644 --- a/openpype/settings/defaults/project_settings/harmony.json +++ b/openpype/settings/defaults/project_settings/harmony.json @@ -1,13 +1,15 @@ { - "general": { - "skip_resolution_check": [], - "skip_timelines_check": [] - }, "publish": { "CollectPalettes": { - "allowed_tasks": [ - "." - ] + "allowed_tasks": [".*"] + }, + "ValidateSceneSettings": { + "enabled": true, + "optional": true, + "active": true, + "frame_check_filter": ["_ch_", "_pr_", "_intd_", "_extd_"], + "skip_resolution_check": ["render", "Render"], + "skip_timelines_check": [] }, "HarmonySubmitDeadline": { "use_published": false, diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_harmony.json b/openpype/settings/entities/schemas/projects_schema/schema_project_harmony.json index c4cdccff42..8b4e379691 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_harmony.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_harmony.json @@ -5,26 +5,6 @@ "label": "Harmony", "is_file": true, "children": [ - { - "type": "dict", - "collapsible": true, - "key": "general", - "label": "General", - "children": [ - { - "type": "list", - "key": "skip_resolution_check", - "object_type": "text", - "label": "Skip Resolution Check for Tasks" - }, - { - "type": "list", - "key": "skip_timelines_check", - "object_type": "text", - "label": "Skip Timeliene Check for Tasks" - } - ] - }, { "type": "dict", "collapsible": true, @@ -45,6 +25,32 @@ } ] }, + { + "type": "dict", + "collapsible": true, + "key": "ValidateSceneSettings", + "label": "Validate Scene Settings", + "children": [ + { + "type": "list", + "key": "frame_check_filter", + "label": "Skip Frame check for Assets with", + "object_type": "text" + }, + { + "type": "list", + "key": "skip_resolution_check", + "object_type": "text", + "label": "Skip Resolution Check for Tasks" + }, + { + "type": "list", + "key": "skip_timelines_check", + "object_type": "text", + "label": "Skip Timeline Check for Tasks" + } + ] + }, { "type": "dict", "collapsible": true, @@ -59,7 +65,7 @@ { "type": "number", "key": "priority", - "label": "priority" + "label": "Priority" }, { "type": "text", @@ -74,7 +80,7 @@ { "type": "number", "key": "chunk_size", - "label": "Chunk Size" + "label": "Frames Per Task" } ] } From 8a1028912bfdcbc02c13e2890b71ddaca8880618 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 May 2021 12:43:29 +0200 Subject: [PATCH 171/303] return no flags if item is None --- openpype/tools/project_manager/project_manager/model.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 8c70c4e9fe..20162847f2 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -313,6 +313,8 @@ class HierarchyModel(QtCore.QAbstractItemModel): def flags(self, index): item = index.internalPointer() + if item is None: + return QtCore.Qt.NoItemFlags column = index.column() key = self.columns[column] return item.flags(key) From 9268f5b8e05565411dfd06d2acd1f8f0572e8462 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 May 2021 12:56:02 +0200 Subject: [PATCH 172/303] jump from type to name index on type commit --- openpype/tools/project_manager/project_manager/view.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index 32eff23209..a1763d3067 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -162,7 +162,15 @@ class HierarchyView(QtWidgets.QTreeView): column = current_index.column() row = current_index.row() skipped_index = None - if column > 0: + # Change column from "type" to "name" + if column == 1: + new_index = self._source_model.index( + current_index.row(), + 0, + current_index.parent() + ) + self.setCurrentIndex(new_index) + elif column > 0: indexes = [] for index in self.selectedIndexes(): if index.column() == column: From 9042e6689437f187ae4e0755196e4531856ae417 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 May 2021 12:56:36 +0200 Subject: [PATCH 173/303] change method name to match plural --- openpype/tools/project_manager/project_manager/view.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index a1763d3067..c1f416a484 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -238,7 +238,7 @@ class HierarchyView(QtWidgets.QTreeView): def keyPressEvent(self, event): call_super = False if event.key() == QtCore.Qt.Key_Delete: - self._delete_item() + self._delete_items() elif event.key() in (QtCore.Qt.Key_Return, QtCore.Qt.Key_Enter): mdfs = event.modifiers() @@ -267,7 +267,7 @@ class HierarchyView(QtWidgets.QTreeView): else: event.accept() - def _delete_item(self, indexes=None): + def _delete_items(self, indexes=None): if indexes is None: indexes = self.selectedIndexes() self._source_model.remove_indexes(indexes) From f6019b2bb14ecdeccc95793d3bfddd35136981e5 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 May 2021 13:53:10 +0200 Subject: [PATCH 174/303] added collapse and expand actions --- .../project_manager/project_manager/view.py | 71 ++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index c1f416a484..042fae6074 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -1,4 +1,6 @@ -from Qt import QtWidgets, QtCore +from queue import Queue + +from Qt import QtWidgets, QtCore, QtGui from .delegates import ( NumberDelegate, @@ -357,6 +359,52 @@ class HierarchyView(QtWidgets.QTreeView): def _remove_delete_flag(self, item_ids): self._source_model.remove_delete_flag(item_ids) + def _expand_items(self, indexes): + item_ids = set() + process_queue = Queue() + for index in indexes: + if index.column() == 0: + process_queue.put(index) + + while not process_queue.empty(): + index = process_queue.get() + item_id = index.data(IDENTIFIER_ROLE) + if item_id in item_ids: + continue + item_ids.add(item_id) + + item = self._source_model._items_by_id[item_id] + if not self.isExpanded(index): + self.expand(index) + + for row in range(item.rowCount()): + process_queue.put(self._source_model.index( + row, 0, index + )) + + def _collapse_items(self, indexes): + item_ids = set() + process_queue = Queue() + for index in indexes: + if index.column() == 0: + process_queue.put(index) + + while not process_queue.empty(): + index = process_queue.get() + item_id = index.data(IDENTIFIER_ROLE) + if item_id in item_ids: + continue + item_ids.add(item_id) + + item = self._source_model._items_by_id[item_id] + if self.isExpanded(index): + self.collapse(index) + + for row in range(item.rowCount()): + process_queue.put(self._source_model.index( + row, 0, index + )) + def _on_context_menu(self, point): index = self.indexAt(point) column = index.column() @@ -408,6 +456,27 @@ class HierarchyView(QtWidgets.QTreeView): ) actions.append(action) + # Collapse/Expand action + show_collapse_expand_action = False + for item_id in item_ids: + item = items_by_id[item_ids[0]] + item_type = item.data(ITEM_TYPE_ROLE) + if item_type != "task": + show_collapse_expand_action = True + break + + if show_collapse_expand_action: + expand_action = QtWidgets.QAction("Expand all", context_menu) + collapse_action = QtWidgets.QAction("Collapse all", context_menu) + expand_action.triggered.connect( + lambda: self._expand_items(indexes) + ) + collapse_action.triggered.connect( + lambda: self._collapse_items(indexes) + ) + actions.append(expand_action) + actions.append(collapse_action) + if not actions: return From ef5dd3c543563f5396f0ac0676dd0ea954a0c450 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 13 May 2021 13:59:53 +0200 Subject: [PATCH 175/303] Harmony - moved filtering of DB setting from init to validator --- openpype/hosts/harmony/api/__init__.py | 30 ++----------------- .../publish/validate_scene_settings.py | 19 ++++++++++-- 2 files changed, 18 insertions(+), 31 deletions(-) diff --git a/openpype/hosts/harmony/api/__init__.py b/openpype/hosts/harmony/api/__init__.py index 523f947be5..fd21725bd5 100644 --- a/openpype/hosts/harmony/api/__init__.py +++ b/openpype/hosts/harmony/api/__init__.py @@ -64,35 +64,9 @@ def get_asset_settings(): "handleStart": handle_start, "handleEnd": handle_end, "resolutionWidth": resolution_width, - "resolutionHeight": resolution_height + "resolutionHeight": resolution_height, + "entityType": entity_type } - settings = get_current_project_settings() - - try: - skip_resolution_check = (settings["plugins"] - ["harmony"] - ["publish"] - ["ValidateSceneSettings"] - ["skip_resolution_check"]) - - skip_timelines_check = (settings["plugins"] - ["harmony"] - ["publish"] - ["ValidateSceneSettings"] - ["skip_timelines_check"]) - except KeyError: - skip_resolution_check = [] - skip_timelines_check = [] - - if (any(re.search(pattern, os.getenv('AVALON_TASK')) - for pattern in skip_resolution_check)): - scene_data.pop("resolutionWidth") - scene_data.pop("resolutionHeight") - - if (any(re.search(pattern, entity_type) - for pattern in skip_timelines_check)): - scene_data.pop('frameStart', None) - scene_data.pop('frameEnd', None) return scene_data diff --git a/openpype/hosts/harmony/plugins/publish/validate_scene_settings.py b/openpype/hosts/harmony/plugins/publish/validate_scene_settings.py index c5cd141636..0371e80095 100644 --- a/openpype/hosts/harmony/plugins/publish/validate_scene_settings.py +++ b/openpype/hosts/harmony/plugins/publish/validate_scene_settings.py @@ -56,12 +56,25 @@ class ValidateSceneSettings(pyblish.api.InstancePlugin): def process(self, instance): """Plugin entry point.""" expected_settings = openpype.hosts.harmony.api.get_asset_settings() - self.log.info(expected_settings) + self.log.info("scene settings from DB:".format(expected_settings)) expected_settings = _update_frames(dict.copy(expected_settings)) expected_settings["frameEndHandle"] = expected_settings["frameEnd"] +\ expected_settings["handleEnd"] + if (any(re.search(pattern, os.getenv('AVALON_TASK')) + for pattern in self.skip_resolution_check)): + expected_settings.pop("resolutionWidth") + expected_settings.pop("resolutionHeight") + + entity_type = expected_settings.get("entityType") + if (any(re.search(pattern, entity_type) + for pattern in self.skip_timelines_check)): + expected_settings.pop('frameStart', None) + expected_settings.pop('frameEnd', None) + + expected_settings.pop("entityType") # not useful after the check + asset_name = instance.context.data['anatomyData']['asset'] if any(re.search(pattern, asset_name) for pattern in self.frame_check_filter): @@ -74,7 +87,7 @@ class ValidateSceneSettings(pyblish.api.InstancePlugin): fps = float( "{:.2f}".format(instance.context.data.get("frameRate"))) - self.log.debug(expected_settings) + self.log.debug("filtered settings: {}".format(expected_settings)) current_settings = { "fps": fps, @@ -86,7 +99,7 @@ class ValidateSceneSettings(pyblish.api.InstancePlugin): "resolutionWidth": instance.context.data.get("resolutionWidth"), "resolutionHeight": instance.context.data.get("resolutionHeight"), } - self.log.debug("curr:: {}".format(current_settings)) + self.log.debug("current scene settings {}".format(current_settings)) invalid_settings = [] for key, value in expected_settings.items(): From 64c6414367c14fe7b39cceff56e8547b590a478c Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 May 2021 14:17:40 +0200 Subject: [PATCH 176/303] implemented copy/paste of tasks --- .../project_manager/project_manager/model.py | 83 ++++++++++++++++++- .../project_manager/project_manager/view.py | 18 ++++ 2 files changed, 99 insertions(+), 2 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 20162847f2..4cb8ec6b90 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -1,5 +1,6 @@ import collections import copy +import json from queue import Queue from uuid import uuid4 @@ -1135,6 +1136,75 @@ class HierarchyModel(QtCore.QAbstractItemModel): self.refresh_project() + def copy_mime_data(self, indexes): + items = [] + processed_ids = set() + for index in indexes: + if not index.isValid(): + continue + item_id = index.data(IDENTIFIER_ROLE) + if item_id in processed_ids: + continue + processed_ids.add(item_id) + item = self._items_by_id[item_id] + items.append(item) + + parent_item = None + for item in items: + if not isinstance(item, TaskItem): + raise ValueError("Can copy only tasks") + + if parent_item is None: + parent_item = item.parent() + elif item.parent() is not parent_item: + raise ValueError("Can copy only tasks from same parent") + + data = [] + for task_item in items: + data.append(task_item.to_json_data()) + + encoded_data = QtCore.QByteArray() + stream = QtCore.QDataStream(encoded_data, QtCore.QIODevice.WriteOnly) + stream.writeQString(json.dumps(data)) + mimedata = QtCore.QMimeData() + mimedata.setData("application/copy_task", encoded_data) + return mimedata + + def paste_mime_data(self, index, mime_data): + if not index.isValid(): + return + + item_id = index.data(IDENTIFIER_ROLE) + item = self._items_by_id[item_id] + if not isinstance(item, (AssetItem, TaskItem)): + return + + raw_data = mime_data.data("application/copy_task") + encoded_data = QtCore.QByteArray.fromRawData(raw_data) + stream = QtCore.QDataStream(encoded_data, QtCore.QIODevice.ReadOnly) + text = stream.readQString() + try: + data = json.loads(text) + except Exception: + data = [] + + if not data: + return + + if isinstance(item, TaskItem): + parent = item.parent() + else: + parent = item + + for task_item_data in data: + task_data = {} + for name, data in task_item_data.items(): + task_data = data + task_data["name"] = name + + task_item = TaskItem(task_data, True) + self.add_item(task_item, parent) + class BaseItem: columns = [] @@ -1730,9 +1800,11 @@ class TaskItem(BaseItem): "type" } - def __init__(self, data=None): + def __init__(self, data=None, is_new=None): self._removed = False - self._is_new = data is None + if is_new is None: + is_new = data is None + self._is_new = is_new if data is None: data = {} @@ -1801,3 +1873,10 @@ class TaskItem(BaseItem): self.parent().on_task_name_change(self) return result + + def to_json_data(self): + """Convert json data without parent reference. + + Method used for mime data on copy/paste + """ + return self.to_doc_data() diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index 042fae6074..236a62425b 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -242,6 +242,12 @@ class HierarchyView(QtWidgets.QTreeView): if event.key() == QtCore.Qt.Key_Delete: self._delete_items() + elif event.matches(QtGui.QKeySequence.Copy): + self._copy_items() + + elif event.matches(QtGui.QKeySequence.Paste): + self._paste_items() + elif event.key() in (QtCore.Qt.Key_Return, QtCore.Qt.Key_Enter): mdfs = event.modifiers() if mdfs == (QtCore.Qt.ShiftModifier | QtCore.Qt.ControlModifier): @@ -269,6 +275,18 @@ class HierarchyView(QtWidgets.QTreeView): else: event.accept() + def _copy_items(self, indexes=None): + if indexes is None: + indexes = self.selectedIndexes() + mime_data = self._source_model.copy_mime_data(indexes) + + QtWidgets.QApplication.clipboard().setMimeData(mime_data) + + def _paste_items(self): + index = self.currentIndex() + mime_data = QtWidgets.QApplication.clipboard().mimeData() + self._source_model.paste_mime_data(index, mime_data) + def _delete_items(self, indexes=None): if indexes is None: indexes = self.selectedIndexes() From 6f68b9599121260d15dffdb39bc4fb5cb9a75ad4 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 May 2021 14:25:21 +0200 Subject: [PATCH 177/303] added basic message showing --- .../project_manager/project_manager/view.py | 23 ++++++++++++++----- .../project_manager/project_manager/window.py | 8 +++++++ 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index 236a62425b..37c3423de5 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -98,9 +98,12 @@ class HierarchyView(QtWidgets.QTreeView): "tools_env" } - def __init__(self, dbcon, source_model, *args, **kwargs): - super(HierarchyView, self).__init__(*args, **kwargs) + def __init__(self, dbcon, source_model, parent): + super(HierarchyView, self).__init__(parent) + # Direct access to model self._source_model = source_model + # Access to parent because of `show_message` method + self._parent = parent project_doc_cache = ProjectDocCache(dbcon) tools_cache = ToolsCache() @@ -276,11 +279,15 @@ class HierarchyView(QtWidgets.QTreeView): event.accept() def _copy_items(self, indexes=None): - if indexes is None: - indexes = self.selectedIndexes() - mime_data = self._source_model.copy_mime_data(indexes) + try: + if indexes is None: + indexes = self.selectedIndexes() + mime_data = self._source_model.copy_mime_data(indexes) - QtWidgets.QApplication.clipboard().setMimeData(mime_data) + QtWidgets.QApplication.clipboard().setMimeData(mime_data) + self._show_message("Tasks copied") + except ValueError as exc: + self._show_message(str(exc)) def _paste_items(self): index = self.currentIndex() @@ -423,6 +430,10 @@ class HierarchyView(QtWidgets.QTreeView): row, 0, index )) + def _show_message(self, message): + """Show message to user.""" + self._parent.show_message(message) + def _on_context_menu(self, point): index = self.indexAt(point) column = index.column() diff --git a/openpype/tools/project_manager/project_manager/window.py b/openpype/tools/project_manager/project_manager/window.py index f2ad399ab5..d98cc0f801 100644 --- a/openpype/tools/project_manager/project_manager/window.py +++ b/openpype/tools/project_manager/project_manager/window.py @@ -52,10 +52,12 @@ class Window(QtWidgets.QWidget): ) buttons_widget = QtWidgets.QWidget(self) + message_label = QtWidgets.QLabel(buttons_widget) save_btn = QtWidgets.QPushButton("Save", buttons_widget) buttons_layout = QtWidgets.QHBoxLayout(buttons_widget) buttons_layout.setContentsMargins(0, 0, 0, 0) + buttons_layout.addWidget(message_label) buttons_layout.addStretch(1) buttons_layout.addWidget(save_btn) @@ -74,6 +76,8 @@ class Window(QtWidgets.QWidget): self.hierarchy_view = hierarchy_view self.hierarchy_model = hierarchy_model + self.message_label = message_label + self.resize(1200, 600) self.refresh_projects() @@ -106,3 +110,7 @@ class Window(QtWidgets.QWidget): def _on_save_click(self): self.hierarchy_model.save() + + def show_message(self, message): + # TODO add nicer message pop + self.message_label.setText(message) From 2d4a2188f21759ef2d2ecf216f7017acf4b3d4e1 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 May 2021 14:42:57 +0200 Subject: [PATCH 178/303] fix variable usage --- openpype/tools/project_manager/project_manager/view.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index 37c3423de5..cd6ee005dc 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -488,7 +488,7 @@ class HierarchyView(QtWidgets.QTreeView): # Collapse/Expand action show_collapse_expand_action = False for item_id in item_ids: - item = items_by_id[item_ids[0]] + item = items_by_id[item_id] item_type = item.data(ITEM_TYPE_ROLE) if item_type != "task": show_collapse_expand_action = True From 275bf78be28dc381051461c1ddfe077e3288c88e Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 May 2021 14:43:14 +0200 Subject: [PATCH 179/303] faster remove deletion tag --- .../project_manager/project_manager/model.py | 45 ++++++++++--------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 4cb8ec6b90..530fee3528 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -441,43 +441,46 @@ class HierarchyModel(QtCore.QAbstractItemModel): return None def remove_delete_flag(self, item_ids, with_children=True): - remove_tag_items_by_id = {} + items_by_id = {} for item_id in item_ids: - item = self.items_by_id[item_id] - if not isinstance(item, (AssetItem, TaskItem)): + if item_id in items_by_id: continue - if item.data(REMOVED_ROLE): - remove_tag_items_by_id[item_id] = item + item = self.items_by_id[item_id] + if isinstance(item, (AssetItem, TaskItem)): + items_by_id[item_id] = item - for item in tuple(remove_tag_items_by_id.values()): + for item in tuple(items_by_id.values()): parent = item.parent() while True: if not isinstance(parent, (AssetItem, TaskItem)): break - if parent.id in remove_tag_items_by_id: - continue - - if parent.data(REMOVED_ROLE): - remove_tag_items_by_id[parent.id] = parent + if parent.id not in items_by_id: + items_by_id[parent.id] = parent parent = parent.parent() if not with_children: continue - def _children_recursion(_item, store_obj): - if isinstance(_item, AssetItem): - for row in range(_item.rowCount()): - _child_item = _item.child(row) - if _child_item.id not in store_obj: - store_obj[_child_item.id] = _child_item - _children_recursion(_child_item, store_obj) - _children_recursion(item, remove_tag_items_by_id) + def _children_recursion(_item): + if not isinstance(_item, AssetItem): + return - for item in remove_tag_items_by_id.values(): - item.setData(False, REMOVED_ROLE) + for row in range(_item.rowCount()): + _child_item = _item.child(row) + if _child_item.id in items_by_id: + continue + + items_by_id[_child_item.id] = _child_item + _children_recursion(_child_item) + + _children_recursion(item) + + for item in items_by_id.values(): + if item.data(REMOVED_ROLE): + item.setData(False, REMOVED_ROLE) def remove_index(self, index): return self.remove_indexes([index]) From db208dbf20d35c0f9253ef95ab2f1c27d317ea33 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 13 May 2021 14:55:25 +0200 Subject: [PATCH 180/303] Nuke: load mov adding option gui for workfile start --- openpype/hosts/nuke/plugins/load/load_mov.py | 106 ++++++++----------- 1 file changed, 45 insertions(+), 61 deletions(-) diff --git a/openpype/hosts/nuke/plugins/load/load_mov.py b/openpype/hosts/nuke/plugins/load/load_mov.py index 8b8c5d0c10..500190d95f 100644 --- a/openpype/hosts/nuke/plugins/load/load_mov.py +++ b/openpype/hosts/nuke/plugins/load/load_mov.py @@ -1,6 +1,5 @@ import nuke -import contextlib - +from avalon.vendor import qargparse from avalon import api, io from openpype.api import get_current_project_settings from openpype.hosts.nuke.api.lib import ( @@ -8,41 +7,6 @@ from openpype.hosts.nuke.api.lib import ( ) -@contextlib.contextmanager -def preserve_trim(node): - """Preserve the relative trim of the Loader tool. - - This tries to preserve the loader's trim (trim in and trim out) after - the context by reapplying the "amount" it trims on the clip's length at - start and end. - - """ - # working script frame range - script_start = nuke.root()["first_frame"].value() - - start_at_frame = None - offset_frame = None - if node['frame_mode'].value() == "start at": - start_at_frame = node['frame'].value() - if node['frame_mode'].value() == "offset": - offset_frame = node['frame'].value() - - try: - yield - finally: - if start_at_frame: - node['frame_mode'].setValue("start at") - node['frame'].setValue(str(script_start)) - print("start frame of Read was set to" - "{}".format(script_start)) - - if offset_frame: - node['frame_mode'].setValue("offset") - node['frame'].setValue(str((script_start + offset_frame))) - print("start frame of Read was set to" - "{}".format(script_start)) - - def add_review_presets_config(): returning = { "families": list(), @@ -79,14 +43,30 @@ class LoadMov(api.Loader): script_start = nuke.root()["first_frame"].value() + # options gui + defaults = { + "start_at_workfile": True + } + + options = [ + qargparse.Boolean( + "start_at_workfile", + help="Load at workfile start frame", + default=True + ) + ] + node_name_template = "{class_name}_{ext}" - def load(self, context, name, namespace, data): + def load(self, context, name, namespace, options): from avalon.nuke import ( containerise, viewer_update_and_undo_stop ) + start_at_workfile = options.get( + "start_at_workfile", self.defaults["start_at_workfile"]) + version = context['version'] version_data = version.get("data", {}) repr_id = context["representation"]["_id"] @@ -149,10 +129,14 @@ class LoadMov(api.Loader): read_node["first"].setValue(first) read_node["origlast"].setValue(last) read_node["last"].setValue(last) - - # start at script start read_node['frame_mode'].setValue("start at") - read_node['frame'].setValue(str(self.script_start)) + + if start_at_workfile: + # start at workfile start + read_node['frame'].setValue(str(self.script_start)) + else: + # start at version frame start + read_node['frame'].setValue(str(orig_first)) if colorspace: read_node["colorspace"].setValue(str(colorspace)) @@ -266,29 +250,29 @@ class LoadMov(api.Loader): # create handles offset (only to last, because of mov) last += handle_start + handle_end - # Update the loader's path whilst preserving some values - with preserve_trim(read_node): - read_node["file"].setValue(file) - self.log.info("__ node['file']: {}".format( - read_node["file"].value())) + read_node["file"].setValue(file) - # Set the global in to the start frame of the sequence - read_node["origfirst"].setValue(first) - read_node["first"].setValue(first) - read_node["origlast"].setValue(last) - read_node["last"].setValue(last) + # Set the global in to the start frame of the sequence + read_node["origfirst"].setValue(first) + read_node["first"].setValue(first) + read_node["origlast"].setValue(last) + read_node["last"].setValue(last) + read_node['frame_mode'].setValue("start at") - # start at script start - read_node['frame_mode'].setValue("start at") + if int(self.script_start) == int(read_node['frame'].value()): + # start at workfile start read_node['frame'].setValue(str(self.script_start)) + else: + # start at version frame start + read_node['frame'].setValue(str(orig_first)) - if colorspace: - read_node["colorspace"].setValue(str(colorspace)) + if colorspace: + read_node["colorspace"].setValue(str(colorspace)) - preset_clrsp = get_imageio_input_colorspace(file) + preset_clrsp = get_imageio_input_colorspace(file) - if preset_clrsp is not None: - read_node["colorspace"].setValue(preset_clrsp) + if preset_clrsp is not None: + read_node["colorspace"].setValue(preset_clrsp) updated_dict = {} updated_dict.update({ @@ -321,8 +305,8 @@ class LoadMov(api.Loader): from avalon.nuke import viewer_update_and_undo_stop - node = nuke.toNode(container['objectName']) - assert node.Class() == "Read", "Must be Read" + read_node = nuke.toNode(container['objectName']) + assert read_node.Class() == "Read", "Must be Read" with viewer_update_and_undo_stop(): - nuke.delete(node) + nuke.delete(read_node) From f2a084912c6e032563e976f7462de388f4ba7bfb Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 13 May 2021 15:12:37 +0200 Subject: [PATCH 181/303] Nuke: load sequence with option gui --- .../hosts/nuke/plugins/load/load_sequence.py | 222 ++++++++---------- 1 file changed, 94 insertions(+), 128 deletions(-) diff --git a/openpype/hosts/nuke/plugins/load/load_sequence.py b/openpype/hosts/nuke/plugins/load/load_sequence.py index 71f0b8c298..9cbd1d4466 100644 --- a/openpype/hosts/nuke/plugins/load/load_sequence.py +++ b/openpype/hosts/nuke/plugins/load/load_sequence.py @@ -1,74 +1,11 @@ import nuke -import contextlib - +from avalon.vendor import qargparse from avalon import api, io from openpype.hosts.nuke.api.lib import ( get_imageio_input_colorspace ) -@contextlib.contextmanager -def preserve_trim(node): - """Preserve the relative trim of the Loader tool. - - This tries to preserve the loader's trim (trim in and trim out) after - the context by reapplying the "amount" it trims on the clip's length at - start and end. - - """ - # working script frame range - script_start = nuke.root()["first_frame"].value() - - start_at_frame = None - offset_frame = None - if node['frame_mode'].value() == "start at": - start_at_frame = node['frame'].value() - if node['frame_mode'].value() == "offset": - offset_frame = node['frame'].value() - - try: - yield - finally: - if start_at_frame: - node['frame_mode'].setValue("start at") - node['frame'].setValue(str(script_start)) - print("start frame of Read was set to" - "{}".format(script_start)) - - if offset_frame: - node['frame_mode'].setValue("offset") - node['frame'].setValue(str((script_start + offset_frame))) - print("start frame of Read was set to" - "{}".format(script_start)) - - -def loader_shift(node, frame, relative=False): - """Shift global in time by i preserving duration - - This moves the loader by i frames preserving global duration. When relative - is False it will shift the global in to the start frame. - - Args: - loader (tool): The fusion loader tool. - frame (int): The amount of frames to move. - relative (bool): When True the shift is relative, else the shift will - change the global in to frame. - - Returns: - int: The resulting relative frame change (how much it moved) - - """ - # working script frame range - script_start = nuke.root()["first_frame"].value() - - if relative: - node['frame_mode'].setValue("start at") - node['frame'].setValue(str(script_start)) - else: - node['frame_mode'].setValue("start at") - node['frame'].setValue(str(frame)) - - class LoadSequence(api.Loader): """Load image sequence into Nuke""" @@ -80,14 +17,32 @@ class LoadSequence(api.Loader): icon = "file-video-o" color = "white" + script_start = nuke.root()["first_frame"].value() + + # option gui + defaults = { + "start_at_workfile": True + } + + options = [ + qargparse.Boolean( + "start_at_workfile", + help="Load at workfile start frame", + default=True + ) + ] + node_name_template = "{class_name}_{ext}" - def load(self, context, name, namespace, data): + def load(self, context, name, namespace, options): from avalon.nuke import ( containerise, viewer_update_and_undo_stop ) + start_at_workfile = options.get( + "start_at_workfile", self.defaults["start_at_workfile"]) + version = context['version'] version_data = version.get("data", {}) repr_id = context["representation"]["_id"] @@ -139,32 +94,31 @@ class LoadSequence(api.Loader): read_name = self.node_name_template.format(**name_data) # Create the Loader with the filename path set - - # TODO: it might be universal read to img/geo/camera - r = nuke.createNode( + read_node = nuke.createNode( "Read", "name {}".format(read_name)) # to avoid multiple undo steps for rest of process # we will switch off undo-ing with viewer_update_and_undo_stop(): - r["file"].setValue(file) + read_node["file"].setValue(file) # Set colorspace defined in version data colorspace = context["version"]["data"].get("colorspace") if colorspace: - r["colorspace"].setValue(str(colorspace)) + read_node["colorspace"].setValue(str(colorspace)) preset_clrsp = get_imageio_input_colorspace(file) if preset_clrsp is not None: - r["colorspace"].setValue(preset_clrsp) + read_node["colorspace"].setValue(preset_clrsp) - loader_shift(r, first, relative=True) - r["origfirst"].setValue(int(first)) - r["first"].setValue(int(first)) - r["origlast"].setValue(int(last)) - r["last"].setValue(int(last)) + # set start frame depending on workfile or version + self.loader_shift(read_node, start_at_workfile) + read_node["origfirst"].setValue(int(first)) + read_node["first"].setValue(int(first)) + read_node["origlast"].setValue(int(last)) + read_node["last"].setValue(int(last)) # add additional metadata from the version to imprint Avalon knob add_keys = ["frameStart", "frameEnd", @@ -181,48 +135,20 @@ class LoadSequence(api.Loader): data_imprint.update({"objectName": read_name}) - r["tile_color"].setValue(int("0x4ecd25ff", 16)) + read_node["tile_color"].setValue(int("0x4ecd25ff", 16)) if version_data.get("retime", None): speed = version_data.get("speed", 1) time_warp_nodes = version_data.get("timewarps", []) - self.make_retimes(r, speed, time_warp_nodes) + self.make_retimes(read_node, speed, time_warp_nodes) - return containerise(r, + return containerise(read_node, name=name, namespace=namespace, context=context, loader=self.__class__.__name__, data=data_imprint) - def make_retimes(self, node, speed, time_warp_nodes): - ''' Create all retime and timewarping nodes with coppied animation ''' - if speed != 1: - rtn = nuke.createNode( - "Retime", - "speed {}".format(speed)) - rtn["before"].setValue("continue") - rtn["after"].setValue("continue") - rtn["input.first_lock"].setValue(True) - rtn["input.first"].setValue( - self.handle_start + self.first_frame - ) - - if time_warp_nodes != []: - for timewarp in time_warp_nodes: - twn = nuke.createNode(timewarp["Class"], - "name {}".format(timewarp["name"])) - if isinstance(timewarp["lookup"], list): - # if array for animation - twn["lookup"].setAnimated() - for i, value in enumerate(timewarp["lookup"]): - twn["lookup"].setValueAt( - (self.first_frame + i) + value, - (self.first_frame + i)) - else: - # if static value `int` - twn["lookup"].setValue(timewarp["lookup"]) - def switch(self, container, representation): self.update(container, representation) @@ -239,9 +165,9 @@ class LoadSequence(api.Loader): update_container ) - node = nuke.toNode(container['objectName']) + read_node = nuke.toNode(container['objectName']) - assert node.Class() == "Read", "Must be Read" + assert read_node.Class() == "Read", "Must be Read" repr_cont = representation["context"] @@ -288,23 +214,23 @@ class LoadSequence(api.Loader): self.log.warning( "Missing start frame for updated version" "assuming starts at frame 0 for: " - "{} ({})".format(node['name'].value(), representation)) + "{} ({})".format(read_node['name'].value(), representation)) first = 0 first -= self.handle_start last += self.handle_end - # Update the loader's path whilst preserving some values - with preserve_trim(node): - node["file"].setValue(file) - self.log.info("__ node['file']: {}".format(node["file"].value())) + read_node["file"].setValue(file) - # Set the global in to the start frame of the sequence - loader_shift(node, first, relative=True) - node["origfirst"].setValue(int(first)) - node["first"].setValue(int(first)) - node["origlast"].setValue(int(last)) - node["last"].setValue(int(last)) + # set start frame depending on workfile or version + self.loader_shift( + read_node, + bool("start at" in read_node['frame_mode'].value())) + + read_node["origfirst"].setValue(int(first)) + read_node["first"].setValue(int(first)) + read_node["origlast"].setValue(int(last)) + read_node["last"].setValue(int(last)) updated_dict = {} updated_dict.update({ @@ -321,20 +247,20 @@ class LoadSequence(api.Loader): "outputDir": version_data.get("outputDir"), }) - # change color of node + # change color of read_node if version.get("name") not in [max_version]: - node["tile_color"].setValue(int("0xd84f20ff", 16)) + read_node["tile_color"].setValue(int("0xd84f20ff", 16)) else: - node["tile_color"].setValue(int("0x4ecd25ff", 16)) + read_node["tile_color"].setValue(int("0x4ecd25ff", 16)) if version_data.get("retime", None): speed = version_data.get("speed", 1) time_warp_nodes = version_data.get("timewarps", []) - self.make_retimes(node, speed, time_warp_nodes) + self.make_retimes(read_node, speed, time_warp_nodes) # Update the imprinted representation update_container( - node, + read_node, updated_dict ) self.log.info("udated to version: {}".format(version.get("name"))) @@ -343,8 +269,48 @@ class LoadSequence(api.Loader): from avalon.nuke import viewer_update_and_undo_stop - node = nuke.toNode(container['objectName']) - assert node.Class() == "Read", "Must be Read" + read_node = nuke.toNode(container['objectName']) + assert read_node.Class() == "Read", "Must be Read" with viewer_update_and_undo_stop(): - nuke.delete(node) + nuke.delete(read_node) + + def make_retimes(self, speed, time_warp_nodes): + ''' Create all retime and timewarping nodes with coppied animation ''' + if speed != 1: + rtn = nuke.createNode( + "Retime", + "speed {}".format(speed)) + rtn["before"].setValue("continue") + rtn["after"].setValue("continue") + rtn["input.first_lock"].setValue(True) + rtn["input.first"].setValue( + self.handle_start + self.first_frame + ) + + if time_warp_nodes != []: + for timewarp in time_warp_nodes: + twn = nuke.createNode(timewarp["Class"], + "name {}".format(timewarp["name"])) + if isinstance(timewarp["lookup"], list): + # if array for animation + twn["lookup"].setAnimated() + for i, value in enumerate(timewarp["lookup"]): + twn["lookup"].setValueAt( + (self.first_frame + i) + value, + (self.first_frame + i)) + else: + # if static value `int` + twn["lookup"].setValue(timewarp["lookup"]) + + def loader_shift(self, read_node, workfile_start=False): + """ Set start frame of read node to a workfile start + + Args: + read_node (nuke.Node): The nuke's read node + workfile_start (bool): set workfile start frame if true + + """ + if workfile_start: + read_node['frame_mode'].setValue("start at") + read_node['frame'].setValue(str(self.script_start)) From afe47dbe1f0368f88b6257069f26be247eb36d49 Mon Sep 17 00:00:00 2001 From: Jakub Jezek Date: Thu, 13 May 2021 15:18:49 +0200 Subject: [PATCH 182/303] Nuke: fix mov start include handles --- openpype/hosts/nuke/plugins/load/load_mov.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/nuke/plugins/load/load_mov.py b/openpype/hosts/nuke/plugins/load/load_mov.py index 500190d95f..e2c9acaa9c 100644 --- a/openpype/hosts/nuke/plugins/load/load_mov.py +++ b/openpype/hosts/nuke/plugins/load/load_mov.py @@ -136,7 +136,7 @@ class LoadMov(api.Loader): read_node['frame'].setValue(str(self.script_start)) else: # start at version frame start - read_node['frame'].setValue(str(orig_first)) + read_node['frame'].setValue(str(orig_first - handle_start)) if colorspace: read_node["colorspace"].setValue(str(colorspace)) @@ -264,7 +264,7 @@ class LoadMov(api.Loader): read_node['frame'].setValue(str(self.script_start)) else: # start at version frame start - read_node['frame'].setValue(str(orig_first)) + read_node['frame'].setValue(str(orig_first - handle_start)) if colorspace: read_node["colorspace"].setValue(str(colorspace)) From bc3c392ba59ef2d2bfa95fb055c03fd414807c2b Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 May 2021 15:36:53 +0200 Subject: [PATCH 183/303] renamed remove_index to delete_index --- openpype/tools/project_manager/project_manager/model.py | 6 +++--- openpype/tools/project_manager/project_manager/view.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 530fee3528..9525d808f8 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -482,10 +482,10 @@ class HierarchyModel(QtCore.QAbstractItemModel): if item.data(REMOVED_ROLE): item.setData(False, REMOVED_ROLE) - def remove_index(self, index): - return self.remove_indexes([index]) + def delete_indexe(self, index): + return self.delete_indexes([index]) - def remove_indexes(self, indexes): + def delete_indexes(self, indexes): items_by_id = {} processed_ids = set() for index in indexes: diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index cd6ee005dc..7a5cdb72da 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -297,7 +297,7 @@ class HierarchyView(QtWidgets.QTreeView): def _delete_items(self, indexes=None): if indexes is None: indexes = self.selectedIndexes() - self._source_model.remove_indexes(indexes) + self._source_model.delete_indexes(indexes) def _on_ctrl_shift_enter_pressed(self): self._add_task() From 890f612c2eb620dfbb0aad152721747b84245f9a Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 May 2021 16:14:46 +0200 Subject: [PATCH 184/303] fixed typo --- openpype/tools/project_manager/project_manager/model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 9525d808f8..00cd22c720 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -482,7 +482,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): if item.data(REMOVED_ROLE): item.setData(False, REMOVED_ROLE) - def delete_indexe(self, index): + def delete_index(self, index): return self.delete_indexes([index]) def delete_indexes(self, indexes): From 6b2548bc00394109680450d5796269ac70b343b1 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 May 2021 16:15:17 +0200 Subject: [PATCH 185/303] implemented new item AddAssetItem --- .../project_manager/project_manager/model.py | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 00cd22c720..575aba8394 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -1472,6 +1472,65 @@ class ProjectItem(BaseItem): return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable +class AddAssetItem(BaseItem): + item_type = "add_asset" + columns = {"name"} + editable_columns = {"name"} + + def __init__(self, parent): + super(AddAssetItem, self).__init__() + self._parent = parent + + @classmethod + def name_icon(cls): + if cls._name_icon is None: + cls._name_icon = qtawesome.icon( + "fa.plus-circle", + color="#333333" + ) + return cls._name_icon + + def data(self, role, key=None): + if role == REMOVED_ROLE: + return True + + if role == HIERARCHY_CHANGE_ABLE_ROLE: + return True + + if key == "name": + if role == QtCore.Qt.DisplayRole: + return "Add Asset" + elif role == QtCore.Qt.EditRole: + return "" + return super(AddAssetItem, self).data(role, key) + + def setData(self, value, role, key=None): + if key == "name": + if not value: + return False + index = self.model().index_for_item(self) + new_index = self.model().add_new_asset(index) + self.model().setData(new_index, value, QtCore.Qt.EditRole) + return True + return super(AddAssetItem, self).setData(value, role, key) + + def flags(self, key): + if key != "name": + return QtCore.Qt.NoItemFlags + + return ( + QtCore.Qt.ItemIsEnabled + | QtCore.Qt.ItemIsSelectable + | QtCore.Qt.ItemIsEditable + ) + + def add_child(self, item, row=None): + raise AssertionError("BUG: Can't add children to AddAssetItem") + + def remove_child(self, item): + raise AssertionError("BUG: Can't remove children from AddAssetItem") + + class AssetItem(BaseItem): item_type = "asset" From c1a7a18cea14bf50d3fd35793b7a7ec72652f3d7 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 May 2021 16:17:24 +0200 Subject: [PATCH 186/303] each AssetItem and ProjectItem have AddAssetItem --- openpype/tools/project_manager/project_manager/model.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 575aba8394..89f20311ad 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -1217,6 +1217,7 @@ class BaseItem: _name_icon = None _is_duplicated = False item_type = "base" + add_asset_item_visible = False _None = object() @@ -1435,6 +1436,8 @@ class ProjectItem(BaseItem): def __init__(self, project_doc): self._mongo_id = project_doc["_id"] + self.add_asset_item = AddAssetItem(self) + data = self.data_from_doc(project_doc) super(ProjectItem, self).__init__(data) @@ -1590,6 +1593,9 @@ class AssetItem(BaseItem): self.mongo_id = asset_doc.get("_id") self._project_id = None + self.add_asset_item_visible = False + self.add_asset_item = AddAssetItem(self) + # Item data self._hierarchy_changes_enabled = True self._removed = False From c4854c02ddbbcaef3e9de9b1bc51e1296f99e503 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 May 2021 16:18:25 +0200 Subject: [PATCH 187/303] make sure AddAsseItem is visible --- .../tools/project_manager/project_manager/model.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 89f20311ad..24e766671f 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -208,11 +208,9 @@ class HierarchyModel(QtCore.QAbstractItemModel): non_modifiable_items = set() while not appending_queue.empty(): parent_id, parent_item = appending_queue.get() - if parent_id not in asset_docs_by_parent_id: - continue + asset_docs = asset_docs_by_parent_id.get(parent_id) or [] new_items = [] - asset_docs = asset_docs_by_parent_id[parent_id] for asset_doc in sorted(asset_docs, key=lambda item: item["name"]): # Create new Item new_item = AssetItem(asset_doc) @@ -228,7 +226,11 @@ class HierarchyModel(QtCore.QAbstractItemModel): # Add item to appending queue appending_queue.put((asset_id, new_item)) - self.add_items(new_items, parent_item) + if isinstance(parent_item, (ProjectItem, AssetItem)): + new_items.append(parent_item.add_asset_item) + + if new_items: + self.add_items(new_items, parent_item) # Handle Asset's that are not modifiable # - pass the information to all it's parents @@ -262,6 +264,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): _task_data["name"] = task_name task_item = TaskItem(_task_data) task_items.append(task_item) + self.add_items(task_items, asset_item) def rowCount(self, parent=None): @@ -379,6 +382,9 @@ class HierarchyModel(QtCore.QAbstractItemModel): if result is not None: self._validate_asset_duplicity(name) + if not new_child.add_asset_item_visible: + self.add_item(new_child.add_asset_item, new_child) + return result def add_new_task(self, parent_index): From 2e703231668acf17889e7bf572b82bb403d0891d Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 May 2021 16:19:13 +0200 Subject: [PATCH 188/303] fix model method on items --- openpype/tools/project_manager/project_manager/model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 24e766671f..1117e505ad 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -1252,7 +1252,7 @@ class BaseItem: return not self._is_duplicated def model(self): - return self._parent.model + return self._parent.model() def move_to(self, item, row): idx = self._children.index(item) From 9e40246514b2d8dbfa204a45959c1c107ea33617 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 May 2021 16:19:42 +0200 Subject: [PATCH 189/303] AddAssetItem are skipped on remove --- openpype/tools/project_manager/project_manager/model.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 1117e505ad..fea76367a0 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -504,7 +504,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): processed_ids.add(item_id) item = self._items_by_id[item_id] - if isinstance(item, (TaskItem, AssetItem)): + if isinstance(item, (TaskItem, AssetItem, AddAssetItem)): items_by_id[item_id] = item if not items_by_id: @@ -514,6 +514,9 @@ class HierarchyModel(QtCore.QAbstractItemModel): self._remove_item(item) def _remove_item(self, item): + if isinstance(item, AddAssetItem): + return + is_removed = item.data(REMOVED_ROLE) if is_removed: return @@ -544,6 +547,9 @@ class HierarchyModel(QtCore.QAbstractItemModel): task_children.append(child_item) continue + elif isinstance(child_item, AddAssetItem): + continue + if not _fill_children(_all_descendants, child_item, cur_item): remove_item = False From 8493274f5c26a2bca3638e080baf0f4b627f5977 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 May 2021 16:20:22 +0200 Subject: [PATCH 190/303] AssetItem and ProjectItem know if it's add asset item is visible --- .../project_manager/project_manager/model.py | 22 +++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index fea76367a0..67a3157cb2 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -1486,6 +1486,18 @@ class ProjectItem(BaseItem): def flags(self, *args, **kwargs): return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable + def add_child(self, item, row=None): + if not self.add_asset_item_visible and item is self.add_asset_item: + self.add_asset_item_visible = True + + super(ProjectItem, self).add_child(item, row) + + def remove_child(self, item): + if self.add_asset_item_visible and item is self.add_asset_item: + self.add_asset_item_visible = False + + super(ProjectItem, self).remove_child(item) + class AddAssetItem(BaseItem): item_type = "add_asset" @@ -1855,14 +1867,20 @@ class AssetItem(BaseItem): super(AssetItem, self).add_child(item, row) - if isinstance(item, TaskItem): + if not self.add_asset_item_visible and item is self.add_asset_item: + self.add_asset_item_visible = True + + elif isinstance(item, TaskItem): self._add_task(item) def remove_child(self, item): if item not in self._children: return - if isinstance(item, TaskItem): + if self.add_asset_item_visible and item is self.add_asset_item: + self.add_asset_item_visible = False + + elif isinstance(item, TaskItem): self._remove_task(item) super(AssetItem, self).remove_child(item) From 1dd406722f963d1e44041f8aa53ae4ef32c57389 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 May 2021 16:20:54 +0200 Subject: [PATCH 191/303] implemented delete_add_asset_item to remove AddAssetItem --- .../tools/project_manager/project_manager/model.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 67a3157cb2..9aa553b8a1 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -488,6 +488,20 @@ class HierarchyModel(QtCore.QAbstractItemModel): if item.data(REMOVED_ROLE): item.setData(False, REMOVED_ROLE) + def delete_add_asset_item(self, parent): + item = parent.add_asset_item + children = parent.children() + if item not in children: + return + parent_index = self.index_for_item(parent) + row = children.index(item) + + self.beginRemoveRows(parent_index, row, row) + + parent.remove_child(item) + + self.endRemoveRows() + def delete_index(self, index): return self.delete_indexes([index]) From a66a9245beb7ee6e08d0ca5bb93cd46afa9e79ba Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 May 2021 16:21:26 +0200 Subject: [PATCH 192/303] use one row less if AddAssetItem is visible on item --- openpype/tools/project_manager/project_manager/model.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 9aa553b8a1..cc0cd1c3bd 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -412,9 +412,13 @@ class HierarchyModel(QtCore.QAbstractItemModel): if start_row is None: start_row = parent.rowCount() + if parent.add_asset_item_visible and start_row == parent.rowCount(): + start_row -= 1 + end_row = start_row + len(items) - 1 parent_index = self.index_from_item(parent.row(), 0, parent.parent()) + self.beginInsertRows(parent_index, start_row, end_row) for idx, item in enumerate(items): @@ -755,6 +759,8 @@ class HierarchyModel(QtCore.QAbstractItemModel): return dst_row = dst_parent.rowCount() + if dst_parent.add_asset_item_visible: + dst_row -= 1 if src_parent is dst_parent: return @@ -1011,6 +1017,8 @@ class HierarchyModel(QtCore.QAbstractItemModel): dst_parent = child_item destination_row = dst_parent.rowCount() + if dst_parent.add_asset_item_visible: + destination_row -= 1 break if dst_parent is not None: From 2e5bec225aa7f796d817f5e99df15edc5f452fd1 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 May 2021 16:22:02 +0200 Subject: [PATCH 193/303] AssetItem handle for adding removing AddAssetItem on change of removed role --- openpype/tools/project_manager/project_manager/model.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index cc0cd1c3bd..79ef5e22e6 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -1802,6 +1802,11 @@ class AssetItem(BaseItem): def setData(self, value, role, key=None): if role == REMOVED_ROLE: self._removed = value + if not value and not self.add_asset_item_visible: + self.model().add_item(self.add_asset_item, self) + elif value and self.add_asset_item_visible: + self.model().delete_add_asset_item(self) + return True if role == HIERARCHY_CHANGE_ABLE_ROLE: From 2a4b4ebdd24a5a38e476039bde290aebe686c667 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 May 2021 16:26:21 +0200 Subject: [PATCH 194/303] HierarchicalModel has project_item property --- .../tools/project_manager/project_manager/model.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 79ef5e22e6..abacac0d32 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -138,6 +138,16 @@ class HierarchyModel(QtCore.QAbstractItemModel): self._current_project = None self.set_project(project_name) + @property + def project_item(self): + output = None + for row in range(self._root_item.rowCount()): + item = self._root_item.child(row) + if isinstance(item, ProjectItem): + output = item + break + return output + def set_project(self, project_name): if self._current_project == project_name: return From 300c7a2c308f95ef0969e763004fb4575ab2c3a7 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 May 2021 16:26:45 +0200 Subject: [PATCH 195/303] HierarchicalView collapse all except project on project load --- openpype/tools/project_manager/project_manager/view.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index 7a5cdb72da..b2f21aaffd 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -156,6 +156,13 @@ class HierarchyView(QtWidgets.QTreeView): # Trigger update of model after all data for delegates are filled self._source_model.set_project(project_name) + self.collapseAll() + + project_item = self._source_model.project_item + if project_item: + index = self._source_model.index_for_item(project_item) + self.expand(index) + def _on_rows_moved(self, index): parent_index = index.parent() if not self.isExpanded(parent_index): From f053323d2e1368d87bca9b9c12787f8c418c3441 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 May 2021 16:38:26 +0200 Subject: [PATCH 196/303] keep name resized to content --- .../project_manager/project_manager/window.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/window.py b/openpype/tools/project_manager/project_manager/window.py index d98cc0f801..25cb7c9975 100644 --- a/openpype/tools/project_manager/project_manager/window.py +++ b/openpype/tools/project_manager/project_manager/window.py @@ -47,9 +47,17 @@ class Window(QtWidgets.QWidget): header = hierarchy_view.header() header.setStretchLastSection(False) - header.setSectionResizeMode( - header.logicalIndex(0), QtWidgets.QHeaderView.Stretch - ) + for idx in range(header.count()): + logical_index = header.logicalIndex(idx) + if idx == 0: + header.setSectionResizeMode( + logical_index, QtWidgets.QHeaderView.Stretch + ) + else: + header.setSectionResizeMode( + logical_index, QtWidgets.QHeaderView.ResizeToContents + ) + buttons_widget = QtWidgets.QWidget(self) message_label = QtWidgets.QLabel(buttons_widget) From 876efd0bc4b41010d5c5395dc58fa9d2bb852429 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 13 May 2021 16:38:53 +0200 Subject: [PATCH 197/303] AE - added documentation for Settings --- website/docs/admin_hosts_aftereffects.md | 39 ++++++++++++++++++ .../admin_hosts_aftereffects_settings.png | Bin 0 -> 31766 bytes website/sidebars.js | 3 +- 3 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 website/docs/admin_hosts_aftereffects.md create mode 100644 website/docs/assets/admin_hosts_aftereffects_settings.png diff --git a/website/docs/admin_hosts_aftereffects.md b/website/docs/admin_hosts_aftereffects.md new file mode 100644 index 0000000000..dc43820465 --- /dev/null +++ b/website/docs/admin_hosts_aftereffects.md @@ -0,0 +1,39 @@ +--- +id: admin_hosts_aftereffects +title: AfterEffects Settings +sidebar_label: AfterEffects +--- + +import Tabs from '@theme/Tabs'; +import TabItem from '@theme/TabItem'; + +## AfterEffects settings + +There is a couple of settings that could configure publishing process for **AfterEffects**. +All of them are Project based, eg. each project could have different configuration. + +Location: Settings > Project > AfterEffects + +![Harmony Project Settings](assets/admin_hosts_aftereffects_settings.png) + +## Publish plugins + +### Validate Scene Settings + +#### Skip Resolution Check for Tasks + +Set regex pattern(s) to look for in a Task name to skip resolution check against values from DB. + +#### Skip Timeline Check for Tasks + +Set regex pattern(s) to look for in a Task name to skip `frameStart`, `frameEnd` check against values from DB. + +### AfterEffects Submit to Deadline + +* `Use Published scene` - Set to True (green) when Deadline should take published scene as a source instead of uploaded local one. +* `Priority` - priority of job on farm +* `Primary Pool` - here is list of pool fetched from server you can select from. +* `Secondary Pool` +* `Frames Per Task` - number of sequence division between individual tasks (chunks) +making one job on farm. + diff --git a/website/docs/assets/admin_hosts_aftereffects_settings.png b/website/docs/assets/admin_hosts_aftereffects_settings.png new file mode 100644 index 0000000000000000000000000000000000000000..9b879585f8c93026ed7389935107834d2aa3cae8 GIT binary patch literal 31766 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 z�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 Date: Thu, 13 May 2021 16:40:46 +0200 Subject: [PATCH 198/303] AE - added new schemas for settings Moved filtering from __init__ to Validator where it has more sense Added defaults --- openpype/hosts/aftereffects/api/__init__.py | 34 ------- .../publish/validate_scene_settings.py | 22 ++++- .../project_settings/aftereffects.json | 18 ++++ .../schemas/projects_schema/schema_main.json | 4 + .../schema_project_aftereffects.json | 90 +++++++++++++++++++ 5 files changed, 131 insertions(+), 37 deletions(-) create mode 100644 openpype/settings/defaults/project_settings/aftereffects.json create mode 100644 openpype/settings/entities/schemas/projects_schema/schema_project_aftereffects.json diff --git a/openpype/hosts/aftereffects/api/__init__.py b/openpype/hosts/aftereffects/api/__init__.py index e914c26435..5f6a64a6d0 100644 --- a/openpype/hosts/aftereffects/api/__init__.py +++ b/openpype/hosts/aftereffects/api/__init__.py @@ -112,38 +112,4 @@ def get_asset_settings(): "duration": duration } - try: - # temporary, in pype3 replace with api.get_current_project_settings - skip_resolution_check = ( - api.get_current_project_settings() - ["plugins"] - ["aftereffects"] - ["publish"] - ["ValidateSceneSettings"] - ["skip_resolution_check"] - ) - skip_timelines_check = ( - api.get_current_project_settings() - ["plugins"] - ["aftereffects"] - ["publish"] - ["ValidateSceneSettings"] - ["skip_timelines_check"] - ) - except KeyError: - skip_resolution_check = ['*'] - skip_timelines_check = ['*'] - - if os.getenv('AVALON_TASK') in skip_resolution_check or \ - '*' in skip_timelines_check: - scene_data.pop("resolutionWidth") - scene_data.pop("resolutionHeight") - - if entity_type in skip_timelines_check or '*' in skip_timelines_check: - scene_data.pop('fps', None) - scene_data.pop('frameStart', None) - scene_data.pop('frameEnd', None) - scene_data.pop('handleStart', None) - scene_data.pop('handleEnd', None) - return scene_data diff --git a/openpype/hosts/aftereffects/plugins/publish/validate_scene_settings.py b/openpype/hosts/aftereffects/plugins/publish/validate_scene_settings.py index cc7db3141f..5301a2f3ea 100644 --- a/openpype/hosts/aftereffects/plugins/publish/validate_scene_settings.py +++ b/openpype/hosts/aftereffects/plugins/publish/validate_scene_settings.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- """Validate scene settings.""" import os +import re import pyblish.api @@ -56,13 +57,26 @@ class ValidateSceneSettings(pyblish.api.InstancePlugin): hosts = ["aftereffects"] optional = True - skip_timelines_check = ["*"] # * >> skip for all - skip_resolution_check = ["*"] + skip_timelines_check = [".*"] # * >> skip for all + skip_resolution_check = [".*"] def process(self, instance): """Plugin entry point.""" expected_settings = api.get_asset_settings() - self.log.info("expected_settings::{}".format(expected_settings)) + self.log.info("config from DB::{}".format(expected_settings)) + + if any(re.search(pattern, os.getenv('AVALON_TASK')) + for pattern in self.skip_resolution_check): + expected_settings.pop("resolutionWidth") + expected_settings.pop("resolutionHeight") + + if any(re.search(pattern, os.getenv('AVALON_TASK')) + for pattern in self.skip_timelines_check): + expected_settings.pop('fps', None) + expected_settings.pop('frameStart', None) + expected_settings.pop('frameEnd', None) + expected_settings.pop('handleStart', None) + expected_settings.pop('handleEnd', None) # handle case where ftrack uses only two decimal places # 23.976023976023978 vs. 23.98 @@ -76,6 +90,8 @@ class ValidateSceneSettings(pyblish.api.InstancePlugin): duration = instance.data.get("frameEndHandle") - \ instance.data.get("frameStartHandle") + 1 + self.log.debug("filtered config::{}".format(expected_settings)) + current_settings = { "fps": fps, "frameStartHandle": instance.data.get("frameStartHandle"), diff --git a/openpype/settings/defaults/project_settings/aftereffects.json b/openpype/settings/defaults/project_settings/aftereffects.json new file mode 100644 index 0000000000..f54dbb9612 --- /dev/null +++ b/openpype/settings/defaults/project_settings/aftereffects.json @@ -0,0 +1,18 @@ +{ + "publish": { + "ValidateSceneSettings": { + "enabled": true, + "optional": true, + "active": true, + "skip_resolution_check": [".*"], + "skip_timelines_check": [".*"] + }, + "AfterEffectsSubmitDeadline": { + "use_published": true, + "priority": 50, + "primary_pool": "", + "secondary_pool": "", + "chunk_size": 1000000 + } + } +} \ No newline at end of file diff --git a/openpype/settings/entities/schemas/projects_schema/schema_main.json b/openpype/settings/entities/schemas/projects_schema/schema_main.json index 6bc158aa60..b4666b302a 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_main.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_main.json @@ -78,6 +78,10 @@ "type": "schema", "name": "schema_project_hiero" }, + { + "type": "schema", + "name": "schema_project_aftereffects" + }, { "type": "schema", "name": "schema_project_harmony" diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_aftereffects.json b/openpype/settings/entities/schemas/projects_schema/schema_project_aftereffects.json new file mode 100644 index 0000000000..63bf9274a3 --- /dev/null +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_aftereffects.json @@ -0,0 +1,90 @@ +{ + "type": "dict", + "collapsible": true, + "key": "aftereffects", + "label": "AfterEffects", + "is_file": true, + "children": [ + { + "type": "dict", + "collapsible": true, + "key": "publish", + "label": "Publish plugins", + "children": [ + { + "type": "dict", + "collapsible": true, + "key": "ValidateSceneSettings", + "label": "Validate Scene Settings", + "checkbox_key": "enabled", + "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "optional", + "label": "Optional" + }, + { + "type": "boolean", + "key": "active", + "label": "Active" + }, + { + "type": "label", + "label": "Validate if FPS and Resolution match shot data" + }, + { + "type": "list", + "key": "skip_resolution_check", + "object_type": "text", + "label": "Skip Resolution Check for Tasks" + }, + { + "type": "list", + "key": "skip_timelines_check", + "object_type": "text", + "label": "Skip Timeline Check for Tasks" + } + ] + }, + { + "type": "dict", + "collapsible": true, + "key": "AfterEffectsSubmitDeadline", + "label": "AfterEffects Submit to Deadline", + "children": [ + { + "type": "boolean", + "key": "use_published", + "label": "Use Published scene" + }, + { + "type": "number", + "key": "priority", + "label": "Priority" + }, + { + "type": "text", + "key": "primary_pool", + "label": "Primary Pool" + }, + { + "type": "text", + "key": "secondary_pool", + "label": "Secondary Pool" + }, + { + "type": "number", + "key": "chunk_size", + "label": "Frames Per Task" + } + ] + } + ] + } + ] +} From c82ab41d7c421f7dcef7bb4116b6cc37f5d5f605 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 13 May 2021 16:46:27 +0200 Subject: [PATCH 199/303] Harmony - modified setting schema to allow enabling, setting optionality --- .../schema_project_harmony.json | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_harmony.json b/openpype/settings/entities/schemas/projects_schema/schema_project_harmony.json index 8b4e379691..8b5d638cd8 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_harmony.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_harmony.json @@ -30,7 +30,27 @@ "collapsible": true, "key": "ValidateSceneSettings", "label": "Validate Scene Settings", + "checkbox_key": "enabled", "children": [ + { + "type": "boolean", + "key": "enabled", + "label": "Enabled" + }, + { + "type": "boolean", + "key": "optional", + "label": "Optional" + }, + { + "type": "boolean", + "key": "active", + "label": "Active" + }, + { + "type": "label", + "label": "Validate if FrameStart, FrameEnd and Resolution match shot data" + }, { "type": "list", "key": "frame_check_filter", From 8db22fbb9870397f48aec54ae499d62dbf1e05b0 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 May 2021 17:56:05 +0200 Subject: [PATCH 200/303] added initial styles --- .../project_manager/style/__init__.py | 13 +++++++++++++ .../project_manager/project_manager/style/style.css | 5 +++++ .../tools/project_manager/project_manager/window.py | 2 ++ 3 files changed, 20 insertions(+) create mode 100644 openpype/tools/project_manager/project_manager/style/__init__.py create mode 100644 openpype/tools/project_manager/project_manager/style/style.css diff --git a/openpype/tools/project_manager/project_manager/style/__init__.py b/openpype/tools/project_manager/project_manager/style/__init__.py new file mode 100644 index 0000000000..5a57642ee1 --- /dev/null +++ b/openpype/tools/project_manager/project_manager/style/__init__.py @@ -0,0 +1,13 @@ +import os +from openpype import resources + + +def load_stylesheet(): + style_path = os.path.join(os.path.dirname(__file__), "style.css") + with open(style_path, "r") as style_file: + stylesheet = style_file.read() + return stylesheet + + +def app_icon_path(): + return resources.pype_icon_filepath() diff --git a/openpype/tools/project_manager/project_manager/style/style.css b/openpype/tools/project_manager/project_manager/style/style.css new file mode 100644 index 0000000000..6730342b30 --- /dev/null +++ b/openpype/tools/project_manager/project_manager/style/style.css @@ -0,0 +1,5 @@ +QTreeView::item { + padding-top: 3px; + padding-bottom: 3px; + padding-right: 3px; +} diff --git a/openpype/tools/project_manager/project_manager/window.py b/openpype/tools/project_manager/project_manager/window.py index 25cb7c9975..bfc76b2bf7 100644 --- a/openpype/tools/project_manager/project_manager/window.py +++ b/openpype/tools/project_manager/project_manager/window.py @@ -7,6 +7,7 @@ from . import ( HierarchySelectionModel, HierarchyView ) +from .style import load_stylesheet from avalon.api import AvalonMongoDB @@ -87,6 +88,7 @@ class Window(QtWidgets.QWidget): self.message_label = message_label self.resize(1200, 600) + self.setStyleSheet(load_stylesheet()) self.refresh_projects() From 5390658044e58334570e53a4499a0c10337e9ff3 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 May 2021 18:13:57 +0200 Subject: [PATCH 201/303] removed AddAssetItem --- .../project_manager/project_manager/model.py | 124 +----------------- 1 file changed, 3 insertions(+), 121 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index abacac0d32..621521b68b 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -236,9 +236,6 @@ class HierarchyModel(QtCore.QAbstractItemModel): # Add item to appending queue appending_queue.put((asset_id, new_item)) - if isinstance(parent_item, (ProjectItem, AssetItem)): - new_items.append(parent_item.add_asset_item) - if new_items: self.add_items(new_items, parent_item) @@ -392,9 +389,6 @@ class HierarchyModel(QtCore.QAbstractItemModel): if result is not None: self._validate_asset_duplicity(name) - if not new_child.add_asset_item_visible: - self.add_item(new_child.add_asset_item, new_child) - return result def add_new_task(self, parent_index): @@ -422,9 +416,6 @@ class HierarchyModel(QtCore.QAbstractItemModel): if start_row is None: start_row = parent.rowCount() - if parent.add_asset_item_visible and start_row == parent.rowCount(): - start_row -= 1 - end_row = start_row + len(items) - 1 parent_index = self.index_from_item(parent.row(), 0, parent.parent()) @@ -502,20 +493,6 @@ class HierarchyModel(QtCore.QAbstractItemModel): if item.data(REMOVED_ROLE): item.setData(False, REMOVED_ROLE) - def delete_add_asset_item(self, parent): - item = parent.add_asset_item - children = parent.children() - if item not in children: - return - parent_index = self.index_for_item(parent) - row = children.index(item) - - self.beginRemoveRows(parent_index, row, row) - - parent.remove_child(item) - - self.endRemoveRows() - def delete_index(self, index): return self.delete_indexes([index]) @@ -532,7 +509,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): processed_ids.add(item_id) item = self._items_by_id[item_id] - if isinstance(item, (TaskItem, AssetItem, AddAssetItem)): + if isinstance(item, (TaskItem, AssetItem)): items_by_id[item_id] = item if not items_by_id: @@ -542,9 +519,6 @@ class HierarchyModel(QtCore.QAbstractItemModel): self._remove_item(item) def _remove_item(self, item): - if isinstance(item, AddAssetItem): - return - is_removed = item.data(REMOVED_ROLE) if is_removed: return @@ -575,9 +549,6 @@ class HierarchyModel(QtCore.QAbstractItemModel): task_children.append(child_item) continue - elif isinstance(child_item, AddAssetItem): - continue - if not _fill_children(_all_descendants, child_item, cur_item): remove_item = False @@ -769,8 +740,6 @@ class HierarchyModel(QtCore.QAbstractItemModel): return dst_row = dst_parent.rowCount() - if dst_parent.add_asset_item_visible: - dst_row -= 1 if src_parent is dst_parent: return @@ -1027,8 +996,6 @@ class HierarchyModel(QtCore.QAbstractItemModel): dst_parent = child_item destination_row = dst_parent.rowCount() - if dst_parent.add_asset_item_visible: - destination_row -= 1 break if dst_parent is not None: @@ -1261,7 +1228,6 @@ class BaseItem: _name_icon = None _is_duplicated = False item_type = "base" - add_asset_item_visible = False _None = object() @@ -1480,8 +1446,6 @@ class ProjectItem(BaseItem): def __init__(self, project_doc): self._mongo_id = project_doc["_id"] - self.add_asset_item = AddAssetItem(self) - data = self.data_from_doc(project_doc) super(ProjectItem, self).__init__(data) @@ -1518,76 +1482,8 @@ class ProjectItem(BaseItem): def flags(self, *args, **kwargs): return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable - def add_child(self, item, row=None): - if not self.add_asset_item_visible and item is self.add_asset_item: - self.add_asset_item_visible = True - - super(ProjectItem, self).add_child(item, row) - - def remove_child(self, item): - if self.add_asset_item_visible and item is self.add_asset_item: - self.add_asset_item_visible = False - - super(ProjectItem, self).remove_child(item) -class AddAssetItem(BaseItem): - item_type = "add_asset" - columns = {"name"} - editable_columns = {"name"} - - def __init__(self, parent): - super(AddAssetItem, self).__init__() - self._parent = parent - - @classmethod - def name_icon(cls): - if cls._name_icon is None: - cls._name_icon = qtawesome.icon( - "fa.plus-circle", - color="#333333" - ) - return cls._name_icon - - def data(self, role, key=None): - if role == REMOVED_ROLE: - return True - - if role == HIERARCHY_CHANGE_ABLE_ROLE: - return True - - if key == "name": - if role == QtCore.Qt.DisplayRole: - return "Add Asset" - elif role == QtCore.Qt.EditRole: - return "" - return super(AddAssetItem, self).data(role, key) - - def setData(self, value, role, key=None): - if key == "name": - if not value: - return False - index = self.model().index_for_item(self) - new_index = self.model().add_new_asset(index) - self.model().setData(new_index, value, QtCore.Qt.EditRole) - return True - return super(AddAssetItem, self).setData(value, role, key) - - def flags(self, key): - if key != "name": - return QtCore.Qt.NoItemFlags - - return ( - QtCore.Qt.ItemIsEnabled - | QtCore.Qt.ItemIsSelectable - | QtCore.Qt.ItemIsEditable - ) - - def add_child(self, item, row=None): - raise AssertionError("BUG: Can't add children to AddAssetItem") - - def remove_child(self, item): - raise AssertionError("BUG: Can't remove children from AddAssetItem") class AssetItem(BaseItem): @@ -1649,9 +1545,6 @@ class AssetItem(BaseItem): self.mongo_id = asset_doc.get("_id") self._project_id = None - self.add_asset_item_visible = False - self.add_asset_item = AddAssetItem(self) - # Item data self._hierarchy_changes_enabled = True self._removed = False @@ -1812,11 +1705,6 @@ class AssetItem(BaseItem): def setData(self, value, role, key=None): if role == REMOVED_ROLE: self._removed = value - if not value and not self.add_asset_item_visible: - self.model().add_item(self.add_asset_item, self) - elif value and self.add_asset_item_visible: - self.model().delete_add_asset_item(self) - return True if role == HIERARCHY_CHANGE_ABLE_ROLE: @@ -1904,20 +1792,14 @@ class AssetItem(BaseItem): super(AssetItem, self).add_child(item, row) - if not self.add_asset_item_visible and item is self.add_asset_item: - self.add_asset_item_visible = True - - elif isinstance(item, TaskItem): + if isinstance(item, TaskItem): self._add_task(item) def remove_child(self, item): if item not in self._children: return - if self.add_asset_item_visible and item is self.add_asset_item: - self.add_asset_item_visible = False - - elif isinstance(item, TaskItem): + if isinstance(item, TaskItem): self._remove_task(item) super(AssetItem, self).remove_child(item) From 4f2e82aeccd5efd40a59efaa555455d5aec8cd73 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 May 2021 18:28:14 +0200 Subject: [PATCH 202/303] asset is created under passed asset --- .../tools/project_manager/project_manager/model.py | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 621521b68b..79c0bc952f 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -370,22 +370,20 @@ class HierarchyModel(QtCore.QAbstractItemModel): item_id = source_index.data(IDENTIFIER_ROLE) item = self.items_by_id[item_id] - new_row = None - name = None - asset_data = {} if isinstance(item, (RootItem, ProjectItem)): name = "ep" - parent = item + new_row = None else: - parent = item.parent() + name = None new_row = item.row() + 1 + asset_data = {} if name: asset_data["name"] = name new_child = AssetItem(asset_data) - result = self.add_item(new_child, parent, new_row) + result = self.add_item(new_child, item, new_row) if result is not None: self._validate_asset_duplicity(name) From 12a7ae0ace910e9e55fb2259b5dbb4d9e763d289 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 May 2021 18:28:32 +0200 Subject: [PATCH 203/303] validate new name instead of passed name --- openpype/tools/project_manager/project_manager/model.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 79c0bc952f..c365bb65f8 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -385,7 +385,9 @@ class HierarchyModel(QtCore.QAbstractItemModel): result = self.add_item(new_child, item, new_row) if result is not None: - self._validate_asset_duplicity(name) + # WARNING Expecting result is index for column 0 ("name") + new_name = result.data(QtCore.Qt.DisplayRole) + self._validate_asset_duplicity(new_name) return result From 2ce685d3e391f92d4619e8dce67659b668b1e3bf Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 May 2021 18:29:11 +0200 Subject: [PATCH 204/303] asset name duplications are handled by item id --- .../project_manager/project_manager/model.py | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index c365bb65f8..84b0c17e8c 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -121,7 +121,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): self._current_project = None self._root_item = None self._items_by_id = {} - self._asset_items_by_name = collections.defaultdict(list) + self._asset_items_by_name = collections.defaultdict(set) self.dbcon = dbcon self._reset_root_item() @@ -431,7 +431,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): if isinstance(item, AssetItem): name = item.data(QtCore.Qt.DisplayRole, "name") - self._asset_items_by_name[name].append(item) + self._asset_items_by_name[name].add(item.id) if item.id not in self._items_by_id: self._items_by_id[item.id] = item @@ -638,13 +638,13 @@ class HierarchyModel(QtCore.QAbstractItemModel): if prev_name == new_name: return - self._asset_items_by_name[prev_name].remove(asset_item) + self._asset_items_by_name[prev_name].remove(asset_item.id) self._validate_asset_duplicity(prev_name) if new_name is None: return - self._asset_items_by_name[new_name].append(asset_item) + self._asset_items_by_name[new_name].add(asset_item.id) self._validate_asset_duplicity(new_name) @@ -652,15 +652,19 @@ class HierarchyModel(QtCore.QAbstractItemModel): if name not in self._asset_items_by_name: return - items = self._asset_items_by_name[name] - if not items: + item_ids = self._asset_items_by_name[name] + if not item_ids: self._asset_items_by_name.pop(name) - elif len(items) == 1: - index = self.index_for_item(items[0]) + elif len(item_ids) == 1: + for item_id in item_ids: + item = self._items_by_id[item_id] + index = self.index_for_item(item) self.setData(index, False, DUPLICATED_ROLE) + else: - for item in items: + for item_id in item_ids: + item = self._items_by_id[item_id] index = self.index_for_item(item) self.setData(index, True, DUPLICATED_ROLE) From cc0cbbfade168cfab07e37b347ab334dbc325c89 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 May 2021 18:29:47 +0200 Subject: [PATCH 205/303] small changes --- .../project_manager/project_manager/delegates.py | 5 +++++ .../project_manager/project_manager/model.py | 3 --- .../project_manager/project_manager/view.py | 16 ++++++++++++++++ 3 files changed, 21 insertions(+), 3 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/delegates.py b/openpype/tools/project_manager/project_manager/delegates.py index 4292224b09..c2b2d07b58 100644 --- a/openpype/tools/project_manager/project_manager/delegates.py +++ b/openpype/tools/project_manager/project_manager/delegates.py @@ -78,6 +78,8 @@ class NumberDelegate(QtWidgets.QStyledItemDelegate): editor = QtWidgets.QDoubleSpinBox(parent) else: editor = QtWidgets.QSpinBox(parent) + + editor.setObjectName("NumberEditor") editor.setMinimum(self.minimum) editor.setMaximum(self.maximum) @@ -97,6 +99,7 @@ class NumberDelegate(QtWidgets.QStyledItemDelegate): class NameDelegate(QtWidgets.QStyledItemDelegate): def createEditor(self, parent, option, index): editor = NameTextEdit(parent) + editor.setObjectName("NameEditor") value = index.data(QtCore.Qt.EditRole) if value is not None: editor.setText(str(value)) @@ -110,6 +113,7 @@ class TypeDelegate(QtWidgets.QStyledItemDelegate): def createEditor(self, parent, option, index): editor = FilterComboBox(parent) + editor.setObjectName("TypeEditor") if not self._project_doc_cache.project_doc: return editor @@ -132,6 +136,7 @@ class ToolsDelegate(QtWidgets.QStyledItemDelegate): def createEditor(self, parent, option, index): editor = MultiSelectionComboBox(parent) + editor.setObjectName("ToolEditor") if not self._tools_cache.tools_data: return editor diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 84b0c17e8c..171aecef7d 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -1487,9 +1487,6 @@ class ProjectItem(BaseItem): return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable - - - class AssetItem(BaseItem): item_type = "asset" diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index b2f21aaffd..8c7902cbd7 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -389,9 +389,16 @@ class HierarchyView(QtWidgets.QTreeView): self.edit(index) def _remove_delete_flag(self, item_ids): + """Remove deletion flag on items marked for deletion.""" self._source_model.remove_delete_flag(item_ids) def _expand_items(self, indexes): + """Expand multiple items with all it's children. + + Args: + indexes (list): List of QModelIndex that should be expanded. + """ + item_ids = set() process_queue = Queue() for index in indexes: @@ -415,6 +422,11 @@ class HierarchyView(QtWidgets.QTreeView): )) def _collapse_items(self, indexes): + """Collapse multiple items with all it's children. + + Args: + indexes (list): List of QModelIndex that should be collapsed. + """ item_ids = set() process_queue = Queue() for index in indexes: @@ -442,6 +454,10 @@ class HierarchyView(QtWidgets.QTreeView): self._parent.show_message(message) def _on_context_menu(self, point): + """Context menu on right click. + + Currently is menu shown only on "name" column. + """ index = self.indexAt(point) column = index.column() if column != 0: From 03cb11d091a2e76925df30ee9f9d95ff77b97c6e Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 May 2021 18:34:52 +0200 Subject: [PATCH 206/303] hide spinbox buttons --- openpype/tools/project_manager/project_manager/delegates.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/tools/project_manager/project_manager/delegates.py b/openpype/tools/project_manager/project_manager/delegates.py index c2b2d07b58..4c6f7f70eb 100644 --- a/openpype/tools/project_manager/project_manager/delegates.py +++ b/openpype/tools/project_manager/project_manager/delegates.py @@ -82,6 +82,7 @@ class NumberDelegate(QtWidgets.QStyledItemDelegate): editor.setObjectName("NumberEditor") editor.setMinimum(self.minimum) editor.setMaximum(self.maximum) + editor.setButtonSymbols(QtWidgets.QSpinBox.NoButtons) value = index.data(QtCore.Qt.EditRole) if value is not None: From 3e58532a4252632677194630ffeda91584aeb76d Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 May 2021 20:01:28 +0200 Subject: [PATCH 207/303] added none project to project model --- openpype/tools/project_manager/project_manager/model.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 171aecef7d..9d0edef886 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -30,6 +30,11 @@ class ProjectModel(QtGui.QStandardItemModel): self.dbcon.Session["AVALON_PROJECT"] = None project_items = [] + + none_project = QtGui.QStandardItem("< Select Project >") + none_project.setData(None) + project_items.append(none_project) + database = self.dbcon.database project_names = set() for project_name in database.collection_names(): From 68aa6b2aac0c31b213015016c8a10ea8cdb61f74 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 May 2021 20:02:05 +0200 Subject: [PATCH 208/303] added cache of resources --- .../project_manager/style/__init__.py | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/openpype/tools/project_manager/project_manager/style/__init__.py b/openpype/tools/project_manager/project_manager/style/__init__.py index 5a57642ee1..bf3a739823 100644 --- a/openpype/tools/project_manager/project_manager/style/__init__.py +++ b/openpype/tools/project_manager/project_manager/style/__init__.py @@ -1,5 +1,73 @@ import os from openpype import resources +from avalon.vendor import qtawesome + + +class ResourceCache: + colors = { + "standard": "#333333", + "warning": "#ff0000", + "new": "#00ff00" + } + icons = None + + @classmethod + def get_icon(cls, *keys): + output = cls.get_icons() + for key in keys: + output = output[key] + return output + + @classmethod + def get_icons(cls): + if cls.icons is None: + cls.icons = { + "asset": { + "existing": qtawesome.icon( + "fa.folder", + color=cls.colors["standard"] + ), + "new": qtawesome.icon( + "fa.folder", + color=cls.colors["new"] + ), + "duplicated": qtawesome.icon( + "fa.folder", + color=cls.colors["warning"] + ), + "removed": qtawesome.icon( + "fa.trash", + color=cls.colors["warning"] + ) + }, + "task": { + "existing": qtawesome.icon( + "fa.check-circle-o", + color=cls.colors["standard"] + ), + "new": qtawesome.icon( + "fa.check-circle", + color=cls.colors["new"] + ), + "duplicated": qtawesome.icon( + "fa.check-circle", + color=cls.colors["warning"] + ), + "removed": qtawesome.icon( + "fa.trash", + color=cls.colors["warning"] + ) + }, + "refresh": qtawesome.icon( + "fa.refresh", + color=cls.colors["standard"] + ) + } + return cls.icons + + @classmethod + def get_color(cls, color_name): + return cls.colors[color_name] def load_stylesheet(): From 93c5f27e3afa4a7b2e8dbe229d9806806cfb47f8 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 May 2021 20:02:26 +0200 Subject: [PATCH 209/303] refresh button has icon --- .../tools/project_manager/project_manager/style/style.css | 4 ++++ openpype/tools/project_manager/project_manager/window.py | 7 +++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/style/style.css b/openpype/tools/project_manager/project_manager/style/style.css index 6730342b30..c62f0fcd81 100644 --- a/openpype/tools/project_manager/project_manager/style/style.css +++ b/openpype/tools/project_manager/project_manager/style/style.css @@ -3,3 +3,7 @@ QTreeView::item { padding-bottom: 3px; padding-right: 3px; } + +#RefreshBtn { + padding: 2px; +} diff --git a/openpype/tools/project_manager/project_manager/window.py b/openpype/tools/project_manager/project_manager/window.py index bfc76b2bf7..cae605af4d 100644 --- a/openpype/tools/project_manager/project_manager/window.py +++ b/openpype/tools/project_manager/project_manager/window.py @@ -7,7 +7,7 @@ from . import ( HierarchySelectionModel, HierarchyView ) -from .style import load_stylesheet +from .style import load_stylesheet, ResourceCache from avalon.api import AvalonMongoDB @@ -27,7 +27,10 @@ class Window(QtWidgets.QWidget): project_combobox.setModel(project_model) project_combobox.setRootModelIndex(QtCore.QModelIndex()) - refresh_projects_btn = QtWidgets.QPushButton("Refresh", project_widget) + refresh_projects_btn = QtWidgets.QPushButton(project_widget) + refresh_projects_btn.setIcon(ResourceCache.get_icon("refresh")) + refresh_projects_btn.setToolTip("Refresh projects") + refresh_projects_btn.setObjectName("RefreshBtn") project_layout = QtWidgets.QHBoxLayout(project_widget) project_layout.setContentsMargins(0, 0, 0, 0) From a993504fa893c3788ae056d6cadb71495c92cabf Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 May 2021 20:02:37 +0200 Subject: [PATCH 210/303] changed order of refresh button --- openpype/tools/project_manager/project_manager/window.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/project_manager/project_manager/window.py b/openpype/tools/project_manager/project_manager/window.py index cae605af4d..ae1ab70c70 100644 --- a/openpype/tools/project_manager/project_manager/window.py +++ b/openpype/tools/project_manager/project_manager/window.py @@ -34,8 +34,8 @@ class Window(QtWidgets.QWidget): project_layout = QtWidgets.QHBoxLayout(project_widget) project_layout.setContentsMargins(0, 0, 0, 0) - project_layout.addWidget(refresh_projects_btn, 0) project_layout.addWidget(project_combobox, 0) + project_layout.addWidget(refresh_projects_btn, 0) project_layout.addStretch(1) hierarchy_model = HierarchyModel(dbcon) From fe52e249d96ef6eb8744ab1e9fce833d00d277c0 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 May 2021 20:04:08 +0200 Subject: [PATCH 211/303] added delete action to context menu --- .../project_manager/project_manager/view.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index 8c7902cbd7..85db6161a6 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -13,7 +13,8 @@ from openpype.lib import ApplicationManager from .constants import ( REMOVED_ROLE, IDENTIFIER_ROLE, - ITEM_TYPE_ROLE + ITEM_TYPE_ROLE, + HIERARCHY_CHANGE_ABLE_ROLE ) @@ -497,9 +498,23 @@ class HierarchyView(QtWidgets.QTreeView): # Remove delete tag on items removed_item_ids = [] + show_delete_items = False for item_id, item in items_by_id.items(): if item.data(REMOVED_ROLE): removed_item_ids.append(item_id) + elif ( + not show_delete_items + and item.data(ITEM_TYPE_ROLE) != "project" + and item.data(HIERARCHY_CHANGE_ABLE_ROLE) + ): + show_delete_items = True + + if show_delete_items: + action = QtWidgets.QAction("Delete items", context_menu) + action.triggered.connect( + lambda: self._delete_items() + ) + actions.append(action) if removed_item_ids: action = QtWidgets.QAction("Keep items", context_menu) From 924dbe408c887fd00b0a63c6a0556a0e0467bdf5 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 May 2021 20:04:49 +0200 Subject: [PATCH 212/303] use icons defined in style --- .../project_manager/project_manager/model.py | 44 +++++++++++++------ 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 9d0edef886..d724b91dab 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -11,6 +11,7 @@ from .constants import ( HIERARCHY_CHANGE_ABLE_ROLE, REMOVED_ROLE ) +from .style import ResourceCache from pymongo import UpdateOne from avalon.vendor import qtawesome from Qt import QtCore, QtGui @@ -1234,7 +1235,7 @@ class BaseItem: # Use `set` for faster result editable_columns = set() - _name_icon = None + _name_icons = None _is_duplicated = False item_type = "base" @@ -1256,9 +1257,8 @@ class BaseItem: if key in self.columns: self._data[key] = value - @classmethod - def name_icon(cls): - return cls._name_icon + def name_icon(self): + return None @property def is_valid(self): @@ -1680,11 +1680,19 @@ class AssetItem(BaseItem): return data - @classmethod - def name_icon(cls): - if cls._name_icon is None: - cls._name_icon = qtawesome.icon("fa.folder", color="#333333") - return cls._name_icon + def name_icon(self): + if self.__class__._name_icons is None: + self.__class__._name_icons = ResourceCache.get_icons()["asset"] + + if self._removed: + icon_type = "removed" + elif self._is_duplicated: + icon_type = "duplicated" + elif self.is_new: + icon_type = "new" + else: + icon_type = "existing" + return self.__class__._name_icons[icon_type] def _get_global_data(self, role): if role == HIERARCHY_CHANGE_ABLE_ROLE: @@ -1838,11 +1846,19 @@ class TaskItem(BaseItem): def is_new(self): return self._is_new - @classmethod - def name_icon(cls): - if cls._name_icon is None: - cls._name_icon = qtawesome.icon("fa.file-o", color="#333333") - return cls._name_icon + def name_icon(self): + if self.__class__._name_icons is None: + self.__class__._name_icons = ResourceCache.get_icons()["task"] + + if self._removed: + icon_type = "removed" + elif self._is_duplicated: + icon_type = "duplicated" + elif self.is_new: + icon_type = "new" + else: + icon_type = "existing" + return self.__class__._name_icons[icon_type] def add_child(self, item, row=None): raise AssertionError("BUG: Can't add children to Task") From b388441342c44d5dc9268bac9cfbfa56734db0a7 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 May 2021 20:05:03 +0200 Subject: [PATCH 213/303] don't change background of items --- openpype/tools/project_manager/project_manager/model.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index d724b91dab..4449628824 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -1707,15 +1707,6 @@ class AssetItem(BaseItem): ) return super(AssetItem, self)._get_global_data(role) - def data(self, role, key=None): - if role == QtCore.Qt.BackgroundRole: - if self._removed: - return QtGui.QColor(255, 0, 0, 127) - elif self.is_new: - return QtGui.QColor(0, 255, 0, 127) - - return super(AssetItem, self).data(role, key) - def setData(self, value, role, key=None): if role == REMOVED_ROLE: self._removed = value From 4f68e953d54d62c7747cbb0568d239af6dc0f25b Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 May 2021 20:38:46 +0200 Subject: [PATCH 214/303] a little bit faster expanding --- openpype/tools/project_manager/project_manager/view.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index 85db6161a6..b7b984fccb 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -400,12 +400,12 @@ class HierarchyView(QtWidgets.QTreeView): indexes (list): List of QModelIndex that should be expanded. """ - item_ids = set() process_queue = Queue() for index in indexes: if index.column() == 0: process_queue.put(index) + item_ids = set() while not process_queue.empty(): index = process_queue.get() item_id = index.data(IDENTIFIER_ROLE) @@ -413,11 +413,9 @@ class HierarchyView(QtWidgets.QTreeView): continue item_ids.add(item_id) - item = self._source_model._items_by_id[item_id] - if not self.isExpanded(index): - self.expand(index) + self.expand(index) - for row in range(item.rowCount()): + for row in range(self._source_model.rowCount(index)): process_queue.put(self._source_model.index( row, 0, index )) From b0f0f94f7708282f916cc3f8cfb246c2352d5cf9 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 13 May 2021 21:25:30 +0200 Subject: [PATCH 215/303] expand/collapse event faster --- .../tools/project_manager/project_manager/view.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index b7b984fccb..96e5be9a40 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -1,3 +1,4 @@ +import collections from queue import Queue from Qt import QtWidgets, QtCore, QtGui @@ -399,13 +400,14 @@ class HierarchyView(QtWidgets.QTreeView): Args: indexes (list): List of QModelIndex that should be expanded. """ - process_queue = Queue() for index in indexes: if index.column() == 0: process_queue.put(index) item_ids = set() + # Use deque as expanding not visible items as first is faster + indexes_deque = collections.deque() while not process_queue.empty(): index = process_queue.get() item_id = index.data(IDENTIFIER_ROLE) @@ -413,13 +415,16 @@ class HierarchyView(QtWidgets.QTreeView): continue item_ids.add(item_id) - self.expand(index) + indexes_deque.append(index) for row in range(self._source_model.rowCount(index)): process_queue.put(self._source_model.index( row, 0, index )) + while indexes_deque: + self.expand(indexes_deque.pop()) + def _collapse_items(self, indexes): """Collapse multiple items with all it's children. @@ -439,11 +444,9 @@ class HierarchyView(QtWidgets.QTreeView): continue item_ids.add(item_id) - item = self._source_model._items_by_id[item_id] - if self.isExpanded(index): - self.collapse(index) + self.collapse(index) - for row in range(item.rowCount()): + for row in range(self._source_model.rowCount(index)): process_queue.put(self._source_model.index( row, 0, index )) From 4439805536f8e42712f734dff62694ef8b2cf0d7 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 14 May 2021 10:38:30 +0200 Subject: [PATCH 216/303] fix adding new asset --- openpype/tools/project_manager/project_manager/model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 4449628824..612411e60b 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -381,7 +381,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): new_row = None else: name = None - new_row = item.row() + 1 + new_row = item.rowCount() asset_data = {} if name: From 0f40eb699d0f7c83c6a6c79f17f671c45bf01838 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 14 May 2021 10:38:51 +0200 Subject: [PATCH 217/303] all assets can be marked for deletion --- openpype/tools/project_manager/project_manager/model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 612411e60b..9f9c081458 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -547,7 +547,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): cur_item.setData(task_removed, REMOVED_ROLE) return task_removed - remove_item = cur_item.data(HIERARCHY_CHANGE_ABLE_ROLE) + remove_item = True task_children = [] for row in range(cur_item.rowCount()): child_item = cur_item.child(row) From 5a9b45f484292d50831f32362fcdd5b5e1c80041 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 14 May 2021 10:39:35 +0200 Subject: [PATCH 218/303] assets without published content are removed from mongo --- .../project_manager/project_manager/model.py | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 9f9c081458..4227771854 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -12,7 +12,7 @@ from .constants import ( REMOVED_ROLE ) from .style import ResourceCache -from pymongo import UpdateOne +from pymongo import UpdateOne, DeleteOne from avalon.vendor import qtawesome from Qt import QtCore, QtGui @@ -1133,10 +1133,15 @@ class HierarchyModel(QtCore.QAbstractItemModel): insert_list.append(item) elif item.data(REMOVED_ROLE): - bulk_writes.append(UpdateOne( - {"_id": item.asset_id}, - {"$set": {"type": "archived_asset"}} - )) + if item.data(HIERARCHY_CHANGE_ABLE_ROLE): + bulk_writes.append(DeleteOne( + {"_id": item.asset_id} + )) + else: + bulk_writes.append(UpdateOne( + {"_id": item.asset_id}, + {"$set": {"type": "archived_asset"}} + )) else: update_data = item.update_data() From 80dbf6a1ce820ce7a32de67b428d7cebf6979bee Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 14 May 2021 10:58:43 +0200 Subject: [PATCH 219/303] add exclamation mark to duplicated --- .../tools/project_manager/project_manager/style/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/style/__init__.py b/openpype/tools/project_manager/project_manager/style/__init__.py index bf3a739823..e7bb116843 100644 --- a/openpype/tools/project_manager/project_manager/style/__init__.py +++ b/openpype/tools/project_manager/project_manager/style/__init__.py @@ -32,7 +32,7 @@ class ResourceCache: color=cls.colors["new"] ), "duplicated": qtawesome.icon( - "fa.folder", + "fa.exclamation-triangle", color=cls.colors["warning"] ), "removed": qtawesome.icon( @@ -50,7 +50,7 @@ class ResourceCache: color=cls.colors["new"] ), "duplicated": qtawesome.icon( - "fa.check-circle", + "fa.exclamation-circle", color=cls.colors["warning"] ), "removed": qtawesome.icon( From f85ecfaf060e8a925876fe98f7fe3f5336f92816 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 14 May 2021 11:03:07 +0200 Subject: [PATCH 220/303] added one more level of asset/task creation --- .../project_manager/project_manager/view.py | 66 ++++++++++--------- 1 file changed, 36 insertions(+), 30 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index 96e5be9a40..6bbae10f02 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -309,16 +309,46 @@ class HierarchyView(QtWidgets.QTreeView): self._source_model.delete_indexes(indexes) def _on_ctrl_shift_enter_pressed(self): - self._add_task() + self._add_task_and_edit() - def _add_task(self, parent_index=None): + def add_asset(self, parent_index=None): if parent_index is None: parent_index = self.currentIndex() if not parent_index.isValid(): return - new_index = self._source_model.add_new_task(parent_index) + # Stop editing + self.setState(HierarchyView.NoState) + QtWidgets.QApplication.processEvents() + + return self._source_model.add_new_asset(parent_index) + + def add_task(self, parent_index=None): + if parent_index is None: + parent_index = self.currentIndex() + + if not parent_index.isValid(): + return + + return self._source_model.add_new_task(parent_index) + + def _add_asset_and_edit(self): + new_index = self.add_asset() + if new_index is None: + return + + # Change current index + self.selectionModel().setCurrentIndex( + new_index, + QtCore.QItemSelectionModel.Clear + | QtCore.QItemSelectionModel.Select + ) + # Start editing + self.edit(new_index) + + def _add_task_and_edit(self): + new_index = self.add_task() if new_index is None: return @@ -339,32 +369,8 @@ class HierarchyView(QtWidgets.QTreeView): # Start editing self.edit(task_type_index) - def _add_asset(self, index=None): - if index is None: - index = self.currentIndex() - - if not index.isValid(): - return - - # Stop editing - self.setState(HierarchyView.NoState) - QtWidgets.QApplication.processEvents() - - new_index = self._source_model.add_new_asset(index) - if new_index is None: - return - - # Change current index - self.selectionModel().setCurrentIndex( - new_index, - QtCore.QItemSelectionModel.Clear - | QtCore.QItemSelectionModel.Select - ) - # Start editing - self.edit(new_index) - def _on_shift_enter_pressed(self): - self._add_asset() + self._add_asset_and_edit() def _on_up_ctrl_pressed(self): indexes = self.selectedIndexes() @@ -486,14 +492,14 @@ class HierarchyView(QtWidgets.QTreeView): if item_type in ("asset", "project"): add_asset_action = QtWidgets.QAction("Add Asset", context_menu) add_asset_action.triggered.connect( - lambda: self._add_asset() + self._add_asset_and_edit ) actions.append(add_asset_action) if item_type in ("asset", "task"): add_task_action = QtWidgets.QAction("Add Task", context_menu) add_task_action.triggered.connect( - lambda: self._add_task() + self._add_task_and_edit ) actions.append(add_task_action) From d0c3a3df758a0d8383dd3ee1c0e55f905877641e Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 14 May 2021 11:03:24 +0200 Subject: [PATCH 221/303] added buttons to add asset and task --- .../project_manager/project_manager/window.py | 42 +++++++++++++++++-- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/window.py b/openpype/tools/project_manager/project_manager/window.py index ae1ab70c70..df762bacce 100644 --- a/openpype/tools/project_manager/project_manager/window.py +++ b/openpype/tools/project_manager/project_manager/window.py @@ -18,11 +18,13 @@ class Window(QtWidgets.QWidget): dbcon = AvalonMongoDB() - # TOP Project selection - project_widget = QtWidgets.QWidget(self) + # Top part of window + top_part_widget = QtWidgets.QWidget(self) + + # Project selection + project_widget = QtWidgets.QWidget(top_part_widget) project_model = ProjectModel(dbcon) - project_combobox = QtWidgets.QComboBox(project_widget) project_combobox.setModel(project_model) project_combobox.setRootModelIndex(QtCore.QModelIndex()) @@ -38,6 +40,30 @@ class Window(QtWidgets.QWidget): project_layout.addWidget(refresh_projects_btn, 0) project_layout.addStretch(1) + # Helper buttons + helper_btns_widget = QtWidgets.QWidget(top_part_widget) + + helper_label = QtWidgets.QLabel("Add:", helper_btns_widget) + add_asset_btn = QtWidgets.QPushButton(helper_btns_widget) + add_asset_btn.setIcon(ResourceCache.get_icon("asset", "existing")) + add_asset_btn.setText("Asset") + add_task_btn = QtWidgets.QPushButton("Task", helper_btns_widget) + add_task_btn.setIcon(ResourceCache.get_icon("task", "existing")) + add_task_btn.setText("Task") + + helper_btns_layout = QtWidgets.QHBoxLayout(helper_btns_widget) + helper_btns_layout.setContentsMargins(0, 0, 0, 0) + helper_btns_layout.addWidget(helper_label) + helper_btns_layout.addWidget(add_asset_btn) + helper_btns_layout.addWidget(add_task_btn) + helper_btns_layout.addStretch(1) + + # Add widgets to top widget layout + top_part_layout = QtWidgets.QVBoxLayout(top_part_widget) + top_part_layout.setContentsMargins(0, 0, 0, 0) + top_part_layout.addWidget(project_widget) + top_part_layout.addWidget(helper_btns_widget) + hierarchy_model = HierarchyModel(dbcon) hierarchy_view = HierarchyView(dbcon, hierarchy_model, self) @@ -74,13 +100,15 @@ class Window(QtWidgets.QWidget): buttons_layout.addWidget(save_btn) main_layout = QtWidgets.QVBoxLayout(self) - main_layout.addWidget(project_widget) + main_layout.addWidget(top_part_widget) main_layout.addWidget(hierarchy_view) main_layout.addWidget(buttons_widget) refresh_projects_btn.clicked.connect(self._on_project_refresh) project_combobox.currentIndexChanged.connect(self._on_project_change) save_btn.clicked.connect(self._on_save_click) + add_asset_btn.clicked.connect(self._on_add_asset) + add_task_btn.clicked.connect(self._on_add_task) self.project_model = project_model self.project_combobox = project_combobox @@ -124,6 +152,12 @@ class Window(QtWidgets.QWidget): def _on_save_click(self): self.hierarchy_model.save() + def _on_add_asset(self): + self.hierarchy_view.add_asset() + + def _on_add_task(self): + self.hierarchy_view.add_task() + def show_message(self, message): # TODO add nicer message pop self.message_label.setText(message) From 988d843b258e8a4c515cd4e4f83b6ed3b72ef79a Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Fri, 14 May 2021 13:27:55 +0200 Subject: [PATCH 222/303] cast representations log into string --- openpype/plugins/publish/extract_review.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/plugins/publish/extract_review.py b/openpype/plugins/publish/extract_review.py index f341ba197f..048d16fabb 100644 --- a/openpype/plugins/publish/extract_review.py +++ b/openpype/plugins/publish/extract_review.py @@ -55,7 +55,7 @@ class ExtractReview(pyblish.api.InstancePlugin): profiles = None def process(self, instance): - self.log.debug(instance.data["representations"]) + self.log.debug(str(instance.data["representations"])) # Skip review when requested. if not instance.data.get("review", True): return From 7171a87a4bf73e2738c0488b0bbd34826f47feaf Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Fri, 14 May 2021 13:28:38 +0200 Subject: [PATCH 223/303] get explicit collections of playblasted files --- .../maya/plugins/publish/extract_playblast.py | 24 ++++++++++++------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_playblast.py b/openpype/hosts/maya/plugins/publish/extract_playblast.py index 358fca6c2a..f79c4a1698 100644 --- a/openpype/hosts/maya/plugins/publish/extract_playblast.py +++ b/openpype/hosts/maya/plugins/publish/extract_playblast.py @@ -96,19 +96,26 @@ class ExtractPlayblast(openpype.api.Extractor): # Remove panel key since it's internal value to capture_gui preset.pop("panel", None) - self.log.info('using viewport preset: {}'.format(preset)) path = capture.capture(**preset) playblast = self._fix_playblast_output_path(path) - self.log.info("file list {}".format(playblast)) + self.log.debug("playblast path {}".format(path)) - collected_frames = os.listdir(stagingdir) - collections, remainder = clique.assemble(collected_frames) - input_path = os.path.join( - stagingdir, collections[0].format('{head}{padding}{tail}')) - self.log.info("input {}".format(input_path)) + collected_files = os.listdir(stagingdir) + collections, remainder = clique.assemble(collected_files) + + self.log.debug("filename {}".format(filename)) + frame_collection = None + for collection in collections: + filebase = collection.format('{head}').rstrip(".") + self.log.debug("collection head {}".format(filebase)) + if filebase in filename: + frame_collection = collection + self.log.info( + "we found collection of interest {}".format( + str(frame_collection))) if "representations" not in instance.data: instance.data["representations"] = [] @@ -119,12 +126,11 @@ class ExtractPlayblast(openpype.api.Extractor): # Add camera node name to representation data camera_node_name = pm.ls(camera)[0].getTransform().name() - representation = { 'name': 'png', 'ext': 'png', - 'files': collected_frames, + 'files': list(frame_collection), "stagingDir": stagingdir, "frameStart": start, "frameEnd": end, From 153dc4b1aad64d8ff54a287c844a7d2e32b598bd Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Fri, 14 May 2021 13:28:57 +0200 Subject: [PATCH 224/303] remove obsolete function _fix_playblast_output_path --- .../maya/plugins/publish/extract_playblast.py | 39 ------------------- 1 file changed, 39 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/extract_playblast.py b/openpype/hosts/maya/plugins/publish/extract_playblast.py index f79c4a1698..0dc91d67a9 100644 --- a/openpype/hosts/maya/plugins/publish/extract_playblast.py +++ b/openpype/hosts/maya/plugins/publish/extract_playblast.py @@ -99,7 +99,6 @@ class ExtractPlayblast(openpype.api.Extractor): self.log.info('using viewport preset: {}'.format(preset)) path = capture.capture(**preset) - playblast = self._fix_playblast_output_path(path) self.log.debug("playblast path {}".format(path)) @@ -141,44 +140,6 @@ class ExtractPlayblast(openpype.api.Extractor): } instance.data["representations"].append(representation) - def _fix_playblast_output_path(self, filepath): - """Workaround a bug in maya.cmds.playblast to return correct filepath. - - When the `viewer` argument is set to False and maya.cmds.playblast - does not automatically open the playblasted file the returned - filepath does not have the file's extension added correctly. - - To workaround this we just glob.glob() for any file extensions and - assume the latest modified file is the correct file and return it. - """ - # Catch cancelled playblast - if filepath is None: - self.log.warning("Playblast did not result in output path. " - "Playblast is probably interrupted.") - return None - - # Fix: playblast not returning correct filename (with extension) - # Lets assume the most recently modified file is the correct one. - if not os.path.exists(filepath): - directory = os.path.dirname(filepath) - filename = os.path.basename(filepath) - # check if the filepath is has frame based filename - # example : capture.####.png - parts = filename.split(".") - if len(parts) == 3: - query = os.path.join(directory, "{}.*.{}".format(parts[0], - parts[-1])) - files = glob.glob(query) - else: - files = glob.glob("{}.*".format(filepath)) - - if not files: - raise RuntimeError("Couldn't find playblast from: " - "{0}".format(filepath)) - filepath = max(files, key=os.path.getmtime) - - return filepath - @contextlib.contextmanager def maintained_time(): From ecd498df857eddcb3fc98a731ce6175152d00818 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 14 May 2021 14:11:59 +0200 Subject: [PATCH 225/303] change pyblish MessageHandler emit method to convert messages to string at the moment of emmiting --- openpype/__init__.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/openpype/__init__.py b/openpype/__init__.py index f63d534e08..a86d2bc2be 100644 --- a/openpype/__init__.py +++ b/openpype/__init__.py @@ -67,6 +67,15 @@ def patched_discover(superclass): @import_wrapper def install(): """Install Pype to Avalon.""" + from pyblish.lib import MessageHandler + + def modified_emit(obj, record): + """Method replacing `emit` in Pyblish's MessageHandler.""" + record.msg = record.getMessage() + obj.records.append(record) + + MessageHandler.emit = modified_emit + log.info("Registering global plug-ins..") pyblish.register_plugin_path(PUBLISH_PATH) pyblish.register_discovery_filter(filter_pyblish_plugins) From b382d5eea8a4a55ceb1d4d5c0237dce1f9d790a3 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 14 May 2021 15:56:29 +0200 Subject: [PATCH 226/303] TaskItem and AssetItem have custom is_valid property --- .../tools/project_manager/project_manager/model.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 4227771854..1f11874ba7 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -1588,6 +1588,12 @@ class AssetItem(BaseItem): def is_new(self): return self.asset_id is None + @property + def is_valid(self): + if self._is_duplicated or not self._data["name"]: + return False + return True + @property def name(self): return self._data["name"] @@ -1842,6 +1848,14 @@ class TaskItem(BaseItem): def is_new(self): return self._is_new + @property + def is_valid(self): + if self._is_duplicated or not self._data["type"]: + return False + if not self.data(QtCore.Qt.EditRole, "name"): + return False + return True + def name_icon(self): if self.__class__._name_icons is None: self.__class__._name_icons = ResourceCache.get_icons()["task"] From c2d5afb18bb6e50151599eb89bc8fbec7d2c5cda Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 14 May 2021 16:04:44 +0200 Subject: [PATCH 227/303] use is_valid property in code --- openpype/tools/project_manager/project_manager/model.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 1f11874ba7..82e4b7f3e0 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -1308,7 +1308,7 @@ class BaseItem: return None if role == QtCore.Qt.ForegroundRole: - if self._is_duplicated and key == "name": + if key == "name" and not self.is_valid: return QtGui.QColor(255, 0, 0) return None @@ -1697,7 +1697,7 @@ class AssetItem(BaseItem): if self._removed: icon_type = "removed" - elif self._is_duplicated: + elif not self.is_valid: icon_type = "duplicated" elif self.is_new: icon_type = "new" @@ -1862,7 +1862,7 @@ class TaskItem(BaseItem): if self._removed: icon_type = "removed" - elif self._is_duplicated: + elif not self.is_valid: icon_type = "duplicated" elif self.is_new: icon_type = "new" From 3e51be7e3b62c403f719d7f9bb9b0fa08be7b6f3 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 14 May 2021 16:05:34 +0200 Subject: [PATCH 228/303] change invalid tooltips based on issue --- .../project_manager/project_manager/model.py | 29 ++++++++++++++----- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 82e4b7f3e0..d33360c413 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -1712,10 +1712,15 @@ class AssetItem(BaseItem): if role == REMOVED_ROLE: return self._removed - if role == QtCore.Qt.ToolTipRole and self._is_duplicated: - return "Asset with name \"{}\" already exists.".format( - self._data["name"] - ) + if role == QtCore.Qt.ToolTipRole: + name = self.data(QtCore.Qt.EditRole, "name") + if not name: + return "Name is not set" + + elif self._is_duplicated: + return "Duplicated asset name \"{}\"".format(name) + return None + return super(AssetItem, self)._get_global_data(role) def setData(self, value, role, key=None): @@ -1877,10 +1882,18 @@ class TaskItem(BaseItem): if role == REMOVED_ROLE: return self._removed - if role == QtCore.Qt.ToolTipRole and self._is_duplicated: - return "Duplicated Task name \"{}\".".format( - self._data["name"] - ) + if role == QtCore.Qt.ToolTipRole: + if not self._data["type"]: + return "Type is not set" + + name = self.data(QtCore.Qt.EditRole, "name") + if not name: + return "Name is not set" + + elif self._is_duplicated: + return "Duplicated task name \"{}".format(name) + return None + return super(TaskItem, self)._get_global_data(role) def to_doc_data(self): From cda522d6541823c019ab0579ff3a90357f00ba66 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 14 May 2021 16:06:11 +0200 Subject: [PATCH 229/303] task may have different edit and display role value --- .../project_manager/project_manager/model.py | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index d33360c413..0471e863d7 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -1907,18 +1907,19 @@ class TaskItem(BaseItem): } def data(self, role, key=None): - if role == QtCore.Qt.BackgroundRole: - if self._removed: - return QtGui.QColor(255, 0, 0, 127) + if role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole): + if key == "type": + return self._data["type"] - elif self.is_new: - return QtGui.QColor(0, 255, 0, 127) + if key == "name": + if not self._data["type"]: + if role == QtCore.Qt.DisplayRole: + return "< Select Type >" + if role == QtCore.Qt.EditRole: + return "" + else: + return self._data[key] or self._data["type"] - if ( - role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole) - and key == "name" - ): - return self._data[key] or self._data["type"] or "< Select Type >" return super(TaskItem, self).data(role, key) def setData(self, value, role, key=None): From b86ab862a956f1014dd0e9cb42566ff205a7c373 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 14 May 2021 16:06:32 +0200 Subject: [PATCH 230/303] task will store empty name as None --- .../project_manager/project_manager/model.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 0471e863d7..b33c4431f0 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -1927,13 +1927,21 @@ class TaskItem(BaseItem): self._removed = value return True + if ( + role == QtCore.Qt.EditRole + and key == "name" + and not value + ): + value = None + result = super(TaskItem, self).setData(value, role, key) - if ( - key == "name" - or (key == "type" and self._data["name"] is None) - ): - self.parent().on_task_name_change(self) + if role == QtCore.Qt.EditRole: + if ( + key == "name" + or (key == "type" and not self._data["name"]) + ): + self.parent().on_task_name_change(self) return result From c2aef84f2e853ec797577c11e86f6dfeec6b16f9 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 14 May 2021 16:13:25 +0200 Subject: [PATCH 231/303] use EditRole to get value of name and type --- .../tools/project_manager/project_manager/model.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index b33c4431f0..5ce45d2b99 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -640,7 +640,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): if not isinstance(asset_item, AssetItem): return - prev_name = asset_item.data(QtCore.Qt.DisplayRole, "name") + prev_name = asset_item.data(QtCore.Qt.EditRole, "name") if prev_name == new_name: return @@ -1619,8 +1619,8 @@ class AssetItem(BaseItem): ) doc = { - "name": self.data(QtCore.Qt.DisplayRole, "name"), - "type": self.data(QtCore.Qt.DisplayRole, "type"), + "name": self.data(QtCore.Qt.EditRole, "name"), + "type": self.data(QtCore.Qt.EditRole, "type"), "schema": schema_name, "data": doc_data, "parent": self.project_id @@ -1632,7 +1632,7 @@ class AssetItem(BaseItem): if key in doc: continue # Use `data` method to get inherited values - doc_data[key] = self.data(QtCore.Qt.DisplayRole, key) + doc_data[key] = self.data(QtCore.Qt.EditRole, key) return doc @@ -1751,7 +1751,7 @@ class AssetItem(BaseItem): return super(AssetItem, self).flags(key) def _add_task(self, item): - name = item.data(QtCore.Qt.DisplayRole, "name").lower() + name = item.data(QtCore.Qt.EditRole, "name").lower() item_id = item.data(IDENTIFIER_ROLE) self._task_name_by_item_id[item_id] = name @@ -1901,7 +1901,7 @@ class TaskItem(BaseItem): return {} data = copy.deepcopy(self._data) data.pop("name") - name = self.data(QtCore.Qt.DisplayRole, "name") + name = self.data(QtCore.Qt.EditRole, "name") return { name: data } From 2d6bb1108aeed2a0e1cc00587e2c37c52d8fa054 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 14 May 2021 16:33:28 +0200 Subject: [PATCH 232/303] SyncServer - fixes for revalidating cache for sync settings Fix wrong methods --- .../modules/sync_server/providers/gdrive.py | 1 - .../modules/sync_server/sync_server_module.py | 120 +++++++++++------- 2 files changed, 74 insertions(+), 47 deletions(-) diff --git a/openpype/modules/sync_server/providers/gdrive.py b/openpype/modules/sync_server/providers/gdrive.py index 65fccb3215..2caf41ba5e 100644 --- a/openpype/modules/sync_server/providers/gdrive.py +++ b/openpype/modules/sync_server/providers/gdrive.py @@ -4,7 +4,6 @@ import time import sys from setuptools.extern import six import platform -from json.decoder import JSONDecodeError from openpype.api import Logger from openpype.api import get_system_settings diff --git a/openpype/modules/sync_server/sync_server_module.py b/openpype/modules/sync_server/sync_server_module.py index aefe8195c4..a9f1a73d77 100644 --- a/openpype/modules/sync_server/sync_server_module.py +++ b/openpype/modules/sync_server/sync_server_module.py @@ -3,6 +3,7 @@ from bson.objectid import ObjectId from datetime import datetime import threading import platform +import copy from avalon.api import AvalonMongoDB @@ -15,7 +16,8 @@ from openpype.api import ( from openpype.lib import PypeLogger from openpype.settings.lib import ( get_default_project_settings, - get_default_anatomy_settings) + get_default_anatomy_settings, + get_anatomy_settings) from .providers.local_drive import LocalDriveHandler from .providers import lib @@ -396,9 +398,13 @@ class SyncServerModule(PypeModule, ITrayModule): return remote_site + def get_local_settings_schema(self): + """Wrapper for Local settings - all projects incl. Default""" + return self.get_configurable_items(EditableScopes.LOCAL) + def get_configurable_items(self, scope=None): """ - Returns list of items that could be configurable for all projects. + Returns list of sites that could be configurable for all projects. Could be filtered by 'scope' argument (list) @@ -443,8 +449,9 @@ class SyncServerModule(PypeModule, ITrayModule): return editable def get_local_settings_schema_for_project(self, project_name): - """Wrapper for Local settings""" - return self.get_configurable_items(project_name, EditableScopes.LOCAL) + """Wrapper for Local settings - for specific 'project_name'""" + return self.get_configurable_items_for_project(project_name, + EditableScopes.LOCAL) def get_configurable_items_for_project(self, project_name=None, scope=None): @@ -480,7 +487,7 @@ class SyncServerModule(PypeModule, ITrayModule): } """ allowed_sites = set() - sites = self.get_all_sites(project_name) + sites = self.get_all_site_configs(project_name) if project_name: # Local Settings can select only from allowed sites for project allowed_sites.update(set(self.get_active_sites(project_name))) @@ -502,10 +509,10 @@ class SyncServerModule(PypeModule, ITrayModule): return editable def get_local_settings_schema_for_site(self, project_name, site_name): - """Wrapper for Local settings""" - return self.get_configurable_items(project_name, - site_name, - EditableScopes.LOCAL) + """Wrapper for Local settings - for particular 'site_name and proj.""" + return self.get_configurable_items_for_site(project_name, + site_name, + EditableScopes.LOCAL) def get_configurable_items_for_site(self, project_name=None, site_name=None, @@ -537,7 +544,8 @@ class SyncServerModule(PypeModule, ITrayModule): if project_name: sync_s = self.get_sync_project_setting(project_name, - exclude_locals=True) + exclude_locals=True, + cached=False) else: sync_s = get_default_project_settings(exclude_locals=True) sync_s = sync_s["global"]["sync_server"] @@ -765,37 +773,49 @@ class SyncServerModule(PypeModule, ITrayModule): exclude_locals (bool): ignore overrides from Local Settings For performance """ - sync_project_settings = {} - - system_sites = self.get_all_sites() - - for collection in self.connection.database.collection_names(False): - sites = dict(system_sites) # get all configured sites - proj_settings = self._parse_sync_settings_from_settings( - get_project_settings(collection, - exclude_locals=exclude_locals)) - sites.update(proj_settings["sites"]) # apply project overrides - proj_settings["sites"] = sites - - sync_project_settings[collection] = proj_settings - - if not sync_project_settings: - log.info("No enabled and configured projects for sync.") + sync_project_settings = self._prepare_sync_project_settings( + exclude_locals) self._sync_project_settings = sync_project_settings - def get_sync_project_setting(self, project_name, exclude_locals=False): + def _prepare_sync_project_settings(self, exclude_locals): + sync_project_settings = {} + system_sites = self.get_all_site_configs() + for collection in self.connection.database.collection_names(False): + sites = copy.deepcopy(system_sites) # get all configured sites + proj_settings = self._parse_sync_settings_from_settings( + get_project_settings(collection, + exclude_locals=exclude_locals)) + sites.update(self._get_default_site_configs( + proj_settings["enabled"], collection)) + sites.update(proj_settings['sites']) + proj_settings["sites"] = sites + + sync_project_settings[collection] = proj_settings + if not sync_project_settings: + log.info("No enabled and configured projects for sync.") + return sync_project_settings + + def get_sync_project_setting(self, project_name, exclude_locals=False, + cached=True): """ Handles pulling sync_server's settings for enabled 'project_name' Args: project_name (str): used in project settings exclude_locals (bool): ignore overrides from Local Settings + cached (bool): use pre-cached values, or return fresh ones + cached values needed for single loop (with all overrides) + fresh values needed for Local settings (without overrides) Returns: (dict): settings dictionary for the enabled project, empty if no settings or sync is disabled """ # presets set already, do not call again and again # self.log.debug("project preset {}".format(self.presets)) + if not cached: + return self._prepare_sync_project_settings(exclude_locals)\ + [project_name] + if not self.sync_project_settings or \ not self.sync_project_settings.get(project_name): self.set_sync_project_settings(exclude_locals) @@ -808,24 +828,7 @@ class SyncServerModule(PypeModule, ITrayModule): return sync_settings - def _get_default_site_configs(self, sync_enabled=True): - """ - Returns skeleton settings for 'studio' and user's local site - """ - anatomy_sett = get_default_anatomy_settings() - roots = {} - for root, config in anatomy_sett["roots"].items(): - roots[root] = config[platform.system().lower()] - studio_config = { - 'provider': 'local_drive', - "root": roots - } - all_sites = {self.DEFAULT_SITE: studio_config} - if sync_enabled: - all_sites[get_local_site_id()] = {'provider': 'local_drive'} - return all_sites - - def get_all_sites(self, project_name=None): + def get_all_site_configs(self, project_name=None): """ Returns (dict) with all sites configured system wide. @@ -849,10 +852,35 @@ class SyncServerModule(PypeModule, ITrayModule): for site, detail in sync_sett.get("sites", {}).items(): system_sites[site] = detail - system_sites.update(self._get_default_site_configs(sync_enabled)) + system_sites.update(self._get_default_site_configs(sync_enabled, + project_name)) return system_sites + def _get_default_site_configs(self, sync_enabled=True, project_name=None): + """ + Returns settings for 'studio' and user's local site + + Returns base values from setting, not overriden by Local Settings, + eg. value used to push TO LS not to get actual value for syncing. + """ + if not project_name: + anatomy_sett = get_default_anatomy_settings(exclude_locals=True) + else: + anatomy_sett = get_anatomy_settings(project_name, + exclude_locals=True) + roots = {} + for root, config in anatomy_sett["roots"].items(): + roots[root] = config[platform.system().lower()] + studio_config = { + 'provider': 'local_drive', + "root": roots + } + all_sites = {self.DEFAULT_SITE: studio_config} + if sync_enabled: + all_sites[get_local_site_id()] = {'provider': 'local_drive'} + return all_sites + def get_provider_for_site(self, project_name=None, site=None): """ Return provider name for site (unique name across all projects. From cf9cf6278a36afd86e19dff36582e969c8184c7b Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 14 May 2021 17:22:21 +0200 Subject: [PATCH 233/303] asset and task items know if should return display role --- .../project_manager/constants.py | 1 + .../project_manager/project_manager/model.py | 42 ++++++++++++++++++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/openpype/tools/project_manager/project_manager/constants.py b/openpype/tools/project_manager/project_manager/constants.py index 703cc0d003..6fb4b991ed 100644 --- a/openpype/tools/project_manager/project_manager/constants.py +++ b/openpype/tools/project_manager/project_manager/constants.py @@ -7,6 +7,7 @@ DUPLICATED_ROLE = QtCore.Qt.UserRole + 2 HIERARCHY_CHANGE_ABLE_ROLE = QtCore.Qt.UserRole + 3 REMOVED_ROLE = QtCore.Qt.UserRole + 4 ITEM_TYPE_ROLE = QtCore.Qt.UserRole + 5 +EDITOR_OPENED_ROLE = QtCore.Qt.UserRole + 6 NAME_ALLOWED_SYMBOLS = "a-zA-Z0-9_" NAME_REGEX = re.compile("^[" + NAME_ALLOWED_SYMBOLS + "]*$") diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 5ce45d2b99..2c4e1838a6 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -9,7 +9,8 @@ from .constants import ( ITEM_TYPE_ROLE, DUPLICATED_ROLE, HIERARCHY_CHANGE_ABLE_ROLE, - REMOVED_ROLE + REMOVED_ROLE, + EDITOR_OPENED_ROLE ) from .style import ResourceCache from pymongo import UpdateOne, DeleteOne @@ -1555,6 +1556,10 @@ class AssetItem(BaseItem): asset_doc = {} self.mongo_id = asset_doc.get("_id") self._project_id = None + self._edited_columns = { + column_name: False + for column_name in self.editable_columns + } # Item data self._hierarchy_changes_enabled = True @@ -1723,7 +1728,24 @@ class AssetItem(BaseItem): return super(AssetItem, self)._get_global_data(role) + def data(self, role, key=None): + if role == EDITOR_OPENED_ROLE: + if key not in self._edited_columns: + return False + return self._edited_columns[key] + + if role == QtCore.Qt.DisplayRole and self._edited_columns.get(key): + return "" + + return super(AssetItem, self).data(role, key) + def setData(self, value, role, key=None): + if role == EDITOR_OPENED_ROLE: + if key not in self._edited_columns: + return False + self._edited_columns[key] = value + return True + if role == REMOVED_ROLE: self._removed = value return True @@ -1846,6 +1868,10 @@ class TaskItem(BaseItem): if data is None: data = {} + self._edited_columns = { + column_name: False + for column_name in self.editable_columns + } self._origin_data = copy.deepcopy(data) super(TaskItem, self).__init__(data) @@ -1907,6 +1933,14 @@ class TaskItem(BaseItem): } def data(self, role, key=None): + if role == EDITOR_OPENED_ROLE: + if key not in self._edited_columns: + return False + return self._edited_columns[key] + + if role == QtCore.Qt.DisplayRole and self._edited_columns.get(key): + return "" + if role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole): if key == "type": return self._data["type"] @@ -1923,6 +1957,12 @@ class TaskItem(BaseItem): return super(TaskItem, self).data(role, key) def setData(self, value, role, key=None): + if role == EDITOR_OPENED_ROLE: + if key not in self._edited_columns: + return False + self._edited_columns[key] = value + return True + if role == REMOVED_ROLE: self._removed = value return True From 0699d8d843c548319d8de81166e6db22f95f07ee Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 14 May 2021 17:23:09 +0200 Subject: [PATCH 234/303] view gives model items info if should return display role --- .../project_manager/project_manager/view.py | 38 +++++++++++++++++-- 1 file changed, 35 insertions(+), 3 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index 6bbae10f02..0131687541 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -15,7 +15,8 @@ from .constants import ( REMOVED_ROLE, IDENTIFIER_ROLE, ITEM_TYPE_ROLE, - HIERARCHY_CHANGE_ABLE_ROLE + HIERARCHY_CHANGE_ABLE_ROLE, + EDITOR_OPENED_ROLE ) @@ -104,6 +105,8 @@ class HierarchyView(QtWidgets.QTreeView): super(HierarchyView, self).__init__(parent) # Direct access to model self._source_model = source_model + self._editors_mapping = {} + self._persisten_editors = set() # Access to parent because of `show_message` method self._parent = parent @@ -214,12 +217,41 @@ class HierarchyView(QtWidgets.QTreeView): def edit(self, index, *args, **kwargs): result = super(HierarchyView, self).edit(index, *args, **kwargs) - self._deselect_editor(self.indexWidget(index)) + if result: + # Mark index to not return text for DisplayRole + editor = self.indexWidget(index) + if ( + editor not in self._persisten_editors + and editor not in self._editors_mapping + ): + self._editors_mapping[editor] = index + self._source_model.setData(index, True, EDITOR_OPENED_ROLE) + # Deselect content of editor + # QUESTION not sure if we want do this all the time + self._deselect_editor(editor) return result + def closeEditor(self, editor, hint): + if ( + editor not in self._persisten_editors + and editor in self._editors_mapping + ): + index = self._editors_mapping.pop(editor) + self._source_model.setData(index, False, EDITOR_OPENED_ROLE) + super(HierarchyView, self).closeEditor(editor, hint) + def openPersistentEditor(self, index): + self._source_model.setData(index, True, EDITOR_OPENED_ROLE) super(HierarchyView, self).openPersistentEditor(index) - self._deselect_editor(self.indexWidget(index)) + editor = self.indexWidget(index) + self._persisten_editors.add(editor) + self._deselect_editor(editor) + + def closePersistentEditor(self, index): + self._source_model.setData(index, False, EDITOR_OPENED_ROLE) + editor = self.indexWidget(index) + self._persisten_editors.remove(editor) + super(HierarchyView, self).closePersistentEditor(index) def rowsInserted(self, parent_index, start, end): super(HierarchyView, self).rowsInserted(parent_index, start, end) From b318387c3cb81e9b8d64cacaa7b2a6e0f9a8f91e Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 14 May 2021 17:23:47 +0200 Subject: [PATCH 235/303] added some styles to qtreeview --- .../project_manager/project_manager/delegates.py | 1 + .../project_manager/project_manager/style/style.css | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/openpype/tools/project_manager/project_manager/delegates.py b/openpype/tools/project_manager/project_manager/delegates.py index 4c6f7f70eb..f53e0442f1 100644 --- a/openpype/tools/project_manager/project_manager/delegates.py +++ b/openpype/tools/project_manager/project_manager/delegates.py @@ -115,6 +115,7 @@ class TypeDelegate(QtWidgets.QStyledItemDelegate): def createEditor(self, parent, option, index): editor = FilterComboBox(parent) editor.setObjectName("TypeEditor") + editor.style().polish(editor) if not self._project_doc_cache.project_doc: return editor diff --git a/openpype/tools/project_manager/project_manager/style/style.css b/openpype/tools/project_manager/project_manager/style/style.css index c62f0fcd81..31196b7cc6 100644 --- a/openpype/tools/project_manager/project_manager/style/style.css +++ b/openpype/tools/project_manager/project_manager/style/style.css @@ -4,6 +4,18 @@ QTreeView::item { padding-right: 3px; } + +QTreeView::item:selected, QTreeView::item:selected:!active { + background: rgba(0, 122, 204, 127); + color: black; +} + #RefreshBtn { padding: 2px; } + +#TypeEditor, #ToolEditor, #NameEditor, #NumberEditor { + background: transparent; + border: 1px solid #005c99; + border-radius: 0.3em; +} From 382febe03fb2081fd16fdf1ca82f48dd1c564be1 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 14 May 2021 17:28:13 +0200 Subject: [PATCH 236/303] renamed icon keys --- openpype/tools/project_manager/project_manager/model.py | 8 ++++---- .../project_manager/project_manager/style/__init__.py | 8 ++++---- openpype/tools/project_manager/project_manager/window.py | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 2c4e1838a6..b371242388 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -1703,11 +1703,11 @@ class AssetItem(BaseItem): if self._removed: icon_type = "removed" elif not self.is_valid: - icon_type = "duplicated" + icon_type = "invalid" elif self.is_new: icon_type = "new" else: - icon_type = "existing" + icon_type = "default" return self.__class__._name_icons[icon_type] def _get_global_data(self, role): @@ -1894,11 +1894,11 @@ class TaskItem(BaseItem): if self._removed: icon_type = "removed" elif not self.is_valid: - icon_type = "duplicated" + icon_type = "invalid" elif self.is_new: icon_type = "new" else: - icon_type = "existing" + icon_type = "default" return self.__class__._name_icons[icon_type] def add_child(self, item, row=None): diff --git a/openpype/tools/project_manager/project_manager/style/__init__.py b/openpype/tools/project_manager/project_manager/style/__init__.py index e7bb116843..6b9d708f76 100644 --- a/openpype/tools/project_manager/project_manager/style/__init__.py +++ b/openpype/tools/project_manager/project_manager/style/__init__.py @@ -23,7 +23,7 @@ class ResourceCache: if cls.icons is None: cls.icons = { "asset": { - "existing": qtawesome.icon( + "default": qtawesome.icon( "fa.folder", color=cls.colors["standard"] ), @@ -31,7 +31,7 @@ class ResourceCache: "fa.folder", color=cls.colors["new"] ), - "duplicated": qtawesome.icon( + "invalid": qtawesome.icon( "fa.exclamation-triangle", color=cls.colors["warning"] ), @@ -41,7 +41,7 @@ class ResourceCache: ) }, "task": { - "existing": qtawesome.icon( + "default": qtawesome.icon( "fa.check-circle-o", color=cls.colors["standard"] ), @@ -49,7 +49,7 @@ class ResourceCache: "fa.check-circle", color=cls.colors["new"] ), - "duplicated": qtawesome.icon( + "invalid": qtawesome.icon( "fa.exclamation-circle", color=cls.colors["warning"] ), diff --git a/openpype/tools/project_manager/project_manager/window.py b/openpype/tools/project_manager/project_manager/window.py index df762bacce..c4243c3cc3 100644 --- a/openpype/tools/project_manager/project_manager/window.py +++ b/openpype/tools/project_manager/project_manager/window.py @@ -45,10 +45,10 @@ class Window(QtWidgets.QWidget): helper_label = QtWidgets.QLabel("Add:", helper_btns_widget) add_asset_btn = QtWidgets.QPushButton(helper_btns_widget) - add_asset_btn.setIcon(ResourceCache.get_icon("asset", "existing")) + add_asset_btn.setIcon(ResourceCache.get_icon("asset", "default")) add_asset_btn.setText("Asset") add_task_btn = QtWidgets.QPushButton("Task", helper_btns_widget) - add_task_btn.setIcon(ResourceCache.get_icon("task", "existing")) + add_task_btn.setIcon(ResourceCache.get_icon("task", "default")) add_task_btn.setText("Task") helper_btns_layout = QtWidgets.QHBoxLayout(helper_btns_widget) From 67b7e8071f25551c2c618d3a7427011e7a8d2dcb Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Fri, 14 May 2021 17:28:42 +0200 Subject: [PATCH 237/303] use semver for version resolution --- igniter/bootstrap_repos.py | 298 ++++++++++---------- poetry.lock | 389 +++++++++++++------------- pyproject.toml | 1 + start.py | 24 +- tests/igniter/test_bootstrap_repos.py | 90 +++--- 5 files changed, 408 insertions(+), 394 deletions(-) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index 8fbb580e8f..8421e42c87 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -1,6 +1,6 @@ # -*- coding: utf-8 -*- """Bootstrap OpenPype repositories.""" -import functools +from __future__ import annotations import logging as log import os import re @@ -9,10 +9,12 @@ import sys import tempfile from pathlib import Path from typing import Union, Callable, List, Tuple + from zipfile import ZipFile, BadZipFile from appdirs import user_data_dir from speedcopy import copyfile +import semver from .user_settings import ( OpenPypeSecureRegistry, @@ -26,159 +28,138 @@ LOG_WARNING = 1 LOG_ERROR = 3 -@functools.total_ordering -class OpenPypeVersion: +class OpenPypeVersion(semver.VersionInfo): """Class for storing information about OpenPype version. Attributes: - major (int): [1].2.3-client-variant - minor (int): 1.[2].3-client-variant - subversion (int): 1.2.[3]-client-variant - client (str): 1.2.3-[client]-variant - variant (str): 1.2.3-client-[variant] + staging (bool): True if it is staging version path (str): path to OpenPype """ - major = 0 - minor = 0 - subversion = 0 - variant = "" - client = None + staging = False path = None + _VERSION_REGEX = re.compile(r"(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$") - _version_regex = re.compile( - r"(?P\d+)\.(?P\d+)\.(?P\d+)(-(?Pstaging)|-(?P.+)(-(?Pstaging)))?") # noqa: E501 + def __init__(self, *args, **kwargs): + """Create OpenPype version. - @property - def version(self): - """return formatted version string.""" - return self._compose_version() + .. deprecated:: 3.0.0-rc.2 + `client` and `variant` are removed. - @version.setter - def version(self, val): - decomposed = self._decompose_version(val) - self.major = decomposed[0] - self.minor = decomposed[1] - self.subversion = decomposed[2] - self.variant = decomposed[3] - self.client = decomposed[4] - def __init__(self, major: int = None, minor: int = None, - subversion: int = None, version: str = None, - variant: str = "", client: str = None, - path: Path = None): - self.path = path + Args: + major (int): version when you make incompatible API changes. + minor (int): version when you add functionality in a + backwards-compatible manner. + patch (int): version when you make backwards-compatible bug fixes. + prerelease (str): an optional prerelease string + build (str): an optional build string + version (str): if set, it will be parsed and will override + parameters like `major`, `minor` and so on. + staging (bool): set to True if version is staging. + path (Path): path to version location. - if ( - major is None or minor is None or subversion is None - ) and version is None: - raise ValueError("Need version specified in some way.") - if version: - values = self._decompose_version(version) - self.major = values[0] - self.minor = values[1] - self.subversion = values[2] - self.variant = values[3] - self.client = values[4] - else: - self.major = major - self.minor = minor - self.subversion = subversion - # variant is set only if it is "staging", otherwise "production" is - # implied and no need to mention it in version string. - if variant == "staging": - self.variant = variant - self.client = client + """ + self.path = None + self.staging = False - def _compose_version(self): - version = "{}.{}.{}".format(self.major, self.minor, self.subversion) + if "version" in kwargs.keys(): + if not kwargs.get("version"): + raise ValueError("Invalid version specified") + v = OpenPypeVersion.parse(kwargs.get("version")) + kwargs["major"] = v.major + kwargs["minor"] = v.minor + kwargs["patch"] = v.patch + kwargs["prerelease"] = v.prerelease + kwargs["build"] = v.build + kwargs.pop("version") - if self.client: - version = "{}-{}".format(version, self.client) + if kwargs.get("path"): + if isinstance(kwargs.get("path"), str): + self.path = Path(kwargs.get("path")) + elif isinstance(kwargs.get("path"), Path): + self.path = kwargs.get("path") + else: + raise TypeError("Path must be str or Path") + kwargs.pop("path") - if self.variant == "staging": - version = "{}-{}".format(version, self.variant) + if "path" in kwargs.keys(): + kwargs.pop("path") - return version + if kwargs.get("staging"): + self.staging = kwargs.get("staging", False) + kwargs.pop("staging") - @classmethod - def _decompose_version(cls, version_string: str) -> tuple: - m = re.search(cls._version_regex, version_string) - if not m: - raise ValueError( - "Cannot parse version string: {}".format(version_string)) + if "staging" in kwargs.keys(): + kwargs.pop("staging") - variant = None - if m.group("var1") == "staging" or m.group("var2") == "staging": - variant = "staging" + if self.staging: + if kwargs.get("build"): + if "staging" not in kwargs.get("build"): + kwargs["build"] = "{}-staging".format(kwargs.get("build")) + else: + kwargs["build"] = "staging" - client = m.group("client") + if kwargs.get("build") and "staging" in kwargs.get("build", ""): + self.staging = True - return (int(m.group("major")), int(m.group("minor")), - int(m.group("sub")), variant, client) + super().__init__(*args, **kwargs) def __eq__(self, other): - if not isinstance(other, self.__class__): - return False - return self.version == other.version - - def __str__(self): - return self.version + result = super().__eq__(other) + return bool(result and self.staging == other.staging) def __repr__(self): - return "{}, {}: {}".format( - self.__class__.__name__, self.version, self.path) - - def __hash__(self): - return hash(self.version) - - def __lt__(self, other): - if (self.major, self.minor, self.subversion) < \ - (other.major, other.minor, other.subversion): - return True - - # 1.2.3-staging < 1.2.3-client-staging - if self.get_main_version() == other.get_main_version() and \ - not self.client and self.variant and \ - other.client and other.variant: - return True - - # 1.2.3 < 1.2.3-staging - if self.get_main_version() == other.get_main_version() and \ - not self.client and self.variant and \ - not other.client and not other.variant: - return True - - # 1.2.3 < 1.2.3-client - if self.get_main_version() == other.get_main_version() and \ - not self.client and not self.variant and \ - other.client and not other.variant: - return True - - # 1.2.3 < 1.2.3-client-staging - if self.get_main_version() == other.get_main_version() and \ - not self.client and not self.variant and other.client: - return True - - # 1.2.3-client-staging < 1.2.3-client - if self.get_main_version() == other.get_main_version() and \ - self.client and self.variant and \ - other.client and not other.variant: - return True + return "<{}: {} - path={}>".format( + self.__class__.__name__, str(self), self.path) + def __lt__(self, other: OpenPypeVersion): + result = super().__lt__(other) # prefer path over no path - if self.version == other.version and \ - not self.path and other.path: + if self == other and not self.path and other.path: return True - # prefer path with dir over path with file - return self.version == other.version and self.path and \ - other.path and self.path.is_file() and \ - other.path.is_dir() + if self == other and self.path and other.path and \ + other.path.is_dir() and self.path.is_file(): + return True + + if self.finalize_version() == other.finalize_version() and \ + self.prerelease == other.prerelease and \ + self.is_staging() and not other.is_staging(): + return True + + return result + + def set_staging(self) -> OpenPypeVersion: + """Set version as staging and return it. + + This will preserve current one. + + Returns: + OpenPypeVersion: Set as staging. + + """ + if self.staging: + return self + return self.replace(parts={"build": f"{self.build}-staging"}) + + def set_production(self) -> OpenPypeVersion: + """Set version as production and return it. + + This will preserve current one. + + Returns: + OpenPypeVersion: Set as production. + + """ + if not self.staging: + return self + return self.replace( + parts={"build": self.build.replace("-staging", "")}) def is_staging(self) -> bool: """Test if current version is staging one.""" - return self.variant == "staging" + return self.staging def get_main_version(self) -> str: """Return main version component. @@ -186,11 +167,13 @@ class OpenPypeVersion: This returns x.x.x part of version from possibly more complex one like x.x.x-foo-bar. + .. deprecated:: 3.0.0-rc.2 + use `finalize_version()` instead. Returns: str: main version component """ - return "{}.{}.{}".format(self.major, self.minor, self.subversion) + return str(self.finalize_version()) @staticmethod def version_in_str(string: str) -> Tuple: @@ -203,15 +186,22 @@ class OpenPypeVersion: tuple: True/False and OpenPypeVersion if found. """ - try: - result = OpenPypeVersion._decompose_version(string) - except ValueError: + m = re.search(OpenPypeVersion._VERSION_REGEX, string) + if not m: return False, None - return True, OpenPypeVersion(major=result[0], - minor=result[1], - subversion=result[2], - variant=result[3], - client=result[4]) + version = OpenPypeVersion.parse(string[m.start():m.end()]) + return True, version + + @classmethod + def parse(cls, version): + """Extends parse to handle ta handle staging variant.""" + v = super().parse(version) + openpype_version = cls(major=v.major, minor=v.minor, + patch=v.patch, prerelease=v.prerelease, + build=v.build) + if v.build and "staging" in v.build: + openpype_version.staging = True + return openpype_version class BootstrapRepos: @@ -269,7 +259,7 @@ class BootstrapRepos: """Get path for specific version in list of OpenPype versions. Args: - version (str): Version string to look for (1.2.4-staging) + version (str): Version string to look for (1.2.4+staging) version_list (list of OpenPypeVersion): list of version to search. Returns: @@ -821,7 +811,6 @@ class BootstrapRepos: OpenPypeVersionIOError: If copying or zipping fail. """ - if self.is_inside_user_data(openpype_version.path) and not openpype_version.path.is_file(): # noqa raise OpenPypeVersionExists( "OpenPype already inside user data dir") @@ -868,26 +857,20 @@ class BootstrapRepos: # set zip as version source openpype_version.path = temp_zip + if self.is_inside_user_data(openpype_version.path): + raise OpenPypeVersionInvalid( + "Version is in user data dir.") + openpype_version.path = self._copy_zip( + openpype_version.path, destination) + elif openpype_version.path.is_file(): # check if file is zip (by extension) if openpype_version.path.suffix.lower() != ".zip": raise OpenPypeVersionInvalid("Invalid file format") - if not self.is_inside_user_data(openpype_version.path): - try: - # copy file to destination - self._print("Copying zip to destination ...") - _destination_zip = destination.parent / openpype_version.path.name # noqa: E501 - copyfile( - openpype_version.path.as_posix(), - _destination_zip.as_posix()) - except OSError as e: - self._print( - "cannot copy version to user data directory", LOG_ERROR, - exc_info=True) - raise OpenPypeVersionIOError(( - f"can't copy version {openpype_version.path.as_posix()} " - f"to destination {destination.parent.as_posix()}")) from e + if not self.is_inside_user_data(openpype_version.path): + openpype_version.path = self._copy_zip( + openpype_version.path, destination) # extract zip there self._print("extracting zip to destination ...") @@ -896,6 +879,23 @@ class BootstrapRepos: return destination + def _copy_zip(self, source: Path, destination: Path) -> Path: + try: + # copy file to destination + self._print("Copying zip to destination ...") + _destination_zip = destination.parent / source.name # noqa: E501 + copyfile( + source.as_posix(), + _destination_zip.as_posix()) + except OSError as e: + self._print( + "cannot copy version to user data directory", LOG_ERROR, + exc_info=True) + raise OpenPypeVersionIOError(( + f"can't copy version {source.as_posix()} " + f"to destination {destination.parent.as_posix()}")) from e + return _destination_zip + def _is_openpype_in_dir(self, dir_item: Path, detected_version: OpenPypeVersion) -> bool: diff --git a/poetry.lock b/poetry.lock index 41a1f636ec..09e2d133e8 100644 --- a/poetry.lock +++ b/poetry.lock @@ -80,11 +80,11 @@ python-dateutil = ">=2.7.0" [[package]] name = "astroid" -version = "2.5.3" +version = "2.5.6" description = "An abstract syntax tree for Python with inference support." category = "dev" optional = false -python-versions = ">=3.6" +python-versions = "~=3.6" [package.dependencies] lazy-object-proxy = ">=1.4.0" @@ -109,21 +109,21 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "attrs" -version = "20.3.0" +version = "21.2.0" description = "Classes Without Boilerplate" category = "main" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" [package.extras] -dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface", "furo", "sphinx", "pre-commit"] -docs = ["furo", "sphinx", "zope.interface"] -tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "zope.interface"] -tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six"] +dev = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface", "furo", "sphinx", "sphinx-notfound-page", "pre-commit"] +docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"] +tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins", "zope.interface"] +tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "six", "mypy", "pytest-mypy-plugins"] [[package]] name = "autopep8" -version = "1.5.6" +version = "1.5.7" description = "A tool that automatically formats Python code to conform to the PEP 8 style guide" category = "dev" optional = false @@ -135,7 +135,7 @@ toml = "*" [[package]] name = "babel" -version = "2.9.0" +version = "2.9.1" description = "Internationalization utilities" category = "dev" optional = false @@ -159,7 +159,7 @@ wcwidth = ">=0.1.4" [[package]] name = "cachetools" -version = "4.2.1" +version = "4.2.2" description = "Extensible memoizing collections and decorators" category = "main" optional = false @@ -335,7 +335,7 @@ python-versions = "*" [[package]] name = "flake8" -version = "3.9.1" +version = "3.9.2" description = "the modular source code checker: pep8 pyflakes and co" category = "dev" optional = false @@ -413,7 +413,7 @@ uritemplate = ">=3.0.0,<4dev" [[package]] name = "google-auth" -version = "1.29.0" +version = "1.30.0" description = "Google Authentication Library" category = "main" optional = false @@ -486,7 +486,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "importlib-metadata" -version = "4.0.0" +version = "4.0.1" description = "Read metadata from Python packages" category = "main" optional = false @@ -736,7 +736,7 @@ python-versions = "*" [[package]] name = "protobuf" -version = "3.15.8" +version = "3.17.0" description = "Protocol Buffers" category = "main" optional = false @@ -826,7 +826,7 @@ python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" [[package]] name = "pygments" -version = "2.8.1" +version = "2.9.0" description = "Pygments is a syntax highlighting package written in Python." category = "dev" optional = false @@ -834,25 +834,22 @@ python-versions = ">=3.5" [[package]] name = "pylint" -version = "2.7.4" +version = "2.8.2" description = "python code static checker" category = "dev" optional = false python-versions = "~=3.6" [package.dependencies] -astroid = ">=2.5.2,<2.7" +astroid = ">=2.5.6,<2.7" colorama = {version = "*", markers = "sys_platform == \"win32\""} isort = ">=4.2.5,<6" mccabe = ">=0.6,<0.7" toml = ">=0.7.1" -[package.extras] -docs = ["sphinx (==3.5.1)", "python-docs-theme (==2020.12)"] - [[package]] name = "pymongo" -version = "3.11.3" +version = "3.11.4" description = "Python driver for MongoDB " category = "main" optional = false @@ -884,7 +881,7 @@ six = "*" [[package]] name = "pyobjc-core" -version = "7.1" +version = "7.2" description = "Python<->ObjC Interoperability Module" category = "main" optional = false @@ -892,26 +889,26 @@ python-versions = ">=3.6" [[package]] name = "pyobjc-framework-cocoa" -version = "7.1" +version = "7.2" description = "Wrappers for the Cocoa frameworks on macOS" category = "main" optional = false python-versions = ">=3.6" [package.dependencies] -pyobjc-core = ">=7.1" +pyobjc-core = ">=7.2" [[package]] name = "pyobjc-framework-quartz" -version = "7.1" +version = "7.2" description = "Wrappers for the Quartz frameworks on macOS" category = "main" optional = false python-versions = ">=3.6" [package.dependencies] -pyobjc-core = ">=7.1" -pyobjc-framework-Cocoa = ">=7.1" +pyobjc-core = ">=7.2" +pyobjc-framework-Cocoa = ">=7.2" [[package]] name = "pyparsing" @@ -943,7 +940,7 @@ python-versions = "*" [[package]] name = "pyqt5-sip" -version = "12.8.1" +version = "12.9.0" description = "The sip module support for PyQt5" category = "main" optional = false @@ -959,7 +956,7 @@ python-versions = ">=3.5" [[package]] name = "pytest" -version = "6.2.3" +version = "6.2.4" description = "pytest: simple powerful testing with Python" category = "dev" optional = false @@ -1124,9 +1121,17 @@ python-versions = ">=3.6" cryptography = ">=2.0" jeepney = ">=0.6" +[[package]] +name = "semver" +version = "2.13.0" +description = "Python helper for Semantic Versioning (http://semver.org/)" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" + [[package]] name = "six" -version = "1.15.0" +version = "1.16.0" description = "Python 2 and 3 compatibility utilities" category = "main" optional = false @@ -1150,19 +1155,20 @@ python-versions = "*" [[package]] name = "sphinx" -version = "3.5.4" +version = "4.0.1" description = "Python documentation generator" category = "dev" optional = false -python-versions = ">=3.5" +python-versions = ">=3.6" [package.dependencies] alabaster = ">=0.7,<0.8" babel = ">=1.3" colorama = {version = ">=0.3.5", markers = "sys_platform == \"win32\""} -docutils = ">=0.12,<0.17" +docutils = ">=0.14,<0.18" imagesize = "*" -Jinja2 = ">=2.3" +Jinja2 = ">=2.3,<3.0" +MarkupSafe = "<2.0" packaging = "*" Pygments = ">=2.0" requests = ">=2.5.0" @@ -1318,7 +1324,7 @@ python-versions = "*" [[package]] name = "typing-extensions" -version = "3.7.4.3" +version = "3.10.0.0" description = "Backported and Experimental Type Hints for Python 3.5+" category = "main" optional = false @@ -1355,7 +1361,7 @@ python-versions = "*" [[package]] name = "websocket-client" -version = "0.58.0" +version = "0.59.0" description = "WebSocket client for Python with low level API options" category = "main" optional = false @@ -1417,7 +1423,7 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pyt [metadata] lock-version = "1.1" python-versions = "3.7.*" -content-hash = "80fde42aade7fc90bb68d85f0d9b3feb27fc3744d72eb5af6a11b6c9d9836aca" +content-hash = "9e067714903bf7e438bc11556b58b6b96be6b079e9a245690c84de8493fa516e" [metadata.files] acre = [] @@ -1481,8 +1487,8 @@ arrow = [ {file = "arrow-0.17.0.tar.gz", hash = "sha256:ff08d10cda1d36c68657d6ad20d74fbea493d980f8b2d45344e00d6ed2bf6ed4"}, ] astroid = [ - {file = "astroid-2.5.3-py3-none-any.whl", hash = "sha256:bea3f32799fbb8581f58431c12591bc20ce11cbc90ad82e2ea5717d94f2080d5"}, - {file = "astroid-2.5.3.tar.gz", hash = "sha256:ad63b8552c70939568966811a088ef0bc880f99a24a00834abd0e3681b514f91"}, + {file = "astroid-2.5.6-py3-none-any.whl", hash = "sha256:4db03ab5fc3340cf619dbc25e42c2cc3755154ce6009469766d7143d1fc2ee4e"}, + {file = "astroid-2.5.6.tar.gz", hash = "sha256:8a398dfce302c13f14bab13e2b14fe385d32b73f4e4853b9bdfb64598baa1975"}, ] async-timeout = [ {file = "async-timeout-3.0.1.tar.gz", hash = "sha256:0c3c816a028d47f659d6ff5c745cb2acf1f966da1fe5c19c77a70282b25f4c5f"}, @@ -1493,24 +1499,24 @@ atomicwrites = [ {file = "atomicwrites-1.4.0.tar.gz", hash = "sha256:ae70396ad1a434f9c7046fd2dd196fc04b12f9e91ffb859164193be8b6168a7a"}, ] attrs = [ - {file = "attrs-20.3.0-py2.py3-none-any.whl", hash = "sha256:31b2eced602aa8423c2aea9c76a724617ed67cf9513173fd3a4f03e3a929c7e6"}, - {file = "attrs-20.3.0.tar.gz", hash = "sha256:832aa3cde19744e49938b91fea06d69ecb9e649c93ba974535d08ad92164f700"}, + {file = "attrs-21.2.0-py2.py3-none-any.whl", hash = "sha256:149e90d6d8ac20db7a955ad60cf0e6881a3f20d37096140088356da6c716b0b1"}, + {file = "attrs-21.2.0.tar.gz", hash = "sha256:ef6aaac3ca6cd92904cdd0d83f629a15f18053ec84e6432106f7a4d04ae4f5fb"}, ] autopep8 = [ - {file = "autopep8-1.5.6-py2.py3-none-any.whl", hash = "sha256:f01b06a6808bc31698db907761e5890eb2295e287af53f6693b39ce55454034a"}, - {file = "autopep8-1.5.6.tar.gz", hash = "sha256:5454e6e9a3d02aae38f866eec0d9a7de4ab9f93c10a273fb0340f3d6d09f7514"}, + {file = "autopep8-1.5.7-py2.py3-none-any.whl", hash = "sha256:aa213493c30dcdac99537249ee65b24af0b2c29f2e83cd8b3f68760441ed0db9"}, + {file = "autopep8-1.5.7.tar.gz", hash = "sha256:276ced7e9e3cb22e5d7c14748384a5cf5d9002257c0ed50c0e075b68011bb6d0"}, ] babel = [ - {file = "Babel-2.9.0-py2.py3-none-any.whl", hash = "sha256:9d35c22fcc79893c3ecc85ac4a56cde1ecf3f19c540bba0922308a6c06ca6fa5"}, - {file = "Babel-2.9.0.tar.gz", hash = "sha256:da031ab54472314f210b0adcff1588ee5d1d1d0ba4dbd07b94dba82bde791e05"}, + {file = "Babel-2.9.1-py2.py3-none-any.whl", hash = "sha256:ab49e12b91d937cd11f0b67cb259a57ab4ad2b59ac7a3b41d6c06c0ac5b0def9"}, + {file = "Babel-2.9.1.tar.gz", hash = "sha256:bc0c176f9f6a994582230df350aa6e05ba2ebe4b3ac317eab29d9be5d2768da0"}, ] blessed = [ {file = "blessed-1.18.0-py2.py3-none-any.whl", hash = "sha256:5b5e2f0563d5a668c282f3f5946f7b1abb70c85829461900e607e74d7725106e"}, {file = "blessed-1.18.0.tar.gz", hash = "sha256:1312879f971330a1b7f2c6341f2ae7e2cbac244bfc9d0ecfbbecd4b0293bc755"}, ] cachetools = [ - {file = "cachetools-4.2.1-py3-none-any.whl", hash = "sha256:1d9d5f567be80f7c07d765e21b814326d78c61eb0c3a637dffc0e5d1796cb2e2"}, - {file = "cachetools-4.2.1.tar.gz", hash = "sha256:f469e29e7aa4cff64d8de4aad95ce76de8ea1125a16c68e0d93f65c3c3dc92e9"}, + {file = "cachetools-4.2.2-py3-none-any.whl", hash = "sha256:2cc0b89715337ab6dbba85b5b50effe2b0c74e035d83ee8ed637cf52f12ae001"}, + {file = "cachetools-4.2.2.tar.gz", hash = "sha256:61b5ed1e22a0924aed1d23b478f37e8d52549ff8a961de2909c69bf950020cff"}, ] certifi = [ {file = "certifi-2020.12.5-py2.py3-none-any.whl", hash = "sha256:719a74fb9e33b9bd44cc7f3a8d94bc35e4049deebe19ba7d8e108280cfd59830"}, @@ -1689,8 +1695,8 @@ evdev = [ {file = "evdev-1.4.0.tar.gz", hash = "sha256:8782740eb1a86b187334c07feb5127d3faa0b236e113206dfe3ae8f77fb1aaf1"}, ] flake8 = [ - {file = "flake8-3.9.1-py2.py3-none-any.whl", hash = "sha256:3b9f848952dddccf635be78098ca75010f073bfe14d2c6bda867154bea728d2a"}, - {file = "flake8-3.9.1.tar.gz", hash = "sha256:1aa8990be1e689d96c745c5682b687ea49f2e05a443aff1f8251092b0014e378"}, + {file = "flake8-3.9.2-py2.py3-none-any.whl", hash = "sha256:bf8fd333346d844f616e8d47905ef3a3384edae6b4e9beb0c5101e25e3110907"}, + {file = "flake8-3.9.2.tar.gz", hash = "sha256:07528381786f2a6237b061f6e96610a4167b226cb926e2aa2b6b1d78057c576b"}, ] ftrack-python-api = [ {file = "ftrack-python-api-2.0.0.tar.gz", hash = "sha256:dd6f02c31daf5a10078196dc9eac4671e4297c762fbbf4df98de668ac12281d9"}, @@ -1708,8 +1714,8 @@ google-api-python-client = [ {file = "google_api_python_client-1.12.8-py2.py3-none-any.whl", hash = "sha256:3c4c4ca46b5c21196bec7ee93453443e477d82cbfa79234d1ce0645f81170eaf"}, ] google-auth = [ - {file = "google-auth-1.29.0.tar.gz", hash = "sha256:010f011c4e27d3d5eb01106fba6aac39d164842dfcd8709955c4638f5b11ccf8"}, - {file = "google_auth-1.29.0-py2.py3-none-any.whl", hash = "sha256:f30a672a64d91cc2e3137765d088c5deec26416246f7a9e956eaf69a8d7ed49c"}, + {file = "google-auth-1.30.0.tar.gz", hash = "sha256:9ad25fba07f46a628ad4d0ca09f38dcb262830df2ac95b217f9b0129c9e42206"}, + {file = "google_auth-1.30.0-py2.py3-none-any.whl", hash = "sha256:588bdb03a41ecb4978472b847881e5518b5d9ec6153d3d679aa127a55e13b39f"}, ] google-auth-httplib2 = [ {file = "google-auth-httplib2-0.1.0.tar.gz", hash = "sha256:a07c39fd632becacd3f07718dfd6021bf396978f03ad3ce4321d060015cc30ac"}, @@ -1732,8 +1738,8 @@ imagesize = [ {file = "imagesize-1.2.0.tar.gz", hash = "sha256:b1f6b5a4eab1f73479a50fb79fcf729514a900c341d8503d62a62dbc4127a2b1"}, ] importlib-metadata = [ - {file = "importlib_metadata-4.0.0-py3-none-any.whl", hash = "sha256:19192b88d959336bfa6bdaaaef99aeafec179eca19c47c804e555703ee5f07ef"}, - {file = "importlib_metadata-4.0.0.tar.gz", hash = "sha256:2e881981c9748d7282b374b68e759c87745c25427b67ecf0cc67fb6637a1bff9"}, + {file = "importlib_metadata-4.0.1-py3-none-any.whl", hash = "sha256:d7eb1dea6d6a6086f8be21784cc9e3bcfa55872b52309bc5fad53a8ea444465d"}, + {file = "importlib_metadata-4.0.1.tar.gz", hash = "sha256:8c501196e49fb9df5df43833bdb1e4328f64847763ec8a50703148b73784d581"}, ] iniconfig = [ {file = "iniconfig-1.1.1-py2.py3-none-any.whl", hash = "sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3"}, @@ -1948,26 +1954,29 @@ prefixed = [ {file = "prefixed-0.3.2.tar.gz", hash = "sha256:ca48277ba5fa8346dd4b760847da930c7b84416387c39e93affef086add2c029"}, ] protobuf = [ - {file = "protobuf-3.15.8-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:fad4f971ec38d8df7f4b632c819bf9bbf4f57cfd7312cf526c69ce17ef32436a"}, - {file = "protobuf-3.15.8-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:f17b352d7ce33c81773cf81d536ca70849de6f73c96413f17309f4b43ae7040b"}, - {file = "protobuf-3.15.8-cp35-cp35m-macosx_10_9_intel.whl", hash = "sha256:4a054b0b5900b7ea7014099e783fb8c4618e4209fffcd6050857517b3f156e18"}, - {file = "protobuf-3.15.8-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:efa4c4d4fc9ba734e5e85eaced70e1b63fb3c8d08482d839eb838566346f1737"}, - {file = "protobuf-3.15.8-cp35-cp35m-win32.whl", hash = "sha256:07eec4e2ccbc74e95bb9b3afe7da67957947ee95bdac2b2e91b038b832dd71f0"}, - {file = "protobuf-3.15.8-cp35-cp35m-win_amd64.whl", hash = "sha256:f9cadaaa4065d5dd4d15245c3b68b967b3652a3108e77f292b58b8c35114b56c"}, - {file = "protobuf-3.15.8-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:2dc0e8a9e4962207bdc46a365b63a3f1aca6f9681a5082a326c5837ef8f4b745"}, - {file = "protobuf-3.15.8-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:f80afc0a0ba13339bbab25ca0409e9e2836b12bb012364c06e97c2df250c3343"}, - {file = "protobuf-3.15.8-cp36-cp36m-win32.whl", hash = "sha256:c5566f956a26cda3abdfacc0ca2e21db6c9f3d18f47d8d4751f2209d6c1a5297"}, - {file = "protobuf-3.15.8-cp36-cp36m-win_amd64.whl", hash = "sha256:dab75b56a12b1ceb3e40808b5bd9dfdaef3a1330251956e6744e5b6ed8f8830b"}, - {file = "protobuf-3.15.8-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3053f13207e7f13dc7be5e9071b59b02020172f09f648e85dc77e3fcb50d1044"}, - {file = "protobuf-3.15.8-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:1f0b5d156c3df08cc54bc2c8b8b875648ea4cd7ebb2a9a130669f7547ec3488c"}, - {file = "protobuf-3.15.8-cp37-cp37m-win32.whl", hash = "sha256:90270fe5732c1f1ff664a3bd7123a16456d69b4e66a09a139a00443a32f210b8"}, - {file = "protobuf-3.15.8-cp37-cp37m-win_amd64.whl", hash = "sha256:f42c2f5fb67da5905bfc03733a311f72fa309252bcd77c32d1462a1ad519521e"}, - {file = "protobuf-3.15.8-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f6077db37bfa16494dca58a4a02bfdacd87662247ad6bc1f7f8d13ff3f0013e1"}, - {file = "protobuf-3.15.8-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:510e66491f1a5ac5953c908aa8300ec47f793130097e4557482803b187a8ee05"}, - {file = "protobuf-3.15.8-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5ff9fa0e67fcab442af9bc8d4ec3f82cb2ff3be0af62dba047ed4187f0088b7d"}, - {file = "protobuf-3.15.8-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:1c0e9e56202b9dccbc094353285a252e2b7940b74fdf75f1b4e1b137833fabd7"}, - {file = "protobuf-3.15.8-py2.py3-none-any.whl", hash = "sha256:a0a08c6b2e6d6c74a6eb5bf6184968eefb1569279e78714e239d33126e753403"}, - {file = "protobuf-3.15.8.tar.gz", hash = "sha256:0277f62b1e42210cafe79a71628c1d553348da81cbd553402a7f7549c50b11d0"}, + {file = "protobuf-3.17.0-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:15351df904347da2081a2eebc42b192c29724eb57dbe56dae440be843f1e4779"}, + {file = "protobuf-3.17.0-cp27-cp27mu-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:5356981c1919782b8c2e3ea5c5d85ad5937b8178a025ac9edc2f2ca5b4a717ae"}, + {file = "protobuf-3.17.0-cp35-cp35m-macosx_10_9_intel.whl", hash = "sha256:eac0a2a7ea99e17175f6e7b53cdc9004ed786c072fbdf933def0e454e14fd323"}, + {file = "protobuf-3.17.0-cp35-cp35m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:4c8d0997fdc0a4cf9de7950d598ce6974b22e8618bbcf1d15e9842010cf8420a"}, + {file = "protobuf-3.17.0-cp35-cp35m-win32.whl", hash = "sha256:9ae321459d4890c3939c536382f75e232c9e91ce506310353c8a15ad5c379e0d"}, + {file = "protobuf-3.17.0-cp35-cp35m-win_amd64.whl", hash = "sha256:295944ef0772498d7bf75f6aa5d4dfcfd02f5ce70f735b406e52e43ac3914d38"}, + {file = "protobuf-3.17.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:850f429bd2399525d339d05bc809f090f16d3d88737bed637d355a5ee8d3b81a"}, + {file = "protobuf-3.17.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:809a96d5a1a74538728710f9104f43ae77f5e48bde274ee321b10a324ba52e4f"}, + {file = "protobuf-3.17.0-cp36-cp36m-win32.whl", hash = "sha256:8a3ac375539055164f31a330770f137875307e6f04c21e2647f2e7139c501295"}, + {file = "protobuf-3.17.0-cp36-cp36m-win_amd64.whl", hash = "sha256:3d338910b10b88b18581cf6877b3938b2e262e8fdc2c1057f5a291787de63183"}, + {file = "protobuf-3.17.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:1488f786bd1912f97796cf5def8cacf433735616896cf7ed9dc786cee693dfc8"}, + {file = "protobuf-3.17.0-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:bcaff977db178f0bfde10bab0d23a5f5adf5964adba70c315e45922a1c55eb90"}, + {file = "protobuf-3.17.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:939ce06846ddfec99c0bff510510b3ee45778e7a3aec6544d1f36526e5fecb67"}, + {file = "protobuf-3.17.0-cp37-cp37m-win32.whl", hash = "sha256:3237acce5b666c7b0f45785cc2d0809796d4df3593bd68338aebf25408139188"}, + {file = "protobuf-3.17.0-cp37-cp37m-win_amd64.whl", hash = "sha256:2f77afe33bb86c7d34221a86193256d69aa10818620fe4a7513d98211d67d672"}, + {file = "protobuf-3.17.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:acc9f2091ace3de429eee424ab7ba0bc52a6aa9ffc9909e5c4de259a3f71db46"}, + {file = "protobuf-3.17.0-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:a29631f4f8bcf79b12a59e83d238d888de5034871461d788c74c68218ad75049"}, + {file = "protobuf-3.17.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:05c304396e309661c45e3a97bd2d8da1fc2bab743ed2ca880bcb757271c40c0e"}, + {file = "protobuf-3.17.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:baea44967071e6a51e705e4e88aebf35f530a14004cc69f60a185e5d7e13de7e"}, + {file = "protobuf-3.17.0-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:3b5c461af5a3cebd796c73370db929b7e24cbaba655eefdc044226bc8a843d6b"}, + {file = "protobuf-3.17.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:44399393c3a8cc04a4cfbdc721dd7f2114497efda582e946a91b8c4290ae5ff5"}, + {file = "protobuf-3.17.0-py2.py3-none-any.whl", hash = "sha256:e32ef0c9f4b548c80d94dfff8b4130ca2ff3d50caaf2455889e3f5b8a01e8038"}, + {file = "protobuf-3.17.0.tar.gz", hash = "sha256:05dfe9319939a8473c21b469f34f6486646e54fb8542637cf7ed8e2fbfe21538"}, ] py = [ {file = "py-1.10.0-py2.py3-none-any.whl", hash = "sha256:3b80836aa6d1feeaa108e046da6423ab8f6ceda6468545ae8d02d9d58d18818a"}, @@ -2028,78 +2037,78 @@ pyflakes = [ {file = "pyflakes-2.3.1.tar.gz", hash = "sha256:f5bc8ecabc05bb9d291eb5203d6810b49040f6ff446a756326104746cc00c1db"}, ] pygments = [ - {file = "Pygments-2.8.1-py3-none-any.whl", hash = "sha256:534ef71d539ae97d4c3a4cf7d6f110f214b0e687e92f9cb9d2a3b0d3101289c8"}, - {file = "Pygments-2.8.1.tar.gz", hash = "sha256:2656e1a6edcdabf4275f9a3640db59fd5de107d88e8663c5d4e9a0fa62f77f94"}, + {file = "Pygments-2.9.0-py3-none-any.whl", hash = "sha256:d66e804411278594d764fc69ec36ec13d9ae9147193a1740cd34d272ca383b8e"}, + {file = "Pygments-2.9.0.tar.gz", hash = "sha256:a18f47b506a429f6f4b9df81bb02beab9ca21d0a5fee38ed15aef65f0545519f"}, ] pylint = [ - {file = "pylint-2.7.4-py3-none-any.whl", hash = "sha256:209d712ec870a0182df034ae19f347e725c1e615b2269519ab58a35b3fcbbe7a"}, - {file = "pylint-2.7.4.tar.gz", hash = "sha256:bd38914c7731cdc518634a8d3c5585951302b6e2b6de60fbb3f7a0220e21eeee"}, + {file = "pylint-2.8.2-py3-none-any.whl", hash = "sha256:f7e2072654a6b6afdf5e2fb38147d3e2d2d43c89f648637baab63e026481279b"}, + {file = "pylint-2.8.2.tar.gz", hash = "sha256:586d8fa9b1891f4b725f587ef267abe2a1bad89d6b184520c7f07a253dd6e217"}, ] pymongo = [ - {file = "pymongo-3.11.3-cp27-cp27m-macosx_10_14_intel.whl", hash = "sha256:4d959e929cec805c2bf391418b1121590b4e7d5cb00af7b1ba521443d45a0918"}, - {file = "pymongo-3.11.3-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:9fbffc5bad4df99a509783cbd449ed0d24fcd5a450c28e7756c8f20eda3d2aa5"}, - {file = "pymongo-3.11.3-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:bd351ceb2decd23d523fc50bad631ee9ae6e97e7cdc355ce5600fe310484f96e"}, - {file = "pymongo-3.11.3-cp27-cp27m-win32.whl", hash = "sha256:7d2ae2f7c50adec20fde46a73465de31a6a6fbb4903240f8b7304549752ca7a1"}, - {file = "pymongo-3.11.3-cp27-cp27m-win_amd64.whl", hash = "sha256:b1aa62903a2c5768b0001632efdea2e8da6c80abdd520c2e8a16001cc9affb23"}, - {file = "pymongo-3.11.3-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:180511abfef70feb022360b35f4863dd68e08334197089201d5c52208de9ca2e"}, - {file = "pymongo-3.11.3-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:42f9ec9d77358f557fe17cc15e796c4d4d492ede1a30cba3664822cae66e97c5"}, - {file = "pymongo-3.11.3-cp34-cp34m-macosx_10_6_intel.whl", hash = "sha256:3dbc67754882d740f17809342892f0b24398770bd99d48c5cb5ba89f5f5dee4e"}, - {file = "pymongo-3.11.3-cp34-cp34m-manylinux1_i686.whl", hash = "sha256:733e1cfffc4cd99848230e2999c8a86e284c6af6746482f8ad2ad554dce14e39"}, - {file = "pymongo-3.11.3-cp34-cp34m-manylinux1_x86_64.whl", hash = "sha256:622a5157ffcd793d305387c1c9fb94185f496c8c9fd66dafb59de0807bc14ad7"}, - {file = "pymongo-3.11.3-cp34-cp34m-win32.whl", hash = "sha256:2aeb108da1ed8e066800fb447ba5ae89d560e6773d228398a87825ac3630452d"}, - {file = "pymongo-3.11.3-cp34-cp34m-win_amd64.whl", hash = "sha256:7c77801620e5e75fb9c7abae235d3cc45d212a67efa98f4972eef63e736a8daa"}, - {file = "pymongo-3.11.3-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:29390c39ca873737689a0749c9c3257aad96b323439b11279fbc0ba8626ec9c5"}, - {file = "pymongo-3.11.3-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:a8b02e0119d6ee381a265d8d2450a38096f82916d895fed2dfd81d4c7a54d6e4"}, - {file = "pymongo-3.11.3-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:28633868be21a187702a8613913e13d1987d831529358c29fc6f6670413df040"}, - {file = "pymongo-3.11.3-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:685b884fa41bd2913fd20af85866c4ff886b7cbb7e4833b918996aa5d45a04be"}, - {file = "pymongo-3.11.3-cp35-cp35m-manylinux2014_i686.whl", hash = "sha256:7cd42c66d49ffb68dea065e1c8a4323e7ceab386e660fee9863d4fa227302ba9"}, - {file = "pymongo-3.11.3-cp35-cp35m-manylinux2014_ppc64le.whl", hash = "sha256:950710f7370613a6bfa2ccd842b488c5b8072e83fb6b7d45d99110bf44651d06"}, - {file = "pymongo-3.11.3-cp35-cp35m-manylinux2014_s390x.whl", hash = "sha256:c7fd18d4b7939408df9315fedbdb05e179760960a92b3752498e2fcd03f24c3d"}, - {file = "pymongo-3.11.3-cp35-cp35m-manylinux2014_x86_64.whl", hash = "sha256:cc359e408712faf9ea775f4c0ec8f2bfc843afe47747a657808d9595edd34d71"}, - {file = "pymongo-3.11.3-cp35-cp35m-win32.whl", hash = "sha256:7814b2cf23aad23464859973c5cd2066ca2fd99e0b934acefbb0b728ac2525bf"}, - {file = "pymongo-3.11.3-cp35-cp35m-win_amd64.whl", hash = "sha256:e1414599a97554d451e441afb362dbee1505e4550852c0068370d843757a3fe2"}, - {file = "pymongo-3.11.3-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:0384d76b409278ddb34ac19cdc4664511685959bf719adbdc051875ded4689aa"}, - {file = "pymongo-3.11.3-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:22ee2c94fee1e391735be63aa1c9af4c69fdcb325ae9e5e4ddff770248ef60a6"}, - {file = "pymongo-3.11.3-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:db6fd53ef5f1914ad801830406440c3bfb701e38a607eda47c38adba267ba300"}, - {file = "pymongo-3.11.3-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:66b688fc139c6742057795510e3b12c4acbf90d11af1eff9689a41d9c84478d6"}, - {file = "pymongo-3.11.3-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:6a5834e392c97f19f36670e34bf9d346d733ad89ee0689a6419dd737dfa4308a"}, - {file = "pymongo-3.11.3-cp36-cp36m-manylinux2014_ppc64le.whl", hash = "sha256:87981008d565f647142869d99915cc4760b7725858da3d39ecb2a606e23f36fd"}, - {file = "pymongo-3.11.3-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:413b18ac2222f5d961eb8d1c8dcca6c6ca176c8613636d8c13aa23abae7f7a21"}, - {file = "pymongo-3.11.3-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:610d5cbbfd026e2f6d15665af51e048e49b68363fedece2ed318cc8fe080dd94"}, - {file = "pymongo-3.11.3-cp36-cp36m-win32.whl", hash = "sha256:3873866534b6527e6863e742eb23ea2a539e3c7ee00ad3f9bec9da27dbaaff6f"}, - {file = "pymongo-3.11.3-cp36-cp36m-win_amd64.whl", hash = "sha256:b17e627844d86031c77147c40bf992a6e1114025a460874deeda6500d0f34862"}, - {file = "pymongo-3.11.3-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:05e2bda928a3a6bc6ddff9e5a8579d41928b75d7417b18f9a67c82bb52150ac6"}, - {file = "pymongo-3.11.3-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:19d52c60dc37520385f538d6d1a4c40bc398e0885f4ed6a36ce10b631dab2852"}, - {file = "pymongo-3.11.3-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:2163d736d6f62b20753be5da3dc07a188420b355f057fcbb3075b05ee6227b2f"}, - {file = "pymongo-3.11.3-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:b4535d98df83abebb572035754fb3d4ad09ce7449375fa09fa9ede2dbc87b62b"}, - {file = "pymongo-3.11.3-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:cd8fc35d4c0c717cc29b0cb894871555cb7137a081e179877ecc537e2607f0b9"}, - {file = "pymongo-3.11.3-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:92e2376ce3ca0e3e443b3c5c2bb5d584c7e59221edfb0035313c6306049ba55a"}, - {file = "pymongo-3.11.3-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:4ca92e15fcf02e02e7c24b448a16599b98c9d0e6a46cd85cc50804450ebf7245"}, - {file = "pymongo-3.11.3-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:5a03ae5ac85b04b2034a0689add9ff597b16d5e24066a87f6ab0e9fa67049156"}, - {file = "pymongo-3.11.3-cp37-cp37m-win32.whl", hash = "sha256:bc2eb67387b8376120a2be6cba9d23f9d6a6c3828e00fb0a64c55ad7b54116d1"}, - {file = "pymongo-3.11.3-cp37-cp37m-win_amd64.whl", hash = "sha256:5e1341276ce8b7752db9aeac6bbb0cbe82a3f6a6186866bf6b4906d8d328d50b"}, - {file = "pymongo-3.11.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4ac387ac1be71b798d1c372a924f9c30352f30e684e06f086091297352698ac0"}, - {file = "pymongo-3.11.3-cp38-cp38-manylinux1_i686.whl", hash = "sha256:728313cc0d59d1a1a004f675607dcf5c711ced3f55e75d82b3f264fd758869f3"}, - {file = "pymongo-3.11.3-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:daa44cefde19978af57ac1d50413cd86ebf2b497328e7a27832f5824bda47439"}, - {file = "pymongo-3.11.3-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:322f6cc7bf23a264151ebc5229a92600c4b55ac83c83c91c9bab1ec92c888a8d"}, - {file = "pymongo-3.11.3-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:6043d251fac27ca04ff22ed8deb5ff7a43dc18e8a4a15b4c442d2a20fa313162"}, - {file = "pymongo-3.11.3-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:66573c8c7808cce4f3b56c23cb7cad6c3d7f4c464b9016d35f5344ad743896d7"}, - {file = "pymongo-3.11.3-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:bf70097bd497089f1baabf9cbb3ec4f69c022dc7a70c41ba9c238fa4d0fff7ab"}, - {file = "pymongo-3.11.3-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:f23abcf6eca5859a2982beadfb5111f8c5e76e30ff99aaee3c1c327f814f9f10"}, - {file = "pymongo-3.11.3-cp38-cp38-win32.whl", hash = "sha256:1d559a76ae87143ad96c2ecd6fdd38e691721e175df7ced3fcdc681b4638bca1"}, - {file = "pymongo-3.11.3-cp38-cp38-win_amd64.whl", hash = "sha256:152e4ac3158b776135d8fce28d2ac06e682b885fcbe86690d66465f262ab244e"}, - {file = "pymongo-3.11.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:34c15f5798f23488e509eae82fbf749c3d17db74379a88c07c869ece1aa806b9"}, - {file = "pymongo-3.11.3-cp39-cp39-manylinux1_i686.whl", hash = "sha256:210ec4a058480b9c3869082e52b66d80c4a48eda9682d7a569a1a5a48100ea54"}, - {file = "pymongo-3.11.3-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:b44fa04720bbfd617b6aef036989c8c30435f11450c0a59136291d7b41ed647f"}, - {file = "pymongo-3.11.3-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:b32e4eed2ef19a20dfb57698497a9bc54e74efb2e260c003e9056c145f130dc7"}, - {file = "pymongo-3.11.3-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:5091aacbdb667b418b751157f48f6daa17142c4f9063d58e5a64c90b2afbdf9a"}, - {file = "pymongo-3.11.3-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:bb6a5777bf558f444cd4883d617546182cfeff8f2d4acd885253f11a16740534"}, - {file = "pymongo-3.11.3-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:980527f4ccc6644855bb68056fe7835da6d06d37776a52df5bcc1882df57c3db"}, - {file = "pymongo-3.11.3-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:65b67637f0a25ac9d25efb13c1578eb065870220ffa82f132c5b2d8e43ac39c3"}, - {file = "pymongo-3.11.3-cp39-cp39-win32.whl", hash = "sha256:f6748c447feeadda059719ef5ab1fb9d84bd370e205b20049a0e8b45ef4ad593"}, - {file = "pymongo-3.11.3-cp39-cp39-win_amd64.whl", hash = "sha256:ee42a8f850143ae7c67ea09a183a6a4ad8d053e1dbd9a1134e21a7b5c1bc6c73"}, - {file = "pymongo-3.11.3-py2.7-macosx-10.14-intel.egg", hash = "sha256:7edff02e44dd0badd749d7342e40705a398d98c5d8f7570f57cff9568c2351fa"}, - {file = "pymongo-3.11.3.tar.gz", hash = "sha256:db5098587f58fbf8582d9bda2462762b367207246d3e19623782fb449c3c5fcc"}, + {file = "pymongo-3.11.4-cp27-cp27m-macosx_10_14_intel.whl", hash = "sha256:b7efc7e7049ef366777cfd35437c18a4166bb50a5606a1c840ee3b9624b54fc9"}, + {file = "pymongo-3.11.4-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:517ba47ca04a55b1f50ee8df9fd97f6c37df5537d118fb2718952b8623860466"}, + {file = "pymongo-3.11.4-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:225c61e08fe517aede7912937939e09adf086c8e6f7e40d4c85ad678c2c2aea3"}, + {file = "pymongo-3.11.4-cp27-cp27m-win32.whl", hash = "sha256:e4e9db78b71db2b1684ee4ecc3e32c4600f18cdf76e6b9ae03e338e52ee4b168"}, + {file = "pymongo-3.11.4-cp27-cp27m-win_amd64.whl", hash = "sha256:8e0004b0393d72d76de94b4792a006cb960c1c65c7659930fbf9a81ce4341982"}, + {file = "pymongo-3.11.4-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:fedf0dee7a412ca6d1d6d92c158fe9cbaa8ea0cae90d268f9ccc0744de7a97d0"}, + {file = "pymongo-3.11.4-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:f947b359cc4769af8b49be7e37af01f05fcf15b401da2528021148e4a54426d1"}, + {file = "pymongo-3.11.4-cp34-cp34m-macosx_10_6_intel.whl", hash = "sha256:3a3498a8326111221560e930f198b495ea6926937e249f475052ffc6893a6680"}, + {file = "pymongo-3.11.4-cp34-cp34m-manylinux1_i686.whl", hash = "sha256:9a4f6e0b01df820ba9ed0b4e618ca83a1c089e48d4f268d0e00dcd49893d4549"}, + {file = "pymongo-3.11.4-cp34-cp34m-manylinux1_x86_64.whl", hash = "sha256:d65bac5f6724d9ea6f0b5a0f0e4952fbbf209adcf6b5583b54c54bd2fcd74dc0"}, + {file = "pymongo-3.11.4-cp34-cp34m-win32.whl", hash = "sha256:15b083d1b789b230e5ac284442d9ecb113c93f3785a6824f748befaab803b812"}, + {file = "pymongo-3.11.4-cp34-cp34m-win_amd64.whl", hash = "sha256:f08665d3cc5abc2f770f472a9b5f720a9b3ab0b8b3bb97c7c1487515e5653d39"}, + {file = "pymongo-3.11.4-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:977b1d4f868986b4ba5d03c317fde4d3b66e687d74473130cd598e3103db34fa"}, + {file = "pymongo-3.11.4-cp35-cp35m-manylinux1_i686.whl", hash = "sha256:510cd3bfabb63a07405b7b79fae63127e34c118b7531a2cbbafc7a24fd878594"}, + {file = "pymongo-3.11.4-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:071552b065e809d24c5653fcc14968cfd6fde4e279408640d5ac58e3353a3c5f"}, + {file = "pymongo-3.11.4-cp35-cp35m-manylinux2014_aarch64.whl", hash = "sha256:f4ba58157e8ae33ee86fadf9062c506e535afd904f07f9be32731f4410a23b7f"}, + {file = "pymongo-3.11.4-cp35-cp35m-manylinux2014_i686.whl", hash = "sha256:b413117210fa6d92664c3d860571e8e8727c3e8f2ff197276c5d0cb365abd3ad"}, + {file = "pymongo-3.11.4-cp35-cp35m-manylinux2014_ppc64le.whl", hash = "sha256:08b8723248730599c9803ae4c97b8f3f76c55219104303c88cb962a31e3bb5ee"}, + {file = "pymongo-3.11.4-cp35-cp35m-manylinux2014_s390x.whl", hash = "sha256:8a41fdc751dc4707a4fafb111c442411816a7c225ebb5cadb57599534b5d5372"}, + {file = "pymongo-3.11.4-cp35-cp35m-manylinux2014_x86_64.whl", hash = "sha256:f664ed7613b8b18f0ce5696b146776266a038c19c5cd6efffa08ecc189b01b73"}, + {file = "pymongo-3.11.4-cp35-cp35m-win32.whl", hash = "sha256:5c36428cc4f7fae56354db7f46677fd21222fc3cb1e8829549b851172033e043"}, + {file = "pymongo-3.11.4-cp35-cp35m-win_amd64.whl", hash = "sha256:d0a70151d7de8a3194cdc906bcc1a42e14594787c64b0c1c9c975e5a2af3e251"}, + {file = "pymongo-3.11.4-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:9b9298964389c180a063a9e8bac8a80ed42de11d04166b20249bfa0a489e0e0f"}, + {file = "pymongo-3.11.4-cp36-cp36m-manylinux1_i686.whl", hash = "sha256:b2f41261b648cf5dee425f37ff14f4ad151c2f24b827052b402637158fd056ef"}, + {file = "pymongo-3.11.4-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:e02beaab433fd1104b2804f909e694cfbdb6578020740a9051597adc1cd4e19f"}, + {file = "pymongo-3.11.4-cp36-cp36m-manylinux2014_aarch64.whl", hash = "sha256:8898f6699f740ca93a0879ed07d8e6db02d68af889d0ebb3d13ab017e6b1af1e"}, + {file = "pymongo-3.11.4-cp36-cp36m-manylinux2014_i686.whl", hash = "sha256:62c29bc36a6d9be68fe7b5aaf1e120b4aa66a958d1e146601fcd583eb12cae7b"}, + {file = "pymongo-3.11.4-cp36-cp36m-manylinux2014_ppc64le.whl", hash = "sha256:424799c71ff435094e5fb823c40eebb4500f0e048133311e9c026467e8ccebac"}, + {file = "pymongo-3.11.4-cp36-cp36m-manylinux2014_s390x.whl", hash = "sha256:3551912f5c34d8dd7c32c6bb00ae04192af47f7b9f653608f107d19c1a21a194"}, + {file = "pymongo-3.11.4-cp36-cp36m-manylinux2014_x86_64.whl", hash = "sha256:5db59223ed1e634d842a053325f85f908359c6dac9c8ddce8ef145061fae7df8"}, + {file = "pymongo-3.11.4-cp36-cp36m-win32.whl", hash = "sha256:fea5cb1c63efe1399f0812532c7cf65458d38fd011be350bc5021dfcac39fba8"}, + {file = "pymongo-3.11.4-cp36-cp36m-win_amd64.whl", hash = "sha256:d4e62417e89b717a7bcd8576ac3108cd063225942cc91c5b37ff5465fdccd386"}, + {file = "pymongo-3.11.4-cp37-cp37m-macosx_10_6_intel.whl", hash = "sha256:4c7e8c8e1e1918dcf6a652ac4b9d87164587c26fd2ce5dd81e73a5ab3b3d492f"}, + {file = "pymongo-3.11.4-cp37-cp37m-manylinux1_i686.whl", hash = "sha256:38a7b5140a48fc91681cdb5cb95b7cd64640b43d19259fdd707fa9d5a715f2b2"}, + {file = "pymongo-3.11.4-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:aff3656af2add93f290731a6b8930b23b35c0c09569150130a58192b3ec6fc61"}, + {file = "pymongo-3.11.4-cp37-cp37m-manylinux2014_aarch64.whl", hash = "sha256:03be7ad107d252bb7325d4af6309fdd2c025d08854d35f0e7abc8bf048f4245e"}, + {file = "pymongo-3.11.4-cp37-cp37m-manylinux2014_i686.whl", hash = "sha256:6060794aac9f7b0644b299f46a9c6cbc0bc470bd01572f4134df140afd41ded6"}, + {file = "pymongo-3.11.4-cp37-cp37m-manylinux2014_ppc64le.whl", hash = "sha256:73326b211e7410c8bd6a74500b1e3f392f39cf10862e243d00937e924f112c01"}, + {file = "pymongo-3.11.4-cp37-cp37m-manylinux2014_s390x.whl", hash = "sha256:20d75ea11527331a2980ab04762a9d960bcfea9475c54bbeab777af880de61cd"}, + {file = "pymongo-3.11.4-cp37-cp37m-manylinux2014_x86_64.whl", hash = "sha256:3135dd574ef1286189f3f04a36c8b7a256376914f8cbbce66b94f13125ded858"}, + {file = "pymongo-3.11.4-cp37-cp37m-win32.whl", hash = "sha256:7c97554ea521f898753d9773891d0347ebfaddcc1dee2ad94850b163171bf1f1"}, + {file = "pymongo-3.11.4-cp37-cp37m-win_amd64.whl", hash = "sha256:a08c8b322b671857c81f4c30cd3c8df2895fd3c0e9358714f39e0ef8fb327702"}, + {file = "pymongo-3.11.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f3d851af3852f16ad4adc7ee054fd9c90a7a5063de94d815b7f6a88477b9f4c6"}, + {file = "pymongo-3.11.4-cp38-cp38-manylinux1_i686.whl", hash = "sha256:3bfc7689a1bacb9bcd2f2d5185d99507aa29f667a58dd8adaa43b5a348139e46"}, + {file = "pymongo-3.11.4-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:b8f94acd52e530a38f25e4d5bf7ddfdd4bea9193e718f58419def0d4406b58d3"}, + {file = "pymongo-3.11.4-cp38-cp38-manylinux2014_aarch64.whl", hash = "sha256:e4b631688dfbdd61b5610e20b64b99d25771c6d52d9da73349342d2a0f11c46a"}, + {file = "pymongo-3.11.4-cp38-cp38-manylinux2014_i686.whl", hash = "sha256:474e21d0e07cd09679e357d1dac76e570dab86665e79a9d3354b10a279ac6fb3"}, + {file = "pymongo-3.11.4-cp38-cp38-manylinux2014_ppc64le.whl", hash = "sha256:421d13523d11c57f57f257152bc4a6bb463aadf7a3918e9c96fefdd6be8dbfb8"}, + {file = "pymongo-3.11.4-cp38-cp38-manylinux2014_s390x.whl", hash = "sha256:0cabfc297f4cf921f15bc789a8fbfd7115eb9f813d3f47a74b609894bc66ab0d"}, + {file = "pymongo-3.11.4-cp38-cp38-manylinux2014_x86_64.whl", hash = "sha256:fe4189846448df013cd9df11bba38ddf78043f8c290a9f06430732a7a8601cce"}, + {file = "pymongo-3.11.4-cp38-cp38-win32.whl", hash = "sha256:eb4d176394c37a76e8b0afe54b12d58614a67a60a7f8c0dd3a5afbb013c01092"}, + {file = "pymongo-3.11.4-cp38-cp38-win_amd64.whl", hash = "sha256:fffff7bfb6799a763d3742c59c6ee7ffadda21abed557637bc44ed1080876484"}, + {file = "pymongo-3.11.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:13acf6164ead81c9fc2afa0e1ea6d6134352973ce2bb35496834fee057063c04"}, + {file = "pymongo-3.11.4-cp39-cp39-manylinux1_i686.whl", hash = "sha256:d360e5d5dd3d55bf5d1776964625018d85b937d1032bae1926dd52253decd0db"}, + {file = "pymongo-3.11.4-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:0aaf4d44f1f819360f9432df538d54bbf850f18152f34e20337c01b828479171"}, + {file = "pymongo-3.11.4-cp39-cp39-manylinux2014_aarch64.whl", hash = "sha256:08bda7b2c522ff9f1e554570da16298271ebb0c56ab9699446aacba249008988"}, + {file = "pymongo-3.11.4-cp39-cp39-manylinux2014_i686.whl", hash = "sha256:1a994a42f49dab5b6287e499be7d3d2751776486229980d8857ad53b8333d469"}, + {file = "pymongo-3.11.4-cp39-cp39-manylinux2014_ppc64le.whl", hash = "sha256:161fcd3281c42f644aa8dec7753cca2af03ce654e17d76da4f0dab34a12480ca"}, + {file = "pymongo-3.11.4-cp39-cp39-manylinux2014_s390x.whl", hash = "sha256:78f07961f4f214ea8e80be63cffd5cc158eb06cd922ffbf6c7155b11728f28f9"}, + {file = "pymongo-3.11.4-cp39-cp39-manylinux2014_x86_64.whl", hash = "sha256:ad31f184dcd3271de26ab1f9c51574afb99e1b0e484ab1da3641256b723e4994"}, + {file = "pymongo-3.11.4-cp39-cp39-win32.whl", hash = "sha256:5e606846c049ed40940524057bfdf1105af6066688c0e6a1a3ce2038589bae70"}, + {file = "pymongo-3.11.4-cp39-cp39-win_amd64.whl", hash = "sha256:3491c7de09e44eded16824cb58cf9b5cc1dc6f066a0bb7aa69929d02aa53b828"}, + {file = "pymongo-3.11.4-py2.7-macosx-10.14-intel.egg", hash = "sha256:506a6dab4c7ffdcacdf0b8e70bd20eb2e77fa994519547c9d88d676400fcad58"}, + {file = "pymongo-3.11.4.tar.gz", hash = "sha256:539d4cb1b16b57026999c53e5aab857fe706e70ae5310cc8c232479923f932e6"}, ] pynput = [ {file = "pynput-1.7.3-py2.py3-none-any.whl", hash = "sha256:fea5777454f896bd79d35393088cd29a089f3b2da166f0848a922b1d5a807d4f"}, @@ -2107,28 +2116,28 @@ pynput = [ {file = "pynput-1.7.3.tar.gz", hash = "sha256:4e50b1a0ab86847e87e58f6d1993688b9a44f9f4c88d4712315ea8eb552ef828"}, ] pyobjc-core = [ - {file = "pyobjc-core-7.1.tar.gz", hash = "sha256:a0616d5d816b4471f8f782c3a9a8923d2cc85014d88ad4f7fec694be9e6ea349"}, - {file = "pyobjc_core-7.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:f9fb45c9916f2a03ecd6b9ecde4c35d1d0f1a590ae2ea2372f9d9a360226ac1d"}, - {file = "pyobjc_core-7.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:fff8e87358c6195a2937004f279050cce3d4c02cd77acd73c5ad367307def855"}, - {file = "pyobjc_core-7.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:afb38efd3f2960eb49eb78552d465cfd025a9d6efa06cd4cd8694dafbe7c6e06"}, - {file = "pyobjc_core-7.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7cb329c4119044fe83bcb3c5d4794d636c706ff0cb7c1c77d36ef5c373100082"}, - {file = "pyobjc_core-7.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7913d7b20217c294900537faf58e5cc15942ed7af277bf05db25667d18255114"}, + {file = "pyobjc-core-7.2.tar.gz", hash = "sha256:9e9ec482d80ea030cdb1613d05a247f31eedabe6666d884d42dd890cc5fb0e05"}, + {file = "pyobjc_core-7.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:94b4d9de9d228db52dd35012096d63bdf8c1ace58ea3be1d5f6f39313cd502f2"}, + {file = "pyobjc_core-7.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:971cbd7189ae1aa03ef0d16124aa5bcd053779e0e6b6011a41c3dbd5b4ea7e88"}, + {file = "pyobjc_core-7.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9d93b20394008373d6d2856d49aaff26f4b97ff42d924a14516c8a82313ec8c0"}, + {file = "pyobjc_core-7.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:860183540d1be792c26426018139ac8ba75e85f675c59ba080ccdc52d8e74c7a"}, + {file = "pyobjc_core-7.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ffe61d3c2a404354daf2d895e34e38c5044453353581b3c396bf5365de26250c"}, ] pyobjc-framework-cocoa = [ - {file = "pyobjc-framework-Cocoa-7.1.tar.gz", hash = "sha256:67966152b3d38a0225176fceca2e9f56d849c8e7445548da09a00cb13155ec3e"}, - {file = "pyobjc_framework_Cocoa-7.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:bef77eafaac5eaf1d91d479d5483fd02216caa3edc27e8f5adc9af0b3fecdac3"}, - {file = "pyobjc_framework_Cocoa-7.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b2ea3582c456827dc20e648c905fdbcf8d3dfae89434f981e9b761cd07262049"}, - {file = "pyobjc_framework_Cocoa-7.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1a4050f2d776f40c2409a151c6f7896420e936934b3bdbfabedf91509637ed9b"}, - {file = "pyobjc_framework_Cocoa-7.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:3f68f022f1f6d5985c418e10c6608c562fcf4bfe3714ec64fd10ce3dc6221bd4"}, - {file = "pyobjc_framework_Cocoa-7.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ecfefd4c48dae42275c18679c69f6f2fff970e711097515a0a8732fc10194018"}, + {file = "pyobjc-framework-Cocoa-7.2.tar.gz", hash = "sha256:c8b23f03dc3f4436d36c0fd006a8a084835c4f6015187df7c3aa5de8ecd5c653"}, + {file = "pyobjc_framework_Cocoa-7.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:8e5dd5daa0096755937ec24c345a4b07c3fa131a457f99e0fdeeb01979178ec7"}, + {file = "pyobjc_framework_Cocoa-7.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:828d183947fc7746953fd0c9b1092cc423745ba0b49719e7b7d1e1614aaa20ec"}, + {file = "pyobjc_framework_Cocoa-7.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:7e4c6d7baa0c2ab5ea5efb8836ad0b3b3976cffcfc6195c1f195e826c6eb5744"}, + {file = "pyobjc_framework_Cocoa-7.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c9a9d1d49cc5a810773c88d6de821e60c8cc41d01113cf1b9e7662938f5f7d66"}, + {file = "pyobjc_framework_Cocoa-7.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:506c2cd09f421eac92b9008a0142174c3d1d70ecd4b0e3fa2b924767995fd14e"}, ] pyobjc-framework-quartz = [ - {file = "pyobjc-framework-Quartz-7.1.tar.gz", hash = "sha256:73102c9f4dbfa13275621014785ab3b684cf03ce93a4b0b270500c795349bea9"}, - {file = "pyobjc_framework_Quartz-7.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:7207a26244f02d4534ebb007fa55a9dc7c1b7fbb490d1e89e0d62cfd175e20f3"}, - {file = "pyobjc_framework_Quartz-7.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:5bc7a4fb3ea80b5af6910cc27729a0774a96327a69583fcf28057cb2ffce33ac"}, - {file = "pyobjc_framework_Quartz-7.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c0469d60d4a79fc252f74adaa8177d2c680621d858c1b8ef19c411e903e2c892"}, - {file = "pyobjc_framework_Quartz-7.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:04953c031fc35020682bd4613b9b5a9688bdb9eab7ed76fd8dcf028783568b4f"}, - {file = "pyobjc_framework_Quartz-7.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:d8e0c086faf649f86386d0ed99194c6d0704b602576e2b258532b635b510b790"}, + {file = "pyobjc-framework-Quartz-7.2.tar.gz", hash = "sha256:ea554e5697bc6747a4ce793c0b0036da16622b44ff75196d6124603008922afa"}, + {file = "pyobjc_framework_Quartz-7.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:dc61fe61d26f797e4335f3ffc891bcef64624c728c2603e3307b3910580b2cb8"}, + {file = "pyobjc_framework_Quartz-7.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:ad8103cc38923f2708904db11a0992ea960125ce6adf7b4c7a77d8fdafd412c4"}, + {file = "pyobjc_framework_Quartz-7.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4549d17ca41f0bf62792d5bc4b4293ba9a6cc560014b3e18ba22c65e4a5030d2"}, + {file = "pyobjc_framework_Quartz-7.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:da16e4f1e13cb7b02e30fa538cbb3a356e4a694bbc2bb26d2bd100ca12a54ff6"}, + {file = "pyobjc_framework_Quartz-7.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c1f6471177a39535cd0358ae29b8f3d31fe778a21deb74105c448c4e726619d7"}, ] pyparsing = [ {file = "pyparsing-2.4.7-py2.py3-none-any.whl", hash = "sha256:ef9d7589ef3c200abe66653d3f1ab1033c3c419ae9b9bdb1240a85b024efc88b"}, @@ -2148,34 +2157,26 @@ pyqt5-qt5 = [ {file = "PyQt5_Qt5-5.15.2-py3-none-win_amd64.whl", hash = "sha256:750b78e4dba6bdf1607febedc08738e318ea09e9b10aea9ff0d73073f11f6962"}, ] pyqt5-sip = [ - {file = "PyQt5_sip-12.8.1-cp35-cp35m-macosx_10_6_intel.whl", hash = "sha256:bb5a87b66fc1445915104ee97f7a20a69decb42f52803e3b0795fa17ff88226c"}, - {file = "PyQt5_sip-12.8.1-cp35-cp35m-manylinux1_x86_64.whl", hash = "sha256:a29e2ac399429d3b7738f73e9081e50783e61ac5d29344e0802d0dcd6056c5a2"}, - {file = "PyQt5_sip-12.8.1-cp35-cp35m-win32.whl", hash = "sha256:0304ca9114b9817a270f67f421355075b78ff9fc25ac58ffd72c2601109d2194"}, - {file = "PyQt5_sip-12.8.1-cp35-cp35m-win_amd64.whl", hash = "sha256:84ba7746762bd223bed22428e8561aa267a229c28344c2d28c5d5d3f8970cffb"}, - {file = "PyQt5_sip-12.8.1-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:7b81382ce188d63890a0e35abe0f9bb946cabc873a31873b73583b0fc84ac115"}, - {file = "PyQt5_sip-12.8.1-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:b6d42250baec52a5f77de64e2951d001c5501c3a2df2179f625b241cbaec3369"}, - {file = "PyQt5_sip-12.8.1-cp36-cp36m-win32.whl", hash = "sha256:6c1ebee60f1d2b3c70aff866b7933d8d8d7646011f7c32f9321ee88c290aa4f9"}, - {file = "PyQt5_sip-12.8.1-cp36-cp36m-win_amd64.whl", hash = "sha256:34dcd29be47553d5f016ff86e89e24cbc5eebae92eb2f96fb32d2d7ba028c43c"}, - {file = "PyQt5_sip-12.8.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:ed897c58acf4a3cdca61469daa31fe6e44c33c6c06a37c3f21fab31780b3b86a"}, - {file = "PyQt5_sip-12.8.1-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:a1b8ef013086e224b8e86c93f880f776d01b59195bdfa2a8e0b23f0480678fec"}, - {file = "PyQt5_sip-12.8.1-cp37-cp37m-win32.whl", hash = "sha256:0cd969be528c27bbd4755bd323dff4a79a8fdda28215364e6ce3e069cb56c2a9"}, - {file = "PyQt5_sip-12.8.1-cp37-cp37m-win_amd64.whl", hash = "sha256:c9800729badcb247765e4ffe2241549d02da1fa435b9db224845bc37c3e99cb0"}, - {file = "PyQt5_sip-12.8.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:9312ec47cac4e33c11503bc1cbeeb0bdae619620472f38e2078c5a51020a930f"}, - {file = "PyQt5_sip-12.8.1-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:2f35e82fd7ec1e1f6716e9154721c7594956a4f5bd4f826d8c6a6453833cc2f0"}, - {file = "PyQt5_sip-12.8.1-cp38-cp38-win32.whl", hash = "sha256:da9c9f1e65b9d09e73bd75befc82961b6b61b5a3b9d0a7c832168e1415f163c6"}, - {file = "PyQt5_sip-12.8.1-cp38-cp38-win_amd64.whl", hash = "sha256:832fd60a264de4134c2824d393320838f3ab648180c9c357ec58a74524d24507"}, - {file = "PyQt5_sip-12.8.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c317ab1263e6417c498b81f5c970a9b1af7acefab1f80b4cc0f2f8e661f29fc5"}, - {file = "PyQt5_sip-12.8.1-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:c9d6d448c29dc6606bb7974696608f81f4316c8234f7c7216396ed110075e777"}, - {file = "PyQt5_sip-12.8.1-cp39-cp39-win32.whl", hash = "sha256:5a011aeff89660622a6d5c3388d55a9d76932f3b82c95e82fc31abd8b1d2990d"}, - {file = "PyQt5_sip-12.8.1-cp39-cp39-win_amd64.whl", hash = "sha256:f168f0a7f32b81bfeffdf003c36f25d81c97dee5eb67072a5183e761fe250f13"}, - {file = "PyQt5_sip-12.8.1.tar.gz", hash = "sha256:30e944db9abee9cc757aea16906d4198129558533eb7fadbe48c5da2bd18e0bd"}, + {file = "PyQt5_sip-12.9.0-cp36-cp36m-macosx_10_6_intel.whl", hash = "sha256:d85002238b5180bce4b245c13d6face848faa1a7a9e5c6e292025004f2fd619a"}, + {file = "PyQt5_sip-12.9.0-cp36-cp36m-manylinux1_x86_64.whl", hash = "sha256:83c3220b1ca36eb8623ba2eb3766637b19eb0ce9f42336ad8253656d32750c0a"}, + {file = "PyQt5_sip-12.9.0-cp36-cp36m-win_amd64.whl", hash = "sha256:69a3ad4259172e2b1aa9060de211efac39ddd734a517b1924d9c6c0cc4f55f96"}, + {file = "PyQt5_sip-12.9.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:42274a501ab4806d2c31659170db14c282b8313d2255458064666d9e70d96206"}, + {file = "PyQt5_sip-12.9.0-cp37-cp37m-manylinux1_x86_64.whl", hash = "sha256:6a8701892a01a5a2a4720872361197cc80fdd5f49c8482d488ddf38c9c84f055"}, + {file = "PyQt5_sip-12.9.0-cp37-cp37m-win_amd64.whl", hash = "sha256:4347bd81d30c8e3181e553b3734f91658cfbdd8f1a19f254777f906870974e6d"}, + {file = "PyQt5_sip-12.9.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c446971c360a0a1030282a69375a08c78e8a61d568bfd6dab3dcc5cf8817f644"}, + {file = "PyQt5_sip-12.9.0-cp38-cp38-manylinux1_x86_64.whl", hash = "sha256:fc43f2d7c438517ee33e929e8ae77132749c15909afab6aeece5fcf4147ffdb5"}, + {file = "PyQt5_sip-12.9.0-cp38-cp38-win_amd64.whl", hash = "sha256:c5216403d4d8d857ec4a61f631d3945e44fa248aa2415e9ee9369ab7c8a4d0c7"}, + {file = "PyQt5_sip-12.9.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a25b9843c7da6a1608f310879c38e6434331aab1dc2fe6cb65c14f1ecf33780e"}, + {file = "PyQt5_sip-12.9.0-cp39-cp39-manylinux1_x86_64.whl", hash = "sha256:dd05c768c2b55ffe56a9d49ce6cc77cdf3d53dbfad935258a9e347cbfd9a5850"}, + {file = "PyQt5_sip-12.9.0-cp39-cp39-win_amd64.whl", hash = "sha256:b09f4cd36a4831229fb77c424d89635fa937d97765ec90685e2f257e56a2685a"}, + {file = "PyQt5_sip-12.9.0.tar.gz", hash = "sha256:d3e4489d7c2b0ece9d203ae66e573939f7f60d4d29e089c9f11daa17cfeaae32"}, ] pyrsistent = [ {file = "pyrsistent-0.17.3.tar.gz", hash = "sha256:2e636185d9eb976a18a8a8e96efce62f2905fea90041958d8cc2a189756ebf3e"}, ] pytest = [ - {file = "pytest-6.2.3-py3-none-any.whl", hash = "sha256:6ad9c7bdf517a808242b998ac20063c41532a570d088d77eec1ee12b0b5574bc"}, - {file = "pytest-6.2.3.tar.gz", hash = "sha256:671238a46e4df0f3498d1c3270e5deb9b32d25134c99b7d75370a68cfbe9b634"}, + {file = "pytest-6.2.4-py3-none-any.whl", hash = "sha256:91ef2131a9bd6be8f76f1f08eac5c5317221d6ad1e143ae03894b862e8976890"}, + {file = "pytest-6.2.4.tar.gz", hash = "sha256:50bcad0a0b9c5a72c8e4e7c9855a3ad496ca6a881a3641b4260605450772c54b"}, ] pytest-cov = [ {file = "pytest-cov-2.11.1.tar.gz", hash = "sha256:359952d9d39b9f822d9d29324483e7ba04a3a17dd7d05aa6beb7ea01e359e5f7"}, @@ -2236,9 +2237,13 @@ secretstorage = [ {file = "SecretStorage-3.3.1-py3-none-any.whl", hash = "sha256:422d82c36172d88d6a0ed5afdec956514b189ddbfb72fefab0c8a1cee4eaf71f"}, {file = "SecretStorage-3.3.1.tar.gz", hash = "sha256:fd666c51a6bf200643495a04abb261f83229dcb6fd8472ec393df7ffc8b6f195"}, ] +semver = [ + {file = "semver-2.13.0-py2.py3-none-any.whl", hash = "sha256:ced8b23dceb22134307c1b8abfa523da14198793d9787ac838e70e29e77458d4"}, + {file = "semver-2.13.0.tar.gz", hash = "sha256:fa0fe2722ee1c3f57eac478820c3a5ae2f624af8264cbdf9000c980ff7f75e3f"}, +] six = [ - {file = "six-1.15.0-py2.py3-none-any.whl", hash = "sha256:8b74bedcbbbaca38ff6d7491d76f2b06b3592611af620f8426e82dddb04a5ced"}, - {file = "six-1.15.0.tar.gz", hash = "sha256:30639c035cdb23534cd4aa2dd52c3bf48f06e5f4a941509c8bafd8ce11080259"}, + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, ] snowballstemmer = [ {file = "snowballstemmer-2.1.0-py2.py3-none-any.whl", hash = "sha256:b51b447bea85f9968c13b650126a888aabd4cb4463fca868ec596826325dedc2"}, @@ -2249,8 +2254,8 @@ speedcopy = [ {file = "speedcopy-2.1.0.tar.gz", hash = "sha256:8bb1a6c735900b83901a7be84ba2175ed3887c13c6786f97dea48f2ea7d504c2"}, ] sphinx = [ - {file = "Sphinx-3.5.4-py3-none-any.whl", hash = "sha256:2320d4e994a191f4b4be27da514e46b3d6b420f2ff895d064f52415d342461e8"}, - {file = "Sphinx-3.5.4.tar.gz", hash = "sha256:19010b7b9fa0dc7756a6e105b2aacd3a80f798af3c25c273be64d7beeb482cb1"}, + {file = "Sphinx-4.0.1-py3-none-any.whl", hash = "sha256:b2566f5f339737a6ef37198c47d56de1f4a746c722bebdb2fe045c34bfd8b9d0"}, + {file = "Sphinx-4.0.1.tar.gz", hash = "sha256:cf5104777571b2b7f06fa88ee08fade24563f4a0594cf4bd17d31c47b8740b4c"}, ] sphinx-qt-documentation = [ {file = "sphinx_qt_documentation-0.3-py3-none-any.whl", hash = "sha256:bee247cb9e4fc03fc496d07adfdb943100e1103320c3e5e820e0cfa7c790d9b6"}, @@ -2328,9 +2333,9 @@ typed-ast = [ {file = "typed_ast-1.4.3.tar.gz", hash = "sha256:fb1bbeac803adea29cedd70781399c99138358c26d05fcbd23c13016b7f5ec65"}, ] typing-extensions = [ - {file = "typing_extensions-3.7.4.3-py2-none-any.whl", hash = "sha256:dafc7639cde7f1b6e1acc0f457842a83e722ccca8eef5270af2d74792619a89f"}, - {file = "typing_extensions-3.7.4.3-py3-none-any.whl", hash = "sha256:7cb407020f00f7bfc3cb3e7881628838e69d8f3fcab2f64742a5e76b2f841918"}, - {file = "typing_extensions-3.7.4.3.tar.gz", hash = "sha256:99d4073b617d30288f569d3f13d2bd7548c3a7e4c8de87db09a9d29bb3a4a60c"}, + {file = "typing_extensions-3.10.0.0-py2-none-any.whl", hash = "sha256:0ac0f89795dd19de6b97debb0c6af1c70987fd80a2d62d1958f7e56fcc31b497"}, + {file = "typing_extensions-3.10.0.0-py3-none-any.whl", hash = "sha256:779383f6086d90c99ae41cf0ff39aac8a7937a9283ce0a414e5dd782f4c94a84"}, + {file = "typing_extensions-3.10.0.0.tar.gz", hash = "sha256:50b6f157849174217d0656f99dc82fe932884fb250826c18350e159ec6cdf342"}, ] uritemplate = [ {file = "uritemplate-3.0.1-py2.py3-none-any.whl", hash = "sha256:07620c3f3f8eed1f12600845892b0e036a2420acf513c53f7de0abd911a5894f"}, @@ -2345,8 +2350,8 @@ wcwidth = [ {file = "wcwidth-0.2.5.tar.gz", hash = "sha256:c4d647b99872929fdb7bdcaa4fbe7f01413ed3d98077df798530e5b04f116c83"}, ] websocket-client = [ - {file = "websocket_client-0.58.0-py2.py3-none-any.whl", hash = "sha256:44b5df8f08c74c3d82d28100fdc81f4536809ce98a17f0757557813275fbb663"}, - {file = "websocket_client-0.58.0.tar.gz", hash = "sha256:63509b41d158ae5b7f67eb4ad20fecbb4eee99434e73e140354dc3ff8e09716f"}, + {file = "websocket-client-0.59.0.tar.gz", hash = "sha256:d376bd60eace9d437ab6d7ee16f4ab4e821c9dae591e1b783c58ebd8aaf80c5c"}, + {file = "websocket_client-0.59.0-py2.py3-none-any.whl", hash = "sha256:2e50d26ca593f70aba7b13a489435ef88b8fc3b5c5643c1ce8808ff9b40f0b32"}, ] wrapt = [ {file = "wrapt-1.12.1.tar.gz", hash = "sha256:b62ffa81fb85f4332a4f609cab4ac40709470da05643a082ec1eb88e6d9b97d7"}, diff --git a/pyproject.toml b/pyproject.toml index 1c3c5ad44e..f7b5dd1426 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,6 +36,7 @@ pyqt5 = "^5.12.2" # ideally should be replaced with PySide2 "Qt.py" = "^1.3.3" speedcopy = "^2.1" six = "^1.15" +semver = "^2.13.0" # for version resolution wsrpc_aiohttp = "^3.1.1" # websocket server pywin32 = { version = "300", markers = "sys_platform == 'win32'" } jinxed = [ diff --git a/start.py b/start.py index baa75aef21..4b8893a3d5 100644 --- a/start.py +++ b/start.py @@ -360,7 +360,7 @@ def _determine_mongodb() -> str: def _initialize_environment(openpype_version: OpenPypeVersion) -> None: version_path = openpype_version.path - os.environ["OPENPYPE_VERSION"] = openpype_version.version + os.environ["OPENPYPE_VERSION"] = str(openpype_version) # set OPENPYPE_REPOS_ROOT to point to currently used OpenPype version. os.environ["OPENPYPE_REPOS_ROOT"] = os.path.normpath( version_path.as_posix() @@ -417,6 +417,19 @@ def _find_frozen_openpype(use_version: str = None, openpype_version = None openpype_versions = bootstrap.find_openpype(include_zips=True, staging=use_staging) + # get local frozen version and add it to detected version so if it is + # newer it will be used instead. + local_version_str = bootstrap.get_version( + Path(os.environ["OPENPYPE_ROOT"])) + if local_version_str: + local_version = OpenPypeVersion( + version=local_version_str, + path=Path(os.environ["OPENPYPE_ROOT"])) + if local_version not in openpype_versions: + openpype_versions.append(local_version) + else: + print("!!! Warning: cannot determine current running version.") + if not os.getenv("OPENPYPE_TRYOUT"): try: # use latest one found (last in the list is latest) @@ -464,12 +477,9 @@ def _find_frozen_openpype(use_version: str = None, use_version, openpype_versions) if not version_path: - if use_version is not None: - if not openpype_version: - ... - else: - print(("!!! Specified version was not found, using " - "latest available")) + if use_version is not None and openpype_version: + print(("!!! Specified version was not found, using " + "latest available")) # specified version was not found so use latest detected. version_path = openpype_version.path print(f">>> Using version [ {openpype_version} ]") diff --git a/tests/igniter/test_bootstrap_repos.py b/tests/igniter/test_bootstrap_repos.py index 54950c546e..743131acfa 100644 --- a/tests/igniter/test_bootstrap_repos.py +++ b/tests/igniter/test_bootstrap_repos.py @@ -24,29 +24,30 @@ def fix_bootstrap(tmp_path, pytestconfig): return bs -def test_openpype_version(): +def test_openpype_version(printer): """Test determination of OpenPype versions.""" v1 = OpenPypeVersion(1, 2, 3) assert str(v1) == "1.2.3" - v2 = OpenPypeVersion(1, 2, 3, client="x") + v2 = OpenPypeVersion(1, 2, 3, prerelease="x") assert str(v2) == "1.2.3-x" - assert v1 < v2 + assert v1 > v2 - v3 = OpenPypeVersion(1, 2, 3, variant="staging") - assert str(v3) == "1.2.3-staging" + v3 = OpenPypeVersion(1, 2, 3, staging=True) + assert str(v3) == "1.2.3+staging" - v4 = OpenPypeVersion(1, 2, 3, variant="staging", client="client") - assert str(v4) == "1.2.3-client-staging" - assert v3 < v4 - assert v1 < v4 + v4 = OpenPypeVersion(1, 2, 3, staging="True", prerelease="rc.1") + assert str(v4) == "1.2.3-rc.1+staging" + assert v3 > v4 + assert v1 > v4 + assert v4 < OpenPypeVersion(1, 2, 3, prerelease="rc.1") - v5 = OpenPypeVersion(1, 2, 3, variant="foo", client="x") - assert str(v5) == "1.2.3-x" + v5 = OpenPypeVersion(1, 2, 3, build="foo", prerelease="x") + assert str(v5) == "1.2.3-x+foo" assert v4 < v5 - v6 = OpenPypeVersion(1, 2, 3, variant="foo") - assert str(v6) == "1.2.3" + v6 = OpenPypeVersion(1, 2, 3, prerelease="foo") + assert str(v6) == "1.2.3-foo" v7 = OpenPypeVersion(2, 0, 0) assert v1 < v7 @@ -72,8 +73,8 @@ def test_openpype_version(): OpenPypeVersion(4, 8, 10), OpenPypeVersion(4, 8, 20), OpenPypeVersion(4, 8, 9), - OpenPypeVersion(1, 2, 3, variant="staging"), - OpenPypeVersion(1, 2, 3, client="client") + OpenPypeVersion(1, 2, 3, staging=True), + OpenPypeVersion(1, 2, 3, build="foo") ] res = sorted(sort_versions) @@ -84,12 +85,12 @@ def test_openpype_version(): str_versions = [ "5.5.1", - "5.5.2-client", - "5.5.3-client-strange", - "5.5.4-staging", - "5.5.5-staging-client", + "5.5.2-foo", + "5.5.3-foo+strange", + "5.5.4+staging", + "5.5.5+staging-client", "5.6.3", - "5.6.3-staging" + "5.6.3+staging" ] res_versions = [OpenPypeVersion(version=v) for v in str_versions] sorted_res_versions = sorted(res_versions) @@ -97,36 +98,33 @@ def test_openpype_version(): assert str(sorted_res_versions[0]) == str_versions[0] assert str(sorted_res_versions[-1]) == str_versions[5] - with pytest.raises(ValueError): + with pytest.raises(TypeError): _ = OpenPypeVersion() - with pytest.raises(ValueError): - _ = OpenPypeVersion(major=1) - with pytest.raises(ValueError): _ = OpenPypeVersion(version="booobaa") - v11 = OpenPypeVersion(version="4.6.7-client-staging") + v11 = OpenPypeVersion(version="4.6.7-foo+staging") assert v11.major == 4 assert v11.minor == 6 - assert v11.subversion == 7 - assert v11.variant == "staging" - assert v11.client == "client" + assert v11.patch == 7 + assert v11.staging is True + assert v11.prerelease == "foo" def test_get_main_version(): - ver = OpenPypeVersion(1, 2, 3, variant="staging", client="foo") + ver = OpenPypeVersion(1, 2, 3, staging=True, prerelease="foo") assert ver.get_main_version() == "1.2.3" def test_get_version_path_from_list(): versions = [ OpenPypeVersion(1, 2, 3, path=Path('/foo/bar')), - OpenPypeVersion(3, 4, 5, variant="staging", path=Path("/bar/baz")), - OpenPypeVersion(6, 7, 8, client="x", path=Path("boo/goo")) + OpenPypeVersion(3, 4, 5, staging=True, path=Path("/bar/baz")), + OpenPypeVersion(6, 7, 8, prerelease="x", path=Path("boo/goo")) ] path = BootstrapRepos.get_version_path_from_list( - "3.4.5-staging", versions) + "3.4.5+staging", versions) assert path == Path("/bar/baz") @@ -183,17 +181,17 @@ def test_find_openpype(fix_bootstrap, tmp_path_factory, monkeypatch, printer): test_versions_1 = [ test_openpype(prefix="foo-v", version="5.5.1", suffix=".zip", type="zip", valid=False), - test_openpype(prefix="bar-v", version="5.5.2-client", + test_openpype(prefix="bar-v", version="5.5.2-rc.1", suffix=".zip", type="zip", valid=True), - test_openpype(prefix="baz-v", version="5.5.3-client-strange", + test_openpype(prefix="baz-v", version="5.5.3-foo-strange", suffix=".zip", type="zip", valid=True), - test_openpype(prefix="bum-v", version="5.5.4-staging", + test_openpype(prefix="bum-v", version="5.5.4+staging", suffix=".zip", type="zip", valid=True), - test_openpype(prefix="zum-v", version="5.5.5-client-staging", + test_openpype(prefix="zum-v", version="5.5.5-foo+staging", suffix=".zip", type="zip", valid=True), test_openpype(prefix="fam-v", version="5.6.3", suffix=".zip", type="zip", valid=True), - test_openpype(prefix="foo-v", version="5.6.3-staging", + test_openpype(prefix="foo-v", version="5.6.3+staging", suffix=".zip", type="zip", valid=True), test_openpype(prefix="fim-v", version="5.6.3", suffix=".zip", type="zip", valid=False), @@ -208,11 +206,11 @@ def test_find_openpype(fix_bootstrap, tmp_path_factory, monkeypatch, printer): suffix=".txt", type="txt", valid=False), test_openpype(prefix="lom-v", version="7.2.6", suffix=".zip", type="zip", valid=True), - test_openpype(prefix="bom-v", version="7.2.7-client", + test_openpype(prefix="bom-v", version="7.2.7-rc.3", suffix=".zip", type="zip", valid=True), - test_openpype(prefix="woo-v", version="7.2.8-client-strange", + test_openpype(prefix="woo-v", version="7.2.8-foo-strange", suffix=".zip", type="zip", valid=True), - test_openpype(prefix="loo-v", version="7.2.10-client-staging", + test_openpype(prefix="loo-v", version="7.2.10-foo+staging", suffix=".zip", type="zip", valid=True), test_openpype(prefix="kok-v", version="7.0.1", suffix=".zip", type="zip", valid=True) @@ -227,13 +225,13 @@ def test_find_openpype(fix_bootstrap, tmp_path_factory, monkeypatch, printer): suffix=".zip", type="zip", valid=True), test_openpype(prefix="foo-v", version="4.1.2", suffix=".zip", type="zip", valid=True), - test_openpype(prefix="foo-v", version="3.0.1-client", + test_openpype(prefix="foo-v", version="3.0.1-foo", suffix=".zip", type="zip", valid=True), - test_openpype(prefix="foo-v", version="3.0.1-client-strange", + test_openpype(prefix="foo-v", version="3.0.1-foo-strange", suffix=".zip", type="zip", valid=True), - test_openpype(prefix="foo-v", version="3.0.1-staging", + test_openpype(prefix="foo-v", version="3.0.1+staging", suffix=".zip", type="zip", valid=True), - test_openpype(prefix="foo-v", version="3.0.1-client-staging", + test_openpype(prefix="foo-v", version="3.0.1-foo+staging", suffix=".zip", type="zip", valid=True), test_openpype(prefix="foo-v", version="3.2.0", suffix=".zip", type="zip", valid=True) @@ -244,9 +242,9 @@ def test_find_openpype(fix_bootstrap, tmp_path_factory, monkeypatch, printer): suffix="", type="dir", valid=True), test_openpype(prefix="lom-v", version="11.2.6", suffix=".zip", type="dir", valid=False), - test_openpype(prefix="bom-v", version="7.2.7-client", + test_openpype(prefix="bom-v", version="7.2.7-foo", suffix=".zip", type="zip", valid=True), - test_openpype(prefix="woo-v", version="7.2.8-client-strange", + test_openpype(prefix="woo-v", version="7.2.8-foo-strange", suffix=".zip", type="txt", valid=False) ] From 354ea1d5818f71a95b1feb94c96e2d2cc74f17b5 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 14 May 2021 17:30:13 +0200 Subject: [PATCH 238/303] unified color source --- openpype/tools/project_manager/project_manager/model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index b371242388..9de4301e8d 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -1310,7 +1310,7 @@ class BaseItem: if role == QtCore.Qt.ForegroundRole: if key == "name" and not self.is_valid: - return QtGui.QColor(255, 0, 0) + return ResourceCache.colors["warning"] return None if role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole): From d5201b718cce9b7fe2363d9c0438c41e9802ae89 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 14 May 2021 17:32:27 +0200 Subject: [PATCH 239/303] modified color a little bit --- .../tools/project_manager/project_manager/style/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/style/__init__.py b/openpype/tools/project_manager/project_manager/style/__init__.py index 6b9d708f76..f3b0e0f9c0 100644 --- a/openpype/tools/project_manager/project_manager/style/__init__.py +++ b/openpype/tools/project_manager/project_manager/style/__init__.py @@ -6,8 +6,8 @@ from avalon.vendor import qtawesome class ResourceCache: colors = { "standard": "#333333", - "warning": "#ff0000", - "new": "#00ff00" + "new": "#2d9a4c", + "warning": "#c83232" } icons = None From 1c1b077fe437fbe1b43ca0193a80b107cda77257 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 14 May 2021 17:42:01 +0200 Subject: [PATCH 240/303] properly handle duplicated asset names on remove and un-remove --- openpype/tools/project_manager/project_manager/model.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 9de4301e8d..1d48e78a19 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -499,6 +499,10 @@ class HierarchyModel(QtCore.QAbstractItemModel): for item in items_by_id.values(): if item.data(REMOVED_ROLE): item.setData(False, REMOVED_ROLE) + if isinstance(item, AssetItem): + name = item.data(QtCore.Qt.EditRole, "name") + self._asset_items_by_name[name].add(item.id) + self._validate_asset_duplicity(name) def delete_index(self, index): return self.delete_indexes([index]) @@ -606,6 +610,8 @@ class HierarchyModel(QtCore.QAbstractItemModel): if end_row is not None: row_ranges.append((start_row, end_row)) start_row = end_row = None + if isinstance(child_item, AssetItem): + self._rename_asset(child_item, None) continue end_row = row From 57dc929bd2e8128cdc4ba312487498662d8964a9 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 14 May 2021 18:09:03 +0200 Subject: [PATCH 241/303] moved header definition to HierarchicalView --- .../project_manager/project_manager/model.py | 3 +++ .../project_manager/project_manager/view.py | 18 ++++++++++++++++++ .../project_manager/project_manager/window.py | 13 ------------- 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 1d48e78a19..7617275b91 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -115,6 +115,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): } index_moved = QtCore.Signal(QtCore.QModelIndex) + project_changed = QtCore.Signal() def __init__(self, dbcon, parent=None): super(HierarchyModel, self).__init__(parent) @@ -281,6 +282,8 @@ class HierarchyModel(QtCore.QAbstractItemModel): self.add_items(task_items, asset_item) + self.project_changed.emit() + def rowCount(self, parent=None): if parent is None or not parent.isValid(): parent_item = self._root_item diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index 0131687541..02d5b40fd6 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -145,6 +145,7 @@ class HierarchyView(QtWidgets.QTreeView): source_model.index_moved.connect(self._on_rows_moved) self.customContextMenuRequested.connect(self._on_context_menu) + self._source_model.project_changed.connect(self._on_project_reset) self._project_doc_cache = project_doc_cache self._tools_cache = tools_cache @@ -153,6 +154,20 @@ class HierarchyView(QtWidgets.QTreeView): self._column_delegates = column_delegates self._column_key_to_index = column_key_to_index + def header_init(self): + header = self.header() + header.setStretchLastSection(False) + for idx in range(header.count()): + logical_index = header.logicalIndex(idx) + if idx == 0: + header.setSectionResizeMode( + logical_index, QtWidgets.QHeaderView.Stretch + ) + else: + header.setSectionResizeMode( + logical_index, QtWidgets.QHeaderView.ResizeToContents + ) + def set_project(self, project_name): # Trigger helpers first self._project_doc_cache.set_project(project_name) @@ -161,6 +176,9 @@ class HierarchyView(QtWidgets.QTreeView): # Trigger update of model after all data for delegates are filled self._source_model.set_project(project_name) + def _on_project_reset(self): + self.header_init() + self.collapseAll() project_item = self._source_model.project_item diff --git a/openpype/tools/project_manager/project_manager/window.py b/openpype/tools/project_manager/project_manager/window.py index c4243c3cc3..49c48912cf 100644 --- a/openpype/tools/project_manager/project_manager/window.py +++ b/openpype/tools/project_manager/project_manager/window.py @@ -75,19 +75,6 @@ class Window(QtWidgets.QWidget): _selection_model.setModel(hierarchy_view.model()) hierarchy_view.setSelectionModel(_selection_model) - header = hierarchy_view.header() - header.setStretchLastSection(False) - for idx in range(header.count()): - logical_index = header.logicalIndex(idx) - if idx == 0: - header.setSectionResizeMode( - logical_index, QtWidgets.QHeaderView.Stretch - ) - else: - header.setSectionResizeMode( - logical_index, QtWidgets.QHeaderView.ResizeToContents - ) - buttons_widget = QtWidgets.QWidget(self) message_label = QtWidgets.QLabel(buttons_widget) From 3ddefade0dd1d6fe3d1dc3b28a7452c5a9d0cd52 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 14 May 2021 18:09:35 +0200 Subject: [PATCH 242/303] it is possible to force project change even if current project name is same --- openpype/tools/project_manager/project_manager/model.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 7617275b91..a597550b36 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -142,9 +142,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): self._root_item = RootItem(self) def refresh_project(self): - project_name = self._current_project - self._current_project = None - self.set_project(project_name) + self.set_project(self._current_project, True) @property def project_item(self): @@ -156,8 +154,8 @@ class HierarchyModel(QtCore.QAbstractItemModel): break return output - def set_project(self, project_name): - if self._current_project == project_name: + def set_project(self, project_name, force=False): + if self._current_project == project_name and not force: return self.clear() From b3aa86bb54eb7d54e7be264633516f6b4a69becc Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 14 May 2021 18:10:03 +0200 Subject: [PATCH 243/303] view has defined some default widths of columns --- .../project_manager/project_manager/view.py | 42 +++++++++++++++---- 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index 02d5b40fd6..e96176debd 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -86,6 +86,27 @@ class HierarchyView(QtWidgets.QTreeView): "pixelAspect": NumberDef(0, decimals=2), "tools_env": ToolsDef() } + + columns_sizes = { + "default": { + "stretch": QtWidgets.QHeaderView.ResizeToContents + }, + "name": { + "stretch": QtWidgets.QHeaderView.Stretch + }, + "type": { + "stretch": QtWidgets.QHeaderView.Interactive, + "width": 100 + }, + "tools_env": { + "stretch": QtWidgets.QHeaderView.Interactive, + "width": 140 + }, + "pixelAspect": { + "stretch": QtWidgets.QHeaderView.Interactive, + "width": 80 + } + } persistent_columns = { "type", "frameStart", @@ -157,16 +178,21 @@ class HierarchyView(QtWidgets.QTreeView): def header_init(self): header = self.header() header.setStretchLastSection(False) + + default_behavior = self.columns_sizes["default"] + widths_by_idx = {} for idx in range(header.count()): + key = self._source_model.columns[idx] + behavior = self.columns_sizes.get(key, default_behavior) logical_index = header.logicalIndex(idx) - if idx == 0: - header.setSectionResizeMode( - logical_index, QtWidgets.QHeaderView.Stretch - ) - else: - header.setSectionResizeMode( - logical_index, QtWidgets.QHeaderView.ResizeToContents - ) + stretch = behavior["stretch"] + header.setSectionResizeMode(logical_index, stretch) + width = behavior.get("width") + if width is not None: + widths_by_idx[idx] = width + + for idx, width in widths_by_idx.items(): + self.setColumnWidth(idx, width) def set_project(self, project_name): # Trigger helpers first From d2bb8e2836c91a9218bae7fa876812375153704f Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 14 May 2021 18:27:55 +0200 Subject: [PATCH 244/303] fixed removing of asset items --- openpype/tools/project_manager/project_manager/model.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index a597550b36..874e1b3a9c 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -566,6 +566,8 @@ class HierarchyModel(QtCore.QAbstractItemModel): if remove_item: cur_item.setData(True, REMOVED_ROLE) + if isinstance(cur_item, AssetItem): + self._rename_asset(cur_item, None) for task_item in task_children: _fill_children(_all_descendants, task_item, cur_item) @@ -611,8 +613,6 @@ class HierarchyModel(QtCore.QAbstractItemModel): if end_row is not None: row_ranges.append((start_row, end_row)) start_row = end_row = None - if isinstance(child_item, AssetItem): - self._rename_asset(child_item, None) continue end_row = row @@ -652,7 +652,8 @@ class HierarchyModel(QtCore.QAbstractItemModel): if prev_name == new_name: return - self._asset_items_by_name[prev_name].remove(asset_item.id) + if asset_item.id in self._asset_items_by_name[prev_name]: + self._asset_items_by_name[prev_name].remove(asset_item.id) self._validate_asset_duplicity(prev_name) From ab35d00183bacc40e097c577b82e7c29af8fea90 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 14 May 2021 18:32:56 +0200 Subject: [PATCH 245/303] added name and icon to window --- openpype/tools/project_manager/project_manager/window.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/window.py b/openpype/tools/project_manager/project_manager/window.py index 49c48912cf..52046fb94b 100644 --- a/openpype/tools/project_manager/project_manager/window.py +++ b/openpype/tools/project_manager/project_manager/window.py @@ -1,4 +1,4 @@ -from Qt import QtWidgets, QtCore +from Qt import QtWidgets, QtCore, QtGui from . import ( ProjectModel, @@ -9,6 +9,7 @@ from . import ( ) from .style import load_stylesheet, ResourceCache +from openpype import resources from avalon.api import AvalonMongoDB @@ -16,7 +17,8 @@ class Window(QtWidgets.QWidget): def __init__(self, parent=None): super(Window, self).__init__(parent) - dbcon = AvalonMongoDB() + self.setWindowTitle("OpenPype Project Manager") + self.setWindowIcon(QtGui.QIcon(resources.pype_icon_filepath())) # Top part of window top_part_widget = QtWidgets.QWidget(self) @@ -24,6 +26,8 @@ class Window(QtWidgets.QWidget): # Project selection project_widget = QtWidgets.QWidget(top_part_widget) + dbcon = AvalonMongoDB() + project_model = ProjectModel(dbcon) project_combobox = QtWidgets.QComboBox(project_widget) project_combobox.setModel(project_model) From 1477dadd341b0e23fd3cfaefab9a2169945f5f8e Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 14 May 2021 18:34:13 +0200 Subject: [PATCH 246/303] renamed class from Window to ProjectManagerWindow --- openpype/tools/project_manager/__init__.py | 4 ++-- openpype/tools/project_manager/project_manager/__init__.py | 6 +++--- openpype/tools/project_manager/project_manager/window.py | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/openpype/tools/project_manager/__init__.py b/openpype/tools/project_manager/__init__.py index 880fc253cf..62fa8af8aa 100644 --- a/openpype/tools/project_manager/__init__.py +++ b/openpype/tools/project_manager/__init__.py @@ -1,10 +1,10 @@ from .project_manager import ( - Window, + ProjectManagerWindow, main ) __all__ = ( - "Window", + "ProjectManagerWindow", "main" ) diff --git a/openpype/tools/project_manager/project_manager/__init__.py b/openpype/tools/project_manager/project_manager/__init__.py index dccc46f771..34beec331d 100644 --- a/openpype/tools/project_manager/project_manager/__init__.py +++ b/openpype/tools/project_manager/project_manager/__init__.py @@ -13,7 +13,7 @@ __all__ = ( "AssetItem", "TaskItem", - "Window", + "ProjectManagerWindow", "main" ) @@ -33,7 +33,7 @@ from .model import ( AssetItem, TaskItem ) -from .window import Window +from .window import ProjectManagerWindow def main(): @@ -42,7 +42,7 @@ def main(): app = QtWidgets.QApplication([]) - window = Window() + window = ProjectManagerWindow() window.show() sys.exit(app.exec_()) diff --git a/openpype/tools/project_manager/project_manager/window.py b/openpype/tools/project_manager/project_manager/window.py index 52046fb94b..81f49d3c26 100644 --- a/openpype/tools/project_manager/project_manager/window.py +++ b/openpype/tools/project_manager/project_manager/window.py @@ -13,9 +13,9 @@ from openpype import resources from avalon.api import AvalonMongoDB -class Window(QtWidgets.QWidget): +class ProjectManagerWindow(QtWidgets.QWidget): def __init__(self, parent=None): - super(Window, self).__init__(parent) + super(ProjectManagerWindow, self).__init__(parent) self.setWindowTitle("OpenPype Project Manager") self.setWindowIcon(QtGui.QIcon(resources.pype_icon_filepath())) From e2b56e5217c8902c40c3525c0d07f595def089bc Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 14 May 2021 19:19:23 +0200 Subject: [PATCH 247/303] SyncServer - wrong six library imported --- openpype/modules/sync_server/providers/gdrive.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/sync_server/providers/gdrive.py b/openpype/modules/sync_server/providers/gdrive.py index 2caf41ba5e..c05b6b9090 100644 --- a/openpype/modules/sync_server/providers/gdrive.py +++ b/openpype/modules/sync_server/providers/gdrive.py @@ -2,7 +2,7 @@ from __future__ import print_function import os.path import time import sys -from setuptools.extern import six +import six import platform from openpype.api import Logger From ced76863671af5bff75cc51c6e90c6e418e1b332 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 14 May 2021 19:45:48 +0200 Subject: [PATCH 248/303] create add asset/task buttons in one step --- .../project_manager/project_manager/window.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/window.py b/openpype/tools/project_manager/project_manager/window.py index 81f49d3c26..61000464ea 100644 --- a/openpype/tools/project_manager/project_manager/window.py +++ b/openpype/tools/project_manager/project_manager/window.py @@ -48,12 +48,16 @@ class ProjectManagerWindow(QtWidgets.QWidget): helper_btns_widget = QtWidgets.QWidget(top_part_widget) helper_label = QtWidgets.QLabel("Add:", helper_btns_widget) - add_asset_btn = QtWidgets.QPushButton(helper_btns_widget) - add_asset_btn.setIcon(ResourceCache.get_icon("asset", "default")) - add_asset_btn.setText("Asset") - add_task_btn = QtWidgets.QPushButton("Task", helper_btns_widget) - add_task_btn.setIcon(ResourceCache.get_icon("task", "default")) - add_task_btn.setText("Task") + add_asset_btn = QtWidgets.QPushButton( + ResourceCache.get_icon("asset", "default"), + "Asset", + helper_btns_widget + ) + add_task_btn = QtWidgets.QPushButton( + ResourceCache.get_icon("task", "default"), + "Task", + helper_btns_widget + ) helper_btns_layout = QtWidgets.QHBoxLayout(helper_btns_widget) helper_btns_layout.setContentsMargins(0, 0, 0, 0) From c05995c238803d1de5a937f5491c35dbb2247fae Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Mon, 17 May 2021 09:56:15 +0200 Subject: [PATCH 249/303] minor fixes in wording --- openpype/settings/defaults/project_settings/harmony.json | 8 +++++--- website/docs/admin_hosts_harmony.md | 8 ++++---- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/openpype/settings/defaults/project_settings/harmony.json b/openpype/settings/defaults/project_settings/harmony.json index 2131516805..0c7a35c058 100644 --- a/openpype/settings/defaults/project_settings/harmony.json +++ b/openpype/settings/defaults/project_settings/harmony.json @@ -1,14 +1,16 @@ { "publish": { "CollectPalettes": { - "allowed_tasks": [".*"] + "allowed_tasks": [ + ".*" + ] }, "ValidateSceneSettings": { "enabled": true, "optional": true, "active": true, - "frame_check_filter": ["_ch_", "_pr_", "_intd_", "_extd_"], - "skip_resolution_check": ["render", "Render"], + "frame_check_filter": [], + "skip_resolution_check": [], "skip_timelines_check": [] }, "HarmonySubmitDeadline": { diff --git a/website/docs/admin_hosts_harmony.md b/website/docs/admin_hosts_harmony.md index 756ca1c27f..2c49d8ba73 100644 --- a/website/docs/admin_hosts_harmony.md +++ b/website/docs/admin_hosts_harmony.md @@ -24,21 +24,21 @@ Location: Settings > Project > Harmony Set regex pattern(s) only for task names when publishing of Palettes should occur. -Use ".*" for publish Palettes for ALL tasks. +Use ".*" to publish Palettes for ALL tasks. ### Validate Scene Settings #### Skip Frame check for Assets with -Set regex pattern(s) to find in Asset name to skip checks of `frameEnd` value from DB. +Set regex pattern(s) for filtering Asset name that should skip validation of `frameEnd` value from DB. #### Skip Resolution Check for Tasks -Set regex pattern(s) to find in Task name to skip resolution check against values from DB. +Set regex pattern(s) for filtering Asset name that should skip validation or `Resolution` value from DB. #### Skip Timeline Check for Tasks -Set regex pattern(s) to find in Task name to skip `frameStart`, `frameEnd` check against values from DB. +Set regex pattern(s) for filtering Task name that should skip validation `frameStart`, `frameEnd` check against values from DB. ### Harmony Submit to Deadline From b5bf29b0f2aca23383e74c0bb50fd46c23d0cc8e Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 17 May 2021 10:12:51 +0200 Subject: [PATCH 250/303] SyncServer - Local Settings expect 'local' instead of real site id --- openpype/modules/sync_server/sync_server_module.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/openpype/modules/sync_server/sync_server_module.py b/openpype/modules/sync_server/sync_server_module.py index a9f1a73d77..962b1e1e53 100644 --- a/openpype/modules/sync_server/sync_server_module.py +++ b/openpype/modules/sync_server/sync_server_module.py @@ -492,9 +492,6 @@ class SyncServerModule(PypeModule, ITrayModule): # Local Settings can select only from allowed sites for project allowed_sites.update(set(self.get_active_sites(project_name))) allowed_sites.update(set(self.get_remote_sites(project_name))) - # Settings allow use of 'local' site, user's site is not 'local' - if 'local' in allowed_sites: - allowed_sites.add(get_local_site_id()) editable = {} for site_name in sites.keys(): @@ -878,7 +875,7 @@ class SyncServerModule(PypeModule, ITrayModule): } all_sites = {self.DEFAULT_SITE: studio_config} if sync_enabled: - all_sites[get_local_site_id()] = {'provider': 'local_drive'} + all_sites['local'] = {'provider': 'local_drive'} return all_sites def get_provider_for_site(self, project_name=None, site=None): From 2053bb7d6da87568a909cfb8ead88e90bb7258ff Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Mon, 17 May 2021 10:36:37 +0200 Subject: [PATCH 251/303] SyncServer - fill {site} placeholder Modified namespace for gdrive --- openpype/modules/sync_server/providers/gdrive.py | 2 +- openpype/modules/sync_server/sync_server_module.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/modules/sync_server/providers/gdrive.py b/openpype/modules/sync_server/providers/gdrive.py index c05b6b9090..18d679b833 100644 --- a/openpype/modules/sync_server/providers/gdrive.py +++ b/openpype/modules/sync_server/providers/gdrive.py @@ -113,7 +113,7 @@ class GDriveHandler(AbstractProvider): EditableScopes.LOCAL], 'label': "Credentials url", 'type': 'text', - 'namespace': '{project_setting}/global/sync_server/sites/{site}/{platform}' # noqa: E501 + 'namespace': '{project_settings}/global/sync_server/sites/{site}/credentials_url/{platform}' # noqa: E501 }, # roots could be override only on Project leve, User cannot 'root': {'scope': [EditableScopes.PROJECT], diff --git a/openpype/modules/sync_server/sync_server_module.py b/openpype/modules/sync_server/sync_server_module.py index 962b1e1e53..ed403b836d 100644 --- a/openpype/modules/sync_server/sync_server_module.py +++ b/openpype/modules/sync_server/sync_server_module.py @@ -573,6 +573,8 @@ class SyncServerModule(PypeModule, ITrayModule): st = "{}'s field value {} should be".format(key, val) # noqa: E501 log.error(st + " multiplatform dict") + item["namespace"] = item["namespace"].replace('{site}', + site_name) children = [] if properties["type"] == "dict": if val: From f51cc93b7ef7ae471da97b5778c42fae779ad148 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 17 May 2021 15:27:21 +0000 Subject: [PATCH 252/303] Create draft PR for #1128 From 0af53c997aca7e6169979accd698454061c526fd Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 17 May 2021 17:29:23 +0200 Subject: [PATCH 253/303] removed enabled key from sync to avalon settings --- openpype/settings/defaults/project_settings/ftrack.json | 1 - .../schemas/projects_schema/schema_project_ftrack.json | 6 ------ 2 files changed, 7 deletions(-) diff --git a/openpype/settings/defaults/project_settings/ftrack.json b/openpype/settings/defaults/project_settings/ftrack.json index 8970aa8ac8..b964ce07c3 100644 --- a/openpype/settings/defaults/project_settings/ftrack.json +++ b/openpype/settings/defaults/project_settings/ftrack.json @@ -1,7 +1,6 @@ { "events": { "sync_to_avalon": { - "enabled": true, "statuses_name_change": [ "ready", "not ready" diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_ftrack.json b/openpype/settings/entities/schemas/projects_schema/schema_project_ftrack.json index a801175031..b1bb207578 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_ftrack.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_ftrack.json @@ -14,13 +14,7 @@ "type": "dict", "key": "sync_to_avalon", "label": "Sync to avalon", - "checkbox_key": "enabled", "children": [ - { - "type": "boolean", - "key": "enabled", - "label": "Enabled" - }, { "type": "label", "label": "Allow name and hierarchy change only if following statuses are on all children tasks" From 33df862d1df6250a4f7d3bf09e11f55ab7421a34 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Mon, 17 May 2021 18:08:15 +0200 Subject: [PATCH 254/303] clean zips files, fix shared version location --- igniter/bootstrap_repos.py | 10 ++++++---- start.py | 17 ++++++++++++++++- tools/build.ps1 | 4 +++- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index 8421e42c87..0a1534bb99 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -622,7 +622,7 @@ class BootstrapRepos: " not implemented yet.")) dir_to_search = self.data_dir - + user_versions = self.get_openpype_versions(self.data_dir, staging) # if we have openpype_path specified, search only there. if openpype_path: dir_to_search = openpype_path @@ -642,6 +642,7 @@ class BootstrapRepos: pass openpype_versions = self.get_openpype_versions(dir_to_search, staging) + openpype_versions += user_versions # remove zip file version if needed. if not include_zips: @@ -754,12 +755,13 @@ class BootstrapRepos: destination = self.data_dir / version.path.stem if destination.exists(): + assert destination.is_dir() try: - destination.unlink() - except OSError: + shutil.rmtree(destination) + except OSError as e: msg = f"!!! Cannot remove already existing {destination}" self._print(msg, LOG_ERROR, exc_info=True) - return None + raise e destination.mkdir(parents=True) diff --git a/start.py b/start.py index 4b8893a3d5..660d0c9006 100644 --- a/start.py +++ b/start.py @@ -427,6 +427,13 @@ def _find_frozen_openpype(use_version: str = None, path=Path(os.environ["OPENPYPE_ROOT"])) if local_version not in openpype_versions: openpype_versions.append(local_version) + openpype_versions.sort() + # if latest is currently running, ditch whole list + # and run from current without installing it. + if local_version == openpype_versions[-1]: + os.environ["OPENPYPE_TRYOUT"] = "1" + openpype_versions = [] + else: print("!!! Warning: cannot determine current running version.") @@ -502,7 +509,15 @@ def _find_frozen_openpype(use_version: str = None, if openpype_version.path.is_file(): print(">>> Extracting zip file ...") - version_path = bootstrap.extract_openpype(openpype_version) + try: + version_path = bootstrap.extract_openpype(openpype_version) + except OSError as e: + print("!!! failed: {}".format(str(e))) + sys.exit(1) + else: + # cleanup zip after extraction + os.unlink(openpype_version.path) + openpype_version.path = version_path _initialize_environment(openpype_version) diff --git a/tools/build.ps1 b/tools/build.ps1 index 566e40cb55..5c392c355c 100644 --- a/tools/build.ps1 +++ b/tools/build.ps1 @@ -164,6 +164,7 @@ Write-Host "OK" -ForegroundColor green Write-Host ">>> " -NoNewline -ForegroundColor green Write-Host "Building OpenPype ..." +$startTime = (Get-Date).Millisecond $out = & poetry run python setup.py build 2>&1 if ($LASTEXITCODE -ne 0) @@ -182,7 +183,8 @@ Write-Host ">>> " -NoNewline -ForegroundColor green Write-Host "restoring current directory" Set-Location -Path $current_dir +$endTime = (Get-Date).Millisecond Write-Host "*** " -NoNewline -ForegroundColor Cyan -Write-Host "All done. You will find OpenPype and build log in " -NoNewLine +Write-Host "All done in $($endTime - $startTime) secs. You will find OpenPype and build log in " -NoNewLine Write-Host "'.\build'" -NoNewline -ForegroundColor Green Write-Host " directory." From 156d9e84e71e13f783671f6492a9a9850fe3f307 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Mon, 17 May 2021 18:10:06 +0200 Subject: [PATCH 255/303] hound fix --- igniter/bootstrap_repos.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index 0a1534bb99..b44689ba89 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -38,7 +38,7 @@ class OpenPypeVersion(semver.VersionInfo): """ staging = False path = None - _VERSION_REGEX = re.compile(r"(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$") + _VERSION_REGEX = re.compile(r"(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)\.(?P0|[1-9]\d*)(?:-(?P(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$") # noqa: E501 def __init__(self, *args, **kwargs): """Create OpenPype version. From fb4762220275a596708c7f59a12036119e2edf3d Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 17 May 2021 18:57:57 +0200 Subject: [PATCH 256/303] initial value has same type as output type so settings does not recognize initial values as changed --- .../settings/entities/dict_mutable_keys_entity.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/openpype/settings/entities/dict_mutable_keys_entity.py b/openpype/settings/entities/dict_mutable_keys_entity.py index 4839dbcdc2..907bf98784 100644 --- a/openpype/settings/entities/dict_mutable_keys_entity.py +++ b/openpype/settings/entities/dict_mutable_keys_entity.py @@ -434,8 +434,19 @@ class DictMutableKeysEntity(EndpointEntity): if using_values_from_state: if _settings_value is NOT_SET: initial_value = NOT_SET + + elif self.store_as_list: + new_initial_value = [] + for key, value in _settings_value: + if key in initial_value: + new_initial_value.append(key, initial_value.pop(key)) + + for key, value in initial_value.items(): + new_initial_value.append(key, value) + initial_value = new_initial_value else: initial_value = _settings_value + self.initial_value = initial_value def _convert_to_regex_valid_key(self, key): From d17a0307ab4599d82436dbbcceec79f64c81c108 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 17 May 2021 19:47:43 +0200 Subject: [PATCH 257/303] defined CURRENT_DOC_SCHEMAS constant in openpype.lib --- openpype/lib/__init__.py | 2 ++ openpype/lib/avalon_context.py | 7 +++++++ .../ftrack/event_handlers_server/event_sync_to_avalon.py | 6 ++---- openpype/modules/ftrack/lib/avalon_sync.py | 8 ++++---- 4 files changed, 15 insertions(+), 8 deletions(-) diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index 457ceb1d56..6e63b3d9a6 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -58,6 +58,7 @@ from .python_module_tools import ( ) from .avalon_context import ( + CURRENT_DOC_SCHEMAS, is_latest, any_outdated, get_asset, @@ -162,6 +163,7 @@ __all__ = [ "recursive_bases_from_class", "classes_from_module", + "CURRENT_DOC_SCHEMAS", "is_latest", "any_outdated", "get_asset", diff --git a/openpype/lib/avalon_context.py b/openpype/lib/avalon_context.py index 2d8726352a..2d608e8279 100644 --- a/openpype/lib/avalon_context.py +++ b/openpype/lib/avalon_context.py @@ -17,6 +17,13 @@ avalon = None log = logging.getLogger("AvalonContext") +CURRENT_DOC_SCHEMAS = { + "project": "openpype:project-3.0", + "asset": "openpype:asset-3.0", + "config": "openpype:config-2.0" +} + + def with_avalon(func): @functools.wraps(func) def wrap_avalon(*args, **kwargs): diff --git a/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py b/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py index 3bb01798e4..410e51e2a4 100644 --- a/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py +++ b/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py @@ -26,9 +26,7 @@ from openpype.modules.ftrack.lib import ( BaseEvent ) -from openpype.modules.ftrack.lib.avalon_sync import ( - EntitySchemas -) +from openpype.lib import CURRENT_DOC_SCHEMAS class SyncToAvalonEvent(BaseEvent): @@ -1128,7 +1126,7 @@ class SyncToAvalonEvent(BaseEvent): "_id": mongo_id, "name": name, "type": "asset", - "schema": EntitySchemas["asset"], + "schema": CURRENT_DOC_SCHEMAS["asset"], "parent": proj["_id"], "data": { "ftrackId": ftrack_ent["id"], diff --git a/openpype/modules/ftrack/lib/avalon_sync.py b/openpype/modules/ftrack/lib/avalon_sync.py index f58e858a5a..a3b926464e 100644 --- a/openpype/modules/ftrack/lib/avalon_sync.py +++ b/openpype/modules/ftrack/lib/avalon_sync.py @@ -34,7 +34,7 @@ log = Logger.get_logger(__name__) # Current schemas for avalon types -EntitySchemas = { +CURRENT_DOC_SCHEMAS = { "project": "openpype:project-3.0", "asset": "openpype:asset-3.0", "config": "openpype:config-2.0" @@ -1862,7 +1862,7 @@ class SyncEntitiesFactory: item["_id"] = new_id item["parent"] = self.avalon_project_id - item["schema"] = EntitySchemas["asset"] + item["schema"] = CURRENT_DOC_SCHEMAS["asset"] item["data"]["visualParent"] = avalon_parent new_id_str = str(new_id) @@ -2003,8 +2003,8 @@ class SyncEntitiesFactory: project_item["_id"] = new_id project_item["parent"] = None - project_item["schema"] = EntitySchemas["project"] - project_item["config"]["schema"] = EntitySchemas["config"] + project_item["schema"] = CURRENT_DOC_SCHEMAS["project"] + project_item["config"]["schema"] = CURRENT_DOC_SCHEMAS["config"] self.ftrack_avalon_mapper[self.ft_project_id] = new_id self.avalon_ftrack_mapper[new_id] = self.ft_project_id From 85f812bfe5b74844685f3dd5fee62bb211f8ee52 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 17 May 2021 20:15:31 +0200 Subject: [PATCH 258/303] define project regex in code --- openpype/lib/__init__.py | 6 ++++++ openpype/lib/avalon_context.py | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index 6e63b3d9a6..9fcc536a3a 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -59,6 +59,9 @@ from .python_module_tools import ( from .avalon_context import ( CURRENT_DOC_SCHEMAS, + PROJECT_NAME_ALLOWED_SYMBOLS, + PROJECT_NAME_REGEX, + create_project, is_latest, any_outdated, get_asset, @@ -164,6 +167,9 @@ __all__ = [ "classes_from_module", "CURRENT_DOC_SCHEMAS", + "PROJECT_NAME_ALLOWED_SYMBOLS", + "PROJECT_NAME_REGEX", + "create_project", "is_latest", "any_outdated", "get_asset", diff --git a/openpype/lib/avalon_context.py b/openpype/lib/avalon_context.py index 2d608e8279..f4a58c74fd 100644 --- a/openpype/lib/avalon_context.py +++ b/openpype/lib/avalon_context.py @@ -22,6 +22,10 @@ CURRENT_DOC_SCHEMAS = { "asset": "openpype:asset-3.0", "config": "openpype:config-2.0" } +PROJECT_NAME_ALLOWED_SYMBOLS = "a-zA-Z0-9_" +PROJECT_NAME_REGEX = re.compile( + "^[{}]+$".format(PROJECT_NAME_ALLOWED_SYMBOLS) +) def with_avalon(func): From 29040d449f42fb68e6596cb95ea8b01c46f0eacd Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 17 May 2021 20:23:11 +0200 Subject: [PATCH 259/303] implemented function to create new project --- openpype/lib/avalon_context.py | 82 ++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/openpype/lib/avalon_context.py b/openpype/lib/avalon_context.py index f4a58c74fd..c53b028a44 100644 --- a/openpype/lib/avalon_context.py +++ b/openpype/lib/avalon_context.py @@ -28,6 +28,88 @@ PROJECT_NAME_REGEX = re.compile( ) +def create_project( + project_name, project_code, library_project=False, dbcon=None +): + """Create project using OpenPype settings. + + This project creation function is not validating project document on + creation. It is because project document is created blindly with only + minimum required information about project which is it's name, code, type + and schema. + + Entered project name must be unique and project must not exist yet. + + Args: + project_name(str): New project name. Should be unique. + project_code(str): Project's code should be unique too. + library_project(bool): Project is library project. + dbcon(AvalonMongoDB): Object of connection to MongoDB. + + Raises: + ValueError: When project name already exists in MongoDB. + + Returns: + dict: Created project document. + """ + + from openpype.settings import ProjectSettings, SaveWarningExc + from avalon.api import AvalonMongoDB + from avalon.schema import validate + + if dbcon is None: + dbcon = AvalonMongoDB() + + if not PROJECT_NAME_REGEX.match(project_name): + raise ValueError(( + "Project name \"{}\" contain invalid characters" + ).format(project_name)) + + database = dbcon.database + project_doc = database[project_name].find_one( + {"type": "project"}, + {"name": 1} + ) + if project_doc: + raise ValueError("Project with name \"{}\" already exists".format( + project_name + )) + + project_doc = { + "type": "project", + "name": project_name, + "data": { + "code": project_code, + "library_project": library_project + }, + "schema": CURRENT_DOC_SCHEMAS["project"] + } + # Insert document with basic data + database[project_name].insert_one(project_doc) + # Load ProjectSettings for the project and save it to store all attributes + # and Anatomy + try: + project_settings_entity = ProjectSettings(project_name) + project_settings_entity.save() + except SaveWarningExc as exc: + print(str(exc)) + except Exception: + database[project_name].delete_one({"type": "project"}) + raise + + project_doc = database[project_name].find_one({"type": "project"}) + + try: + # Validate created project document + validate(project_doc) + except Exception as exc: + # Remove project if is not valid + database[project_name].delete_one({"type": "project"}) + raise + + return project_doc + + def with_avalon(func): @functools.wraps(func) def wrap_avalon(*args, **kwargs): From 7d6741f18517e5ad8c17050ede6b10f41a6264cf Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 17 May 2021 20:23:29 +0200 Subject: [PATCH 260/303] use CURRENT_DOC_SCHEMAS in hierarchy model --- openpype/tools/project_manager/project_manager/model.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 874e1b3a9c..8f6fe04006 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -13,6 +13,8 @@ from .constants import ( EDITOR_OPENED_ROLE ) from .style import ResourceCache + +from openpype.lib import CURRENT_DOC_SCHEMAS from pymongo import UpdateOne, DeleteOne from avalon.vendor import qtawesome from Qt import QtCore, QtGui @@ -1628,7 +1630,8 @@ class AssetItem(BaseItem): "tasks": tasks } schema_name = ( - self._origin_asset_doc.get("schema") or "openpype:asset-3.0" + self._origin_asset_doc.get("schema") + or CURRENT_DOC_SCHEMAS["asset"] ) doc = { From cc559527e249cb39fc246012cf2ea94f607f1605 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 17 May 2021 20:23:47 +0200 Subject: [PATCH 261/303] implemented CreateProjectDialog to be able create projects --- .../project_manager/widgets.py | 182 ++++++++++++++++++ 1 file changed, 182 insertions(+) diff --git a/openpype/tools/project_manager/project_manager/widgets.py b/openpype/tools/project_manager/project_manager/widgets.py index 566e17ea86..c650a4a8b7 100644 --- a/openpype/tools/project_manager/project_manager/widgets.py +++ b/openpype/tools/project_manager/project_manager/widgets.py @@ -1,8 +1,16 @@ import re + from .constants import ( NAME_ALLOWED_SYMBOLS, NAME_REGEX ) +from openpype.lib import ( + create_project, + PROJECT_NAME_ALLOWED_SYMBOLS, + PROJECT_NAME_REGEX +) +from avalon.api import AvalonMongoDB + from Qt import QtWidgets, QtCore @@ -80,3 +88,177 @@ class FilterComboBox(QtWidgets.QComboBox): self._completer.setCompletionColumn(column) self._filter_proxy_model.setFilterKeyColumn(column) super(FilterComboBox, self).setModelColumn(column) + + +class CreateProjectDialog(QtWidgets.QDialog): + def __init__(self, parent=None, dbcon=None): + super(CreateProjectDialog, self).__init__(parent) + + self.setWindowTitle("Create Project") + + self.allowed_regex = "[^{}]+".format(PROJECT_NAME_ALLOWED_SYMBOLS) + + if dbcon is None: + dbcon = AvalonMongoDB() + + self.dbcon = dbcon + self._ignore_code_change = False + self._project_name_is_valid = False + self._project_code_is_valid = False + self._project_code_value = None + + project_names, project_codes = self._get_existing_projects() + + inputs_widget = QtWidgets.QWidget(self) + project_name_input = QtWidgets.QLineEdit(inputs_widget) + project_code_input = QtWidgets.QLineEdit(inputs_widget) + library_project_input = QtWidgets.QCheckBox(inputs_widget) + + inputs_layout = QtWidgets.QFormLayout(inputs_widget) + inputs_layout.setContentsMargins(0, 0, 0, 0) + inputs_layout.addRow("Project name:", project_name_input) + inputs_layout.addRow("Project code:", project_code_input) + inputs_layout.addRow("Library project:", library_project_input) + + project_name_label = QtWidgets.QLabel(self) + project_code_label = QtWidgets.QLabel(self) + + btns_widget = QtWidgets.QWidget(self) + ok_btn = QtWidgets.QPushButton("Ok", btns_widget) + ok_btn.setEnabled(False) + cancel_btn = QtWidgets.QPushButton("Cancel", btns_widget) + btns_layout = QtWidgets.QHBoxLayout(btns_widget) + btns_layout.setContentsMargins(0, 0, 0, 0) + btns_layout.addStretch(1) + btns_layout.addWidget(ok_btn) + btns_layout.addWidget(cancel_btn) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.addWidget(inputs_widget, 0) + main_layout.addWidget(project_name_label, 1) + main_layout.addWidget(project_code_label, 1) + main_layout.addStretch(1) + main_layout.addWidget(btns_widget, 0) + + project_name_input.textChanged.connect(self._on_project_name_change) + project_code_input.textChanged.connect(self._on_project_code_change) + ok_btn.clicked.connect(self._on_ok_clicked) + cancel_btn.clicked.connect(self._on_cancel_clicked) + + self.invalid_project_names = project_names + self.invalid_project_codes = project_codes + + self.project_name_label = project_name_label + self.project_code_label = project_code_label + + self.project_name_input = project_name_input + self.project_code_input = project_code_input + self.library_project_input = library_project_input + + self.ok_btn = ok_btn + + @property + def project_name(self): + return self.project_name_input.text() + + def _on_project_name_change(self, value): + if self._project_code_value is None: + self._ignore_code_change = True + self.project_code_input.setText(value.lower()) + self._ignore_code_change = False + + self._update_valid_project_name(value) + + def _on_project_code_change(self, value): + if not value: + value = None + + self._update_valid_project_code(value) + + if not self._ignore_code_change: + self._project_code_value = value + + def _update_valid_project_name(self, value): + message = "" + is_valid = True + if not value: + message = "Project name is empty" + is_valid = False + + elif value in self.invalid_project_names: + message = "Project name \"{}\" already exist".format(value) + is_valid = False + + elif not PROJECT_NAME_REGEX.match(value): + message = ( + "Project name \"{}\" contain not supported symbols" + ).format(value) + is_valid = False + + self._project_name_is_valid = is_valid + self.project_name_label.setText(message) + self._enable_button() + + def _update_valid_project_code(self, value): + message = "" + is_valid = True + if not value: + message = "Project code is empty" + is_valid = False + + elif value in self.invalid_project_names: + message = "Project code \"{}\" already exist".format(value) + is_valid = False + + elif not PROJECT_NAME_REGEX.match(value): + message = ( + "Project code \"{}\" contain not supported symbols" + ).format(value) + is_valid = False + + self._project_code_is_valid = is_valid + self.project_code_label.setText(message) + self._enable_button() + + def _enable_button(self): + self.ok_btn.setEnabled( + self._project_name_is_valid and self._project_code_is_valid + ) + + def _on_cancel_clicked(self): + self.done(0) + + def _on_ok_clicked(self): + if not self._project_name_is_valid or not self._project_code_is_valid: + return + + project_name = self.project_name_input.text() + project_code = self.project_code_input.text() + library_project = self.library_project_input.isChecked() + create_project(project_name, project_code, library_project, self.dbcon) + + self.done(1) + + def _get_existing_projects(self): + project_names = set() + project_codes = set() + for project_name in self.dbcon.database.collection_names(): + # Each collection will have exactly one project document + project_doc = self.dbcon.database[project_name].find_one( + {"type": "project"}, + {"name": 1, "data.code": 1} + ) + if not project_doc: + continue + + project_name = project_doc.get("name") + if not project_name: + continue + + project_names.add(project_name) + project_code = project_doc.get("data", {}).get("code") + if not project_code: + project_code = project_name + + project_codes.add(project_code) + return project_names, project_codes From 8ec20d1b7e67911b94602216ff470fe41331569e Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 17 May 2021 20:25:08 +0200 Subject: [PATCH 262/303] use CreateProjectDialog in project manager --- .../project_manager/project_manager/window.py | 34 +++++++++++++++---- 1 file changed, 27 insertions(+), 7 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/window.py b/openpype/tools/project_manager/project_manager/window.py index 61000464ea..ad377c4403 100644 --- a/openpype/tools/project_manager/project_manager/window.py +++ b/openpype/tools/project_manager/project_manager/window.py @@ -5,7 +5,9 @@ from . import ( HierarchyModel, HierarchySelectionModel, - HierarchyView + HierarchyView, + + CreateProjectDialog ) from .style import load_stylesheet, ResourceCache @@ -38,10 +40,15 @@ class ProjectManagerWindow(QtWidgets.QWidget): refresh_projects_btn.setToolTip("Refresh projects") refresh_projects_btn.setObjectName("RefreshBtn") + create_project_btn = QtWidgets.QPushButton( + "Create project...", project_widget + ) + project_layout = QtWidgets.QHBoxLayout(project_widget) project_layout.setContentsMargins(0, 0, 0, 0) project_layout.addWidget(project_combobox, 0) project_layout.addWidget(refresh_projects_btn, 0) + project_layout.addWidget(create_project_btn, 0) project_layout.addStretch(1) # Helper buttons @@ -100,6 +107,7 @@ class ProjectManagerWindow(QtWidgets.QWidget): main_layout.addWidget(buttons_widget) refresh_projects_btn.clicked.connect(self._on_project_refresh) + create_project_btn.clicked.connect(self._on_project_create) project_combobox.currentIndexChanged.connect(self._on_project_change) save_btn.clicked.connect(self._on_save_click) add_asset_btn.clicked.connect(self._on_add_asset) @@ -121,18 +129,18 @@ class ProjectManagerWindow(QtWidgets.QWidget): def _set_project(self, project_name=None): self.hierarchy_view.set_project(project_name) - def refresh_projects(self): - current_project = None - if self.project_combobox.count() > 0: - current_project = self.project_combobox.currentText() + def refresh_projects(self, project_name=None): + if project_name is None: + if self.project_combobox.count() > 0: + project_name = self.project_combobox.currentText() self.project_model.refresh() if self.project_combobox.count() == 0: return self._set_project() - if current_project: - row = self.project_combobox.findText(current_project) + if project_name: + row = self.project_combobox.findText(project_name) if row >= 0: self.project_combobox.setCurrentIndex(row) @@ -156,3 +164,15 @@ class ProjectManagerWindow(QtWidgets.QWidget): def show_message(self, message): # TODO add nicer message pop self.message_label.setText(message) + + def _on_project_create(self): + dialog = CreateProjectDialog(self) + dialog.exec_() + print(dialog.result()) + if dialog.result() != 1: + return + + project_name = dialog.project_name + print("Created project \"{}\"".format(project_name)) + self.show_message("Created project \"{}\"".format(project_name)) + self.refresh_projects(project_name) From 6fe08049d6730c240aeefc7dda30970b3072e281 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 17 May 2021 20:26:36 +0200 Subject: [PATCH 263/303] make visualParent not required on asset doc --- openpype/tools/project_manager/project_manager/model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 8f6fe04006..0259a75f21 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -216,7 +216,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): asset_docs_by_parent_id = collections.defaultdict(list) for asset_doc in asset_docs_by_id.values(): - parent_id = asset_doc["data"]["visualParent"] + parent_id = asset_doc["data"].get("visualParent") asset_docs_by_parent_id[parent_id].append(asset_doc) appending_queue = Queue() From 1850687160d26f85e821c502a712ac05b7813158 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 17 May 2021 20:27:19 +0200 Subject: [PATCH 264/303] added CreateProjectDialog to init of project manager module --- openpype/tools/project_manager/project_manager/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/openpype/tools/project_manager/project_manager/__init__.py b/openpype/tools/project_manager/project_manager/__init__.py index 34beec331d..49ade4a989 100644 --- a/openpype/tools/project_manager/project_manager/__init__.py +++ b/openpype/tools/project_manager/project_manager/__init__.py @@ -4,6 +4,7 @@ __all__ = ( "HierarchyView", "ProjectModel", + "CreateProjectDialog", "HierarchyModel", "HierarchySelectionModel", @@ -21,6 +22,7 @@ __all__ = ( from .constants import ( IDENTIFIER_ROLE ) +from .widgets import CreateProjectDialog from .view import HierarchyView from .model import ( ProjectModel, From 376c3dffed965025063d3dca59cdf386e502eb38 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 17 May 2021 20:35:42 +0200 Subject: [PATCH 265/303] add filling of style data to stylesheets --- .../project_manager/style/__init__.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/openpype/tools/project_manager/project_manager/style/__init__.py b/openpype/tools/project_manager/project_manager/style/__init__.py index f3b0e0f9c0..a268d657f1 100644 --- a/openpype/tools/project_manager/project_manager/style/__init__.py +++ b/openpype/tools/project_manager/project_manager/style/__init__.py @@ -69,11 +69,24 @@ class ResourceCache: def get_color(cls, color_name): return cls.colors[color_name] + @classmethod + def style_fill_data(cls): + output = {} + for color_name, color_value in cls.colors.items(): + key = "color:{}".format(color_name) + output[key] = color_value + return output + def load_stylesheet(): - style_path = os.path.join(os.path.dirname(__file__), "style.css") + current_dir = os.path.dirname(os.path.abspath(__file__)) + style_path = os.path.join(current_dir, "style.css") with open(style_path, "r") as style_file: stylesheet = style_file.read() + + for key, value in ResourceCache.style_fill_data().items(): + replacement_key = "{" + key + "}" + stylesheet = stylesheet.replace(replacement_key, value) return stylesheet From 77108c554245ac3d1b311f511fcac42b880eb43c Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 17 May 2021 20:44:53 +0200 Subject: [PATCH 266/303] added base of resources --- .../project_manager/style/__init__.py | 1 + .../style/images/combobox_arrow.png | Bin 0 -> 166 bytes .../style/images/combobox_arrow_disabled.png | Bin 0 -> 165 bytes .../project_manager/style/pyqt5_resources.py | 94 ++++++++++++++++++ .../style/pyside2_resources.py | 75 ++++++++++++++ .../project_manager/style/rc_resources.py | 12 +++ .../project_manager/style/resources.qrc | 6 ++ 7 files changed, 188 insertions(+) create mode 100644 openpype/tools/project_manager/project_manager/style/images/combobox_arrow.png create mode 100644 openpype/tools/project_manager/project_manager/style/images/combobox_arrow_disabled.png create mode 100644 openpype/tools/project_manager/project_manager/style/pyqt5_resources.py create mode 100644 openpype/tools/project_manager/project_manager/style/pyside2_resources.py create mode 100644 openpype/tools/project_manager/project_manager/style/rc_resources.py create mode 100644 openpype/tools/project_manager/project_manager/style/resources.qrc diff --git a/openpype/tools/project_manager/project_manager/style/__init__.py b/openpype/tools/project_manager/project_manager/style/__init__.py index a268d657f1..6fd7d304cb 100644 --- a/openpype/tools/project_manager/project_manager/style/__init__.py +++ b/openpype/tools/project_manager/project_manager/style/__init__.py @@ -79,6 +79,7 @@ class ResourceCache: def load_stylesheet(): + from . import rc_resources current_dir = os.path.dirname(os.path.abspath(__file__)) style_path = os.path.join(current_dir, "style.css") with open(style_path, "r") as style_file: diff --git a/openpype/tools/project_manager/project_manager/style/images/combobox_arrow.png b/openpype/tools/project_manager/project_manager/style/images/combobox_arrow.png new file mode 100644 index 0000000000000000000000000000000000000000..5805d9842bb3c8bdf9ae741ebabc690a4929585a GIT binary patch literal 166 zcmeAS@N?(olHy`uVBq!ia0vp^oIuRR!2%?ApR4f$QjEnx?oJHr&dIz4a+s35-CY>| zxA&jf59DzcctjR6FmMZlFeAgPITAoY_7YEDSN1y`;vAyZcdU741BJ9aT^vI=t|uoP zVCdoDDYjGKUczBuWT3#kjKjddz-flSK@mm~;ef4+85xf4WSw z1e^Sc1@brxJR*x37`TN&n2}-D90{Nxdx@v7EBhS|ac(A-+!@lDKp{;}7sn8e>&XcR z7pulY;Wn^IBG(*7A%~?b^VC!N=hHC=sm-IdEdjT~uc)I$ztaD0e F0ssj2CNKa1 literal 0 HcmV?d00001 diff --git a/openpype/tools/project_manager/project_manager/style/pyqt5_resources.py b/openpype/tools/project_manager/project_manager/style/pyqt5_resources.py new file mode 100644 index 0000000000..34b6551210 --- /dev/null +++ b/openpype/tools/project_manager/project_manager/style/pyqt5_resources.py @@ -0,0 +1,94 @@ +# -*- coding: utf-8 -*- + +# Resource object code +# +# Created by: The Resource Compiler for PyQt5 (Qt v5.15.2) +# +# WARNING! All changes made in this file will be lost! + +from PyQt5 import QtCore + +qt_resource_data = b"\ +\x00\x00\x00\xa5\ +\x89\ +\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ +\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce\x7c\x4e\ +\x00\x00\x00\x01\x73\x52\x47\x42\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x02\x62\x4b\x47\x44\x00\x9c\x53\x34\xfc\x5d\x00\x00\x00\x09\x70\ +\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00\x07\x74\x49\x4d\x45\x07\xdc\x08\x17\x0b\x02\x04\x6d\ +\x98\x1b\x69\x00\x00\x00\x29\x49\x44\x41\x54\x08\xd7\x63\x60\xc0\ +\x00\x8c\x0c\x0c\xff\xcf\xa3\x08\x18\x32\x32\x30\x20\x0b\x32\x1a\ +\x32\x30\x30\x42\x98\x10\x41\x46\x43\x14\x13\x50\xb5\xa3\x01\x00\ +\xd6\x10\x07\xd2\x2f\x48\xdf\x4a\x00\x00\x00\x00\x49\x45\x4e\x44\ +\xae\x42\x60\x82\ +\x00\x00\x00\xa6\ +\x89\ +\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\ +\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce\x7c\x4e\ +\x00\x00\x00\x01\x73\x52\x47\x42\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x02\x62\x4b\x47\x44\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09\x70\ +\x48\x59\x73\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00\x07\x74\x49\x4d\x45\x07\xdc\x08\x17\x08\x15\x3b\xdc\ +\x3b\x0c\x9b\x00\x00\x00\x2a\x49\x44\x41\x54\x08\xd7\x63\x60\xc0\ +\x00\x8c\x0c\x0c\x73\x3e\x20\x0b\xa4\x08\x30\x32\x30\x20\x0b\xa6\ +\x08\x30\x30\x30\x42\x98\x10\xc1\x14\x01\x14\x13\x50\xb5\xa3\x01\ +\x00\xc6\xb9\x07\x90\x5d\x66\x1f\x83\x00\x00\x00\x00\x49\x45\x4e\ +\x44\xae\x42\x60\x82\ +" + +qt_resource_name = b"\ +\x00\x08\ +\x06\xc5\x8e\xa5\ +\x00\x6f\ +\x00\x70\x00\x65\x00\x6e\x00\x70\x00\x79\x00\x70\x00\x65\ +\x00\x06\ +\x07\x03\x7d\xc3\ +\x00\x69\ +\x00\x6d\x00\x61\x00\x67\x00\x65\x00\x73\ +\x00\x12\ +\x01\x2e\x03\x27\ +\x00\x63\ +\x00\x6f\x00\x6d\x00\x62\x00\x6f\x00\x62\x00\x6f\x00\x78\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x2e\x00\x70\x00\x6e\ +\x00\x67\ +\x00\x1b\ +\x03\x5a\x32\x27\ +\x00\x63\ +\x00\x6f\x00\x6d\x00\x62\x00\x6f\x00\x62\x00\x6f\x00\x78\x00\x5f\x00\x61\x00\x72\x00\x72\x00\x6f\x00\x77\x00\x5f\x00\x64\x00\x69\ +\x00\x73\x00\x61\x00\x62\x00\x6c\x00\x65\x00\x64\x00\x2e\x00\x70\x00\x6e\x00\x67\ +" + +qt_resource_struct_v1 = b"\ +\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x01\ +\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x02\ +\x00\x00\x00\x16\x00\x02\x00\x00\x00\x02\x00\x00\x00\x03\ +\x00\x00\x00\x28\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\ +\x00\x00\x00\x52\x00\x00\x00\x00\x00\x01\x00\x00\x00\xa9\ +" + +qt_resource_struct_v2 = b"\ +\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x01\ +\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x02\ +\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x16\x00\x02\x00\x00\x00\x02\x00\x00\x00\x03\ +\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x28\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\ +\x00\x00\x01\x76\x41\x9d\xa2\x35\ +\x00\x00\x00\x52\x00\x00\x00\x00\x00\x01\x00\x00\x00\xa9\ +\x00\x00\x01\x76\x41\x9d\xa2\x35\ +" + +qt_version = [int(v) for v in QtCore.qVersion().split('.')] +if qt_version < [5, 8, 0]: + rcc_version = 1 + qt_resource_struct = qt_resource_struct_v1 +else: + rcc_version = 2 + qt_resource_struct = qt_resource_struct_v2 + +def qInitResources(): + QtCore.qRegisterResourceData(rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data) + +def qCleanupResources(): + QtCore.qUnregisterResourceData(rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data) diff --git a/openpype/tools/project_manager/project_manager/style/pyside2_resources.py b/openpype/tools/project_manager/project_manager/style/pyside2_resources.py new file mode 100644 index 0000000000..ecd5e7fa52 --- /dev/null +++ b/openpype/tools/project_manager/project_manager/style/pyside2_resources.py @@ -0,0 +1,75 @@ +# Resource object code (Python 3) +# Created by: object code +# Created by: The Resource Compiler for Qt version 5.15.2 +# WARNING! All changes made in this file will be lost! + +from PySide2 import QtCore + +qt_resource_data = b"\ +\x00\x00\x00\xa5\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce|N\ +\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x02bKGD\x00\x9cS4\xfc]\x00\x00\x00\x09p\ +HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x0b\x02\x04m\ +\x98\x1bi\x00\x00\x00)IDAT\x08\xd7c`\xc0\ +\x00\x8c\x0c\x0c\xff\xcf\xa3\x08\x18220 \x0b2\x1a\ +200B\x98\x10AFC\x14\x13P\xb5\xa3\x01\x00\ +\xd6\x10\x07\xd2/H\xdfJ\x00\x00\x00\x00IEND\ +\xaeB`\x82\ +\x00\x00\x00\xa6\ +\x89\ +PNG\x0d\x0a\x1a\x0a\x00\x00\x00\x0dIHDR\x00\ +\x00\x00\x09\x00\x00\x00\x06\x08\x04\x00\x00\x00\xbb\xce|N\ +\x00\x00\x00\x01sRGB\x00\xae\xce\x1c\xe9\x00\x00\x00\ +\x02bKGD\x00\xff\x87\x8f\xcc\xbf\x00\x00\x00\x09p\ +HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ +\x00\x00\x00\x07tIME\x07\xdc\x08\x17\x08\x15;\xdc\ +;\x0c\x9b\x00\x00\x00*IDAT\x08\xd7c`\xc0\ +\x00\x8c\x0c\x0cs> \x0b\xa4\x08020 \x0b\xa6\ +\x08000B\x98\x10\xc1\x14\x01\x14\x13P\xb5\xa3\x01\ +\x00\xc6\xb9\x07\x90]f\x1f\x83\x00\x00\x00\x00IEN\ +D\xaeB`\x82\ +" + +qt_resource_name = b"\ +\x00\x08\ +\x06\xc5\x8e\xa5\ +\x00o\ +\x00p\x00e\x00n\x00p\x00y\x00p\x00e\ +\x00\x06\ +\x07\x03}\xc3\ +\x00i\ +\x00m\x00a\x00g\x00e\x00s\ +\x00\x12\ +\x01.\x03'\ +\x00c\ +\x00o\x00m\x00b\x00o\x00b\x00o\x00x\x00_\x00a\x00r\x00r\x00o\x00w\x00.\x00p\x00n\ +\x00g\ +\x00\x1b\ +\x03Z2'\ +\x00c\ +\x00o\x00m\x00b\x00o\x00b\x00o\x00x\x00_\x00a\x00r\x00r\x00o\x00w\x00_\x00d\x00i\ +\x00s\x00a\x00b\x00l\x00e\x00d\x00.\x00p\x00n\x00g\ +" + +qt_resource_struct = b"\ +\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x01\ +\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x02\ +\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00\x16\x00\x02\x00\x00\x00\x02\x00\x00\x00\x03\ +\x00\x00\x00\x00\x00\x00\x00\x00\ +\x00\x00\x00(\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\ +\x00\x00\x01vA\x9d\xa25\ +\x00\x00\x00R\x00\x00\x00\x00\x00\x01\x00\x00\x00\xa9\ +\x00\x00\x01vA\x9d\xa25\ +" + +def qInitResources(): + QtCore.qRegisterResourceData(0x03, qt_resource_struct, qt_resource_name, qt_resource_data) + +def qCleanupResources(): + QtCore.qUnregisterResourceData(0x03, qt_resource_struct, qt_resource_name, qt_resource_data) diff --git a/openpype/tools/project_manager/project_manager/style/rc_resources.py b/openpype/tools/project_manager/project_manager/style/rc_resources.py new file mode 100644 index 0000000000..19dcda9564 --- /dev/null +++ b/openpype/tools/project_manager/project_manager/style/rc_resources.py @@ -0,0 +1,12 @@ +import Qt + + +resources = None +if Qt.__binding__ == "PySide2": + from . import pyside2_resources as resources +elif Qt.__binding__ == "PyQt5": + from . import pyqt5_resources as resources + + +if resources is not None: + resources.qInitResources() diff --git a/openpype/tools/project_manager/project_manager/style/resources.qrc b/openpype/tools/project_manager/project_manager/style/resources.qrc new file mode 100644 index 0000000000..9281c69479 --- /dev/null +++ b/openpype/tools/project_manager/project_manager/style/resources.qrc @@ -0,0 +1,6 @@ + + + images/combobox_arrow.png + images/combobox_arrow_disabled.png + + From 807eefee6dbdb54044c9a33b96c05ebbcbc3da44 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 17 May 2021 20:47:12 +0200 Subject: [PATCH 267/303] hide project name messages without text --- openpype/tools/project_manager/project_manager/widgets.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/tools/project_manager/project_manager/widgets.py b/openpype/tools/project_manager/project_manager/widgets.py index c650a4a8b7..263b15daeb 100644 --- a/openpype/tools/project_manager/project_manager/widgets.py +++ b/openpype/tools/project_manager/project_manager/widgets.py @@ -197,6 +197,7 @@ class CreateProjectDialog(QtWidgets.QDialog): self._project_name_is_valid = is_valid self.project_name_label.setText(message) + self.project_name_label.setVisible(bool(message)) self._enable_button() def _update_valid_project_code(self, value): From 0b159479facbb4e5b7cc19ca14223b610e0637b6 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 17 May 2021 20:48:42 +0200 Subject: [PATCH 268/303] removed debug prints --- openpype/tools/project_manager/project_manager/window.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/window.py b/openpype/tools/project_manager/project_manager/window.py index ad377c4403..a800214517 100644 --- a/openpype/tools/project_manager/project_manager/window.py +++ b/openpype/tools/project_manager/project_manager/window.py @@ -168,11 +168,9 @@ class ProjectManagerWindow(QtWidgets.QWidget): def _on_project_create(self): dialog = CreateProjectDialog(self) dialog.exec_() - print(dialog.result()) if dialog.result() != 1: return project_name = dialog.project_name - print("Created project \"{}\"".format(project_name)) self.show_message("Created project \"{}\"".format(project_name)) self.refresh_projects(project_name) From 5134ae743b7d27ef6b8c2a50388cbe8ef63fb2ea Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 17 May 2021 20:53:58 +0200 Subject: [PATCH 269/303] hound fixes --- openpype/lib/avalon_context.py | 2 +- .../project_manager/style/__init__.py | 5 +++- .../project_manager/style/pyqt5_resources.py | 7 ++++++ .../style/pyside2_resources.py | 5 ++++ .../project_manager/style/qrc_resources.py | 25 +++++++++++++++++++ .../project_manager/style/rc_resources.py | 12 --------- 6 files changed, 42 insertions(+), 14 deletions(-) create mode 100644 openpype/tools/project_manager/project_manager/style/qrc_resources.py delete mode 100644 openpype/tools/project_manager/project_manager/style/rc_resources.py diff --git a/openpype/lib/avalon_context.py b/openpype/lib/avalon_context.py index c53b028a44..2a7c58c4ee 100644 --- a/openpype/lib/avalon_context.py +++ b/openpype/lib/avalon_context.py @@ -102,7 +102,7 @@ def create_project( try: # Validate created project document validate(project_doc) - except Exception as exc: + except Exception: # Remove project if is not valid database[project_name].delete_one({"type": "project"}) raise diff --git a/openpype/tools/project_manager/project_manager/style/__init__.py b/openpype/tools/project_manager/project_manager/style/__init__.py index 6fd7d304cb..b686967ddd 100644 --- a/openpype/tools/project_manager/project_manager/style/__init__.py +++ b/openpype/tools/project_manager/project_manager/style/__init__.py @@ -79,7 +79,10 @@ class ResourceCache: def load_stylesheet(): - from . import rc_resources + from . import qrc_resources + + qrc_resources.qInitResources() + current_dir = os.path.dirname(os.path.abspath(__file__)) style_path = os.path.join(current_dir, "style.css") with open(style_path, "r") as style_file: diff --git a/openpype/tools/project_manager/project_manager/style/pyqt5_resources.py b/openpype/tools/project_manager/project_manager/style/pyqt5_resources.py index 34b6551210..bb26221a46 100644 --- a/openpype/tools/project_manager/project_manager/style/pyqt5_resources.py +++ b/openpype/tools/project_manager/project_manager/style/pyqt5_resources.py @@ -8,6 +8,7 @@ from PyQt5 import QtCore + qt_resource_data = b"\ \x00\x00\x00\xa5\ \x89\ @@ -37,6 +38,7 @@ qt_resource_data = b"\ \x44\xae\x42\x60\x82\ " + qt_resource_name = b"\ \x00\x08\ \x06\xc5\x8e\xa5\ @@ -58,6 +60,7 @@ qt_resource_name = b"\ \x00\x73\x00\x61\x00\x62\x00\x6c\x00\x65\x00\x64\x00\x2e\x00\x70\x00\x6e\x00\x67\ " + qt_resource_struct_v1 = b"\ \x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x01\ \x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x02\ @@ -66,6 +69,7 @@ qt_resource_struct_v1 = b"\ \x00\x00\x00\x52\x00\x00\x00\x00\x00\x01\x00\x00\x00\xa9\ " + qt_resource_struct_v2 = b"\ \x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x01\ \x00\x00\x00\x00\x00\x00\x00\x00\ @@ -79,6 +83,7 @@ qt_resource_struct_v2 = b"\ \x00\x00\x01\x76\x41\x9d\xa2\x35\ " + qt_version = [int(v) for v in QtCore.qVersion().split('.')] if qt_version < [5, 8, 0]: rcc_version = 1 @@ -87,8 +92,10 @@ else: rcc_version = 2 qt_resource_struct = qt_resource_struct_v2 + def qInitResources(): QtCore.qRegisterResourceData(rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data) + def qCleanupResources(): QtCore.qUnregisterResourceData(rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data) diff --git a/openpype/tools/project_manager/project_manager/style/pyside2_resources.py b/openpype/tools/project_manager/project_manager/style/pyside2_resources.py index ecd5e7fa52..a8a368601d 100644 --- a/openpype/tools/project_manager/project_manager/style/pyside2_resources.py +++ b/openpype/tools/project_manager/project_manager/style/pyside2_resources.py @@ -5,6 +5,7 @@ from PySide2 import QtCore + qt_resource_data = b"\ \x00\x00\x00\xa5\ \x89\ @@ -34,6 +35,7 @@ HYs\x00\x00\x0b\x13\x00\x00\x0b\x13\x01\x00\x9a\x9c\x18\ D\xaeB`\x82\ " + qt_resource_name = b"\ \x00\x08\ \x06\xc5\x8e\xa5\ @@ -55,6 +57,7 @@ qt_resource_name = b"\ \x00s\x00a\x00b\x00l\x00e\x00d\x00.\x00p\x00n\x00g\ " + qt_resource_struct = b"\ \x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x01\ \x00\x00\x00\x00\x00\x00\x00\x00\ @@ -68,8 +71,10 @@ qt_resource_struct = b"\ \x00\x00\x01vA\x9d\xa25\ " + def qInitResources(): QtCore.qRegisterResourceData(0x03, qt_resource_struct, qt_resource_name, qt_resource_data) + def qCleanupResources(): QtCore.qUnregisterResourceData(0x03, qt_resource_struct, qt_resource_name, qt_resource_data) diff --git a/openpype/tools/project_manager/project_manager/style/qrc_resources.py b/openpype/tools/project_manager/project_manager/style/qrc_resources.py new file mode 100644 index 0000000000..be859cae46 --- /dev/null +++ b/openpype/tools/project_manager/project_manager/style/qrc_resources.py @@ -0,0 +1,25 @@ +import Qt + + +initialized = False +resources = None +if Qt.__binding__ == "PySide2": + from . import pyside2_resources as resources +elif Qt.__binding__ == "PyQt5": + from . import pyqt5_resources as resources + + +def qInitResources(): + global resources + global initialized + if resources is not None and not initialized: + initialized = True + resources.qInitResources() + + +def qCleanupResources(): + global resources + global initialized + if resources is not None: + initialized = False + resources.qCleanupResources() diff --git a/openpype/tools/project_manager/project_manager/style/rc_resources.py b/openpype/tools/project_manager/project_manager/style/rc_resources.py deleted file mode 100644 index 19dcda9564..0000000000 --- a/openpype/tools/project_manager/project_manager/style/rc_resources.py +++ /dev/null @@ -1,12 +0,0 @@ -import Qt - - -resources = None -if Qt.__binding__ == "PySide2": - from . import pyside2_resources as resources -elif Qt.__binding__ == "PyQt5": - from . import pyqt5_resources as resources - - -if resources is not None: - resources.qInitResources() From 3600686ab06d83ef723d9bee4b82c07d93d3a349 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 17 May 2021 20:59:41 +0200 Subject: [PATCH 270/303] a little bit more cleanup --- .../project_manager/style/pyqt5_resources.py | 8 ++++++-- .../project_manager/style/pyside2_resources.py | 8 ++++++-- .../project_manager/style/qrc_resources.py | 7 +++++++ 3 files changed, 19 insertions(+), 4 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/style/pyqt5_resources.py b/openpype/tools/project_manager/project_manager/style/pyqt5_resources.py index bb26221a46..836934019d 100644 --- a/openpype/tools/project_manager/project_manager/style/pyqt5_resources.py +++ b/openpype/tools/project_manager/project_manager/style/pyqt5_resources.py @@ -94,8 +94,12 @@ else: def qInitResources(): - QtCore.qRegisterResourceData(rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data) + QtCore.qRegisterResourceData( + rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data + ) def qCleanupResources(): - QtCore.qUnregisterResourceData(rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data) + QtCore.qUnregisterResourceData( + rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data + ) diff --git a/openpype/tools/project_manager/project_manager/style/pyside2_resources.py b/openpype/tools/project_manager/project_manager/style/pyside2_resources.py index a8a368601d..b73d5e334a 100644 --- a/openpype/tools/project_manager/project_manager/style/pyside2_resources.py +++ b/openpype/tools/project_manager/project_manager/style/pyside2_resources.py @@ -73,8 +73,12 @@ qt_resource_struct = b"\ def qInitResources(): - QtCore.qRegisterResourceData(0x03, qt_resource_struct, qt_resource_name, qt_resource_data) + QtCore.qRegisterResourceData( + 0x03, qt_resource_struct, qt_resource_name, qt_resource_data + ) def qCleanupResources(): - QtCore.qUnregisterResourceData(0x03, qt_resource_struct, qt_resource_name, qt_resource_data) + QtCore.qUnregisterResourceData( + 0x03, qt_resource_struct, qt_resource_name, qt_resource_data + ) diff --git a/openpype/tools/project_manager/project_manager/style/qrc_resources.py b/openpype/tools/project_manager/project_manager/style/qrc_resources.py index be859cae46..a9e219c9ad 100644 --- a/openpype/tools/project_manager/project_manager/style/qrc_resources.py +++ b/openpype/tools/project_manager/project_manager/style/qrc_resources.py @@ -23,3 +23,10 @@ def qCleanupResources(): if resources is not None: initialized = False resources.qCleanupResources() + + +__all__ = ( + "resources", + "qInitResources", + "qCleanupResources" +) From 42c950a033bdfa22e8b193ce6814cef3f9ed8e7b Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 17 May 2021 22:26:02 +0200 Subject: [PATCH 271/303] fix new asset name validation --- openpype/tools/project_manager/project_manager/model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 0259a75f21..66693b292f 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -396,7 +396,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): result = self.add_item(new_child, item, new_row) if result is not None: # WARNING Expecting result is index for column 0 ("name") - new_name = result.data(QtCore.Qt.DisplayRole) + new_name = result.data(QtCore.Qt.EditRole) self._validate_asset_duplicity(new_name) return result From 229b2a17959f37e1dc258953690b1d2e6b1888e5 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Mon, 17 May 2021 22:30:11 +0200 Subject: [PATCH 272/303] even more fixes of name getting --- openpype/tools/project_manager/project_manager/model.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 66693b292f..6e20dd368f 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -440,7 +440,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): parent.add_child(item, row) if isinstance(item, AssetItem): - name = item.data(QtCore.Qt.DisplayRole, "name") + name = item.data(QtCore.Qt.EditRole, "name") self._asset_items_by_name[name].add(item.id) if item.id not in self._items_by_id: @@ -1810,7 +1810,7 @@ class AssetItem(BaseItem): _item.setData(False, DUPLICATED_ROLE) def _rename_task(self, item): - new_name = item.data(QtCore.Qt.DisplayRole, "name").lower() + new_name = item.data(QtCore.Qt.EditRole, "name").lower() item_id = item.data(IDENTIFIER_ROLE) prev_name = self._task_name_by_item_id[item_id] if new_name == prev_name: From b1b68af224e2fe05bc839650708ef89e7c10e45c Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Mon, 17 May 2021 23:51:27 +0200 Subject: [PATCH 273/303] add and restructure site sync admin docs --- website/docs/module_site_sync.md | 88 +++++++++++++++++++++++--------- 1 file changed, 65 insertions(+), 23 deletions(-) diff --git a/website/docs/module_site_sync.md b/website/docs/module_site_sync.md index ec34305815..6ee6660048 100644 --- a/website/docs/module_site_sync.md +++ b/website/docs/module_site_sync.md @@ -7,45 +7,88 @@ sidebar_label: Site Sync import Tabs from '@theme/Tabs'; import TabItem from '@theme/TabItem'; -## Site Sync -Site Sync allows users to publish assets and synchronize them between 'sites'. Site denotes a location, -it could be a local disk or remote repository. +:::warning +**This feature is** currently **in a beta stage** and it is not recommended to rely on it fully for production. +::: -### Main configuration +Site Sync allows users and studios to synchronize published assets between multiple 'sites'. Site denotes a storage location, +which could be a physical disk, server, cloud storage. To be able to use site sync, it first needs to be configured. -To use synchronization *Site Sync* needs to be enabled globally in **OpnePype Settings** in **System** tab. +The general idea is that each user acts as an individual site and can download and upload any published project files when they are needed. that way, artist can have access to the whole project, but only every store files that are relevant to them on their home workstation. + +:::note +At the moment site sync is only able to deal with publishes files. No workfiles will be synchronized unless they are published. We are working on making workfile synchronization possible as well. +::: + +## System Settings + +To use synchronization, *Site Sync* needs to be enabled globally in **OpenPype Settings/System/Modules/Site Sync**. ![Configure module](assets/site_sync_system.png) -Each site implements a so called `provider` which handles most common operations (list files, copy files etc.). -Multiple configured sites could share the same provider (multiple mounted disk - each disk is a separate site, -all share the same provider). -Currently implemented providers: -- **local_drive** - handles files stored on local disk (could be a mounted one) -- **gdrive** - handles files on Google Drive +## Project Settings -By default there are two sites created for each OpenPype Tray app: -- **studio** - default site - usually mounted disk accessible to all artists -- **local** - each Tray app has its own with unique site name +Sites need to be made available for each project. Of course this is possible to do on the default project as well, in which case all other projects will inherit these settings until overriden explicitly. -There might be many different sites created and configured. +You'll find the setting in **Settings/Project/Global/Site Sync** -Each OpenPype Tray app works with two sites at one time. (Sites could be the same, no synching is done in this setup). +The attributes that can be configured will vary between sites and their providers. + +## Local settings + +Each user should configure root folder for their 'local' site via **Local Settings** in OpenPype Tray. This folder will be used for all files that the user publishes or downloads while working on a project. Artist has the option to set the folder as "default"in which case it is used for all the projects, or it can be set on a project level individually. + +Artists can also override which site they use as active and remote if need be. + +![Local overrides](assets/site_sync_local_setting.png) + + +## Sites + +By default there are two sites created for each OpenPype installation: +- **studio** - default site - usually a centralized mounted disk accessible to all artists. Studio site is used if Site Sync is disabled. +- **local** - each workstation or server running OpenPype Tray receives its own with unique site name. Workstation refers to itself as "local"however all other sites will see it under it's unique ID. + +Artists can explore their site ID by opening OpenPype Info tool by clicking on a version number in the tray app. + +Many different sites can be created and configured on the system level, and some or all can be assigned to each project. + +Each OpenPype Tray app works with two sites at one time. (Sites can be the same, and no synching is done in this setup). Sites could be configured differently per project basis. -### Sync to Google Drive + +## Providers + +Each site implements a so called `provider` which handles most common operations (list files, copy files etc.) and provides interface with a particular type of storage. (disk, gdrive, aws, etc.) +Multiple configured sites could share the same provider with different settings (multiple mounted disks - each disk can be a separate site, while +all share the same provider). + +**Currently implemented providers:** + +### Local Drive + +Handles files stored on disk storage. + +Local drive provider is the most basic one that is used for accessing all standard hard disk storage scenarios. It will work with any storage that can be mounted on your system in a standard way. This could correspond to a physical external hard drive, network mounted storage, internal drive or even VPN connected network drive. It doesn't care about how te drive is mounted, but you must be able to point to it with a simple directory path. + +Default sites `local` and `studio` both use local drive provider. + + +### Google Drive + +Handles files on Google Drive (this). GDrive is provided as a production example for implementing other cloud providers Let's imagine a small globally distributed studio which wants all published work for all their freelancers uploaded to Google Drive folder. -For this use case admin need to configure: +For this use case admin needs to configure: - how many times it tries to synchronize file in case of some issue (network, permissions) - how often should synchronization check for new assets -- sites for synchronization - 'local' and 'gdrive' +- sites for synchronization - 'local' and 'gdrive' (this can be overriden in local settings) - user credentials -- folder location on Google Drive side +- root folder location on Google Drive side Configuration would look like this: @@ -63,8 +106,7 @@ To get working connection to Google Drive there are some necessary steps: - add new site back in OpenPype Settings, name as you want, provider needs to be 'gdrive' - distribute credentials file via shared mounted disk location -### Local setting +### Custom providers -Each user can configure root folder for 'local' site via **Local Settings** in OpenPype Tray +If a studio needs to use other services for cloud storage, or want to implement totally different storage providers, they can do so by writing their own provider plugin. We're working on a developer documentation, however, for now we recommend looking at `abstract_provider.py`and `gdrive.py` inside `openpype/modules/sync_server/providers` and using it as a template. -![Local overrides](assets/site_sync_local_setting.png) From 0795df31c4c905bbff08d64644f15f7d2b56799a Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 18 May 2021 09:38:12 +0200 Subject: [PATCH 274/303] shift + enter create asset as sibling --- .../tools/project_manager/project_manager/view.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/view.py b/openpype/tools/project_manager/project_manager/view.py index e96176debd..70af11e68d 100644 --- a/openpype/tools/project_manager/project_manager/view.py +++ b/openpype/tools/project_manager/project_manager/view.py @@ -409,8 +409,8 @@ class HierarchyView(QtWidgets.QTreeView): return self._source_model.add_new_task(parent_index) - def _add_asset_and_edit(self): - new_index = self.add_asset() + def _add_asset_and_edit(self, parent_index=None): + new_index = self.add_asset(parent_index) if new_index is None: return @@ -446,7 +446,13 @@ class HierarchyView(QtWidgets.QTreeView): self.edit(task_type_index) def _on_shift_enter_pressed(self): - self._add_asset_and_edit() + parent_index = self.currentIndex() + if not parent_index.isValid(): + return + + if parent_index.data(ITEM_TYPE_ROLE) == "asset": + parent_index = parent_index.parent() + self._add_asset_and_edit(parent_index) def _on_up_ctrl_pressed(self): indexes = self.selectedIndexes() From 84b42581ff1fe671fd983c839400db6d66f9f31d Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 18 May 2021 10:36:36 +0200 Subject: [PATCH 275/303] added value cleanup to filter combobox --- .../project_manager/delegates.py | 4 +++ .../project_manager/widgets.py | 27 +++++++++++++------ 2 files changed, 23 insertions(+), 8 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/delegates.py b/openpype/tools/project_manager/project_manager/delegates.py index f53e0442f1..51edff028f 100644 --- a/openpype/tools/project_manager/project_manager/delegates.py +++ b/openpype/tools/project_manager/project_manager/delegates.py @@ -130,6 +130,10 @@ class TypeDelegate(QtWidgets.QStyledItemDelegate): if index >= 0: editor.setCurrentIndex(index) + def setModelData(self, editor, model, index): + editor.value_cleanup() + super(TypeDelegate, self).setModelData(editor, model, index) + class ToolsDelegate(QtWidgets.QStyledItemDelegate): def __init__(self, tools_cache, *args, **kwargs): diff --git a/openpype/tools/project_manager/project_manager/widgets.py b/openpype/tools/project_manager/project_manager/widgets.py index 263b15daeb..503ecbb6bc 100644 --- a/openpype/tools/project_manager/project_manager/widgets.py +++ b/openpype/tools/project_manager/project_manager/widgets.py @@ -65,20 +65,31 @@ class FilterComboBox(QtWidgets.QComboBox): super(FilterComboBox, self).focusInEvent(event) self.lineEdit().selectAll() - def focusOutEvent(self, event): - idx = self.currentIndex() - if idx > -1: - index = self.model().index(idx, 0) - text = index.data(QtCore.Qt.DisplayRole) - if text != self.lineEdit().text(): - self.lineEdit().setText(text) - super(FilterComboBox, self).focusOutEvent(event) + def value_cleanup(self): + text = self.lineEdit().text() + idx = self.findText(text) + if idx < 0: + count = self._completer.completionModel().rowCount() + if count > 0: + index = self._completer.completionModel().index(0, 0) + text = index.data(QtCore.Qt.DisplayRole) + idx = self.findText(text) + + if idx < 0: + idx = 0 + self.setCurrentIndex(idx) def on_completer_activated(self, text): if text: index = self.findText(text) self.setCurrentIndex(index) + def keyPressEvent(self, event): + if event.key() in (QtCore.Qt.Key_Return, QtCore.Qt.Key_Enter): + self.value_cleanup() + + super(FilterComboBox, self).keyPressEvent(event) + def setModel(self, model): super(FilterComboBox, self).setModel(model) self._filter_proxy_model.setSourceModel(model) From c5525ab6936dee33915f88d5f97aec5bd09e806c Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 18 May 2021 11:22:19 +0200 Subject: [PATCH 276/303] set last value if current is invalid --- openpype/tools/project_manager/project_manager/widgets.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/openpype/tools/project_manager/project_manager/widgets.py b/openpype/tools/project_manager/project_manager/widgets.py index 503ecbb6bc..9c57febcf6 100644 --- a/openpype/tools/project_manager/project_manager/widgets.py +++ b/openpype/tools/project_manager/project_manager/widgets.py @@ -40,6 +40,8 @@ class FilterComboBox(QtWidgets.QComboBox): def __init__(self, parent=None): super(FilterComboBox, self).__init__(parent) + self._last_value = None + self.setFocusPolicy(QtCore.Qt.StrongFocus) self.setEditable(True) @@ -63,6 +65,7 @@ class FilterComboBox(QtWidgets.QComboBox): def focusInEvent(self, event): super(FilterComboBox, self).focusInEvent(event) + self._last_value = self.lineEdit().text() self.lineEdit().selectAll() def value_cleanup(self): @@ -74,6 +77,8 @@ class FilterComboBox(QtWidgets.QComboBox): index = self._completer.completionModel().index(0, 0) text = index.data(QtCore.Qt.DisplayRole) idx = self.findText(text) + elif self._last_value is not None: + idx = self.findText(self._last_value) if idx < 0: idx = 0 From 370ac65e1eadfb7667ad3ae6c0ef578b0d44c2f8 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 18 May 2021 09:36:41 +0000 Subject: [PATCH 277/303] Create draft PR for #1405 From 384d8a9c170a9e0a286d3931f17bd0da4e0a7514 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 18 May 2021 12:11:27 +0200 Subject: [PATCH 278/303] host implementation may have `add_implementation_envs` function in openpype.hosts. --- openpype/lib/applications.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index c5c192f51b..29cec57c4f 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -1089,7 +1089,22 @@ def prepare_host_environments(data): # Merge dictionaries env_values = _merge_env(tool_env, env_values) - final_env = _merge_env(acre.compute(env_values), data["env"]) + loaded_env = _merge_env(acre.compute(env_values), data["env"]) + + final_env = None + if app.host_name: + module = __import__("openpype.hosts", fromlist=[app.host_name]) + host_module = getattr(module, app.host_name, None) + add_implementation_envs = None + if host_module: + add_implementation_envs = getattr( + host_module, "add_implementation_envs", None + ) + if add_implementation_envs: + final_env = add_implementation_envs(loaded_env) + + if final_env is None: + final_env = loaded_env # Update env data["env"].update(final_env) From a3e89486ad323c4afef3d44210ac899645a4fdf0 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 18 May 2021 12:11:38 +0200 Subject: [PATCH 279/303] small cleanup --- openpype/lib/applications.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index 29cec57c4f..83f11a6b07 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -3,7 +3,6 @@ import re import copy import json import platform -import getpass import collections import inspect import subprocess @@ -362,7 +361,6 @@ class ApplicationManager: context = ApplicationLaunchContext( app, executable, **data ) - # TODO pass context through launch hooks return context.launch() @@ -626,7 +624,7 @@ class ApplicationLaunchContext: # Logger logger_name = "{}-{}".format(self.__class__.__name__, self.app_name) - self.log = PypeLogger().get_logger(logger_name) + self.log = PypeLogger.get_logger(logger_name) self.executable = executable From f8f64b5a05198ddc2c4342f23aa908b6d520baa6 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 18 May 2021 12:20:49 +0200 Subject: [PATCH 280/303] prepare_host_environments can not add implementation environments --- openpype/lib/applications.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index 83f11a6b07..b95ba85fbe 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -1031,7 +1031,7 @@ def _merge_env(env, current_env): return result -def prepare_host_environments(data): +def prepare_host_environments(data, implementation_envs=True): """Modify launch environments based on launched app and context. Args: @@ -1090,7 +1090,8 @@ def prepare_host_environments(data): loaded_env = _merge_env(acre.compute(env_values), data["env"]) final_env = None - if app.host_name: + # + if app.host_name and implementation_envs: module = __import__("openpype.hosts", fromlist=[app.host_name]) host_module = getattr(module, app.host_name, None) add_implementation_envs = None @@ -1099,6 +1100,7 @@ def prepare_host_environments(data): host_module, "add_implementation_envs", None ) if add_implementation_envs: + # Function may only modify passed dict without returning value final_env = add_implementation_envs(loaded_env) if final_env is None: From 2de2f20bdfc5b860a7e8d81985fece326d27c49f Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 18 May 2021 12:27:22 +0200 Subject: [PATCH 281/303] SyncServer - fix for 'local' in Local Setting broke regular sync loop 'local' cannot be used in regular sync loop, only for LS --- openpype/modules/sync_server/sync_server_module.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/modules/sync_server/sync_server_module.py b/openpype/modules/sync_server/sync_server_module.py index ed403b836d..15de4b12e9 100644 --- a/openpype/modules/sync_server/sync_server_module.py +++ b/openpype/modules/sync_server/sync_server_module.py @@ -501,6 +501,8 @@ class SyncServerModule(PypeModule, ITrayModule): items = self.get_configurable_items_for_site(project_name, site_name, scope) + # Local Settings need 'local' instead of real value + site_name = site_name.replace(get_local_site_id(), 'local') editable[site_name] = items return editable @@ -591,8 +593,6 @@ class SyncServerModule(PypeModule, ITrayModule): else: item["value"] = val - - editable.append(item) return editable @@ -877,7 +877,7 @@ class SyncServerModule(PypeModule, ITrayModule): } all_sites = {self.DEFAULT_SITE: studio_config} if sync_enabled: - all_sites['local'] = {'provider': 'local_drive'} + all_sites[get_local_site_id()] = {'provider': 'local_drive'} return all_sites def get_provider_for_site(self, project_name=None, site=None): From e062d960b3e3a98d48006febaabaee87cc18d717 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Tue, 18 May 2021 12:34:18 +0200 Subject: [PATCH 282/303] SyncServer - handle possible race condition This might throw FileNotFoundError for missing file if progress check is faster than file creation --- openpype/modules/sync_server/providers/local_drive.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/openpype/modules/sync_server/providers/local_drive.py b/openpype/modules/sync_server/providers/local_drive.py index 3b3e699d00..4b80ed44f2 100644 --- a/openpype/modules/sync_server/providers/local_drive.py +++ b/openpype/modules/sync_server/providers/local_drive.py @@ -170,7 +170,10 @@ class LocalDriveHandler(AbstractProvider): site=site, progress=status_val ) - target_file_size = os.path.getsize(target_path) + try: + target_file_size = os.path.getsize(target_path) + except FileNotFoundError: + pass time.sleep(0.5) def _normalize_site_name(self, site_name): From bf07cbf494e52be3f53dc542c84039256b36771d Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 18 May 2021 13:24:21 +0200 Subject: [PATCH 283/303] initial implementation of blender's add_implementation_envs --- openpype/hosts/blender/__init__.py | 41 ++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/openpype/hosts/blender/__init__.py b/openpype/hosts/blender/__init__.py index e69de29bb2..1a81c3add1 100644 --- a/openpype/hosts/blender/__init__.py +++ b/openpype/hosts/blender/__init__.py @@ -0,0 +1,41 @@ +import os + + +def add_implementation_envs(env): + """Modify environments to contain all required for implementation.""" + # Prepare path to implementation script + implementation_user_script_path = os.path.join( + os.environ["OPENPYPE_REPOS_ROOT"], + "repos", + "avalon-core", + "setup", + "blender" + ) + + # Add blender implementation script path to PYTHONPATH + python_path = env.get("PYTHONPATH") or "" + python_path_parts = [ + path + for path in python_path.split(os.pathsep) + if path + ] + python_path_parts.insert(0, implementation_user_script_path) + env["PYTHONPATH"] = os.pathsep.join(python_path_parts) + + # Modify Blender user scripts path + blender_user_scripts = env.get("BLENDER_USER_SCRIPTS") or "" + previous_user_scripts = [] + for path in blender_user_scripts.split(os.pathsep): + if path and os.path.exists(path): + path = os.path.normpath(path) + if path != implementation_user_script_path: + previous_user_scripts.append(path) + + env["OPENPYPE_BLENDER_USER_SCRIPTS"] = os.pathsep.join( + previous_user_scripts + ) + env["BLENDER_USER_SCRIPTS"] = implementation_user_script_path + + # Define Qt binding if not defined + if not env.get("QT_PREFERRED_BINDING"): + env["QT_PREFERRED_BINDING"] = "PySide2" From eb70dbd62ae391e3123ceb1a8f5290ad5af0f818 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 18 May 2021 13:34:08 +0200 Subject: [PATCH 284/303] removed blender's environments --- .../settings/defaults/system_settings/applications.json | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json index 63d6da4633..d439ae35d9 100644 --- a/openpype/settings/defaults/system_settings/applications.json +++ b/openpype/settings/defaults/system_settings/applications.json @@ -852,14 +852,7 @@ "label": "Blender", "icon": "{}/app_icons/blender.png", "host_name": "blender", - "environment": { - "BLENDER_USER_SCRIPTS": "{OPENPYPE_REPOS_ROOT}/repos/avalon-core/setup/blender", - "PYTHONPATH": [ - "{OPENPYPE_REPOS_ROOT}/repos/avalon-core/setup/blender", - "{PYTHONPATH}" - ], - "QT_PREFERRED_BINDING": "PySide2" - }, + "environment": {}, "variants": { "2-83": { "use_python_2": false, From ad9087e3983fb1d9ce7082d5459be8f181a2f971 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 18 May 2021 13:36:13 +0200 Subject: [PATCH 285/303] add maya's environments to add_implementation_envs --- openpype/hosts/maya/__init__.py | 29 +++++++++++++++++++ .../system_settings/applications.json | 15 +--------- 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/openpype/hosts/maya/__init__.py b/openpype/hosts/maya/__init__.py index e69de29bb2..10a128583e 100644 --- a/openpype/hosts/maya/__init__.py +++ b/openpype/hosts/maya/__init__.py @@ -0,0 +1,29 @@ +import os + + +def add_implementation_envs(env): + # Add requirements to PYTHONPATH + pype_root = os.environ["OPENPYPE_REPOS_ROOT"] + new_python_path = os.pathsep.join([ + os.path.join(pype_root, "openpype", "hosts", "maya", "startup"), + os.path.join(pype_root, "repos", "avalon-core", "setup", "maya"), + os.path.join(pype_root, "tools", "mayalookassigner") + ]) + old_python_path = env.get("PYTHONPATH") + if old_python_path: + new_python_path = os.pathsep.join([new_python_path, old_python_path]) + env["PYTHONPATH"] = new_python_path + + # Set default values if are not already set via settings + defaults = { + "MAYA_DISABLE_CLIC_IPM": "Yes", + "MAYA_DISABLE_CIP": "Yes", + "MAYA_DISABLE_CER": "Yes", + "PYMEL_SKIP_MEL_INIT": "Yes", + "LC_ALL": "C", + "OPENPYPE_LOG_NO_COLORS": "Yes" + } + for key, value in defaults.items(): + if env.get(key): + continue + env[key] = value diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json index d439ae35d9..2904530cad 100644 --- a/openpype/settings/defaults/system_settings/applications.json +++ b/openpype/settings/defaults/system_settings/applications.json @@ -4,20 +4,7 @@ "label": "Maya", "icon": "{}/app_icons/maya.png", "host_name": "maya", - "environment": { - "PYTHONPATH": [ - "{OPENPYPE_REPOS_ROOT}/openpype/hosts/maya/startup", - "{OPENPYPE_REPOS_ROOT}/repos/avalon-core/setup/maya", - "{OPENPYPE_REPOS_ROOT}/repos/maya-look-assigner", - "{PYTHONPATH}" - ], - "MAYA_DISABLE_CLIC_IPM": "Yes", - "MAYA_DISABLE_CIP": "Yes", - "MAYA_DISABLE_CER": "Yes", - "PYMEL_SKIP_MEL_INIT": "Yes", - "LC_ALL": "C", - "OPENPYPE_LOG_NO_COLORS": "Yes" - }, + "environment": {}, "variants": { "2022": { "use_python_2": false, From 7c8661bc2de72d2d4f20327f3ed0fb642e723515 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Tue, 18 May 2021 13:37:31 +0200 Subject: [PATCH 286/303] add progress bars --- tools/build_dependencies.py | 53 ++++++++++++++++++++++++++++++++++--- 1 file changed, 49 insertions(+), 4 deletions(-) diff --git a/tools/build_dependencies.py b/tools/build_dependencies.py index fb52e2b5fd..de3b6da021 100644 --- a/tools/build_dependencies.py +++ b/tools/build_dependencies.py @@ -26,10 +26,12 @@ import platform from pathlib import Path import shutil import blessed +import enlighten import time term = blessed.Terminal() +manager = enlighten.get_manager() def _print(msg: str, type: int = 0) -> None: @@ -52,6 +54,24 @@ def _print(msg: str, type: int = 0) -> None: print("{}{}".format(header, msg)) +def count_folders(path: Path) -> int: + """Recursively count items inside given Path. + + Args: + path (Path): Path to count. + + Returns: + int: number of items. + + """ + cnt = 0 + for child in path.iterdir(): + if child.is_dir(): + cnt += 1 + cnt += count_folders(child) + return cnt + + _print("Starting dependency cleanup ...") start_time = time.time_ns() @@ -96,30 +116,55 @@ deps_dir = build_dir / "dependencies" # copy all files _print("Copying dependencies ...") -shutil.copytree(site_pkg.as_posix(), deps_dir.as_posix()) +total_files = count_folders(site_pkg) +progress_bar = enlighten.Counter( + total=total_files, desc="Processing Dependencies", + units="%", color="green") + + +def _progress(_base, _names): + progress_bar.update() + return [] + + +shutil.copytree(site_pkg.as_posix(), + deps_dir.as_posix(), + ignore=_progress) +progress_bar.close() # iterate over frozen libs and create list to delete libs_dir = build_dir / "lib" to_delete = [] -_print("Finding duplicates ...") +# _print("Finding duplicates ...") deps_items = list(deps_dir.iterdir()) +item_count = len(list(libs_dir.iterdir())) +find_progress_bar = enlighten.Counter( + total=item_count, desc="Finding duplicates", units="%", color="yellow") + for d in libs_dir.iterdir(): if (deps_dir / d.name) in deps_items: to_delete.append(d) - _print(f"found {d}", 3) + # _print(f"found {d}", 3) + find_progress_bar.update() +find_progress_bar.close() # add openpype and igniter in libs too to_delete.append(libs_dir / "openpype") to_delete.append(libs_dir / "igniter") # delete duplicates -_print(f"Deleting {len(to_delete)} duplicates ...") +# _print(f"Deleting {len(to_delete)} duplicates ...") +delete_progress_bar = enlighten.Counter( + total=len(to_delete), desc="Deleting duplicates", units="%", color="red") for d in to_delete: if d.is_dir(): shutil.rmtree(d) else: d.unlink() + delete_progress_bar.update() + +delete_progress_bar.close() end_time = time.time_ns() total_time = (end_time - start_time) / 1000000000 From 46c603464f1fb04d7f310dd381fc284af67e489e Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 18 May 2021 13:46:07 +0200 Subject: [PATCH 287/303] skip duplicated python paths --- openpype/hosts/maya/__init__.py | 23 ++++++++++++++--------- 1 file changed, 14 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/maya/__init__.py b/openpype/hosts/maya/__init__.py index 10a128583e..43a1e6f7bf 100644 --- a/openpype/hosts/maya/__init__.py +++ b/openpype/hosts/maya/__init__.py @@ -4,15 +4,21 @@ import os def add_implementation_envs(env): # Add requirements to PYTHONPATH pype_root = os.environ["OPENPYPE_REPOS_ROOT"] - new_python_path = os.pathsep.join([ + new_python_paths = [ os.path.join(pype_root, "openpype", "hosts", "maya", "startup"), os.path.join(pype_root, "repos", "avalon-core", "setup", "maya"), os.path.join(pype_root, "tools", "mayalookassigner") - ]) - old_python_path = env.get("PYTHONPATH") - if old_python_path: - new_python_path = os.pathsep.join([new_python_path, old_python_path]) - env["PYTHONPATH"] = new_python_path + ] + old_python_path = env.get("PYTHONPATH") or "" + for path in old_python_path.split(os.pathsep): + if not path or not os.path.exists(path): + continue + + norm_path = os.path.normpath(path) + if norm_path not in new_python_paths: + new_python_paths.append(norm_path) + + env["PYTHONPATH"] = os.pathsep.join(new_python_paths) # Set default values if are not already set via settings defaults = { @@ -24,6 +30,5 @@ def add_implementation_envs(env): "OPENPYPE_LOG_NO_COLORS": "Yes" } for key, value in defaults.items(): - if env.get(key): - continue - env[key] = value + if not env.get(key): + env[key] = value From 5c366f2ee97a635e5db7b48dbec1ef5d25f701e7 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 18 May 2021 13:46:25 +0200 Subject: [PATCH 288/303] implemented add_implementation_envs for nuke --- openpype/hosts/nuke/__init__.py | 42 +++++++++++++++++++ .../system_settings/applications.json | 10 +---- 2 files changed, 43 insertions(+), 9 deletions(-) diff --git a/openpype/hosts/nuke/__init__.py b/openpype/hosts/nuke/__init__.py index e69de29bb2..901ee7c54c 100644 --- a/openpype/hosts/nuke/__init__.py +++ b/openpype/hosts/nuke/__init__.py @@ -0,0 +1,42 @@ +import os +import platform + + +def add_implementation_envs(env): + # Add requirements to NUKE_PATH + pype_root = os.environ["OPENPYPE_REPOS_ROOT"] + new_nuke_paths = [ + os.path.join(pype_root, "openpype", "hosts", "maya", "startup"), + os.path.join(pype_root, "repos", "avalon-core", "setup", "maya"), + os.path.join(pype_root, "tools", "mayalookassigner") + ] + old_nuke_path = env.get("NUKE_PATH") or "" + for path in old_nuke_path.split(os.pathsep): + if not path or not os.path.exists(path): + continue + + norm_path = os.path.normpath(path) + if norm_path not in new_nuke_paths: + new_nuke_paths.append(norm_path) + + env["NUKE_PATH"] = os.pathsep.join(new_nuke_paths) + + # Try to add QuickTime to PATH + quick_time_path = "C:/Program Files (x86)/QuickTime/QTSystem" + if platform.system() == "windows" and os.path.exists(quick_time_path): + path_value = env.get("PATH") or "" + path_paths = [ + path + for path in path_value.split(os.pathsep) + if path + ] + path_paths.append(quick_time_path) + env["PATH"] = os.pathsep.join(path_paths) + + # Set default values if are not already set via settings + defaults = { + "LOGLEVEL": "DEBUG" + } + for key, value in defaults.items(): + if not env.get(key): + env[key] = value diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json index 2904530cad..30c697ddeb 100644 --- a/openpype/settings/defaults/system_settings/applications.json +++ b/openpype/settings/defaults/system_settings/applications.json @@ -97,15 +97,7 @@ "icon": "{}/app_icons/nuke.png", "host_name": "nuke", "environment": { - "NUKE_PATH": [ - "{OPENPYPE_REPOS_ROOT}/repos/avalon-core/setup/nuke/nuke_path", - "{OPENPYPE_REPOS_ROOT}/openpype/hosts/nuke/startup", - "{OPENPYPE_STUDIO_PLUGINS}/nuke" - ], - "PATH": { - "windows": "C:/Program Files (x86)/QuickTime/QTSystem/;{PATH}" - }, - "LOGLEVEL": "DEBUG" + "NUKE_PATH": "{OPENPYPE_STUDIO_PLUGINS}/nuke" }, "variants": { "13-0": { From 7bdc937f697c660361365422902ecc27cbb1848b Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 18 May 2021 13:50:50 +0200 Subject: [PATCH 289/303] fixed default paths in nuke host and removed defaults for nuke x --- openpype/hosts/nuke/__init__.py | 7 ++++--- .../defaults/system_settings/applications.json | 10 +--------- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/openpype/hosts/nuke/__init__.py b/openpype/hosts/nuke/__init__.py index 901ee7c54c..ca4d931e2c 100644 --- a/openpype/hosts/nuke/__init__.py +++ b/openpype/hosts/nuke/__init__.py @@ -6,9 +6,10 @@ def add_implementation_envs(env): # Add requirements to NUKE_PATH pype_root = os.environ["OPENPYPE_REPOS_ROOT"] new_nuke_paths = [ - os.path.join(pype_root, "openpype", "hosts", "maya", "startup"), - os.path.join(pype_root, "repos", "avalon-core", "setup", "maya"), - os.path.join(pype_root, "tools", "mayalookassigner") + os.path.join(pype_root, "openpype", "hosts", "nuke", "startup"), + os.path.join( + pype_root, "repos", "avalon-core", "setup", "nuke", "nuke_path" + ) ] old_nuke_path = env.get("NUKE_PATH") or "" for path in old_nuke_path.split(os.pathsep): diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json index 30c697ddeb..01602bf549 100644 --- a/openpype/settings/defaults/system_settings/applications.json +++ b/openpype/settings/defaults/system_settings/applications.json @@ -203,15 +203,7 @@ "icon": "{}/app_icons/nuke.png", "host_name": "nuke", "environment": { - "NUKE_PATH": [ - "{OPENPYPE_REPOS_ROOT}/repos/avalon-core/setup/nuke/nuke_path", - "{OPENPYPE_REPOS_ROOT}/openpype/hosts/nuke/startup", - "{OPENPYPE_STUDIO_PLUGINS}/nuke" - ], - "PATH": { - "windows": "C:/Program Files (x86)/QuickTime/QTSystem/;{PATH}" - }, - "LOGLEVEL": "DEBUG" + "NUKE_PATH": "{OPENPYPE_STUDIO_PLUGINS}/nuke" }, "variants": { "13-0": { From 243de91a8ef6a0ee5862a86e43c3a0efce7d5d11 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 18 May 2021 13:52:14 +0200 Subject: [PATCH 290/303] converted hiero and nukestudio environments to host defaults --- openpype/hosts/hiero/__init__.py | 40 +++++++++++++++++++ .../system_settings/applications.json | 18 +-------- 2 files changed, 42 insertions(+), 16 deletions(-) diff --git a/openpype/hosts/hiero/__init__.py b/openpype/hosts/hiero/__init__.py index e69de29bb2..5b036224e4 100644 --- a/openpype/hosts/hiero/__init__.py +++ b/openpype/hosts/hiero/__init__.py @@ -0,0 +1,40 @@ +import os +import platform + + +def add_implementation_envs(env): + # Add requirements to HIERO_PLUGIN_PATH + pype_root = os.environ["OPENPYPE_REPOS_ROOT"] + new_hiero_paths = [ + os.path.join(pype_root, "openpype", "hosts", "hiero", "startup") + ] + old_hiero_path = env.get("HIERO_PLUGIN_PATH") or "" + for path in old_hiero_path.split(os.pathsep): + if not path or not os.path.exists(path): + continue + + norm_path = os.path.normpath(path) + if norm_path not in new_hiero_paths: + new_hiero_paths.append(norm_path) + + env["HIERO_PLUGIN_PATH"] = os.pathsep.join(new_hiero_paths) + + # Try to add QuickTime to PATH + quick_time_path = "C:/Program Files (x86)/QuickTime/QTSystem" + if platform.system() == "windows" and os.path.exists(quick_time_path): + path_value = env.get("PATH") or "" + path_paths = [ + path + for path in path_value.split(os.pathsep) + if path + ] + path_paths.append(quick_time_path) + env["PATH"] = os.pathsep.join(path_paths) + + # Set default values if are not already set via settings + defaults = { + "LOGLEVEL": "DEBUG" + } + for key, value in defaults.items(): + if not env.get(key): + env[key] = value diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json index 01602bf549..486aa06849 100644 --- a/openpype/settings/defaults/system_settings/applications.json +++ b/openpype/settings/defaults/system_settings/applications.json @@ -339,15 +339,8 @@ "icon": "{}/app_icons/nuke.png", "host_name": "hiero", "environment": { - "HIERO_PLUGIN_PATH": [ - "{OPENPYPE_REPOS_ROOT}/openpype/hosts/hiero/startup" - ], - "PATH": { - "windows": "C:/Program Files (x86)/QuickTime/QTSystem/;{PATH}" - }, "WORKFILES_STARTUP": "0", - "TAG_ASSETBUILD_STARTUP": "0", - "LOGLEVEL": "DEBUG" + "TAG_ASSETBUILD_STARTUP": "0" }, "variants": { "13-0": { @@ -481,15 +474,8 @@ "icon": "{}/app_icons/hiero.png", "host_name": "hiero", "environment": { - "HIERO_PLUGIN_PATH": [ - "{OPENPYPE_REPOS_ROOT}/openpype/hosts/hiero/startup" - ], - "PATH": { - "windows": "C:/Program Files (x86)/QuickTime/QTSystem/;{PATH}" - }, "WORKFILES_STARTUP": "0", - "TAG_ASSETBUILD_STARTUP": "0", - "LOGLEVEL": "DEBUG" + "TAG_ASSETBUILD_STARTUP": "0" }, "variants": { "13-0": { From 65b308c7a54a96bf44c4e7062ff69e8e3ab64700 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 18 May 2021 14:10:00 +0200 Subject: [PATCH 291/303] converted tvpaintconverted aftereffects harmony photoshop tvpaint and unreal to define default environments --- openpype/hosts/aftereffects/__init__.py | 9 +++++++++ openpype/hosts/harmony/__init__.py | 10 ++++++++++ openpype/hosts/photoshop/__init__.py | 9 +++++++++ openpype/hosts/tvpaint/__init__.py | 8 ++++++++ openpype/hosts/unreal/__init__.py | 18 ++++++++++++++++++ .../defaults/system_settings/applications.json | 16 +++------------- 6 files changed, 57 insertions(+), 13 deletions(-) diff --git a/openpype/hosts/aftereffects/__init__.py b/openpype/hosts/aftereffects/__init__.py index e69de29bb2..1c2d473728 100644 --- a/openpype/hosts/aftereffects/__init__.py +++ b/openpype/hosts/aftereffects/__init__.py @@ -0,0 +1,9 @@ +def add_implementation_envs(env): + """Modify environments to contain all required for implementation.""" + defaults = { + "OPENPYPE_LOG_NO_COLORS": "True", + "WEBSOCKET_URL": "ws://localhost:8097/ws/" + } + for key, value in defaults.items(): + if not env.get(key): + env[key] = value diff --git a/openpype/hosts/harmony/__init__.py b/openpype/hosts/harmony/__init__.py index e69de29bb2..13b72f98d6 100644 --- a/openpype/hosts/harmony/__init__.py +++ b/openpype/hosts/harmony/__init__.py @@ -0,0 +1,10 @@ +import os + + +def add_implementation_envs(env): + """Modify environments to contain all required for implementation.""" + openharmony_path = os.path.join( + os.environ["OPENPYPE_REPOS_ROOT"], "pype", "vendor", "OpenHarmony" + ) + # TODO check if is already set? What to do if is already set? + env["LIB_OPENHARMONY_PATH"] = openharmony_path diff --git a/openpype/hosts/photoshop/__init__.py b/openpype/hosts/photoshop/__init__.py index e69de29bb2..babacba9a8 100644 --- a/openpype/hosts/photoshop/__init__.py +++ b/openpype/hosts/photoshop/__init__.py @@ -0,0 +1,9 @@ +def add_implementation_envs(env): + """Modify environments to contain all required for implementation.""" + defaults = { + "OPENPYPE_LOG_NO_COLORS": "True", + "WEBSOCKET_URL": "ws://localhost:8099/ws/" + } + for key, value in defaults.items(): + if not env.get(key): + env[key] = value diff --git a/openpype/hosts/tvpaint/__init__.py b/openpype/hosts/tvpaint/__init__.py index e69de29bb2..0e58e31a5b 100644 --- a/openpype/hosts/tvpaint/__init__.py +++ b/openpype/hosts/tvpaint/__init__.py @@ -0,0 +1,8 @@ +def add_implementation_envs(env): + """Modify environments to contain all required for implementation.""" + defaults = { + "OPENPYPE_LOG_NO_COLORS": "True" + } + for key, value in defaults.items(): + if not env.get(key): + env[key] = value diff --git a/openpype/hosts/unreal/__init__.py b/openpype/hosts/unreal/__init__.py index e69de29bb2..dd7a575995 100644 --- a/openpype/hosts/unreal/__init__.py +++ b/openpype/hosts/unreal/__init__.py @@ -0,0 +1,18 @@ +import os + + +def add_implementation_envs(env): + """Modify environments to contain all required for implementation.""" + # Set AVALON_UNREAL_PLUGIN required for Unreal implementation + unreal_plugin_path = os.path.join( + os.environ["OPENPYPE_REPOS_ROOT"], "repos", "avalon-unreal-integration" + ) + env["AVALON_UNREAL_PLUGIN"] = unreal_plugin_path + + # Set default environments if are not set via settings + defaults = { + "OPENPYPE_LOG_NO_COLORS": "True" + } + for key, value in defaults.items(): + if not env.get(key): + env[key] = value diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json index 486aa06849..fd0a5b8c52 100644 --- a/openpype/settings/defaults/system_settings/applications.json +++ b/openpype/settings/defaults/system_settings/applications.json @@ -890,8 +890,7 @@ "icon": "{}/app_icons/harmony.png", "host_name": "harmony", "environment": { - "AVALON_HARMONY_WORKFILES_ON_LAUNCH": "1", - "LIB_OPENHARMONY_PATH": "{OPENPYPE_REPOS_ROOT}/pype/vendor/OpenHarmony" + "AVALON_HARMONY_WORKFILES_ON_LAUNCH": "1" }, "variants": { "20": { @@ -935,9 +934,7 @@ "label": "TVPaint", "icon": "{}/app_icons/tvpaint.png", "host_name": "tvpaint", - "environment": { - "OPENPYPE_LOG_NO_COLORS": "True" - }, + "environment": {}, "variants": { "animation_11-64bits": { "use_python_2": false, @@ -984,8 +981,6 @@ "host_name": "photoshop", "environment": { "AVALON_PHOTOSHOP_WORKFILES_ON_LAUNCH": "1", - "OPENPYPE_LOG_NO_COLORS": "Yes", - "WEBSOCKET_URL": "ws://localhost:8099/ws/", "WORKFILES_SAVE_AS": "Yes" }, "variants": { @@ -1034,8 +1029,6 @@ "host_name": "aftereffects", "environment": { "AVALON_AFTEREFFECTS_WORKFILES_ON_LAUNCH": "1", - "OPENPYPE_LOG_NO_COLORS": "Yes", - "WEBSOCKET_URL": "ws://localhost:8097/ws/", "WORKFILES_SAVE_AS": "Yes" }, "variants": { @@ -1109,10 +1102,7 @@ "label": "Unreal Editor", "icon": "{}/app_icons/ue4.png'", "host_name": "unreal", - "environment": { - "AVALON_UNREAL_PLUGIN": "{OPENPYPE_REPOS_ROOT}/repos/avalon-unreal-integration", - "OPENPYPE_LOG_NO_COLORS": "True" - }, + "environment": {}, "variants": { "4-26": { "use_python_2": false, From 6690f4a88c297f64c85ead174dcc28bc1cb77e21 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 18 May 2021 14:10:19 +0200 Subject: [PATCH 292/303] houdini can define default environments --- openpype/hosts/houdini/__init__.py | 38 +++++++++++++++++++ .../system_settings/applications.json | 13 +------ 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/openpype/hosts/houdini/__init__.py b/openpype/hosts/houdini/__init__.py index e69de29bb2..ab552513e8 100644 --- a/openpype/hosts/houdini/__init__.py +++ b/openpype/hosts/houdini/__init__.py @@ -0,0 +1,38 @@ +import os + + +def add_implementation_envs(env): + # Add requirements to HOUDINI_PATH and HOUDINI_MENU_PATH + pype_root = os.environ["OPENPYPE_REPOS_ROOT"] + + startup_path = os.path.join( + pype_root, "openpype", "hosts", "houdini", "startup" + ) + new_houdini_path = [startup_path] + new_houdini_menu_path = [startup_path] + + old_houdini_path = env.get("HOUDINI_PATH") or "" + old_houdini_menu_path = env.get("HOUDINI_MENU_PATH") or "" + + for path in old_houdini_path.split(os.pathsep): + if not path or not os.path.exists(path): + continue + + norm_path = os.path.normpath(path) + if norm_path not in new_houdini_path: + new_houdini_path.append(norm_path) + + for path in old_houdini_menu_path.split(os.pathsep): + if not path or not os.path.exists(path): + continue + + norm_path = os.path.normpath(path) + if norm_path not in new_houdini_menu_path: + new_houdini_menu_path.append(norm_path) + + # Add ampersand for unknown reason (Maybe is needed in Houdini?) + new_houdini_path.append("&") + new_houdini_menu_path.append("&") + + env["HOUDINI_PATH"] = os.pathsep.join(new_houdini_path) + env["HOUDINI_MENU_PATH"] = os.pathsep.join(new_houdini_menu_path) diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json index fd0a5b8c52..d1976a9776 100644 --- a/openpype/settings/defaults/system_settings/applications.json +++ b/openpype/settings/defaults/system_settings/applications.json @@ -740,18 +740,7 @@ "label": "Houdini", "icon": "{}/app_icons/houdini.png", "host_name": "houdini", - "environment": { - "HOUDINI_PATH": { - "darwin": "{OPENPYPE_REPOS_ROOT}/openpype/hosts/houdini/startup:&", - "linux": "{OPENPYPE_REPOS_ROOT}/openpype/hosts/houdini/startup:&", - "windows": "{OPENPYPE_REPOS_ROOT}/openpype/hosts/houdini/startup;&" - }, - "HOUDINI_MENU_PATH": { - "darwin": "{OPENPYPE_REPOS_ROOT}/openpype/hosts/houdini/startup:&", - "linux": "{OPENPYPE_REPOS_ROOT}/openpype/hosts/houdini/startup:&", - "windows": "{OPENPYPE_REPOS_ROOT}/openpype/hosts/houdini/startup;&" - } - }, + "environment": {}, "variants": { "18-5": { "use_python_2": true, From d43b3d1b2c0e61a67587700400a7b4d89fcd5357 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 18 May 2021 14:16:00 +0200 Subject: [PATCH 293/303] also pass Application object to `add_implementation_envs` --- openpype/hosts/aftereffects/__init__.py | 2 +- openpype/hosts/blender/__init__.py | 2 +- openpype/hosts/harmony/__init__.py | 2 +- openpype/hosts/hiero/__init__.py | 2 +- openpype/hosts/houdini/__init__.py | 2 +- openpype/hosts/maya/__init__.py | 2 +- openpype/hosts/nuke/__init__.py | 2 +- openpype/hosts/photoshop/__init__.py | 2 +- openpype/hosts/tvpaint/__init__.py | 2 +- openpype/hosts/unreal/__init__.py | 2 +- openpype/lib/applications.py | 4 ++-- 11 files changed, 12 insertions(+), 12 deletions(-) diff --git a/openpype/hosts/aftereffects/__init__.py b/openpype/hosts/aftereffects/__init__.py index 1c2d473728..deae48d122 100644 --- a/openpype/hosts/aftereffects/__init__.py +++ b/openpype/hosts/aftereffects/__init__.py @@ -1,4 +1,4 @@ -def add_implementation_envs(env): +def add_implementation_envs(env, _app): """Modify environments to contain all required for implementation.""" defaults = { "OPENPYPE_LOG_NO_COLORS": "True", diff --git a/openpype/hosts/blender/__init__.py b/openpype/hosts/blender/__init__.py index 1a81c3add1..4d93233449 100644 --- a/openpype/hosts/blender/__init__.py +++ b/openpype/hosts/blender/__init__.py @@ -1,7 +1,7 @@ import os -def add_implementation_envs(env): +def add_implementation_envs(env, _app): """Modify environments to contain all required for implementation.""" # Prepare path to implementation script implementation_user_script_path = os.path.join( diff --git a/openpype/hosts/harmony/__init__.py b/openpype/hosts/harmony/__init__.py index 13b72f98d6..8560fbaf4b 100644 --- a/openpype/hosts/harmony/__init__.py +++ b/openpype/hosts/harmony/__init__.py @@ -1,7 +1,7 @@ import os -def add_implementation_envs(env): +def add_implementation_envs(env, _app): """Modify environments to contain all required for implementation.""" openharmony_path = os.path.join( os.environ["OPENPYPE_REPOS_ROOT"], "pype", "vendor", "OpenHarmony" diff --git a/openpype/hosts/hiero/__init__.py b/openpype/hosts/hiero/__init__.py index 5b036224e4..1781f808e2 100644 --- a/openpype/hosts/hiero/__init__.py +++ b/openpype/hosts/hiero/__init__.py @@ -2,7 +2,7 @@ import os import platform -def add_implementation_envs(env): +def add_implementation_envs(env, _app): # Add requirements to HIERO_PLUGIN_PATH pype_root = os.environ["OPENPYPE_REPOS_ROOT"] new_hiero_paths = [ diff --git a/openpype/hosts/houdini/__init__.py b/openpype/hosts/houdini/__init__.py index ab552513e8..8c12d13c81 100644 --- a/openpype/hosts/houdini/__init__.py +++ b/openpype/hosts/houdini/__init__.py @@ -1,7 +1,7 @@ import os -def add_implementation_envs(env): +def add_implementation_envs(env, _app): # Add requirements to HOUDINI_PATH and HOUDINI_MENU_PATH pype_root = os.environ["OPENPYPE_REPOS_ROOT"] diff --git a/openpype/hosts/maya/__init__.py b/openpype/hosts/maya/__init__.py index 43a1e6f7bf..d3562e2cdd 100644 --- a/openpype/hosts/maya/__init__.py +++ b/openpype/hosts/maya/__init__.py @@ -1,7 +1,7 @@ import os -def add_implementation_envs(env): +def add_implementation_envs(env, _app): # Add requirements to PYTHONPATH pype_root = os.environ["OPENPYPE_REPOS_ROOT"] new_python_paths = [ diff --git a/openpype/hosts/nuke/__init__.py b/openpype/hosts/nuke/__init__.py index ca4d931e2c..f1e81617e0 100644 --- a/openpype/hosts/nuke/__init__.py +++ b/openpype/hosts/nuke/__init__.py @@ -2,7 +2,7 @@ import os import platform -def add_implementation_envs(env): +def add_implementation_envs(env, _app): # Add requirements to NUKE_PATH pype_root = os.environ["OPENPYPE_REPOS_ROOT"] new_nuke_paths = [ diff --git a/openpype/hosts/photoshop/__init__.py b/openpype/hosts/photoshop/__init__.py index babacba9a8..a91e0a65ff 100644 --- a/openpype/hosts/photoshop/__init__.py +++ b/openpype/hosts/photoshop/__init__.py @@ -1,4 +1,4 @@ -def add_implementation_envs(env): +def add_implementation_envs(env, _app): """Modify environments to contain all required for implementation.""" defaults = { "OPENPYPE_LOG_NO_COLORS": "True", diff --git a/openpype/hosts/tvpaint/__init__.py b/openpype/hosts/tvpaint/__init__.py index 0e58e31a5b..0e793fcf9f 100644 --- a/openpype/hosts/tvpaint/__init__.py +++ b/openpype/hosts/tvpaint/__init__.py @@ -1,4 +1,4 @@ -def add_implementation_envs(env): +def add_implementation_envs(env, _app): """Modify environments to contain all required for implementation.""" defaults = { "OPENPYPE_LOG_NO_COLORS": "True" diff --git a/openpype/hosts/unreal/__init__.py b/openpype/hosts/unreal/__init__.py index dd7a575995..1280442916 100644 --- a/openpype/hosts/unreal/__init__.py +++ b/openpype/hosts/unreal/__init__.py @@ -1,7 +1,7 @@ import os -def add_implementation_envs(env): +def add_implementation_envs(env, _app): """Modify environments to contain all required for implementation.""" # Set AVALON_UNREAL_PLUGIN required for Unreal implementation unreal_plugin_path = os.path.join( diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index b95ba85fbe..a44c43102f 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -1090,7 +1090,7 @@ def prepare_host_environments(data, implementation_envs=True): loaded_env = _merge_env(acre.compute(env_values), data["env"]) final_env = None - # + # Add host specific environments if app.host_name and implementation_envs: module = __import__("openpype.hosts", fromlist=[app.host_name]) host_module = getattr(module, app.host_name, None) @@ -1101,7 +1101,7 @@ def prepare_host_environments(data, implementation_envs=True): ) if add_implementation_envs: # Function may only modify passed dict without returning value - final_env = add_implementation_envs(loaded_env) + final_env = add_implementation_envs(loaded_env, app) if final_env is None: final_env = loaded_env From 6e82f2e268c4511040f7d2b5085417a8b247569e Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Tue, 18 May 2021 14:16:29 +0100 Subject: [PATCH 294/303] Create layout now works by selecting the collection --- .../blender/plugins/create/create_layout.py | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/openpype/hosts/blender/plugins/create/create_layout.py b/openpype/hosts/blender/plugins/create/create_layout.py index f72e364f50..5404cec587 100644 --- a/openpype/hosts/blender/plugins/create/create_layout.py +++ b/openpype/hosts/blender/plugins/create/create_layout.py @@ -20,21 +20,9 @@ class CreateLayout(openpype.hosts.blender.api.plugin.Creator): asset = self.data["asset"] subset = self.data["subset"] name = openpype.hosts.blender.api.plugin.asset_name(asset, subset) - collection = bpy.data.collections.new(name=name) - bpy.context.scene.collection.children.link(collection) + collection = bpy.context.collection + collection.name = name self.data['task'] = api.Session.get('AVALON_TASK') lib.imprint(collection, self.data) - # Add the rig object and all the children meshes to - # a set and link them all at the end to avoid duplicates. - # Blender crashes if trying to link an object that is already linked. - # This links automatically the children meshes if they were not - # selected, and doesn't link them twice if they, insted, - # were manually selected by the user. - objects_to_link = set() - - if (self.options or {}).get("useSelection"): - for obj in lib.get_selection(): - collection.children.link(obj.users_collection[0]) - return collection From cd76b6d1f4f20babd65136c12c0373ccc7021772 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Tue, 18 May 2021 15:24:27 +0200 Subject: [PATCH 295/303] fix time benchmark in build script --- tools/build.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/build.ps1 b/tools/build.ps1 index 5c392c355c..db94dbf1af 100644 --- a/tools/build.ps1 +++ b/tools/build.ps1 @@ -164,7 +164,7 @@ Write-Host "OK" -ForegroundColor green Write-Host ">>> " -NoNewline -ForegroundColor green Write-Host "Building OpenPype ..." -$startTime = (Get-Date).Millisecond +$startTime = [int][double]::Parse((Get-Date -UFormat %s)) $out = & poetry run python setup.py build 2>&1 if ($LASTEXITCODE -ne 0) @@ -183,7 +183,7 @@ Write-Host ">>> " -NoNewline -ForegroundColor green Write-Host "restoring current directory" Set-Location -Path $current_dir -$endTime = (Get-Date).Millisecond +$endTime = [int][double]::Parse((Get-Date -UFormat %s)) Write-Host "*** " -NoNewline -ForegroundColor Cyan Write-Host "All done in $($endTime - $startTime) secs. You will find OpenPype and build log in " -NoNewLine Write-Host "'.\build'" -NoNewline -ForegroundColor Green From 1cc1f290eedd3857633aad607792ed5f3717a0ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Tue, 18 May 2021 16:42:37 +0200 Subject: [PATCH 296/303] disable submodule update with `--no-submodule-update` --- tools/build.ps1 | 18 ++++++++++++++---- tools/build.sh | 25 ++++++++++++++++++++++++- 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/tools/build.ps1 b/tools/build.ps1 index 5c392c355c..930544a054 100644 --- a/tools/build.ps1 +++ b/tools/build.ps1 @@ -14,6 +14,12 @@ PS> .\build.ps1 #> +$arguments=$ARGS +$disable_submodule_update="" +if($arguments -eq "--no-submodule-update") { + $disable_submodule_update=$true +} + function Start-Progress { param([ScriptBlock]$code) $scroll = "/-\|/-\|" @@ -134,10 +140,14 @@ catch { Write-Host $_.Exception.Message Exit-WithCode 1 } - -Write-Host ">>> " -NoNewLine -ForegroundColor green -Write-Host "Making sure submodules are up-to-date ..." -git submodule update --init --recursive +if (-not $disable_submodule_update) { + Write-Host ">>> " -NoNewLine -ForegroundColor green + Write-Host "Making sure submodules are up-to-date ..." + git submodule update --init --recursive +} else { + Write-Host "*** " -NoNewLine -ForegroundColor yellow + Write-Host "Not updating submodules ..." +} Write-Host ">>> " -NoNewline -ForegroundColor green Write-Host "OpenPype [ " -NoNewline -ForegroundColor white diff --git a/tools/build.sh b/tools/build.sh index 953d51bd81..ccd97ea4c1 100755 --- a/tools/build.sh +++ b/tools/build.sh @@ -57,6 +57,26 @@ BIPurple='\033[1;95m' # Purple BICyan='\033[1;96m' # Cyan BIWhite='\033[1;97m' # White +args=$@ +disable_submodule_update = 0 +while :; do + case $1 in + --no-submodule-update) + disable_submodule_update=1 + ;; + --) + shift + break + ;; + *) + break + esac + + shift +done + + + ############################################################################## # Detect required version of python @@ -172,9 +192,12 @@ main () { . "$openpype_root/tools/create_env.sh" || { echo -e "${BIRed}!!!${RST} Poetry installation failed"; return; } fi +if [ "$disable_submodule_update" == 1 ]; then echo -e "${BIGreen}>>>${RST} Making sure submodules are up-to-date ..." git submodule update --init --recursive - + else + echo -e "${BIYellow}***${RST} Not updating submodules ..." + fi echo -e "${BIGreen}>>>${RST} Building ..." if [[ "$OSTYPE" == "linux-gnu"* ]]; then poetry run python "$openpype_root/setup.py" build > "$openpype_root/build/build.log" || { echo -e "${BIRed}!!!${RST} Build failed, see the build log."; return; } From 8e4d56a8c519b9251817248cf2578f35edea61cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Samohel?= Date: Tue, 18 May 2021 17:40:33 +0200 Subject: [PATCH 297/303] fix look assignment --- openpype/tools/mayalookassigner/app.py | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/openpype/tools/mayalookassigner/app.py b/openpype/tools/mayalookassigner/app.py index 81aa841eb7..1ec3f6f66b 100644 --- a/openpype/tools/mayalookassigner/app.py +++ b/openpype/tools/mayalookassigner/app.py @@ -192,7 +192,7 @@ class App(QtWidgets.QWidget): for i, (asset, item) in enumerate(asset_nodes.items()): # Label prefix - prefix = "({}/{})".format(i+1, len(asset_nodes)) + prefix = "({}/{})".format(i + 1, len(asset_nodes)) # Assign the first matching look relevant for this asset # (since assigning multiple to the same nodes makes no sense) @@ -212,18 +212,19 @@ class App(QtWidgets.QWidget): self.echo("{} Assigning {} to {}\t".format(prefix, subset_name, asset)) + nodes = item["nodes"] - self.echo("Getting vray proxy nodes ...") - vray_proxies = set(cmds.ls(type="VRayProxy")) - nodes = set(item["nodes"]).difference(vray_proxies) + if cmds.pluginInfo('vrayformaya', query=True, loaded=True): + self.echo("Getting vray proxy nodes ...") + vray_proxies = set(cmds.ls(type="VRayProxy")) + nodes = [set(item["nodes"]).difference(vray_proxies)] + if vray_proxies: + for vp in vray_proxies: + vrayproxy_assign_look(vp, subset_name) - # Assign look + # Assign look if nodes: - assign_look_by_version([nodes], version_id=version["_id"]) - - if vray_proxies: - for vp in vray_proxies: - vrayproxy_assign_look(vp, subset_name) + assign_look_by_version(nodes, version_id=version["_id"]) end = time.time() From 916d3457f5f016da75bc3553b038f30e14f4158d Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Tue, 18 May 2021 18:10:25 +0200 Subject: [PATCH 298/303] moved optional maya environments back to settings --- openpype/hosts/maya/__init__.py | 5 ----- .../settings/defaults/system_settings/applications.json | 8 +++++++- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/openpype/hosts/maya/__init__.py b/openpype/hosts/maya/__init__.py index d3562e2cdd..549f100007 100644 --- a/openpype/hosts/maya/__init__.py +++ b/openpype/hosts/maya/__init__.py @@ -22,11 +22,6 @@ def add_implementation_envs(env, _app): # Set default values if are not already set via settings defaults = { - "MAYA_DISABLE_CLIC_IPM": "Yes", - "MAYA_DISABLE_CIP": "Yes", - "MAYA_DISABLE_CER": "Yes", - "PYMEL_SKIP_MEL_INIT": "Yes", - "LC_ALL": "C", "OPENPYPE_LOG_NO_COLORS": "Yes" } for key, value in defaults.items(): diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json index d1976a9776..020924db67 100644 --- a/openpype/settings/defaults/system_settings/applications.json +++ b/openpype/settings/defaults/system_settings/applications.json @@ -4,7 +4,13 @@ "label": "Maya", "icon": "{}/app_icons/maya.png", "host_name": "maya", - "environment": {}, + "environment": { + "MAYA_DISABLE_CLIC_IPM": "Yes", + "MAYA_DISABLE_CIP": "Yes", + "MAYA_DISABLE_CER": "Yes", + "PYMEL_SKIP_MEL_INIT": "Yes", + "LC_ALL": "C" + }, "variants": { "2022": { "use_python_2": false, From 3cde37241bb3b345b64159ab0c486cafda3109f5 Mon Sep 17 00:00:00 2001 From: Ondrej Samohel Date: Tue, 18 May 2021 18:58:09 +0200 Subject: [PATCH 299/303] fix cast --- openpype/tools/mayalookassigner/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/mayalookassigner/app.py b/openpype/tools/mayalookassigner/app.py index 1ec3f6f66b..1fa3a3868a 100644 --- a/openpype/tools/mayalookassigner/app.py +++ b/openpype/tools/mayalookassigner/app.py @@ -217,7 +217,7 @@ class App(QtWidgets.QWidget): if cmds.pluginInfo('vrayformaya', query=True, loaded=True): self.echo("Getting vray proxy nodes ...") vray_proxies = set(cmds.ls(type="VRayProxy")) - nodes = [set(item["nodes"]).difference(vray_proxies)] + nodes = list(set(item["nodes"]).difference(vray_proxies)) if vray_proxies: for vp in vray_proxies: vrayproxy_assign_look(vp, subset_name) From c76d2d4ce3271ad4c8545a591eab11f395f9eacd Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Wed, 19 May 2021 09:57:24 +0200 Subject: [PATCH 300/303] update changelog --- website/docs/changelog.md | 95 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 95 insertions(+) diff --git a/website/docs/changelog.md b/website/docs/changelog.md index bec4a02173..57048b9398 100644 --- a/website/docs/changelog.md +++ b/website/docs/changelog.md @@ -4,6 +4,101 @@ title: Changelog sidebar_label: Changelog --- +## [2.18.0](https://github.com/pypeclub/openpype/tree/2.18.0) +_**release date:** (2021-05-18)_ + +[Full Changelog](https://github.com/pypeclub/openpype/compare/2.17.3...2.18.0) + +**Enhancements:** + +- Use SubsetLoader and multiple contexts for delete_old_versions [\#1484](ttps://github.com/pypeclub/OpenPype/pull/1484)) +- TVPaint: Increment workfile version on successfull publish. [\#1489](https://github.com/pypeclub/OpenPype/pull/1489) +- Maya: Use of multiple deadline servers [\#1483](https://github.com/pypeclub/OpenPype/pull/1483) + +**Fixed bugs:** + +- Use instance frame start instead of timeline. [\#1486](https://github.com/pypeclub/OpenPype/pull/1486) +- Maya: Redshift - set proper start frame on proxy [\#1480](https://github.com/pypeclub/OpenPype/pull/1480) +- Maya: wrong collection of playblasted frames [\#1517](https://github.com/pypeclub/OpenPype/pull/1517) +- Existing subsets hints in creator [\#1502](https://github.com/pypeclub/OpenPype/pull/1502) + + +### [2.17.3](https://github.com/pypeclub/openpype/tree/2.17.3) +_**release date:** (2021-05-06)_ + +[Full Changelog](https://github.com/pypeclub/openpype/compare/CI/3.0.0-rc.3...2.17.3) + +**Fixed bugs:** + +- Nuke: workfile version synced to db version always [\#1479](https://github.com/pypeclub/OpenPype/pull/1479) + +### [2.17.2](https://github.com/pypeclub/openpype/tree/2.17.2) +_**release date:** (2021-05-04)_ + +[Full Changelog](https://github.com/pypeclub/openpype/compare/CI/3.0.0-rc.1...2.17.2) + +**Enhancements:** + +- Forward/Backward compatible apps and tools with OpenPype 3 [\#1463](https://github.com/pypeclub/OpenPype/pull/1463) + +### [2.17.1](https://github.com/pypeclub/openpype/tree/2.17.1) +_**release date:** (2021-04-30)_ + +[Full Changelog](https://github.com/pypeclub/openpype/compare/2.17.0...2.17.1) + +**Enhancements:** + +- Faster settings UI loading [\#1442](https://github.com/pypeclub/OpenPype/pull/1442) +- Nuke: deadline submission with gpu [\#1414](https://github.com/pypeclub/OpenPype/pull/1414) +- TVPaint frame range definition [\#1424](https://github.com/pypeclub/OpenPype/pull/1424) +- PS - group all published instances [\#1415](https://github.com/pypeclub/OpenPype/pull/1415) +- Add task name to context pop up. [\#1383](https://github.com/pypeclub/OpenPype/pull/1383) +- Enhance review letterbox feature. [\#1371](https://github.com/pypeclub/OpenPype/pull/1371) +- AE add duration validation [\#1363](https://github.com/pypeclub/OpenPype/pull/1363) + +**Fixed bugs:** + +- Houdini menu filename [\#1417](https://github.com/pypeclub/OpenPype/pull/1417) +- Nuke: fixing undo for loaded mov and sequence [\#1433](https://github.com/pypeclub/OpenPype/pull/1433) +- AE - validation for duration was 1 frame shorter [\#1426](https://github.com/pypeclub/OpenPype/pull/1426) + +**Merged pull requests:** + +- Maya: Vray - problem getting all file nodes for look publishing [\#1399](https://github.com/pypeclub/OpenPype/pull/1399) +- Maya: Support for Redshift proxies [\#1360](https://github.com/pypeclub/OpenPype/pull/1360) + +## [2.17.0](https://github.com/pypeclub/openpype/tree/2.17.0) +_**release date:** (2021-04-20)_ + +[Full Changelog](https://github.com/pypeclub/openpype/compare/CI/3.0.0-beta.2...2.17.0) + +**Enhancements:** + +- Forward compatible ftrack group [\#1243](https://github.com/pypeclub/OpenPype/pull/1243) +- Maya: Make tx option configurable with presets [\#1328](https://github.com/pypeclub/OpenPype/pull/1328) +- TVPaint asset name validation [\#1302](https://github.com/pypeclub/OpenPype/pull/1302) +- TV Paint: Set initial project settings. [\#1299](https://github.com/pypeclub/OpenPype/pull/1299) +- TV Paint: Validate mark in and out. [\#1298](https://github.com/pypeclub/OpenPype/pull/1298) +- Validate project settings [\#1297](https://github.com/pypeclub/OpenPype/pull/1297) +- After Effects: added SubsetManager [\#1234](https://github.com/pypeclub/OpenPype/pull/1234) +- Show error message in pyblish UI [\#1206](https://github.com/pypeclub/OpenPype/pull/1206) + +**Fixed bugs:** + +- Hiero: fixing source frame from correct object [\#1362](https://github.com/pypeclub/OpenPype/pull/1362) +- Nuke: fix colourspace, prerenders and nuke panes opening [\#1308](https://github.com/pypeclub/OpenPype/pull/1308) +- AE remove orphaned instance from workfile - fix self.stub [\#1282](https://github.com/pypeclub/OpenPype/pull/1282) +- Nuke: deadline submission with search replaced env values from preset [\#1194](https://github.com/pypeclub/OpenPype/pull/1194) +- Ftrack custom attributes in bulks [\#1312](https://github.com/pypeclub/OpenPype/pull/1312) +- Ftrack optional pypclub role [\#1303](https://github.com/pypeclub/OpenPype/pull/1303) +- After Effects: remove orphaned instances [\#1275](https://github.com/pypeclub/OpenPype/pull/1275) +- Avalon schema names [\#1242](https://github.com/pypeclub/OpenPype/pull/1242) +- Handle duplication of Task name [\#1226](https://github.com/pypeclub/OpenPype/pull/1226) +- Modified path of plugin loads for Harmony and TVPaint [\#1217](https://github.com/pypeclub/OpenPype/pull/1217) +- Regex checks in profiles filtering [\#1214](https://github.com/pypeclub/OpenPype/pull/1214) +- Update custom ftrack session attributes [\#1202](https://github.com/pypeclub/OpenPype/pull/1202) +- Nuke: write node colorspace ignore `default\(\)` label [\#1199](https://github.com/pypeclub/OpenPype/pull/1199) + ## [2.16.0](https://github.com/pypeclub/pype/tree/2.16.0) _**release date:** 2021-03-22_ From 0629338afdf3b23f80ddd9bc42abbf3e1d961890 Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Wed, 19 May 2021 09:57:39 +0200 Subject: [PATCH 301/303] small cleanup on the feature page --- website/src/pages/features.js | 230 ++++++++++++++++++++++++---------- 1 file changed, 165 insertions(+), 65 deletions(-) diff --git a/website/src/pages/features.js b/website/src/pages/features.js index 35e156a230..661f631959 100644 --- a/website/src/pages/features.js +++ b/website/src/pages/features.js @@ -37,15 +37,21 @@ const key_features = [ docs: "/docs/artist_tools#publisher", }, { - label: "Inventory", + label: "Scene manager", description: "Universal GUI for managing versions of assets loaded into your working scene.", docs: "docs/artist_tools#inventory", - }, - { + }, + { + label: "Project manager", + docs: "", + description: + "Tools for creating shots, assets and task within your project if you don't use third party project management", + }, + { label: "Library Loader", description: - "A loader GUI that allows yo to load content from dedicated cross project asset library", + "A loader GUI that allows yo to load content from dedicated cross project asset library", docs: "docs/artist_tools#library-loader", image: "", }, @@ -56,12 +62,6 @@ const key_features = [ "A standalone GUI for publishing data into pipeline without going though DCC app.", image: "", }, - { - label: "Pype Tray", - link: "", - description: - "Cross platform wrapper app, which is the central point of pype. All other tools are ran from here.", - }, { label: "App Launcher", link: "", @@ -69,14 +69,26 @@ const key_features = [ "Standalone GUI for launching application in the chosen context directly from tray", }, { - label: "Timer Manager", + label: "Configuration GUI", link: "", + description: + "All settings and configuration are done via openPype Settings tool. No need to dig around .json and .yaml", + }, + { + label: "Site Sync", + docs: "docs/module_site_sync", + description: + "Built in file synchronization between your central storage (cloud or physical) and all your freelancers", + }, + { + label: "Timers Manager", + link: "docs/admin_settings_system#timers-manager", description: "Service for monitoring the user activity to start, stop and synchronise time tracking.", }, { label: "Farm rendering", - link: "", + docs: "docs/module_deadline", description: "Integrations with Deadline and Muster render managers. Render, publish and generate reviews on the farm.", }, @@ -93,10 +105,10 @@ const key_features = [ "System for simple scene building. Loads pre-defined publishes to scene with single click, speeding up scene preparation.", }, { - label: "Configuration GUI", - link: "", + label: "Reviewables", + docs: "docs/project_settings/settings_project_global#extract-review", description: - "All settings and configuration are done via openPype Settings tool. No need to dig around .json and .yaml", + "Generate automated reviewable quicktimes and sequences in any format, with metadata burnins.", }, ]; @@ -109,6 +121,10 @@ const ftrack = [ docs: "docs/manager_ftrack#project-management", label: "Project Setup", description: "Quickly sets up project with customisable pre-defined structure and attributes." + }, { + docs: "docs/module_ftrack#update-status-on-task-action", + label: "Automate statuses", + description: "Quickly sets up project with customisable pre-defined structure and attributes." }, { docs: "docs/admin_ftrack#event-server", label: "Event Server", @@ -118,7 +134,7 @@ const ftrack = [ label: "Review publishing", description: "All reviewables from all DCC aps, including farm renders are pushed to ftrack online review." }, { - docs: "", + docs: "docs/admin_settings_system#timers-manager", label: "Auto Time Tracker", description: "Automatically starts and stops ftrack time tracker, base on artist activity." } @@ -185,8 +201,8 @@ const maya_features = [ description:"Makes all your playblasts consistent, with burnins and correct viewport settings" }, { - label: "Model > Render", - description:"We cover full project data flow from model through animation, till final render.", + label: "Renderlayers and AOVs", + description:"Full support of rendersetup layers and AOVs in all major renderers.", docs: "docs/artist_hosts_maya#working-with-pype-in-maya" }, { @@ -211,6 +227,7 @@ const maya_families = [ {label:"VDB Cache"}, {label:"Assembly"}, {label:"Camera"}, + {label:"CameraRig"}, {label:"RenderSetup"}, {label:"Render"}, {label:"Plate"}, @@ -231,7 +248,7 @@ const nuke_features = [ docs: "docs/artist_hosts_nuke#set-colorspace" }, { label: "Script Building", - description:"Automatically build first workfiles from published plates or renders", + description:"Automatically build initial workfiles from published plates or renders", docs: "docs/artist_hosts_nuke#build-first-work-file" }, { @@ -254,10 +271,8 @@ const nuke_families = [ {label: "Render"}, {label: "Plate"}, {label: "Review"}, - {label: "Group"}, {label: "Workfile"}, {label: "LUT"}, - {label: "Cache"}, {label: "Gizmo"}, {label: "Prerender"}, ] @@ -294,6 +309,26 @@ const deadline_families = [ ] const hiero_features = [ + { + label: "Project setup", + description:"Automatic colour, timeline and fps setup of you hiero project." + }, + { + label: "Create shots", + description:"Populate project with shots based on your conformed edit." + }, + { + label: "Publish plates", + description:"Publish multiple tracks with plates to you shots from a single timeline." + }, + { + label: "Retimes", + description:"Publish retime information for individual plates." + }, + { + label: "LUTS and fx", + description:"Publish soft effects from your timeline to be used on shots." + }, ] const hiero_families = [ @@ -330,7 +365,6 @@ const houdini_families = [ {label:"Point Cache"}, {label:"VDB Cache"}, {label:"Camera"}, - {label:"Review"}, {label:"Workfile"}, ] @@ -355,11 +389,29 @@ const harmony_families = [ {label: "Workfile"} ] +const tvpaint_families = [ + {label: "Render"}, + {label: "Review"}, + {label: "Image"}, + {label: "Audio"}, + {label: "Workfile"} +] + const photoshop_families = [ {label: "Render"}, {label: "Plate"}, {label: "Image"}, {label: "LayeredImage"}, + {label: "Background"}, + {label: "Workfile"} +] + +const aftereffects_families = [ + {label: "Render"}, + {label: "Plate"}, + {label: "Image"}, + {label: "Audio"}, + {label: "Background"}, {label: "Workfile"} ] @@ -512,9 +564,15 @@ function Home() {
-

Autodesk Maya

+

Autodesk Maya

versions 2017 and higher

+

+ OpenPype includes very robust Maya implementation that can handle full CG workflow from model, + through animation till final renders. Scene settings, Your artists won't need to touch file browser at all and OpenPype will + take care of all the file management. +

+ {maya_features && maya_features.length && (
{maya_features.map((props, idx) => ( @@ -537,7 +595,7 @@ function Home() {
-

Foundry Nuke | NukeX

+

Foundry Nuke | NukeX

versions 11.0 and higher

@@ -563,10 +621,18 @@ function Home() {
-

Foundry Hiero | Nuke Studio

+

Foundry Hiero | Nuke Studio

versions 11.0 and higher

+ {hiero_features && hiero_features.length && ( +
+ {hiero_features.map((props, idx) => ( + + ))} +
+ )} +

Supported Families

{hiero_families && hiero_families.length && ( @@ -579,6 +645,78 @@ function Home() {
+
+
+

After Effects

+ +

versions 2020 and higher

+ +

Supported Families

+ + {aftereffects_families && aftereffects_families.length && ( +
+ {aftereffects_families.map((props, idx) => ( + + ))} +
+ )} +
+
+ +
+
+

Photoshop

+ +

versions 2020 and higher

+ +

Supported Families

+ + {photoshop_families && photoshop_families.length && ( +
+ {photoshop_families.map((props, idx) => ( + + ))} +
+ )} +
+
+ +
+
+

Harmony

+ +

versions 17 and higher

+ +

Supported Families

+ + {harmony_families && harmony_families.length && ( +
+ {harmony_families.map((props, idx) => ( + + ))} +
+ )} +
+
+ +
+
+

TV Paint

+ +

versions 11

+ +

Supported Families

+ + {tvpaint_families && tvpaint_families.length && ( +
+ {tvpaint_families.map((props, idx) => ( + + ))} +
+ )} +
+
+

Houdini

@@ -600,9 +738,9 @@ function Home() {
-

Blender

+

Blender

-

versions 2.80 and higher

+

versions 2.83 and higher

Supported Families

@@ -636,44 +774,6 @@ function Home() {
-
-
-

Harmony

- -

versions 17 and higher

- -

Supported Families

- - {harmony_families && harmony_families.length && ( -
- {harmony_families.map((props, idx) => ( - - ))} -
- )} -
-
- - -
-
-

Photoshop

- -

versions 2020 and higher

- -

Supported Families

- - {photoshop_families && photoshop_families.length && ( -
- {photoshop_families.map((props, idx) => ( - - ))} -
- )} -
-
- - ); } From 225a645b49da1f7f7450d0ca977355183cbb24cb Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Wed, 19 May 2021 09:58:55 +0200 Subject: [PATCH 302/303] bump version --- openpype/version.py | 2 +- website/src/pages/features.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/version.py b/openpype/version.py index 27186ad2bb..a88ae329d0 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.0.0-rc4" +__version__ = "3.0.0-rc.5" diff --git a/website/src/pages/features.js b/website/src/pages/features.js index 661f631959..d5c036eb89 100644 --- a/website/src/pages/features.js +++ b/website/src/pages/features.js @@ -570,7 +570,7 @@ function Home() {

OpenPype includes very robust Maya implementation that can handle full CG workflow from model, through animation till final renders. Scene settings, Your artists won't need to touch file browser at all and OpenPype will - take care of all the file management. + take care of all the file management. Most of maya workflows are supported including gpucaches, referencing, nested references and render proxies.

{maya_features && maya_features.length && ( From c80bdefe475ab88d63e3c37e475bc7642c84c23b Mon Sep 17 00:00:00 2001 From: Milan Kolar Date: Wed, 19 May 2021 10:01:13 +0200 Subject: [PATCH 303/303] update changelog with 2.x and bump version --- CHANGELOG.md | 80 +++++++++++++++++++++++++++----------------------- pyproject.toml | 2 +- setup.py | 2 +- 3 files changed, 46 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b70f3f98f4..09882896f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,37 @@ # Changelog +## [2.18.0](https://github.com/pypeclub/openpype/tree/2.18.0) (2021-05-18) + +[Full Changelog](https://github.com/pypeclub/openpype/compare/2.17.3...2.18.0) + +**Enhancements:** + +- Use SubsetLoader and multiple contexts for delete_old_versions [\#1484](ttps://github.com/pypeclub/OpenPype/pull/1484)) +- TVPaint: Increment workfile version on successfull publish. [\#1489](https://github.com/pypeclub/OpenPype/pull/1489) +- Maya: Use of multiple deadline servers [\#1483](https://github.com/pypeclub/OpenPype/pull/1483) + +**Fixed bugs:** + +- Use instance frame start instead of timeline. [\#1486](https://github.com/pypeclub/OpenPype/pull/1486) +- Maya: Redshift - set proper start frame on proxy [\#1480](https://github.com/pypeclub/OpenPype/pull/1480) +- Maya: wrong collection of playblasted frames [\#1517](https://github.com/pypeclub/OpenPype/pull/1517) +- Existing subsets hints in creator [\#1502](https://github.com/pypeclub/OpenPype/pull/1502) + +## [2.17.3](https://github.com/pypeclub/openpype/tree/2.17.3) (2021-05-06) + +[Full Changelog](https://github.com/pypeclub/openpype/compare/CI/3.0.0-rc.3...2.17.3) + +**Fixed bugs:** + +- Nuke: workfile version synced to db version always [\#1479](https://github.com/pypeclub/OpenPype/pull/1479) + +## [2.17.2](https://github.com/pypeclub/openpype/tree/2.17.2) (2021-05-04) + +[Full Changelog](https://github.com/pypeclub/openpype/compare/CI/3.0.0-rc.1...2.17.2) + +**Enhancements:** + +- Forward/Backward compatible apps and tools with OpenPype 3 [\#1463](https://github.com/pypeclub/OpenPype/pull/1463) ## [2.17.1](https://github.com/pypeclub/openpype/tree/2.17.1) (2021-04-30) @@ -7,28 +39,30 @@ **Enhancements:** +- Nuke: deadline submission with gpu [\#1414](https://github.com/pypeclub/OpenPype/pull/1414) - TVPaint frame range definition [\#1424](https://github.com/pypeclub/OpenPype/pull/1424) - PS - group all published instances [\#1415](https://github.com/pypeclub/OpenPype/pull/1415) -- Nuke: deadline submission with gpu [\#1414](https://github.com/pypeclub/OpenPype/pull/1414) - Add task name to context pop up. [\#1383](https://github.com/pypeclub/OpenPype/pull/1383) -- AE add duration validation [\#1363](https://github.com/pypeclub/OpenPype/pull/1363) -- Maya: Support for Redshift proxies [\#1360](https://github.com/pypeclub/OpenPype/pull/1360) +- Enhance review letterbox feature. [\#1371](https://github.com/pypeclub/OpenPype/pull/1371) **Fixed bugs:** -- Nuke: fixing undo for loaded mov and sequence [\#1433](https://github.com/pypeclub/OpenPype/pull/1433) -- AE - validation for duration was 1 frame shorter [\#1426](https://github.com/pypeclub/OpenPype/pull/1426) - Houdini menu filename [\#1417](https://github.com/pypeclub/OpenPype/pull/1417) -- Maya: Vray - problem getting all file nodes for look publishing [\#1399](https://github.com/pypeclub/OpenPype/pull/1399) +- AE - validation for duration was 1 frame shorter [\#1426](https://github.com/pypeclub/OpenPype/pull/1426) +**Merged pull requests:** + +- Maya: Vray - problem getting all file nodes for look publishing [\#1399](https://github.com/pypeclub/OpenPype/pull/1399) +- Maya: Support for Redshift proxies [\#1360](https://github.com/pypeclub/OpenPype/pull/1360) ## [2.17.0](https://github.com/pypeclub/openpype/tree/2.17.0) (2021-04-20) -[Full Changelog](https://github.com/pypeclub/openpype/compare/3.0.0-beta2...2.17.0) +[Full Changelog](https://github.com/pypeclub/openpype/compare/CI/3.0.0-beta.2...2.17.0) **Enhancements:** - Forward compatible ftrack group [\#1243](https://github.com/pypeclub/OpenPype/pull/1243) +- Settings in mongo as dict [\#1221](https://github.com/pypeclub/OpenPype/pull/1221) - Maya: Make tx option configurable with presets [\#1328](https://github.com/pypeclub/OpenPype/pull/1328) - TVPaint asset name validation [\#1302](https://github.com/pypeclub/OpenPype/pull/1302) - TV Paint: Set initial project settings. [\#1299](https://github.com/pypeclub/OpenPype/pull/1299) @@ -56,35 +90,6 @@ - Nuke: reverse search to make it more versatile [\#1178](https://github.com/pypeclub/OpenPype/pull/1178) -## [2.16.1](https://github.com/pypeclub/pype/tree/2.16.1) (2021-04-13) - -[Full Changelog](https://github.com/pypeclub/pype/compare/2.16.0...2.16.1) - -**Enhancements:** - -- Nuke: comp renders mix up [\#1301](https://github.com/pypeclub/pype/pull/1301) -- Validate project settings [\#1297](https://github.com/pypeclub/pype/pull/1297) -- After Effects: added SubsetManager [\#1234](https://github.com/pypeclub/pype/pull/1234) - -**Fixed bugs:** - -- Ftrack custom attributes in bulks [\#1312](https://github.com/pypeclub/pype/pull/1312) -- Ftrack optional pypclub role [\#1303](https://github.com/pypeclub/pype/pull/1303) -- AE remove orphaned instance from workfile - fix self.stub [\#1282](https://github.com/pypeclub/pype/pull/1282) -- Avalon schema names [\#1242](https://github.com/pypeclub/pype/pull/1242) -- Handle duplication of Task name [\#1226](https://github.com/pypeclub/pype/pull/1226) -- Modified path of plugin loads for Harmony and TVPaint [\#1217](https://github.com/pypeclub/pype/pull/1217) -- Regex checks in profiles filtering [\#1214](https://github.com/pypeclub/pype/pull/1214) -- Bulk mov strict task [\#1204](https://github.com/pypeclub/pype/pull/1204) -- Update custom ftrack session attributes [\#1202](https://github.com/pypeclub/pype/pull/1202) -- Nuke: write node colorspace ignore `default\(\)` label [\#1199](https://github.com/pypeclub/pype/pull/1199) -- Nuke: reverse search to make it more versatile [\#1178](https://github.com/pypeclub/pype/pull/1178) - -**Merged pull requests:** - -- Forward compatible ftrack group [\#1243](https://github.com/pypeclub/pype/pull/1243) -- Error message in pyblish UI [\#1206](https://github.com/pypeclub/pype/pull/1206) -- Nuke: deadline submission with search replaced env values from preset [\#1194](https://github.com/pypeclub/pype/pull/1194) ## [2.16.0](https://github.com/pypeclub/pype/tree/2.16.0) (2021-03-22) @@ -1145,4 +1150,7 @@ A large cleanup release. Most of the change are under the hood. \* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)* +\* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)* + + \* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)* diff --git a/pyproject.toml b/pyproject.toml index f7b5dd1426..f7eeafd04f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.0.0-rc4" +version = "3.0.0-rc.5" description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License" diff --git a/setup.py b/setup.py index c096befa34..5fb0b33f2a 100644 --- a/setup.py +++ b/setup.py @@ -98,7 +98,7 @@ executables = [ setup( name="OpenPype", version=__version__, - description="Ultimate pipeline", + description="OpenPype", cmdclass={"build_sphinx": BuildDoc}, options={ "build_exe": build_exe_options,