diff --git a/openpype/cli.py b/openpype/cli.py index 9c498257210..df38c74a219 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/lib/__init__.py b/openpype/lib/__init__.py index 838c5aa7a19..c97545fdf42 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -58,6 +58,10 @@ ) from .avalon_context import ( + CURRENT_DOC_SCHEMAS, + PROJECT_NAME_ALLOWED_SYMBOLS, + PROJECT_NAME_REGEX, + create_project, is_latest, any_outdated, get_asset, @@ -163,6 +167,10 @@ "recursive_bases_from_class", "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 2d8726352af..2a7c58c4eec 100644 --- a/openpype/lib/avalon_context.py +++ b/openpype/lib/avalon_context.py @@ -17,6 +17,99 @@ log = logging.getLogger("AvalonContext") +CURRENT_DOC_SCHEMAS = { + "project": "openpype:project-3.0", + "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 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: + # 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): 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 3bb01798e44..410e51e2a4c 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 @@ BaseEvent ) -from openpype.modules.ftrack.lib.avalon_sync import ( - EntitySchemas -) +from openpype.lib import CURRENT_DOC_SCHEMAS class SyncToAvalonEvent(BaseEvent): @@ -1128,7 +1126,7 @@ def create_entity_in_avalon(self, ftrack_ent, parent_avalon): "_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 f58e858a5a4..a3b926464ed 100644 --- a/openpype/modules/ftrack/lib/avalon_sync.py +++ b/openpype/modules/ftrack/lib/avalon_sync.py @@ -34,7 +34,7 @@ # 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 @@ def create_avalon_entity(self, ftrack_id): 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 @@ def create_avalon_project(self): 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 diff --git a/openpype/pype_commands.py b/openpype/pype_commands.py index 981cca82dc8..326ca8349a7 100644 --- a/openpype/pype_commands.py +++ b/openpype/pype_commands.py @@ -110,6 +110,12 @@ def extractenvironments(output_json_path, project, asset, task, app): 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/openpype/tools/project_manager/__init__.py b/openpype/tools/project_manager/__init__.py new file mode 100644 index 00000000000..62fa8af8aa6 --- /dev/null +++ b/openpype/tools/project_manager/__init__.py @@ -0,0 +1,10 @@ +from .project_manager import ( + ProjectManagerWindow, + main +) + + +__all__ = ( + "ProjectManagerWindow", + "main" +) diff --git a/openpype/tools/project_manager/__main__.py b/openpype/tools/project_manager/__main__.py new file mode 100644 index 00000000000..2e57af5f114 --- /dev/null +++ b/openpype/tools/project_manager/__main__.py @@ -0,0 +1,5 @@ +from project_manager import main + + +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 00000000000..49ade4a989b --- /dev/null +++ b/openpype/tools/project_manager/project_manager/__init__.py @@ -0,0 +1,50 @@ +__all__ = ( + "IDENTIFIER_ROLE", + + "HierarchyView", + + "ProjectModel", + "CreateProjectDialog", + + "HierarchyModel", + "HierarchySelectionModel", + "BaseItem", + "RootItem", + "ProjectItem", + "AssetItem", + "TaskItem", + + "ProjectManagerWindow", + "main" +) + + +from .constants import ( + IDENTIFIER_ROLE +) +from .widgets import CreateProjectDialog +from .view import HierarchyView +from .model import ( + ProjectModel, + + HierarchyModel, + HierarchySelectionModel, + BaseItem, + RootItem, + ProjectItem, + AssetItem, + TaskItem +) +from .window import ProjectManagerWindow + + +def main(): + import sys + from Qt import QtWidgets + + app = QtWidgets.QApplication([]) + + window = ProjectManagerWindow() + window.show() + + sys.exit(app.exec_()) 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 00000000000..6fb4b991ed0 --- /dev/null +++ b/openpype/tools/project_manager/project_manager/constants.py @@ -0,0 +1,13 @@ +import re +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 +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/delegates.py b/openpype/tools/project_manager/project_manager/delegates.py new file mode 100644 index 00000000000..51edff028f7 --- /dev/null +++ b/openpype/tools/project_manager/project_manager/delegates.py @@ -0,0 +1,159 @@ +from Qt import QtWidgets, QtCore + +from .widgets import ( + NameTextEdit, + FilterComboBox +) +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) + self.minimum = minimum + self.maximum = maximum + self.decimals = decimals + + def createEditor(self, parent, option, index): + if self.decimals > 0: + editor = QtWidgets.QDoubleSpinBox(parent) + else: + editor = QtWidgets.QSpinBox(parent) + + 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: + try: + if isinstance(value, str): + value = float(value) + editor.setValue(value) + + except Exception: + print("Couldn't set invalid value \"{}\"".format(str(value))) + + return editor + + +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)) + return editor + + +class TypeDelegate(QtWidgets.QStyledItemDelegate): + 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 = FilterComboBox(parent) + editor.setObjectName("TypeEditor") + editor.style().polish(editor) + if not self._project_doc_cache.project_doc: + return editor + + task_type_defs = self._project_doc_cache.project_doc["config"]["tasks"] + editor.addItems(list(task_type_defs.keys())) + + return editor + + def setEditorData(self, editor, index): + value = index.data(QtCore.Qt.EditRole) + index = editor.findText(value) + 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): + self._tools_cache = tools_cache + super(ToolsDelegate, self).__init__(*args, **kwargs) + + def createEditor(self, parent, option, index): + editor = MultiSelectionComboBox(parent) + editor.setObjectName("ToolEditor") + if not self._tools_cache.tools_data: + return editor + + for key, label in self._tools_cache.tools_data: + 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) 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 00000000000..6e20dd368fe --- /dev/null +++ b/openpype/tools/project_manager/project_manager/model.py @@ -0,0 +1,2004 @@ +import collections +import copy +import json +from queue import Queue +from uuid import uuid4 + +from .constants import ( + IDENTIFIER_ROLE, + ITEM_TYPE_ROLE, + DUPLICATED_ROLE, + HIERARCHY_CHANGE_ABLE_ROLE, + REMOVED_ROLE, + 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 + + +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 = [] + + 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(): + # 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 __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() in self.multiselection_columns: + 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_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") + ] + multiselection_columns = { + "frameStart", + "frameEnd", + "fps", + "resolutionWidth", + "resolutionHeight", + "handleStart", + "handleEnd", + "clipIn", + "clipOut", + "pixelAspect", + "tools_env" + } + 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) + project_changed = QtCore.Signal() + + 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 + self._items_by_id = {} + self._asset_items_by_name = collections.defaultdict(set) + self.dbcon = dbcon + + self._reset_root_item() + + @property + def items_by_id(self): + return self._items_by_id + + def _reset_root_item(self): + self._root_item = RootItem(self) + + def refresh_project(self): + self.set_project(self._current_project, True) + + @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, force=False): + if self._current_project == project_name and not force: + return + + self.clear() + + self._current_project = project_name + if not project_name: + return + + 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_id = { + 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"].get("visualParent") + asset_docs_by_parent_id[parent_id].append(asset_doc) + + appending_queue = Queue() + 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() + asset_docs = asset_docs_by_parent_id.get(parent_id) or [] + + new_items = [] + 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 + new_items.append(new_item) + + # 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)) + + 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 + 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(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"] + if not asset_tasks: + continue + + task_items = [] + 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) + + 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 + else: + parent_item = parent.internalPointer() + return parent_item.rowCount() + + def columnCount(self, *args, **kwargs): + return self.columns_len + + def data(self, index, role): + if not index.isValid(): + return None + + column = index.column() + key = self.columns[column] + + item = index.internalPointer() + return item.data(role, key) + + 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] + if ( + key == "name" + and role in (QtCore.Qt.EditRole, QtCore.Qt.DisplayRole) + ): + self._rename_asset(item, value) + + result = item.setData(value, role, key) + if result: + self.dataChanged.emit(index, index, [role]) + + return result + + def headerData(self, section, orientation, role): + if role == QtCore.Qt.DisplayRole: + if section < self.columnCount(): + return self.column_labels[section] + + return super(HierarchyModel, self).headerData( + section, orientation, role + ) + + 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) + + def parent(self, index=None): + if not index.isValid(): + return QtCore.QModelIndex() + + 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 = "ep" + new_row = None + else: + name = None + new_row = item.rowCount() + + asset_data = {} + if name: + asset_data["name"] = name + + new_child = AssetItem(asset_data) + + 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.EditRole) + self._validate_asset_duplicity(new_name) + + return result + + def add_new_task(self, parent_index): + item_id = parent_index.data(IDENTIFIER_ROLE) + item = self.items_by_id[item_id] + + if isinstance(item, TaskItem): + parent = item.parent() + else: + parent = item + + if not isinstance(parent, AssetItem): + return None + + new_child = TaskItem() + return self.add_item(new_child, parent) + + def add_items(self, items, parent=None, start_row=None): + if parent is None: + parent = self._root_item + + if parent.data(REMOVED_ROLE): + return [] + + 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, start_row, end_row) + + 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) + + if isinstance(item, AssetItem): + 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: + self._items_by_id[item.id] = item + + self.endInsertRows() + + 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): + result = self.add_items([item], parent, row) + if result: + return result[0] + return None + + def remove_delete_flag(self, item_ids, with_children=True): + items_by_id = {} + for item_id in item_ids: + if item_id in items_by_id: + continue + + item = self.items_by_id[item_id] + if isinstance(item, (AssetItem, TaskItem)): + items_by_id[item_id] = item + + for item in tuple(items_by_id.values()): + parent = item.parent() + while True: + if not isinstance(parent, (AssetItem, TaskItem)): + break + + 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): + if not isinstance(_item, AssetItem): + return + + 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) + 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]) + + def delete_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(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(REMOVED_ROLE) + task_removed = True + if not was_removed and parent_item is not None: + task_removed = parent_item.data(REMOVED_ROLE) + if not was_removed: + cur_item.setData(task_removed, REMOVED_ROLE) + return task_removed + + remove_item = True + 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(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) + 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(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 + + prev_name = asset_item.data(QtCore.Qt.EditRole, "name") + if prev_name == new_name: + return + + 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) + + if new_name is None: + return + self._asset_items_by_name[new_name].add(asset_item.id) + + self._validate_asset_duplicity(new_name) + + def _validate_asset_duplicity(self, name): + if name not in self._asset_items_by_name: + return + + item_ids = self._asset_items_by_name[name] + if not item_ids: + self._asset_items_by_name.pop(name) + + 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_id in item_ids: + item = self._items_by_id[item_id] + index = self.index_for_item(item) + self.setData(index, True, DUPLICATED_ROLE) + + def _move_horizontal_single(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 item.data(REMOVED_ROLE): + return + + if ( + isinstance(item, AssetItem) + and not item.data(HIERARCHY_CHANGE_ABLE_ROLE) + ): + 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 + + 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: + src_row_count = src_parent.rowCount() + if src_row_count == 1: + return + + item_row = item.row() + dst_parent = None + for row in reversed(range(item_row)): + _item = src_parent.child(row) + 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: + return + + dst_row = dst_parent.rowCount() + + if src_parent is dst_parent: + return + + if ( + isinstance(item, TaskItem) + and not isinstance(dst_parent, AssetItem) + ): + return + + 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() + + 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: + 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) + 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_horizontal_single(index, direction) + + def _move_vertical_single(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 item.data(REMOVED_ROLE): + return + + if ( + isinstance(item, AssetItem) + and not item.data(HIERARCHY_CHANGE_ABLE_ROLE) + ): + return + + if abs(direction) != 1: + 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 + # Down + if direction == 1: + 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 + + 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: + 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 + + 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, 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) + if not updated: + return + + 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 is None: + return + + 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 + ) + + 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() + + 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: + 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) + 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_vertical_single(index, direction) + + 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() + + 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 + + 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) + + bulk_writes = [] + 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.is_new: + insert_list.append(item) + + elif item.data(REMOVED_ROLE): + 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() + if update_data: + bulk_writes.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 bulk_writes: + project_col.bulk_write(bulk_writes) + + 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 = [] + # Use `set` for faster result + editable_columns = set() + + _name_icons = None + _is_duplicated = False + item_type = "base" + + _None = object() + + def __init__(self, data=None): + self._id = uuid4() + self._children = list() + self._parent = None + + self._data = { + key: None + for key in self.columns + } + self._global_data = {} + self._source_data = data + if data: + for key, value in data.items(): + if key in self.columns: + self._data[key] = value + + def name_icon(self): + return None + + @property + def is_valid(self): + return not self._is_duplicated + + 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 _get_global_data(self, role): + if role == ITEM_TYPE_ROLE: + return self.item_type + + if role == IDENTIFIER_ROLE: + return self._id + + if role == DUPLICATED_ROLE: + return self._is_duplicated + + if role == REMOVED_ROLE: + return False + + 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, role, key=None): + value = self._get_global_data(role) + if value is not self._None: + return value + + if key not in self.columns: + return None + + if role == QtCore.Qt.ForegroundRole: + if key == "name" and not self.is_valid: + return ResourceCache.colors["warning"] + return None + + if role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole): + value = self._data[key] + if value is None: + 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, value, role, key=None): + if role == DUPLICATED_ROLE: + if value == self._is_duplicated: + return False + + self._is_duplicated = value + return True + + if role == QtCore.Qt.EditRole: + if key in self.editable_columns: + self._data[key] = value + # must return true if successful + return True + + return self._set_global_data(value, role) + + @property + def id(self): + return self._id + + @property + def is_new(self): + return False + + def rowCount(self): + return len(self._children) + + def child(self, row): + if -1 < row < self.rowCount(): + 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, 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: + self._children.remove(item) + + def flags(self, key): + flags = QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable + if key in self.editable_columns: + flags |= QtCore.Qt.ItemIsEditable + return flags + + +class RootItem(BaseItem): + item_type = "root" + + 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): + item_type = "project" + + columns = { + "name", + "type", + "frameStart", + "frameEnd", + "fps", + "resolutionWidth", + "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.handleStart": 1, + "data.handleEnd": 1, + "data.clipIn": 1, + "data.clipOut": 1, + "data.pixelAspect": 1, + "data.tools_env": 1 + } + + 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"] + + def child_parents(self): + return [] + + @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 + + +class AssetItem(BaseItem): + item_type = "asset" + + columns = { + "name", + "type", + "fps", + "frameStart", + "frameEnd", + "resolutionWidth", + "resolutionHeight", + "handleStart", + "handleEnd", + "clipIn", + "clipOut", + "pixelAspect", + "tools_env" + } + editable_columns = { + "name", + "frameStart", + "frameEnd", + "fps", + "resolutionWidth", + "resolutionHeight", + "handleStart", + "handleEnd", + "clipIn", + "clipOut", + "pixelAspect", + "tools_env" + } + query_projection = { + "_id": 1, + "data.tasks": 1, + "data.visualParent": 1, + "schema": 1, + + "name": 1, + "type": 1, + "data.frameStart": 1, + "data.frameEnd": 1, + "data.fps": 1, + "data.resolutionWidth": 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): + if not asset_doc: + 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 + 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) + + self._origin_data = copy.deepcopy(data) + + 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 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"] + + 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(): + if isinstance(item, TaskItem): + tasks.update(item.to_doc_data()) + + doc_data = { + "parents": self.parent().child_parents(), + "visualParent": self.parent().asset_id, + "tasks": tasks + } + schema_name = ( + self._origin_asset_doc.get("schema") + or CURRENT_DOC_SCHEMAS["asset"] + ) + + doc = { + "name": self.data(QtCore.Qt.EditRole, "name"), + "type": self.data(QtCore.Qt.EditRole, "type"), + "schema": schema_name, + "data": doc_data, + "parent": self.project_id + } + if self.mongo_id: + doc["_id"] = self.mongo_id + + for key in self._data.keys(): + if key in doc: + continue + # Use `data` method to get inherited values + doc_data[key] = self.data(QtCore.Qt.EditRole, key) + + 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 = { + "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: + continue + + data[key] = doc_data.get(key) + + return data + + 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 not self.is_valid: + icon_type = "invalid" + elif self.is_new: + icon_type = "new" + else: + icon_type = "default" + return self.__class__._name_icons[icon_type] + + def _get_global_data(self, role): + if role == HIERARCHY_CHANGE_ABLE_ROLE: + return self._hierarchy_changes_enabled + + if role == REMOVED_ROLE: + return self._removed + + 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 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 + + 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(value, role, key) + + 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) + + def _add_task(self, item): + name = item.data(QtCore.Qt.EditRole, "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(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.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) + + 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(False, DUPLICATED_ROLE) + + def _rename_task(self, item): + 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: + 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: + self._duplicated_task_names.remove(prev_name) + for _item in self._task_items_by_name[prev_name]: + _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(True, DUPLICATED_ROLE) + else: + item.setData(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, self).remove_child(item) + + +class TaskItem(BaseItem): + item_type = "task" + + columns = { + "name", + "type" + } + editable_columns = { + "name", + "type" + } + + def __init__(self, data=None, is_new=None): + self._removed = False + if is_new is None: + is_new = data is None + self._is_new = is_new + 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) + + @property + 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"] + + if self._removed: + icon_type = "removed" + elif not self.is_valid: + icon_type = "invalid" + elif self.is_new: + icon_type = "new" + else: + icon_type = "default" + return self.__class__._name_icons[icon_type] + + def add_child(self, item, row=None): + raise AssertionError("BUG: Can't add children to Task") + + def _get_global_data(self, role): + if role == REMOVED_ROLE: + return self._removed + + 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): + if self._removed: + return {} + data = copy.deepcopy(self._data) + data.pop("name") + name = self.data(QtCore.Qt.EditRole, "name") + return { + name: data + } + + 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"] + + 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"] + + 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 + + if ( + role == QtCore.Qt.EditRole + and key == "name" + and not value + ): + value = None + + result = super(TaskItem, self).setData(value, role, key) + + 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 + + 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/multiselection_combobox.py b/openpype/tools/project_manager/project_manager/multiselection_combobox.py new file mode 100644 index 00000000000..b26976d3c6c --- /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) 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 00000000000..b686967dddb --- /dev/null +++ b/openpype/tools/project_manager/project_manager/style/__init__.py @@ -0,0 +1,98 @@ +import os +from openpype import resources +from avalon.vendor import qtawesome + + +class ResourceCache: + colors = { + "standard": "#333333", + "new": "#2d9a4c", + "warning": "#c83232" + } + 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": { + "default": qtawesome.icon( + "fa.folder", + color=cls.colors["standard"] + ), + "new": qtawesome.icon( + "fa.folder", + color=cls.colors["new"] + ), + "invalid": qtawesome.icon( + "fa.exclamation-triangle", + color=cls.colors["warning"] + ), + "removed": qtawesome.icon( + "fa.trash", + color=cls.colors["warning"] + ) + }, + "task": { + "default": qtawesome.icon( + "fa.check-circle-o", + color=cls.colors["standard"] + ), + "new": qtawesome.icon( + "fa.check-circle", + color=cls.colors["new"] + ), + "invalid": qtawesome.icon( + "fa.exclamation-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] + + @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(): + 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: + stylesheet = style_file.read() + + for key, value in ResourceCache.style_fill_data().items(): + replacement_key = "{" + key + "}" + stylesheet = stylesheet.replace(replacement_key, value) + return stylesheet + + +def app_icon_path(): + return resources.pype_icon_filepath() 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 00000000000..5805d9842bb Binary files /dev/null and b/openpype/tools/project_manager/project_manager/style/images/combobox_arrow.png differ diff --git a/openpype/tools/project_manager/project_manager/style/images/combobox_arrow_disabled.png b/openpype/tools/project_manager/project_manager/style/images/combobox_arrow_disabled.png new file mode 100644 index 00000000000..e271f7f90b4 Binary files /dev/null and b/openpype/tools/project_manager/project_manager/style/images/combobox_arrow_disabled.png differ 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 00000000000..836934019d4 --- /dev/null +++ b/openpype/tools/project_manager/project_manager/style/pyqt5_resources.py @@ -0,0 +1,105 @@ +# -*- 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 00000000000..b73d5e334a5 --- /dev/null +++ b/openpype/tools/project_manager/project_manager/style/pyside2_resources.py @@ -0,0 +1,84 @@ +# 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/qrc_resources.py b/openpype/tools/project_manager/project_manager/style/qrc_resources.py new file mode 100644 index 00000000000..a9e219c9ad4 --- /dev/null +++ b/openpype/tools/project_manager/project_manager/style/qrc_resources.py @@ -0,0 +1,32 @@ +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() + + +__all__ = ( + "resources", + "qInitResources", + "qCleanupResources" +) 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 00000000000..9281c694797 --- /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 + + 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 00000000000..31196b7cc6a --- /dev/null +++ b/openpype/tools/project_manager/project_manager/style/style.css @@ -0,0 +1,21 @@ +QTreeView::item { + padding-top: 3px; + padding-bottom: 3px; + 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; +} 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 00000000000..70af11e68d9 --- /dev/null +++ b/openpype/tools/project_manager/project_manager/view.py @@ -0,0 +1,643 @@ +import collections +from queue import Queue + +from Qt import QtWidgets, QtCore, QtGui + +from .delegates import ( + NumberDelegate, + NameDelegate, + TypeDelegate, + ToolsDelegate +) + +from openpype.lib import ApplicationManager +from .constants import ( + REMOVED_ROLE, + IDENTIFIER_ROLE, + ITEM_TYPE_ROLE, + HIERARCHY_CHANGE_ABLE_ROLE, + EDITOR_OPENED_ROLE +) + + +class NameDef: + pass + + +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 TypeDef: + pass + + +class ToolsDef: + pass + + +class ProjectDocCache: + 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 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 = { + "name": NameDef(), + "type": TypeDef(), + "frameStart": NumberDef(1), + "frameEnd": NumberDef(1), + "fps": NumberDef(1, decimals=2), + "resolutionWidth": NumberDef(0), + "resolutionHeight": NumberDef(0), + "handleStart": NumberDef(0), + "handleEnd": NumberDef(0), + "clipIn": NumberDef(1), + "clipOut": NumberDef(1), + "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", + "frameEnd", + "fps", + "resolutionWidth", + "resolutionHeight", + "handleStart", + "handleEnd", + "clipIn", + "clipOut", + "pixelAspect", + "tools_env" + } + + def __init__(self, dbcon, source_model, parent): + 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 + + project_doc_cache = ProjectDocCache(dbcon) + tools_cache = ToolsCache() + + main_delegate = QtWidgets.QStyledItemDelegate() + self.setItemDelegate(main_delegate) + self.setAlternatingRowColors(True) + self.setSelectionMode(HierarchyView.ExtendedSelection) + self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + + column_delegates = {} + column_key_to_index = {} + for key, item_type in self.column_delegate_defs.items(): + if isinstance(item_type, NameDef): + delegate = NameDelegate() + + elif isinstance(item_type, NumberDef): + delegate = NumberDelegate( + item_type.minimum, + item_type.maximum, + item_type.decimals + ) + + elif isinstance(item_type, TypeDef): + 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) + column_delegates[key] = delegate + column_key_to_index[key] = column + + 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 + + self._delegate = main_delegate + self._column_delegates = column_delegates + self._column_key_to_index = column_key_to_index + + 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) + 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 + 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) + + def _on_project_reset(self): + self.header_init() + + 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): + self.expand(parent_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 + # 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: + 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, QtWidgets.QDoubleSpinBox) + ): + 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) + 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) + 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) + + for row in range(start, end + 1): + 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) + 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): + 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_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): + self._on_ctrl_shift_enter_pressed() + elif mdfs == 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 _copy_items(self, indexes=None): + try: + if indexes is None: + indexes = self.selectedIndexes() + mime_data = self._source_model.copy_mime_data(indexes) + + 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() + 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() + self._source_model.delete_indexes(indexes) + + def _on_ctrl_shift_enter_pressed(self): + self._add_task_and_edit() + + def add_asset(self, parent_index=None): + if parent_index is None: + parent_index = self.currentIndex() + + if not parent_index.isValid(): + return + + # 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, parent_index=None): + new_index = self.add_asset(parent_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 _add_task_and_edit(self): + new_index = self.add_task() + if new_index is None: + return + + # Stop editing + 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.selectionModel().setCurrentIndex( + task_type_index, + QtCore.QItemSelectionModel.Clear + | QtCore.QItemSelectionModel.Select + ) + # Start editing + self.edit(task_type_index) + + def _on_shift_enter_pressed(self): + 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() + self._source_model.move_vertical(indexes, -1) + + def _on_down_ctrl_pressed(self): + indexes = self.selectedIndexes() + self._source_model.move_vertical(indexes, 1) + + def _on_left_ctrl_pressed(self): + indexes = self.selectedIndexes() + self._source_model.move_horizontal(indexes, -1) + + def _on_right_ctrl_pressed(self): + indexes = self.selectedIndexes() + self._source_model.move_horizontal(indexes, 1) + + def _on_enter_pressed(self): + index = self.currentIndex() + if ( + index.isValid() + and index.flags() & QtCore.Qt.ItemIsEditable + ): + 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. + """ + 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) + if item_id in item_ids: + continue + item_ids.add(item_id) + + 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. + + Args: + indexes (list): List of QModelIndex that should be collapsed. + """ + 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) + + self.collapse(index) + + for row in range(self._source_model.rowCount(index)): + process_queue.put(self._source_model.index( + row, 0, index + )) + + def _show_message(self, message): + """Show message to user.""" + 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: + return + + actions = [] + + context_menu = QtWidgets.QMenu(self) + + indexes = self.selectedIndexes() + + items_by_id = {} + for index in indexes: + 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(ITEM_TYPE_ROLE) + if item_type in ("asset", "project"): + add_asset_action = QtWidgets.QAction("Add Asset", context_menu) + add_asset_action.triggered.connect( + 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( + self._add_task_and_edit + ) + actions.append(add_task_action) + + # 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) + action.triggered.connect( + lambda: self._remove_delete_flag(removed_item_ids) + ) + actions.append(action) + + # Collapse/Expand action + show_collapse_expand_action = False + for item_id in item_ids: + item = items_by_id[item_id] + 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 + + for action in actions: + context_menu.addAction(action) + + global_point = self.viewport().mapToGlobal(point) + context_menu.exec_(global_point) 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 00000000000..9c57febcf68 --- /dev/null +++ b/openpype/tools/project_manager/project_manager/widgets.py @@ -0,0 +1,281 @@ +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 + + +class NameTextEdit(QtWidgets.QLineEdit): + def __init__(self, *args, **kwargs): + super(NameTextEdit, self).__init__(*args, **kwargs) + + self.textChanged.connect(self._on_text_change) + + def _on_text_change(self, 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): + def __init__(self, parent=None): + super(FilterComboBox, self).__init__(parent) + + self._last_value = None + + 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._last_value = self.lineEdit().text() + self.lineEdit().selectAll() + + 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) + elif self._last_value is not None: + idx = self.findText(self._last_value) + + 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) + 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) + + +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.project_name_label.setVisible(bool(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 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 00000000000..a800214517c --- /dev/null +++ b/openpype/tools/project_manager/project_manager/window.py @@ -0,0 +1,176 @@ +from Qt import QtWidgets, QtCore, QtGui + +from . import ( + ProjectModel, + + HierarchyModel, + HierarchySelectionModel, + HierarchyView, + + CreateProjectDialog +) +from .style import load_stylesheet, ResourceCache + +from openpype import resources +from avalon.api import AvalonMongoDB + + +class ProjectManagerWindow(QtWidgets.QWidget): + def __init__(self, parent=None): + super(ProjectManagerWindow, self).__init__(parent) + + self.setWindowTitle("OpenPype Project Manager") + self.setWindowIcon(QtGui.QIcon(resources.pype_icon_filepath())) + + # Top part of window + top_part_widget = QtWidgets.QWidget(self) + + # 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) + project_combobox.setRootModelIndex(QtCore.QModelIndex()) + + 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") + + 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 + helper_btns_widget = QtWidgets.QWidget(top_part_widget) + + helper_label = QtWidgets.QLabel("Add:", helper_btns_widget) + 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) + 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) + hierarchy_view.setModel(hierarchy_model) + + _selection_model = HierarchySelectionModel( + hierarchy_model.multiselection_column_indexes + ) + _selection_model.setModel(hierarchy_view.model()) + hierarchy_view.setSelectionModel(_selection_model) + + 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) + + main_layout = QtWidgets.QVBoxLayout(self) + 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) + 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) + add_task_btn.clicked.connect(self._on_add_task) + + self.project_model = project_model + self.project_combobox = project_combobox + + self.hierarchy_view = hierarchy_view + self.hierarchy_model = hierarchy_model + + self.message_label = message_label + + self.resize(1200, 600) + self.setStyleSheet(load_stylesheet()) + + self.refresh_projects() + + def _set_project(self, project_name=None): + self.hierarchy_view.set_project(project_name) + + 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 project_name: + row = self.project_combobox.findText(project_name) + if row >= 0: + self.project_combobox.setCurrentIndex(row) + + self._set_project(self.project_combobox.currentText()) + + def _on_project_change(self): + self._set_project(self.project_combobox.currentText()) + + def _on_project_refresh(self): + self.refresh_projects() + + 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) + + def _on_project_create(self): + dialog = CreateProjectDialog(self) + dialog.exec_() + if dialog.result() != 1: + return + + project_name = dialog.project_name + self.show_message("Created project \"{}\"".format(project_name)) + self.refresh_projects(project_name) diff --git a/tools/run_project_manager.ps1 b/tools/run_project_manager.ps1 new file mode 100644 index 00000000000..78dce19df1d --- /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