From 56153c1d6c4e0b85389f7c5ce8f8d2dbf6ab9459 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 20 May 2021 19:56:32 +0200 Subject: [PATCH 1/8] first commit of added docstrings and comments --- .../project_manager/constants.py | 9 ++ .../project_manager/delegates.py | 46 ++++++++ .../project_manager/project_manager/model.py | 110 +++++++++++++++++- 3 files changed, 161 insertions(+), 4 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/constants.py b/openpype/tools/project_manager/project_manager/constants.py index 6fb4b991ed..67dea79e59 100644 --- a/openpype/tools/project_manager/project_manager/constants.py +++ b/openpype/tools/project_manager/project_manager/constants.py @@ -2,12 +2,21 @@ import re from Qt import QtCore +# Item identifier (unique ID - uuid4 is used) IDENTIFIER_ROLE = QtCore.Qt.UserRole + 1 +# Item has duplicated name (Asset and Task items) DUPLICATED_ROLE = QtCore.Qt.UserRole + 2 +# It is possible to move and rename items +# - that is disabled if e.g. Asset has published content HIERARCHY_CHANGE_ABLE_ROLE = QtCore.Qt.UserRole + 3 +# Item is marked for deletion +# - item will be deleted after hitting save REMOVED_ROLE = QtCore.Qt.UserRole + 4 +# Item type in string ITEM_TYPE_ROLE = QtCore.Qt.UserRole + 5 +# Item has opened editor (per column) EDITOR_OPENED_ROLE = QtCore.Qt.UserRole + 6 +# Allowed symbols for any name NAME_ALLOWED_SYMBOLS = "a-zA-Z0-9_" NAME_REGEX = re.compile("^[" + NAME_ALLOWED_SYMBOLS + "]*$") diff --git a/openpype/tools/project_manager/project_manager/delegates.py b/openpype/tools/project_manager/project_manager/delegates.py index 51edff028f..0e8dd38e68 100644 --- a/openpype/tools/project_manager/project_manager/delegates.py +++ b/openpype/tools/project_manager/project_manager/delegates.py @@ -8,6 +8,10 @@ from .multiselection_combobox import MultiSelectionComboBox class ResizeEditorDelegate(QtWidgets.QStyledItemDelegate): + """Implementation of private method from QStyledItemDelegate. + + Force editor to resize into item size. + """ @staticmethod def _q_smart_min_size(editor): min_size_hint = editor.minimumSizeHint() @@ -67,6 +71,16 @@ class ResizeEditorDelegate(QtWidgets.QStyledItemDelegate): class NumberDelegate(QtWidgets.QStyledItemDelegate): + """Delegate for number attributes. + + Editor correspond passed arguments. + + Args: + minimum(int, float): Minimum possible value. + maximum(int, float): Maximum possible value. + decimals(int): How many decimal points can be used. Float will be used + as value if is higher than 0. + """ def __init__(self, minimum, maximum, decimals, *args, **kwargs): super(NumberDelegate, self).__init__(*args, **kwargs) self.minimum = minimum @@ -80,10 +94,13 @@ class NumberDelegate(QtWidgets.QStyledItemDelegate): editor = QtWidgets.QSpinBox(parent) editor.setObjectName("NumberEditor") + # Set min/max editor.setMinimum(self.minimum) editor.setMaximum(self.maximum) + # Hide spinbox buttons editor.setButtonSymbols(QtWidgets.QSpinBox.NoButtons) + # Try to set value from item value = index.data(QtCore.Qt.EditRole) if value is not None: try: @@ -98,6 +115,8 @@ class NumberDelegate(QtWidgets.QStyledItemDelegate): class NameDelegate(QtWidgets.QStyledItemDelegate): + """Specific delegate for "name" key.""" + def createEditor(self, parent, option, index): editor = NameTextEdit(parent) editor.setObjectName("NameEditor") @@ -108,11 +127,26 @@ class NameDelegate(QtWidgets.QStyledItemDelegate): class TypeDelegate(QtWidgets.QStyledItemDelegate): + """Specific delegate for "type" key. + + It is expected that will be used only for TaskItem which has modifiable + type. Type values are defined with cached project document. + + Args: + project_doc_cache(ProjectDocCache): Project cache shared across all + delegates (kind of a struct pointer). + """ + 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 is using filtrable combobox. + + Editor should not be possible to create new items or set values that + are not in this method. + """ editor = FilterComboBox(parent) editor.setObjectName("TypeEditor") editor.style().polish(editor) @@ -136,6 +170,18 @@ class TypeDelegate(QtWidgets.QStyledItemDelegate): class ToolsDelegate(QtWidgets.QStyledItemDelegate): + """Specific delegate for "tools_env" key. + + Exected that editor will be used only on AssetItem which is only item that + can have `tools_env` (except project). + + Delegate requires tools cache which is shared across all ToolsDelegate + objects. + + Args: + tools_cache (ToolsCache): Possible values of tools. + """ + def __init__(self, tools_cache, *args, **kwargs): self._tools_cache = tools_cache super(ToolsDelegate, self).__init__(*args, **kwargs) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 5000729adf..026f6e0228 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -21,7 +21,11 @@ from Qt import QtCore, QtGui class ProjectModel(QtGui.QStandardItemModel): - project_changed = QtCore.Signal() + """Load possible projects to modify from MongoDB. + + Mongo collection must contain project document with "type" "project" and + matching "name" value with name of collection. + """ def __init__(self, dbcon, *args, **kwargs): self.dbcon = dbcon @@ -31,6 +35,7 @@ class ProjectModel(QtGui.QStandardItemModel): super(ProjectModel, self).__init__(*args, **kwargs) def refresh(self): + """Reload projects.""" self.dbcon.Session["AVALON_PROJECT"] = None project_items = [] @@ -63,6 +68,12 @@ class ProjectModel(QtGui.QStandardItemModel): class HierarchySelectionModel(QtCore.QItemSelectionModel): + """Selection model with defined allowed multiselection columns. + + This model allows to select multiple rows and enter one of their + editors to edit value of all selected rows. + """ + def __init__(self, multiselection_columns, *args, **kwargs): super(HierarchySelectionModel, self).__init__(*args, **kwargs) self.multiselection_columns = multiselection_columns @@ -78,6 +89,21 @@ class HierarchySelectionModel(QtCore.QItemSelectionModel): class HierarchyModel(QtCore.QAbstractItemModel): + """Main model for hierarchy modification and value changes. + + Main part of ProjectManager. + + Model should be able to load existing entities, create new, handle their + validations like name duplication and validate if is possible to save it's + data. + + Args: + dbcon (AvalonMongoDB): Connection to MongoDB with set AVALON_PROJECT in + it's Session to current project. + """ + + # Definition of all possible columns with their labels in default order + # - order is important as column names are used as keys for column indexes _columns_def = [ ("name", "Name"), ("type", "Type"), @@ -93,6 +119,8 @@ class HierarchyModel(QtCore.QAbstractItemModel): ("pixelAspect", "Pixel aspect"), ("tools_env", "Tools") ] + # Columns allowing multiselection in edit mode + # - gives ability to set all of keys below on multiple items at once multiselection_columns = { "frameStart", "frameEnd", @@ -141,13 +169,19 @@ class HierarchyModel(QtCore.QAbstractItemModel): return self._items_by_id def _reset_root_item(self): + """Removes all previous content related to model.""" self._root_item = RootItem(self) def refresh_project(self): + """Reload project data and discard unsaved changes.""" self.set_project(self._current_project, True) @property def project_item(self): + """Access to current project item. + + Model can have 0-1 ProjectItems at once. + """ output = None for row in range(self._root_item.rowCount()): item = self._root_item.child(row) @@ -157,25 +191,41 @@ class HierarchyModel(QtCore.QAbstractItemModel): return output def set_project(self, project_name, force=False): + """Change project and discard unsaved changes. + + Args: + project_name(str): New project name. Or None if just clearing + content. + force(bool): Force to change project even if project name is same + as current project. + """ if self._current_project == project_name and not force: return + # Clear all current content self.clear() self._current_project = project_name + + # Skip if project is None if not project_name: return + # Find project'd document project_doc = self.dbcon.database[project_name].find_one( {"type": "project"}, ProjectItem.query_projection ) + # Skip if project document does not exist + # - this shouldn't happen using only UI elements if not project_doc: return + # Create project item project_item = ProjectItem(project_doc) self.add_item(project_item) + # Query all assets of the project asset_docs = self.dbcon.database[project_name].find( {"type": "asset"}, AssetItem.query_projection @@ -185,7 +235,8 @@ class HierarchyModel(QtCore.QAbstractItemModel): for asset_doc in asset_docs } - # Prepare booleans if asset item can be modified (name or hierarchy) + # Check if asset have published content and prepare booleans + # if asset item can be modified (name and hierarchy change) # - the same must be applied to all it's parents asset_ids = list(asset_docs_by_id.keys()) result = [] @@ -214,6 +265,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): count = item["count"] asset_modifiable[asset_id] = count < 1 + # Store assets by their visual parent to be able create their hierarchy asset_docs_by_parent_id = collections.defaultdict(list) for asset_doc in asset_docs_by_id.values(): parent_id = asset_doc["data"].get("visualParent") @@ -282,9 +334,11 @@ class HierarchyModel(QtCore.QAbstractItemModel): self.add_items(task_items, asset_item) + # Emit that project was successfully changed self.project_changed.emit() def rowCount(self, parent=None): + """Number of rows for passed parent.""" if parent is None or not parent.isValid(): parent_item = self._root_item else: @@ -292,9 +346,15 @@ class HierarchyModel(QtCore.QAbstractItemModel): return parent_item.rowCount() def columnCount(self, *args, **kwargs): + """Number of columns is static for this model.""" return self.columns_len def data(self, index, role): + """Access data for passed index and it's role. + + Model is using principles implemented in BaseItem so converts passed + index column into key and ask item to return value for passed role. + """ if not index.isValid(): return None @@ -305,18 +365,24 @@ class HierarchyModel(QtCore.QAbstractItemModel): return item.data(role, key) def setData(self, index, value, role=QtCore.Qt.EditRole): + """Store data to passed index under role. + + Pass values to corresponding item and behave by it's result. + """ if not index.isValid(): return False item = index.internalPointer() column = index.column() key = self.columns[column] + # Capture asset name changes for duplcated asset names validation. if ( key == "name" and role in (QtCore.Qt.EditRole, QtCore.Qt.DisplayRole) ): self._rename_asset(item, value) + # Pass values to item and by result emi dataChanged signal or not result = item.setData(value, role, key) if result: self.dataChanged.emit(index, index, [role]) @@ -324,6 +390,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): return result def headerData(self, section, orientation, role): + """Header labels.""" if role == QtCore.Qt.DisplayRole: if section < self.columnCount(): return self.column_labels[section] @@ -333,6 +400,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): ) def flags(self, index): + """Index flags are defined by corresponding item.""" item = index.internalPointer() if item is None: return QtCore.Qt.NoItemFlags @@ -341,6 +409,11 @@ class HierarchyModel(QtCore.QAbstractItemModel): return item.flags(key) def parent(self, index=None): + """Parent for passed index as QModelIndex. + + Args: + index(QModelIndex): Parent index. Root item is used if not passed. + """ if not index.isValid(): return QtCore.QModelIndex() @@ -354,7 +427,13 @@ class HierarchyModel(QtCore.QAbstractItemModel): return self.createIndex(parent_item.row(), 0, parent_item) def index(self, row, column, parent=None): - """Return index for row/column under parent""" + """Return index for row/column under parent. + + Args: + row(int): Row number. + column(int): Column number. + parent(QModelIndex): Parent index. Root item is used if not passed. + """ parent_item = None if parent is not None and parent.isValid(): parent_item = parent.internalPointer() @@ -362,11 +441,31 @@ class HierarchyModel(QtCore.QAbstractItemModel): return self.index_from_item(row, column, parent_item) def index_for_item(self, item, column=0): + """Index for passet item. + + This is for cases that index operations are required on specific item. + + Args: + item(BaseItem): Item from model that will be converted to + corresponding QModelIndex. + column(int): Which column will be part of returned index. By + default is used column 0. + """ return self.index_from_item( item.row(), column, item.parent() ) def index_from_item(self, row, column, parent=None): + """Index for passed row, column and parent item. + + Same implementation as `index` method but "parent" is one of + BaseItem objects instead of QModelIndex. + + Args: + row(int): Row number. + column(int): Column number. + parent(BaseItem): Parent item. Root item is used if not passed. + """ if parent is None: parent = self._root_item @@ -377,15 +476,18 @@ class HierarchyModel(QtCore.QAbstractItemModel): return QtCore.QModelIndex() def add_new_asset(self, source_index): + """Method to create new asset item in hierarchy.""" 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: + elif isinstance(item, AssetItem): name = None new_row = item.rowCount() + else: + return asset_data = {} if name: From f804a443e56552f836016dec8c59a5cbb9605d43 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 21 May 2021 01:29:54 +0200 Subject: [PATCH 2/8] few more docstrings and comments in project manager --- .../project_manager/project_manager/model.py | 218 +++++++++++++++++- 1 file changed, 217 insertions(+), 1 deletion(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 026f6e0228..ab4569947e 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -476,7 +476,12 @@ class HierarchyModel(QtCore.QAbstractItemModel): return QtCore.QModelIndex() def add_new_asset(self, source_index): - """Method to create new asset item in hierarchy.""" + """Create new asset item in hierarchy. + + Args: + source_index(QModelIndex): Parent under which new asset will be + added. + """ item_id = source_index.data(IDENTIFIER_ROLE) item = self.items_by_id[item_id] @@ -504,6 +509,13 @@ class HierarchyModel(QtCore.QAbstractItemModel): return result def add_new_task(self, parent_index): + """Create new TaskItem under passed parent index or it's parent. + + Args: + parent_index(QModelIndex): Index of parent AssetItem under which + will be task added. If index represents TaskItem it's parent is + used as parent. + """ item_id = parent_index.data(IDENTIFIER_ROLE) item = self.items_by_id[item_id] @@ -519,6 +531,18 @@ class HierarchyModel(QtCore.QAbstractItemModel): return self.add_item(new_child, parent) def add_items(self, items, parent=None, start_row=None): + """Add new items with definition of QAbstractItemModel. + + Trigger `beginInsertRows` and `endInsertRows` to trigger proper + callbacks in view or proxy model. + + Args: + items(list[BaseItem]): List of item that will be inserted in model. + parent(RootItem, ProjectItem, AssetItem): Parent of items under + which will be items added. Root item is used if not passed. + start_row(int): Define to which row will be items added. Next + available row of parent is used if not passed. + """ if parent is None: parent = self._root_item @@ -558,12 +582,25 @@ class HierarchyModel(QtCore.QAbstractItemModel): return indexes def add_item(self, item, parent=None, row=None): + """Add single item into model.""" result = self.add_items([item], parent, row) if result: return result[0] return None def remove_delete_flag(self, item_ids, with_children=True): + """Remove deletion flag on items with matching ids. + + Flag is also removed on all parents of passed children as it wouldn't + make sence to not to do so. + + Children of passed item ids are by default also unset for deletion. + + Args: + list(uuid4): Ids of model items where remove flag should be unset. + with_children(bool): Unset remove flag also on all children of + passed items. + """ items_by_id = {} for item_id in item_ids: if item_id in items_by_id: @@ -610,9 +647,11 @@ class HierarchyModel(QtCore.QAbstractItemModel): self._validate_asset_duplicity(name) def delete_index(self, index): + """Delete item of the index from model.""" return self.delete_indexes([index]) def delete_indexes(self, indexes): + """Delete items from model.""" items_by_id = {} processed_ids = set() for index in indexes: @@ -1156,12 +1195,32 @@ class HierarchyModel(QtCore.QAbstractItemModel): self.index_moved.emit(new_index) def move_vertical(self, indexes, direction): + """Move item vertically in model to matching parent if possible. + + If passed indexes contain items that has parent<->child relation at any + hierarchy level only the top parent is actually moved. + + Example (items marked with star are passed in `indexes`): + - shots* + - ep01 + - ep01_sh0010* + - ep01_sh0020* + In this case only `shots` item will be moved vertically and + both "ep01_sh0010" "ep01_sh0020" will stay as children of "ep01". + + Args: + indexes(list[QModelIndex]): Indexes that should be moved + vertically. + direction(int): Which way will be moved -1 or 1 to determine. + """ if not indexes: return + # Convert single index to list of indexes if isinstance(indexes, QtCore.QModelIndex): indexes = [indexes] + # Just process single index if len(indexes) == 1: self._move_vertical_single(indexes[0], direction) return @@ -1196,6 +1255,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): self._move_vertical_single(index, direction) def child_removed(self, child): + """Callback for removed child.""" self._items_by_id.pop(child.id, None) def column_name(self, column): @@ -1205,11 +1265,19 @@ class HierarchyModel(QtCore.QAbstractItemModel): return None def clear(self): + """Reset model.""" self.beginResetModel() self._reset_root_item() self.endResetModel() def save(self): + """Save all changes from current project manager session. + + Will create new asset documents, update existing and asset documents + marked for deletion are removed from mongo if has published content or + their type is changed to `archived_asset` to not loose their data. + """ + # Check if all items are valid before save all_valid = True for item in self._items_by_id.values(): if not item.is_valid: @@ -1219,6 +1287,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): if not all_valid: return + # Check project item and do not save without it project_item = None for _project_item in self._root_item.children(): project_item = _project_item @@ -1229,6 +1298,9 @@ class HierarchyModel(QtCore.QAbstractItemModel): project_name = project_item.name project_col = self.dbcon.database[project_name] + # Process asset items per one hierarchical level. + # - new assets are inserted per one parent + # - update and delete data are stored and processed at once at the end to_process = Queue() to_process.put(project_item) @@ -1349,6 +1421,14 @@ class HierarchyModel(QtCore.QAbstractItemModel): class BaseItem: + """Base item for HierarchyModel. + + Is not meant to be used as real item but as superclass for all items used + in HierarchyModel. + + TODO cleanup some attributes and methods related only to AssetItem and + TaskItem. + """ columns = [] # Use `set` for faster result editable_columns = set() @@ -1376,6 +1456,10 @@ class BaseItem: self._data[key] = value def name_icon(self): + """Icon shown next to name. + + Item must imlpement this method to change it. + """ return None @property @@ -1394,6 +1478,7 @@ class BaseItem: self._children.insert(row, item) def _get_global_data(self, role): + """Global data getter without column specification.""" if role == ITEM_TYPE_ROLE: return self.item_type @@ -1521,6 +1606,7 @@ class BaseItem: class RootItem(BaseItem): + """Invisible root item used as base item for model.""" item_type = "root" def __init__(self, model): @@ -1535,6 +1621,10 @@ class RootItem(BaseItem): class ProjectItem(BaseItem): + """Item representing project document in Mongo. + + Item is used only to read it's data. It is not possible to modify them. + """ item_type = "project" columns = { @@ -1578,21 +1668,32 @@ class ProjectItem(BaseItem): @property def project_id(self): + """Project Mongo ID.""" return self._mongo_id @property def asset_id(self): + """Should not be implemented. + + TODO Remove this method from ProjectItem. + """ return None @property def name(self): + """Project name""" return self._data["name"] def child_parents(self): + """Used by children AssetItems for filling `data.parents` key.""" return [] @classmethod def data_from_doc(cls, project_doc): + """Convert document data into item data. + + Project data are used as default value for it's children. + """ data = { "name": project_doc["name"], "type": project_doc["type"] @@ -1607,10 +1708,17 @@ class ProjectItem(BaseItem): return data def flags(self, *args, **kwargs): + """Project is enabled and selectable.""" return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable class AssetItem(BaseItem): + """Item represent asset document. + + Item have ability to set all required and optional data for OpenPype + workflow. Some of them are not modifiable in specific cases e.g. when asset + has published content it is not possible to change it's name or parent. + """ item_type = "asset" columns = { @@ -1693,34 +1801,57 @@ class AssetItem(BaseItem): @property def project_id(self): + """Access to project "parent" id which is always set.""" if self._project_id is None: self._project_id = self.parent().project_id return self._project_id @property def asset_id(self): + """Property access to mongo id.""" return self.mongo_id @property def is_new(self): + """Item was created during current project manager session.""" return self.asset_id is None @property def is_valid(self): + """Item is invalid for saving.""" if self._is_duplicated or not self._data["name"]: return False return True @property def name(self): + """Asset name. + + Returns: + str: If name is set. + None: If name is not yet set in that case is AssetItem marked as + invalid. + """ return self._data["name"] def child_parents(self): + """Chilren AssetItem can use this method to get it's parent names. + + This is used for `data.parents` key on document. + """ parents = self.parent().child_parents() parents.append(self.name) return parents def to_doc(self): + """Convert item to Mongo document matching asset schema. + + Method does no validate if item is valid or children are valid. + + Returns: + dict: Document with all related data about asset item also + contains task children. + """ tasks = {} for item in self.children(): if isinstance(item, TaskItem): @@ -1755,6 +1886,22 @@ class AssetItem(BaseItem): return doc def update_data(self): + """Changes dictionary ready for Mongo's update. + + Method should be used on save. There is not other usage of this method. + + # Example + ```python + { + "$set": { + "name": "new_name" + } + } + ``` + + Returns: + dict: May be empty if item was not changed. + """ if not self.mongo_id: return {} @@ -1791,6 +1938,8 @@ class AssetItem(BaseItem): @classmethod def data_from_doc(cls, asset_doc): + """Convert asset document from Mongo to item data.""" + # Minimum required data for cases that it is new AssetItem withoud doc data = { "name": None, "type": "asset" @@ -1810,6 +1959,7 @@ class AssetItem(BaseItem): return data def name_icon(self): + """Icon shown next to name.""" if self.__class__._name_icons is None: self.__class__._name_icons = ResourceCache.get_icons()["asset"] @@ -1824,6 +1974,7 @@ class AssetItem(BaseItem): return self.__class__._name_icons[icon_type] def _get_global_data(self, role): + """Global data getter without column specification.""" if role == HIERARCHY_CHANGE_ABLE_ROLE: return self._hierarchy_changes_enabled @@ -1853,6 +2004,8 @@ class AssetItem(BaseItem): return super(AssetItem, self).data(role, key) def setData(self, value, role, key=None): + # Store information that column has opened editor + # - DisplayRole for the column will return empty string if role == EDITOR_OPENED_ROLE: if key not in self._edited_columns: return False @@ -1863,12 +2016,15 @@ class AssetItem(BaseItem): self._removed = value return True + # This can be set only on project load (or save) if role == HIERARCHY_CHANGE_ABLE_ROLE: if self._hierarchy_changes_enabled == value: return False self._hierarchy_changes_enabled = value return True + # Do not allow to change name if item is marked to not be able do any + # hierarchical changes. if ( role == QtCore.Qt.EditRole and key == "name" @@ -1916,6 +2072,8 @@ class AssetItem(BaseItem): _item.setData(False, DUPLICATED_ROLE) def _rename_task(self, item): + # Skip processing if item is marked for removing + # - item is not in any of attributes below if item.data(REMOVED_ROLE): return @@ -1947,9 +2105,22 @@ class AssetItem(BaseItem): self._task_name_by_item_id[item_id] = new_name def on_task_name_change(self, task_item): + """Method called from TaskItem children on name change. + + Helps to handle duplicated task name validations. + """ + self._rename_task(task_item) def on_task_remove_state_change(self, task_item): + """Method called from children TaskItem to handle name duplications. + + Method is called when TaskItem children is marked for deletion or + deletion was reversed. + + Name is removed/added to task item mapping attribute and removed/added + to `_task_items_by_name` used for determination of duplicated tasks. + """ is_removed = task_item.data(REMOVED_ROLE) item_id = task_item.data(IDENTIFIER_ROLE) if is_removed: @@ -1976,18 +2147,35 @@ class AssetItem(BaseItem): _item.setData(True, DUPLICATED_ROLE) def add_child(self, item, row=None): + """Add new children. + + Args: + item(AssetItem, TaskItem): New added item. + row(int): Optionally can be passed on which row (index) should be + children added. + """ if item in self._children: return super(AssetItem, self).add_child(item, row) + # Call inner method for checking task name duplications if isinstance(item, TaskItem): self._add_task(item) def remove_child(self, item): + """Remove one of children from AssetItem children. + + Skipped if item is not children of item. + + Args: + item(AssetItem, TaskItem): Child item. + """ if item not in self._children: return + # Call inner method to remove task from registered task name + # validations. if isinstance(item, TaskItem): self._remove_task(item) @@ -1995,6 +2183,16 @@ class AssetItem(BaseItem): class TaskItem(BaseItem): + """Item representing Task item on Asset document. + + Always should be AssetItem children and never should have any other + childrens. + + It's name value should be validated with it's parent which only knows if + has same name as other sibling under same parent. + """ + + # String representation of item item_type = "task" columns = { @@ -2023,10 +2221,12 @@ class TaskItem(BaseItem): @property def is_new(self): + """Task was created during current project manager session.""" return self._is_new @property def is_valid(self): + """Task valid for saving.""" if self._is_duplicated or not self._data["type"]: return False if not self.data(QtCore.Qt.EditRole, "name"): @@ -2034,6 +2234,7 @@ class TaskItem(BaseItem): return True def name_icon(self): + """Icon shown next to name.""" if self.__class__._name_icons is None: self.__class__._name_icons = ResourceCache.get_icons()["task"] @@ -2048,9 +2249,11 @@ class TaskItem(BaseItem): return self.__class__._name_icons[icon_type] def add_child(self, item, row=None): + """Reimplement `add_child` to avoid adding items under task.""" raise AssertionError("BUG: Can't add children to Task") def _get_global_data(self, role): + """Global data getter without column specification.""" if role == REMOVED_ROLE: return self._removed @@ -2069,6 +2272,12 @@ class TaskItem(BaseItem): return super(TaskItem, self)._get_global_data(role) def to_doc_data(self): + """Data for Asset document. + + Returns: + dict: May be empty if task is marked as removed or with single key + dict with name as key and task data as value. + """ if self._removed: return {} data = copy.deepcopy(self._data) @@ -2084,6 +2293,7 @@ class TaskItem(BaseItem): return False return self._edited_columns[key] + # Return empty string if column is edited if role == QtCore.Qt.DisplayRole and self._edited_columns.get(key): return "" @@ -2091,6 +2301,7 @@ class TaskItem(BaseItem): if key == "type": return self._data["type"] + # Always require task type filled if key == "name": if not self._data["type"]: if role == QtCore.Qt.DisplayRole: @@ -2103,6 +2314,8 @@ class TaskItem(BaseItem): return super(TaskItem, self).data(role, key) def setData(self, value, role, key=None): + # Store information that item on a column is edited + # - DisplayRole will return empty string in that case if role == EDITOR_OPENED_ROLE: if key not in self._edited_columns: return False @@ -2110,12 +2323,14 @@ class TaskItem(BaseItem): return True if role == REMOVED_ROLE: + # Skip value change if is same as already set value if value == self._removed: return False self._removed = value self.parent().on_task_remove_state_change(self) return True + # Convert empty string to None on EditRole if ( role == QtCore.Qt.EditRole and key == "name" @@ -2126,6 +2341,7 @@ class TaskItem(BaseItem): result = super(TaskItem, self).setData(value, role, key) if role == QtCore.Qt.EditRole: + # Trigger task name change of parent AssetItem if ( key == "name" or (key == "type" and not self._data["name"]) From f7c8ec1c00741295ab18623a8a16991a32f6beb7 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Fri, 21 May 2021 01:46:42 +0200 Subject: [PATCH 3/8] even more comments --- .../project_manager/project_manager/model.py | 41 +++++++++++++++---- 1 file changed, 34 insertions(+), 7 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index ab4569947e..a045ecaf27 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -674,12 +674,26 @@ class HierarchyModel(QtCore.QAbstractItemModel): self._remove_item(item) def _remove_item(self, item): + """Remove item from model or mark item for deletion. + + Deleted items are using definition of QAbstractItemModel which call + `beginRemoveRows` and `endRemoveRows` to trigger proper view and proxy + model callbacks. + + Item is not just removed but is checked if can be removed from model or + just mark it for deletion for save. + + First of all will find all related children and based on their + attributes define if can be removed. + """ + # Skip if item is already marked for deletion is_removed = item.data(REMOVED_ROLE) if is_removed: return parent = item.parent() + # Find all descendants and store them by parent id all_descendants = collections.defaultdict(dict) all_descendants[parent.id][item.id] = item @@ -712,6 +726,8 @@ class HierarchyModel(QtCore.QAbstractItemModel): if isinstance(cur_item, AssetItem): self._rename_asset(cur_item, None) + # Process tasks as last because their logic is based on parent + # - tasks may be processed before parent check all asset children for task_item in task_children: _fill_children(_all_descendants, task_item, cur_item) return remove_item @@ -737,21 +753,29 @@ class HierarchyModel(QtCore.QAbstractItemModel): if not all_without_children: continue - parent_item = self._items_by_id[parent_id] + # Row ranges of items to remove + # - store tuples of row "start", "end" (can be the same) row_ranges = [] + # Predefine start, end variables start_row = end_row = None chilren_by_row = {} + parent_item = self._items_by_id[parent_id] for row in range(parent_item.rowCount()): child_item = parent_item.child(row) child_id = child_item.id + # Not sure if this can happend + # TODO validate this line it seems dangerous as start/end + # row is not changed 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: + removed_mark = child_item.data(REMOVED_ROLE) + if not removed_mark or not child_item.is_new: + # Skip row sequence store child for later processing + # and store current start/end row range modified_children.append(child_item) if end_row is not None: row_ranges.append((start_row, end_row)) @@ -765,11 +789,12 @@ class HierarchyModel(QtCore.QAbstractItemModel): 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) + if not row_ranges: + continue + # Remove items from model + parent_index = self.index_for_item(parent_item) + for start, end in row_ranges: self.beginRemoveRows(parent_index, start, end) for idx in range(start, end + 1): @@ -782,6 +807,8 @@ class HierarchyModel(QtCore.QAbstractItemModel): self.endRemoveRows() + # Trigger data change to repaint items + # - `BackgroundRole` is random role without any specific reason for item in modified_children: s_index = self.index_for_item(item) e_index = self.index_for_item(item, column=self.columns_len - 1) From c91a4637758c31e46d69e3bbc7cb4c23540feb92 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 10 Jun 2021 17:46:51 +0200 Subject: [PATCH 4/8] fixed typos --- openpype/tools/project_manager/project_manager/delegates.py | 2 +- openpype/tools/project_manager/project_manager/model.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/delegates.py b/openpype/tools/project_manager/project_manager/delegates.py index 0e8dd38e68..758ab079a9 100644 --- a/openpype/tools/project_manager/project_manager/delegates.py +++ b/openpype/tools/project_manager/project_manager/delegates.py @@ -172,7 +172,7 @@ class TypeDelegate(QtWidgets.QStyledItemDelegate): class ToolsDelegate(QtWidgets.QStyledItemDelegate): """Specific delegate for "tools_env" key. - Exected that editor will be used only on AssetItem which is only item that + Expected that editor will be used only on AssetItem which is only item that can have `tools_env` (except project). Delegate requires tools cache which is shared across all ToolsDelegate diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index a045ecaf27..8ec4fe627d 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -441,7 +441,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): return self.index_from_item(row, column, parent_item) def index_for_item(self, item, column=0): - """Index for passet item. + """Index for passed item. This is for cases that index operations are required on specific item. @@ -592,7 +592,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): """Remove deletion flag on items with matching ids. Flag is also removed on all parents of passed children as it wouldn't - make sence to not to do so. + make sense to not to do so. Children of passed item ids are by default also unset for deletion. From 30af1869b389dff522ad2ade95d89bb8fbc0a2d0 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 10 Jun 2021 18:07:26 +0200 Subject: [PATCH 5/8] added basic info about the tool --- openpype/tools/project_manager/__init__.py | 21 +++++++++++++++++++ .../project_manager/project_manager/window.py | 2 ++ 2 files changed, 23 insertions(+) diff --git a/openpype/tools/project_manager/__init__.py b/openpype/tools/project_manager/__init__.py index 62fa8af8aa..1c5bfdcbd5 100644 --- a/openpype/tools/project_manager/__init__.py +++ b/openpype/tools/project_manager/__init__.py @@ -1,3 +1,24 @@ +"""Project Manager tool + +Purpose of the tool is to be able create and modify hierarchy under project +ready for OpenPype pipeline usage. Tool also give ability to create new +projects. + +# Brief info +Project hierarchy consist of two types "asset" and "task". Assets can be +children of Project or other Asset. Task can be children of Asset. + +It is not possible to have duplicated Asset name across whole project. +It is not possible to have duplicated Task name under one Asset. + +Asset can't be moved or renamed if has or it's children has published content. + +Deleted assets are not deleted from database but their type is changed to +"archived_asset". + +Tool allows to modify Asset attributes like frame start/end, fps, etc. +""" + from .project_manager import ( ProjectManagerWindow, main diff --git a/openpype/tools/project_manager/project_manager/window.py b/openpype/tools/project_manager/project_manager/window.py index a800214517..ea68b02bbd 100644 --- a/openpype/tools/project_manager/project_manager/window.py +++ b/openpype/tools/project_manager/project_manager/window.py @@ -16,6 +16,8 @@ from avalon.api import AvalonMongoDB class ProjectManagerWindow(QtWidgets.QWidget): + """Main widget of Project Manager tool.""" + def __init__(self, parent=None): super(ProjectManagerWindow, self).__init__(parent) From 012139e389fdafb5193ee5bb9e75a58c2d712b44 Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 10 Jun 2021 18:12:24 +0200 Subject: [PATCH 6/8] added `the` into docstrings --- openpype/tools/project_manager/project_manager/delegates.py | 4 ++-- openpype/tools/project_manager/project_manager/model.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/delegates.py b/openpype/tools/project_manager/project_manager/delegates.py index 758ab079a9..842352cba1 100644 --- a/openpype/tools/project_manager/project_manager/delegates.py +++ b/openpype/tools/project_manager/project_manager/delegates.py @@ -172,8 +172,8 @@ class TypeDelegate(QtWidgets.QStyledItemDelegate): class ToolsDelegate(QtWidgets.QStyledItemDelegate): """Specific delegate for "tools_env" key. - Expected that editor will be used only on AssetItem which is only item that - can have `tools_env` (except project). + Expected that editor will be used only on AssetItem which is the only item + that can have `tools_env` (except project). Delegate requires tools cache which is shared across all ToolsDelegate objects. diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 8ec4fe627d..76e673f920 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -591,7 +591,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): def remove_delete_flag(self, item_ids, with_children=True): """Remove deletion flag on items with matching ids. - Flag is also removed on all parents of passed children as it wouldn't + The flag is also removed on all parents of passed children as it wouldn't make sense to not to do so. Children of passed item ids are by default also unset for deletion. From f95ea456b9009d93987da4c00fa51d791a5c06fa Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 10 Jun 2021 18:12:54 +0200 Subject: [PATCH 7/8] fix long line --- openpype/tools/project_manager/project_manager/model.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 76e673f920..bf8e450912 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -591,8 +591,8 @@ class HierarchyModel(QtCore.QAbstractItemModel): def remove_delete_flag(self, item_ids, with_children=True): """Remove deletion flag on items with matching ids. - The flag is also removed on all parents of passed children as it wouldn't - make sense to not to do so. + The flag is also removed on all parents of passed children as it + wouldn't make sense to not to do so. Children of passed item ids are by default also unset for deletion. From 008dc3b7d05454c647b8a1d12cb95e3e0a8822eb Mon Sep 17 00:00:00 2001 From: iLLiCiTiT Date: Thu, 10 Jun 2021 18:28:25 +0200 Subject: [PATCH 8/8] fixed on->from --- openpype/tools/project_manager/project_manager/model.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index bf8e450912..14c697dd5f 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -589,9 +589,9 @@ class HierarchyModel(QtCore.QAbstractItemModel): return None def remove_delete_flag(self, item_ids, with_children=True): - """Remove deletion flag on items with matching ids. + """Remove deletion flag from items with matching ids. - The flag is also removed on all parents of passed children as it + The flag is also removed from all parents of passed children as it wouldn't make sense to not to do so. Children of passed item ids are by default also unset for deletion.