diff --git a/pype/tools/assetcreator/app.py b/pype/tools/assetcreator/app.py index f3a2a25e9c..d35790c991 100644 --- a/pype/tools/assetcreator/app.py +++ b/pype/tools/assetcreator/app.py @@ -50,13 +50,13 @@ class Window(QtWidgets.QDialog): input_outlink.setStyleSheet("background-color: #333333;") checkbox_outlink = QtWidgets.QCheckBox("Use outlink") # Parent - label_parent = QtWidgets.QLabel("Parent:") + label_parent = QtWidgets.QLabel("*Parent:") input_parent = QtWidgets.QLineEdit() input_parent.setReadOnly(True) input_parent.setStyleSheet("background-color: #333333;") # Name - label_name = QtWidgets.QLabel("Name:") + label_name = QtWidgets.QLabel("*Name:") input_name = QtWidgets.QLineEdit() input_name.setPlaceholderText("") @@ -103,7 +103,7 @@ class Window(QtWidgets.QDialog): task_view = QtWidgets.QTreeView() task_view.setIndentation(0) - task_model = model.TasksTemplateModel() + task_model = model.TasksModel() task_view.setModel(task_model) info_layout.addWidget(inputs_widget) @@ -162,6 +162,7 @@ class Window(QtWidgets.QDialog): # signals btn_create_asset.clicked.connect(self.create_asset) assets.selection_changed.connect(self.on_asset_changed) + input_name.textChanged.connect(self.on_asset_name_change) checkbox_outlink.toggled.connect(self.on_outlink_checkbox_change) combo_task_template.currentTextChanged.connect( self.on_task_template_changed @@ -198,6 +199,8 @@ class Window(QtWidgets.QDialog): schemas_items = config.get_presets().get('ftrack', {}).get( 'project_schemas', {} ) + # Get info if it is silo project + self.silos = io.distinct("silo") key = "default" if schema_name in schemas_items: @@ -374,9 +377,6 @@ class Window(QtWidgets.QDialog): session.create('Task', task_data) av_project = io.find_one({'type': 'project'}) - silo = parent['silo'] - if silo is None: - silo = parent['name'] hiearchy_items = [] hiearchy_items.extend(self.get_avalon_parent(parent)) @@ -395,10 +395,14 @@ class Window(QtWidgets.QDialog): 'parent': av_project['_id'], 'name': name, 'schema': "avalon-core:asset-3.0", - 'silo': silo, 'type': 'asset', 'data': new_asset_data } + + # Backwards compatibility (add silo from parent if is silo project) + if self.silos: + new_asset_info["silo"] = parent["silo"] + try: schema.validate(new_asset_info) except Exception: @@ -576,17 +580,35 @@ class Window(QtWidgets.QDialog): assets_model = self.data["model"]["assets"] parent_input = self.data['inputs']['parent'] selected = assets_model.get_selected_assets() + + self.valid_parent = False if len(selected) > 1: - self.valid_parent = False parent_input.setText('< Please select only one asset! >') elif len(selected) == 1: - self.valid_parent = True - asset = io.find_one({"_id": selected[0], "type": "asset"}) - parent_input.setText(asset['name']) + if isinstance(selected[0], io.ObjectId): + self.valid_parent = True + asset = io.find_one({"_id": selected[0], "type": "asset"}) + parent_input.setText(asset['name']) + else: + parent_input.setText('< Selected invalid parent(silo) >') else: - self.valid_parent = False parent_input.setText('< Nothing is selected >') + self.creatability_check() + + def on_asset_name_change(self): + self.creatability_check() + + def creatability_check(self): + name_input = self.data['inputs']['name'] + name = str(name_input.text()).strip() + creatable = False + if name and self.valid_parent: + creatable = True + + self.data["buttons"]["create_asset"].setEnabled(creatable) + + def show(parent=None, debug=False, context=None): """Display Loader GUI diff --git a/pype/tools/assetcreator/model.py b/pype/tools/assetcreator/model.py index 0f74b1140c..b77ffa7a5d 100644 --- a/pype/tools/assetcreator/model.py +++ b/pype/tools/assetcreator/model.py @@ -3,26 +3,26 @@ import logging import collections from avalon.vendor.Qt import QtCore, QtWidgets -from avalon.vendor import qtawesome as awesome +from avalon.vendor import qtawesome from avalon import io from avalon import style log = logging.getLogger(__name__) -class Node(dict): - """A node that can be represented in a tree view. +class Item(dict): + """An item that can be represented in a tree view using `TreeModel`. - The node can store data just like a dictionary. + The item can store data just like a regular dictionary. >>> data = {"name": "John", "score": 10} - >>> node = Node(data) - >>> assert node["name"] == "John" + >>> item = Item(data) + >>> assert item["name"] == "John" """ def __init__(self, data=None): - super(Node, self).__init__() + super(Item, self).__init__() self._children = list() self._parent = None @@ -51,36 +51,36 @@ class Node(dict): def row(self): """ Returns: - int: Index of this node under parent""" + int: Index of this item under parent""" if self._parent is not None: siblings = self.parent().children() return siblings.index(self) def add_child(self, child): - """Add a child to this node""" + """Add a child to this item""" child._parent = self self._children.append(child) class TreeModel(QtCore.QAbstractItemModel): - COLUMNS = list() - NodeRole = QtCore.Qt.UserRole + 1 + Columns = list() + ItemRole = QtCore.Qt.UserRole + 1 def __init__(self, parent=None): super(TreeModel, self).__init__(parent) - self._root_node = Node() + self._root_item = Item() def rowCount(self, parent): if parent.isValid(): - node = parent.internalPointer() + item = parent.internalPointer() else: - node = self._root_node + item = self._root_item - return node.childCount() + return item.childCount() def columnCount(self, parent): - return len(self.COLUMNS) + return len(self.Columns) def data(self, index, role): @@ -89,17 +89,17 @@ class TreeModel(QtCore.QAbstractItemModel): if role == QtCore.Qt.DisplayRole or role == QtCore.Qt.EditRole: - node = index.internalPointer() + item = index.internalPointer() column = index.column() - key = self.COLUMNS[column] - return node.get(key, None) + key = self.Columns[column] + return item.get(key, None) - if role == self.NodeRole: + if role == self.ItemRole: return index.internalPointer() def setData(self, index, value, role=QtCore.Qt.EditRole): - """Change the data on the nodes. + """Change the data on the items. Returns: bool: Whether the edit was successful @@ -108,10 +108,10 @@ class TreeModel(QtCore.QAbstractItemModel): if index.isValid(): if role == QtCore.Qt.EditRole: - node = index.internalPointer() + item = index.internalPointer() column = index.column() - key = self.COLUMNS[column] - node[key] = value + key = self.Columns[column] + item[key] = value # passing `list()` for PyQt5 (see PYSIDE-462) self.dataChanged.emit(index, index, list()) @@ -123,78 +123,96 @@ class TreeModel(QtCore.QAbstractItemModel): def setColumns(self, keys): assert isinstance(keys, (list, tuple)) - self.COLUMNS = keys + self.Columns = keys def headerData(self, section, orientation, role): if role == QtCore.Qt.DisplayRole: - if section < len(self.COLUMNS): - return self.COLUMNS[section] + if section < len(self.Columns): + return self.Columns[section] super(TreeModel, self).headerData(section, orientation, role) def flags(self, index): - return ( - QtCore.Qt.ItemIsEnabled | - QtCore.Qt.ItemIsSelectable - ) + flags = QtCore.Qt.ItemIsEnabled + + item = index.internalPointer() + if item.get("enabled", True): + flags |= QtCore.Qt.ItemIsSelectable + + return flags def parent(self, index): - node = index.internalPointer() - parent_node = node.parent() + item = index.internalPointer() + parent_item = item.parent() # If it has no parents we return invalid - if parent_node == self._root_node or not parent_node: + if parent_item == self._root_item or not parent_item: return QtCore.QModelIndex() - return self.createIndex(parent_node.row(), 0, parent_node) + return self.createIndex(parent_item.row(), 0, parent_item) def index(self, row, column, parent): """Return index for row/column under parent""" if not parent.isValid(): - parentNode = self._root_node + parent_item = self._root_item else: - parentNode = parent.internalPointer() + parent_item = parent.internalPointer() - childItem = parentNode.child(row) - if childItem: - return self.createIndex(row, column, childItem) + child_item = parent_item.child(row) + if child_item: + return self.createIndex(row, column, child_item) else: return QtCore.QModelIndex() - def add_child(self, node, parent=None): + def add_child(self, item, parent=None): if parent is None: - parent = self._root_node + parent = self._root_item - parent.add_child(node) + parent.add_child(item) def column_name(self, column): """Return column key by index""" - if column < len(self.COLUMNS): - return self.COLUMNS[column] + if column < len(self.Columns): + return self.Columns[column] def clear(self): self.beginResetModel() - self._root_node = Node() + self._root_item = Item() self.endResetModel() -class TasksTemplateModel(TreeModel): +class TasksModel(TreeModel): """A model listing the tasks combined for a list of assets""" - COLUMNS = ["Tasks"] + Columns = ["Tasks"] def __init__(self): - super(TasksTemplateModel, self).__init__() - self.selectable = False + super(TasksModel, self).__init__() + self._num_assets = 0 self._icons = { - "__default__": awesome.icon("fa.folder-o", - color=style.colors.default) + "__default__": qtawesome.icon("fa.male", + color=style.colors.default), + "__no_task__": qtawesome.icon("fa.exclamation-circle", + color=style.colors.mid) } + self._get_task_icons() + + def _get_task_icons(self): + # Get the project configured icons from database + project = io.find_one({"type": "project"}) + tasks = project["config"].get("tasks", []) + for task in tasks: + icon_name = task.get("icon", None) + if icon_name: + icon = qtawesome.icon("fa.{}".format(icon_name), + color=style.colors.default) + self._icons[task["name"]] = icon + def set_tasks(self, tasks): """Set assets to track by their database id @@ -213,23 +231,28 @@ class TasksTemplateModel(TreeModel): icon = self._icons["__default__"] for task in tasks: - node = Node({ + item = Item({ "Tasks": task, "icon": icon }) - self.add_child(node) + self.add_child(item) self.endResetModel() def flags(self, index): - if self.selectable is False: - return QtCore.Qt.ItemIsEnabled - else: - return ( - QtCore.Qt.ItemIsEnabled | - QtCore.Qt.ItemIsSelectable - ) + return QtCore.Qt.ItemIsEnabled + + def headerData(self, section, orientation, role): + + # Override header for count column to show amount of assets + # it is listing the tasks for + if role == QtCore.Qt.DisplayRole: + if orientation == QtCore.Qt.Horizontal: + if section == 1: # count column + return "count ({0})".format(self._num_assets) + + return super(TasksModel, self).headerData(section, orientation, role) def data(self, index, role): @@ -239,9 +262,9 @@ class TasksTemplateModel(TreeModel): # Add icon to the first column if role == QtCore.Qt.DecorationRole: if index.column() == 0: - return index.internalPointer()['icon'] + return index.internalPointer()["icon"] - return super(TasksTemplateModel, self).data(index, role) + return super(TasksModel, self).data(index, role) class DeselectableTreeView(QtWidgets.QTreeView): @@ -259,33 +282,6 @@ class DeselectableTreeView(QtWidgets.QTreeView): QtWidgets.QTreeView.mousePressEvent(self, event) -class ExactMatchesFilterProxyModel(QtCore.QSortFilterProxyModel): - """Filter model to where key column's value is in the filtered tags""" - - def __init__(self, *args, **kwargs): - super(ExactMatchesFilterProxyModel, self).__init__(*args, **kwargs) - self._filters = set() - - def setFilters(self, filters): - self._filters = set(filters) - - def filterAcceptsRow(self, source_row, source_parent): - - # No filter - if not self._filters: - return True - - else: - model = self.sourceModel() - column = self.filterKeyColumn() - idx = model.index(source_row, column, source_parent) - data = model.data(idx, self.filterRole()) - if data in self._filters: - return True - else: - return False - - class RecursiveSortFilterProxyModel(QtCore.QSortFilterProxyModel): """Filters to the regex if any of the children matches allow parent""" def filterAcceptsRow(self, row, parent): diff --git a/pype/tools/assetcreator/widget.py b/pype/tools/assetcreator/widget.py index c6fa10697f..13dc982865 100644 --- a/pype/tools/assetcreator/widget.py +++ b/pype/tools/assetcreator/widget.py @@ -1,14 +1,15 @@ import logging import contextlib +import collections -from avalon.vendor import qtawesome as awesome +from avalon.vendor import qtawesome from avalon.vendor.Qt import QtWidgets, QtCore, QtGui from avalon import io from avalon import style from .model import ( TreeModel, - Node, + Item, RecursiveSortFilterProxyModel, DeselectableTreeView ) @@ -150,7 +151,7 @@ class AssetModel(TreeModel): """ - COLUMNS = ["label"] + Columns = ["label"] Name = 0 Deprecated = 2 ObjectId = 3 @@ -162,50 +163,88 @@ class AssetModel(TreeModel): super(AssetModel, self).__init__(parent=parent) self.refresh() - def _add_hierarchy(self, parent=None): + def _add_hierarchy(self, assets, parent=None, silos=None): + """Add the assets that are related to the parent as children items. - # Find the assets under the parent - find_data = { - "type": "asset" - } - if parent is None: - find_data['$or'] = [ - {'data.visualParent': {'$exists': False}}, - {'data.visualParent': None} - ] - else: - find_data["data.visualParent"] = parent['_id'] + This method does *not* query the database. These instead are queried + in a single batch upfront as an optimization to reduce database + queries. Resulting in up to 10x speed increase. - assets = io.find(find_data).sort('name', 1) - for asset in assets: + Args: + assets (dict): All assets in the currently active silo stored + by key/value + + Returns: + None + + """ + if silos: + # WARNING: Silo item "_id" is set to silo value + # mainly because GUI issue with perserve selection and expanded row + # and because of easier hierarchy parenting (in "assets") + for silo in silos: + item = Item({ + "_id": silo, + "name": silo, + "label": silo, + "type": "silo" + }) + self.add_child(item, parent=parent) + self._add_hierarchy(assets, parent=item) + + parent_id = parent["_id"] if parent else None + current_assets = assets.get(parent_id, list()) + + for asset in current_assets: # get label from data, otherwise use name data = asset.get("data", {}) - label = data.get("label", asset['name']) + label = data.get("label", asset["name"]) tags = data.get("tags", []) # store for the asset for optimization deprecated = "deprecated" in tags - node = Node({ - "_id": asset['_id'], + item = Item({ + "_id": asset["_id"], "name": asset["name"], "label": label, - "type": asset['type'], + "type": asset["type"], "tags": ", ".join(tags), "deprecated": deprecated, "_document": asset }) - self.add_child(node, parent=parent) + self.add_child(item, parent=parent) - # Add asset's children recursively - self._add_hierarchy(node) + # Add asset's children recursively if it has children + if asset["_id"] in assets: + self._add_hierarchy(assets, parent=item) def refresh(self): """Refresh the data for the model.""" self.clear() self.beginResetModel() - self._add_hierarchy(parent=None) + + # Get all assets in current silo sorted by name + db_assets = io.find({"type": "asset"}).sort("name", 1) + silos = db_assets.distinct("silo") or None + + # Group the assets by their visual parent's id + assets_by_parent = collections.defaultdict(list) + for asset in db_assets: + parent_id = ( + asset.get("data", {}).get("visualParent") or + asset.get("silo") + ) + assets_by_parent[parent_id].append(asset) + + # Build the hierarchical tree items recursively + self._add_hierarchy( + assets_by_parent, + parent=None, + silos=silos + ) + self.endResetModel() def flags(self, index): @@ -216,15 +255,17 @@ class AssetModel(TreeModel): if not index.isValid(): return - node = index.internalPointer() + item = index.internalPointer() if role == QtCore.Qt.DecorationRole: # icon column = index.column() if column == self.Name: # Allow a custom icon and custom icon color to be defined - data = node["_document"]["data"] + data = item.get("_document", {}).get("data", {}) icon = data.get("icon", None) + if icon is None and item.get("type") == "silo": + icon = "database" color = data.get("color", style.colors.default) if icon is None: @@ -235,12 +276,12 @@ class AssetModel(TreeModel): icon = "folder" if has_children else "folder-o" # Make the color darker when the asset is deprecated - if node.get("deprecated", False): + if item.get("deprecated", False): color = QtGui.QColor(color).darker(250) try: key = "fa.{0}".format(icon) # font-awesome key - icon = awesome.icon(key, color=color) + icon = qtawesome.icon(key, color=color) return icon except Exception as exception: # Log an error message instead of erroring out completely @@ -250,32 +291,18 @@ class AssetModel(TreeModel): return if role == QtCore.Qt.ForegroundRole: # font color - if "deprecated" in node.get("tags", []): + if "deprecated" in item.get("tags", []): return QtGui.QColor(style.colors.light).darker(250) if role == self.ObjectIdRole: - return node.get("_id", None) + return item.get("_id", None) if role == self.DocumentRole: - return node.get("_document", None) + return item.get("_document", None) return super(AssetModel, self).data(index, role) -class AssetView(DeselectableTreeView): - """Item view. - - This implements a context menu. - - """ - - def __init__(self): - super(AssetView, self).__init__() - self.setIndentation(15) - self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) - self.setHeaderHidden(True) - - class AssetWidget(QtWidgets.QWidget): """A Widget to display a tree of assets with filter @@ -286,7 +313,6 @@ class AssetWidget(QtWidgets.QWidget): """ - silo_changed = QtCore.Signal(str) # on silo combobox change assets_refreshed = QtCore.Signal() # on model refresh selection_changed = QtCore.Signal() # on view selection change current_changed = QtCore.Signal() # on view current index change @@ -300,17 +326,21 @@ class AssetWidget(QtWidgets.QWidget): layout.setSpacing(4) # Tree View - model = AssetModel() + model = AssetModel(self) proxy = RecursiveSortFilterProxyModel() proxy.setSourceModel(model) proxy.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive) - view = AssetView() + + view = DeselectableTreeView() + view.setIndentation(15) + view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + view.setHeaderHidden(True) view.setModel(proxy) # Header header = QtWidgets.QHBoxLayout() - icon = awesome.icon("fa.refresh", color=style.colors.light) + icon = qtawesome.icon("fa.refresh", color=style.colors.light) refresh = QtWidgets.QPushButton(icon, "") refresh.setToolTip("Refresh items") @@ -337,7 +367,14 @@ class AssetWidget(QtWidgets.QWidget): self.view = view def _refresh_model(self): - self.model.refresh() + with preserve_expanded_rows( + self.view, column=0, role=self.model.ObjectIdRole + ): + with preserve_selection( + self.view, column=0, role=self.model.ObjectIdRole + ): + self.model.refresh() + self.assets_refreshed.emit() def refresh(self): @@ -346,7 +383,7 @@ class AssetWidget(QtWidgets.QWidget): def get_active_asset(self): """Return the asset id the current asset.""" current = self.view.currentIndex() - return current.data(self.model.ObjectIdRole) + return current.data(self.model.ItemRole) def get_active_index(self): return self.view.currentIndex() @@ -357,7 +394,7 @@ class AssetWidget(QtWidgets.QWidget): rows = selection.selectedRows() return [row.data(self.model.ObjectIdRole) for row in rows] - def select_assets(self, assets, expand=True): + def select_assets(self, assets, expand=True, key="name"): """Select assets by name. Args: @@ -370,8 +407,14 @@ class AssetWidget(QtWidgets.QWidget): """ # TODO: Instead of individual selection optimize for many assets - assert isinstance(assets, - (tuple, list)), "Assets must be list or tuple" + if not isinstance(assets, (tuple, list)): + assets = [assets] + assert isinstance( + assets, (tuple, list) + ), "Assets must be list or tuple" + + # convert to list - tuple cant be modified + assets = list(assets) # Clear selection selection_model = self.view.selectionModel() @@ -379,16 +422,25 @@ class AssetWidget(QtWidgets.QWidget): # Select mode = selection_model.Select | selection_model.Rows - for index in _iter_model_rows(self.proxy, - column=0, - include_root=False): - data = index.data(self.model.NodeRole) - name = data['name'] - if name in assets: - selection_model.select(index, mode) + for index in iter_model_rows( + self.proxy, column=0, include_root=False + ): + # stop iteration if there are no assets to process + if not assets: + break - if expand: - self.view.expand(index) + value = index.data(self.model.ItemRole).get(key) + if value not in assets: + continue - # Set the currently active index - self.view.setCurrentIndex(index) + # Remove processed asset + assets.pop(assets.index(value)) + + selection_model.select(index, mode) + + if expand: + # Expand parent index + self.view.expand(self.proxy.parent(index)) + + # Set the currently active index + self.view.setCurrentIndex(index)