diff --git a/client/ayon_core/hosts/batchpublisher/__init__.py b/client/ayon_core/hosts/batchpublisher/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/client/ayon_core/hosts/batchpublisher/addon.py b/client/ayon_core/hosts/batchpublisher/addon.py new file mode 100644 index 0000000000..a4eff54bf8 --- /dev/null +++ b/client/ayon_core/hosts/batchpublisher/addon.py @@ -0,0 +1,97 @@ +import click + +# from openpype.lib import get_openpype_execute_args +# from openpype.lib.execute import run_detached_process +from openpype.modules import OpenPypeModule, ITrayAction, IHostAddon + + +class BatchPublishAddon(OpenPypeModule, IHostAddon, ITrayAction): + label = "Batch Publisher" + name = "batchpublisher" + host_name = "batchpublisher" + + def initialize(self, modules_settings): + self.enabled = True + # UI which must not be created at this time + self._dialog = None + + def tray_init(self): + return + + def on_action_trigger(self): + self.show_dialog() + # self.run_batchpublisher() + + def connect_with_modules(self, enabled_modules): + """Collect publish paths from other modules.""" + return + + # def run_batchpublisher(self): + # name = "traypublisher" + # args = get_openpype_execute_args( + # "module", name, "launch" + # # "module", self.name, "launch" + # ) + # print("ARGS" , args) + # run_detached_process(args) + + def cli(self, click_group): + click_group.add_command(cli_main) + + def _create_dialog(self): + # # Don't recreate dialog if already exists + # if self._dialog is not None: + # return + + from importlib import reload + + import openpype.hosts.batchpublisher.controller + import openpype.hosts.batchpublisher.ui.batch_publisher_model + import openpype.hosts.batchpublisher.ui.batch_publisher_delegate + import openpype.hosts.batchpublisher.ui.batch_publisher_view + import openpype.hosts.batchpublisher.ui.window + + # TODO: These lines are only for testing current branch + reload(openpype.hosts.batchpublisher.controller) + reload(openpype.hosts.batchpublisher.ui.batch_publisher_model) + reload( + openpype.hosts.batchpublisher.ui.batch_publisher_delegate) + reload(openpype.hosts.batchpublisher.ui.batch_publisher_view) + reload(openpype.hosts.batchpublisher.ui.window) + + import openpype.hosts.batchpublisher.deadline + import openpype.hosts.batchpublisher.publish + import openpype.hosts.batchpublisher.utils + + reload(openpype.hosts.batchpublisher.deadline) + reload(openpype.hosts.batchpublisher.publish) + reload(openpype.hosts.batchpublisher.utils) + + # from openpype.hosts.batchpublisher.ui.window \ + # import BatchPublisherWindow + self._dialog = openpype.hosts.batchpublisher.ui.window. \ + BatchPublisherWindow() + + def show_dialog(self): + """Show dialog with connected modules. + This can be called from anywhere but can also crash in headless mode. + There is no way to prevent addon to do invalid operations if he's + not handling them. + """ + # Make sure dialog is created + self._create_dialog() + # Show dialog + self._dialog.show() + + +@click.group(BatchPublishAddon.name, help="BatchPublisher related commands.") +def cli_main(): + pass + + +@cli_main.command() +def launch(): + """Launch BatchPublisher tool UI.""" + print("LAUNCHING BATCH PUBLISHER") + from openpype.hosts.batchpublisher.ui import window + window.main() \ No newline at end of file diff --git a/client/ayon_core/hosts/batchpublisher/controller.py b/client/ayon_core/hosts/batchpublisher/controller.py new file mode 100644 index 0000000000..70b42262c9 --- /dev/null +++ b/client/ayon_core/hosts/batchpublisher/controller.py @@ -0,0 +1,379 @@ +import collections +import glob +import os +import re + +# from openpype.settings import get_project_settings +from openpype.client.entities import ( + get_projects, + get_assets, +) +from openpype.hosts.batchpublisher import publish + + +# List that contains dictionary including glob statement to check for match. +# If filepath matches then it becomes product type. +# TODO: add to OpenPype settings so other studios can change +GLOB_SEARCH_TO_PRODUCT_INFO_MAP = [ + { + "glob": "*/fbx/*.fbx", + "is_sequence": False, + "product_type": "model", + "representation_name": "fbx" + }, + { + "glob": "*/ma/*.ma", + "is_sequence": False, + "product_type": "model", + "representation_name": "maya" + }, +] + +# Dictionary that maps the extension name to the representation name. +# We only use this as fallback if +# GLOB_SEARCH_TO_PRODUCT_INFO_MAP doesn't match any glob statement. +# TODO: add to OpenPype settings so other studios can change +EXT_TO_REP_NAME_MAP = { + ".nk": "nuke", + ".ma": "maya", + ".mb": "maya", + ".hip": "houdini", + ".sfx": "silhouette", + ".mocha": "mocha", + ".psd": "photoshop" +} + +# Dictionary that maps the product type (family) to file extensions +# We only use this as fallback if +# GLOB_SEARCH_TO_PRODUCT_INFO_MAP doesn't match any glob statement. +# TODO: add to OpenPype settings so other studios can change +PRODUCT_TYPE_TO_EXT_MAP = { + "render": {".exr", ".dpx", ".tif", ".tiff", ".jpg", ".jpeg"}, + "pointcache": {".abc"}, + "camera": {".abc", ".fbx"}, + "reference": {".mov", ".mp4", ".mxf", ".avi", ".wmv"}, + "workfile": {".nk", ".ma", ".mb", ".hip", ".sfx", ".mocha", ".psd"}, + "distortion": {".nk", ".exr"}, + "color_grade": {".ccc", ".cc"}, +} + + +class ProductItem(object): + def __init__( + self, + filepath, + product_type, + representation_name, + product_name=None, + version=None, + comment=None, + enabled=True, + folder_path=None, + task_name=None, + frame_start=None, + frame_end=None): + self.enabled = enabled + self.filepath = filepath + self.product_type = product_type + self.product_name = product_name + self.representation_name = representation_name + self.version = version + self.folder_path = folder_path + self.comment = comment + self.task_name = task_name + self.frame_start = frame_start + self.frame_end = frame_end + + self.derive_product_name() + + def derive_product_name(self): + filename = os.path.basename(self.filepath) + filename_no_ext, extension = os.path.splitext(filename) + # Exclude possible frame in product name + product_name = filename_no_ext.split(".")[0] + # Add the product type as prefix to product name + if product_name.startswith("_"): + product_name = self.product_type + product_name + else: + product_name = self.product_type + "_" + product_name + # Try to extract version number from filename + self.version = None + results = re.findall("_v[0-9]*", self.filepath) + if results: + self.version = int(results[0].replace("_v", "")) + # Remove version from product name + self.product_name = re.sub("_v[0-9]*", "", product_name) + return self.product_name + + @property + def defined(self): + return all([ + self.filepath, + self.folder_path, + self.task_name, + self.product_type, + self.product_name, + self.representation_name]) + + +class HierarchyItem: + def __init__(self, folder_name, folder_path, folder_id, parent_id): + self.folder_name = folder_name + self.folder_path = folder_path + self.folder_id = folder_id + self.parent_id = parent_id + + +class BatchPublisherController(object): + + def __init__(self): + self._selected_project_name = None + self._project_names = None + self._asset_docs_by_project = {} + self._asset_docs_by_path = {} + + def get_project_names(self): + if self._project_names is None: + projects = get_projects(fields={"name"}) + project_names = [] + for project in projects: + project_names.append(project["name"]) + self._project_names = project_names + return self._project_names + + def get_selected_project_name(self): + return self._selected_project_name + + def set_selected_project_name(self, project_name): + self._selected_project_name = project_name + + def _get_asset_docs(self): + """ + Returns: + dict[str, dict]: Dictionary of asset documents by path. + """ + + project_name = self._selected_project_name + if not project_name: + return {} + + asset_docs = self._asset_docs_by_project.get(project_name) + if asset_docs is None: + asset_docs = list(get_assets( + project_name, + fields={ + "name", + # "data.visualParent", + "data.parents", + "data.tasks", + } + )) + asset_docs_by_path = self._prepare_assets_by_path(asset_docs) + self._asset_docs_by_project[project_name] = asset_docs_by_path + return self._asset_docs_by_project[project_name] + + def get_hierarchy_items(self): + """ + Returns: + list[HierarchyItem]: List of hierarchy items. + """ + + asset_docs = self._get_asset_docs() + if not asset_docs: + return [] + + output = [] + for folder_path, asset_doc in asset_docs.items(): + folder_name = asset_doc["name"] + folder_id = asset_doc["_id"] + parent_id = asset_doc["data"]["visualParent"] + hierarchy_item = HierarchyItem( + folder_name, folder_path, folder_id, parent_id) + output.append(hierarchy_item) + return output + + def get_task_names(self, folder_path): + asset_docs_by_path = self._get_asset_docs() + if not asset_docs_by_path: + return [] + asset_doc = asset_docs_by_path.get(folder_path) + if not asset_doc: + return [] + return list(asset_doc["data"]["tasks"].keys()) + + def _prepare_assets_by_path(self, asset_docs): + output = {} + for asset_doc in asset_docs: + parents = list(asset_doc["data"]["parents"]) + parents.append(asset_doc["name"]) + folder_path = "/" + "/".join(parents) + output[folder_path] = asset_doc + return output + + def get_product_items(self, directory): + """ + Returns: + list[ProductItem]: List of ingest files for the given directory + """ + product_items = collections.OrderedDict() + if not directory or not os.path.exists(directory): + return product_items + # project_name = self._selected_project_name + # project_settings = get_project_settings(project_name) + # file_mappings = project_settings["batchpublisher"].get( + # "file_mappings", []) + file_mappings = GLOB_SEARCH_TO_PRODUCT_INFO_MAP + for file_mapping in file_mappings: + product_type = file_mapping["product_type"] + glob_full_path = directory + "/" + file_mapping["glob"] + representation_name = file_mapping.get("representation_name") + files = glob.glob(glob_full_path, recursive=False) + for filepath in files: + filename = os.path.basename(filepath) + frame_start = None + frame_end = None + if filename.count(".") >= 2: + # Lets add the star in place of the frame number + filepath_parts = filepath.split(".") + filepath_parts[-2] = "#" * len(filepath_parts[-2]) + # Replace the file path with the version with star in it + _filepath = ".".join(filepath_parts) + frames = self._get_frames_for_filepath(_filepath) + if frames: + filepath = _filepath + frame_start = frames[0] + frame_end = frames[-1] + # Do not add ingest file path, if it's already been added + if filepath in product_items: + continue + _filename_no_ext, extension = os.path.splitext(filename) + # Create representation name from extension + representation_name = representation_name or \ + EXT_TO_REP_NAME_MAP.get(extension) + if not representation_name: + representation_name = extension.lstrip(".") + product_item = ProductItem( + filepath, + product_type, + representation_name, + frame_start=frame_start, + frame_end=frame_end) + product_items[filepath] = product_item + + # Walk the entire directory structure again + # and look for product items to add. + # This time we are looking for product types + # by PRODUCT_TYPE_TO_EXT_MAP + for root, _dirs, filenames in os.walk(directory, topdown=False): + for filename in filenames: + filepath = os.path.join(root, filename) + filename = os.path.basename(filepath) + # Get frame infomration (if any) + frame_start = None + frame_end = None + if filename.count(".") >= 2: + # Lets add the star in place of the frame number + filepath_parts = filepath.split(".") + filepath_parts[-2] = "*" + # Replace the file path with the version with star in it + _filepath = ".".join(filepath_parts) + frames = self._get_frames_for_filepath(_filepath) + if frames: + filepath = _filepath + frame_start = frames[0] + frame_end = frames[-1] + # Do not add ingest file path, if it's already been added + if filepath in product_items: + continue + _filename_no_ext, extension = os.path.splitext(filename) + product_type = None + for _product_type, extensions in \ + PRODUCT_TYPE_TO_EXT_MAP.items(): + if extension in extensions: + product_type = _product_type + break + if not product_type: + continue + # Create representation name from extension + representation_name = EXT_TO_REP_NAME_MAP.get(extension) + if not representation_name: + representation_name = extension.lstrip(".") + product_item = ProductItem( + filepath, + product_type, + representation_name, + frame_start=frame_start, + frame_end=frame_end) + product_items[filepath] = product_item + + return list(product_items.values()) + + def publish_product_items(self, product_items): + """ + Args: + product_items (list[ProductItem]): List of ingest files to publish. + """ + + for product_item in product_items: + if product_item.enabled and product_item.defined: + self._publish_product_item(product_item) + + def _publish_product_item(self, product_item): + msg = f""" +Publishing (ingesting): {product_item.filepath} +As Folder (Asset): {product_item.folder_path} +Task: {product_item.task_name} +Product Type (Family): {product_item.product_type} +Product Name (Subset): {product_item.product_name} +Representation: {product_item.representation_name} +Version: {product_item.version} +Comment: {product_item.comment} +Frame start: {product_item.frame_start} +Frame end: {product_item.frame_end} +Project: {self._selected_project_name} +""" + print(msg) + publish_data = dict() + publish_data["version"] = product_item.version + publish_data["comment"] = product_item.comment + expected_representations = dict() + expected_representations[product_item.representation_name] = \ + product_item.filepath + publish.publish_version_pyblish( + self._selected_project_name, + product_item.folder_path, + product_item.task_name, + product_item.product_type, + product_item.product_name, + expected_representations, + publish_data, + frame_start=product_item.frame_start, + frame_end=product_item.frame_end) + # publish.publish_version( + # self._selected_project_name, + # product_item.folder_path, + # product_item.task_name, + # product_item.product_type, + # product_item.product_name, + # expected_representations, + # publish_data) + # publish.publish_version( + # project_name, + # asset_name, + # task_name, + # family_name, + # subset_name, + # expected_representations, + # publish_data, + + def _get_frames_for_filepath(self, filepath): + # Collect all the frames found within the paths of glob search string + frames = list() + for _filepath in glob.glob(filepath): + filepath_parts = _filepath.split(".") + try: + frame = int(filepath_parts[-2]) + except Exception: + continue + frames.append(frame) + return sorted(frames) \ No newline at end of file diff --git a/client/ayon_core/hosts/batchpublisher/ui/__init__.py b/client/ayon_core/hosts/batchpublisher/ui/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/client/ayon_core/hosts/batchpublisher/ui/batch_publisher_delegate.py b/client/ayon_core/hosts/batchpublisher/ui/batch_publisher_delegate.py new file mode 100644 index 0000000000..fb4397125c --- /dev/null +++ b/client/ayon_core/hosts/batchpublisher/ui/batch_publisher_delegate.py @@ -0,0 +1,189 @@ +import collections + +from qtpy import QtWidgets, QtCore, QtGui + +from .batch_publisher_model import BatchPublisherModel + +FOLDER_PATH_ROLE = QtCore.Qt.UserRole + 1 + + +class BatchPublisherTableDelegate(QtWidgets.QStyledItemDelegate): + + def __init__(self, controller, parent=None): + super(BatchPublisherTableDelegate, self).__init__(parent) + self._controller = controller + + def createEditor(self, parent, option, index): + model = index.model() + ingest_file = model.get_product_items()[index.row()] + + if index.column() == BatchPublisherModel.COLUMN_OF_FOLDER: + editor = None + view = parent.parent() + # NOTE: Project name has been disabled to change from this dialog + accepted, _project_name, folder_path, task_name = \ + self._on_choose_context(ingest_file.folder_path) + if accepted and folder_path: + for _index in view.selectedIndexes(): + model.setData( + model.index(_index.row(), model.COLUMN_OF_FOLDER), + folder_path, + QtCore.Qt.EditRole) + if task_name: + model.setData( + model.index(_index.row(), model.COLUMN_OF_TASK), + task_name, + QtCore.Qt.EditRole) + # # clear the folder + # model.setData(index, None, QtCore.Qt.EditRole) + # # clear the task + # model.setData( + # model.index(index.row(), BatchPublisherModel.COLUMN_OF_TASK), + # None, + # QtCore.Qt.EditRole) + # treeview = QtWidgets.QTreeView() + # treeview.setEditTriggers(QtWidgets.QTreeView.NoEditTriggers) + # treeview.setSelectionBehavior(QtWidgets.QTreeView.SelectRows) + # treeview.setSelectionMode(QtWidgets.QTreeView.SingleSelection) + # treeview.setItemsExpandable(True) + # treeview.header().setVisible(False) + # treeview.setMinimumHeight(250) + # editor = ComboBox(parent) + # editor.setInsertPolicy(QtWidgets.QComboBox.NoInsert) + # editor.setView(treeview) + # model = QtGui.QStandardItemModel() + # editor.setModel(model) + # self._fill_model_with_hierarchy(model) + # editor.view().expandAll() + # # editor.showPopup() + # # editor = QtWidgets.QLineEdit(parent) + # # completer = QtWidgets.QCompleter(self._folder_names, self) + # # completer.setCaseSensitivity(QtCore.Qt.CaseInsensitive) + # # editor.setCompleter(completer) + return editor + + elif index.column() == BatchPublisherModel.COLUMN_OF_TASK: + task_names = self._controller.get_task_names( + ingest_file.folder_path) + # editor = QtWidgets.QLineEdit(parent) + # completer = QtWidgets.QCompleter( + # task_names, + # self) + # completer.setCaseSensitivity(QtCore.Qt.CaseInsensitive) + # editor.setCompleter(completer) + editor = QtWidgets.QComboBox(parent) + editor.addItems(task_names) + return editor + + elif index.column() == BatchPublisherModel.COLUMN_OF_PRODUCT_TYPE: + from openpype.plugins.publish import integrate + product_types = sorted(integrate.IntegrateAsset.families) + editor = QtWidgets.QComboBox(parent) + editor.addItems(product_types) + return editor + # return QtWidgets.QStyledItemDelegate.createEditor( + # self, + # parent, + # option, + # index) + + def setEditorData(self, editor, index): + # if index.column() == BatchPublisherModel.COLUMN_OF_FOLDER: + # editor.blockSignals(True) + # # value = index.data(QtCore.Qt.DisplayRole) + # # editor.setText(value) + # # Lets return the QComboxBox back to unselected state + # editor.setRootModelIndex(QtCore.QModelIndex()) + # editor.setCurrentIndex(-1) + # editor.blockSignals(False) + if index.column() == BatchPublisherModel.COLUMN_OF_TASK: + editor.blockSignals(True) + value = index.data(QtCore.Qt.DisplayRole) + row = editor.findText(value) + editor.setCurrentIndex(row) + editor.blockSignals(False) + elif index.column() == BatchPublisherModel.COLUMN_OF_PRODUCT_TYPE: + editor.blockSignals(True) + value = index.data(QtCore.Qt.DisplayRole) + row = editor.findText(value) + editor.setCurrentIndex(row) + editor.blockSignals(False) + + def setModelData(self, editor, model, index): + model = index.model() + # if index.column() == BatchPublisherModel.COLUMN_OF_FOLDER: + # # value = editor.text() + # value = editor.model().data( + # editor.view().currentIndex(), + # FOLDER_PATH_ROLE) + # model.setData(index, value, QtCore.Qt.EditRole) + if index.column() == BatchPublisherModel.COLUMN_OF_TASK: + value = editor.currentText() + model.setData(index, value, QtCore.Qt.EditRole) + elif index.column() == BatchPublisherModel.COLUMN_OF_PRODUCT_TYPE: + value = editor.currentText() + model.setData(index, value, QtCore.Qt.EditRole) + + def _fill_model_with_hierarchy(self, model): + hierarchy_items = self._controller.get_hierarchy_items() + hierarchy_items_by_parent_id = collections.defaultdict(list) + for hierarchy_item in hierarchy_items: + hierarchy_items_by_parent_id[hierarchy_item.parent_id].append( + hierarchy_item + ) + + root_item = model.invisibleRootItem() + + hierarchy_queue = collections.deque() + hierarchy_queue.append((root_item, None)) + + while hierarchy_queue: + (parent_item, parent_id) = hierarchy_queue.popleft() + new_rows = [] + for hierarchy_item in hierarchy_items_by_parent_id[parent_id]: + new_row = QtGui.QStandardItem(hierarchy_item.folder_name) + new_row.setData(hierarchy_item.folder_path, FOLDER_PATH_ROLE) + new_row.setData( + hierarchy_item.folder_path, QtCore.Qt.ToolTipRole) + # new_row.setFlags( + # QtCore.Qt.ItemIsSelectable | QtCore.Qt.ItemIsEnabled) + new_rows.append(new_row) + hierarchy_queue.append((new_row, hierarchy_item.folder_id)) + + if new_rows: + parent_item.appendRows(new_rows) + + def _on_choose_context(self, folder_path): + from openpype.tools.context_dialog import ContextDialog + project_name = self._controller.get_selected_project_name() + dialog = ContextDialog() + dialog._project_combobox.hide() + dialog.set_context( + project_name=project_name) + accepted = dialog.exec_() + if accepted: + context = dialog.get_context() + project = context["project"] + asset = context["asset"] + # AYON version of dialog stores the folder path + folder_path = context.get("folder_path") + if folder_path: + # Folder path returned by ContextDialog is missing slash + folder_path = "/" + folder_path + folder_path = folder_path or asset + task_name = context["task"] + return accepted, project, folder_path, task_name + return accepted, None, None, None + + +class ComboBox(QtWidgets.QComboBox): + + def keyPressEvent(self, event): + # This is to prevent pressing "a" button with folder cell + # selected and the "assets" is selected in QComboBox. + # A default behaviour coming from QComboBox, when key is pressed + # it selects first matching item in QComboBox root model index. + # We don't want to select the "assets", since its not a full path + # of folder. + if event.type() == QtCore.QEvent.KeyPress: + return \ No newline at end of file diff --git a/client/ayon_core/hosts/batchpublisher/ui/batch_publisher_model.py b/client/ayon_core/hosts/batchpublisher/ui/batch_publisher_model.py new file mode 100644 index 0000000000..4a9253651d --- /dev/null +++ b/client/ayon_core/hosts/batchpublisher/ui/batch_publisher_model.py @@ -0,0 +1,213 @@ +from qtpy import QtCore, QtGui + +from openpype.plugins.publish import integrate + + +class BatchPublisherModel(QtCore.QAbstractTableModel): + HEADER_LABELS = [ + str(), + "Filepath", + "Folder", + "Task", + "Product Type", + "Product Name", + "Representation", + "Version", + "Comment"] + COLUMN_OF_ENABLED = 0 + COLUMN_OF_FILEPATH = 1 + COLUMN_OF_FOLDER = 2 + COLUMN_OF_TASK = 3 + COLUMN_OF_PRODUCT_TYPE = 4 + COLUMN_OF_PRODUCT_NAME = 5 + COLUMN_OF_REPRESENTATION = 6 + COLUMN_OF_VERSION = 7 + COLUMN_OF_COMMENT = 8 + + def __init__(self, controller): + super(BatchPublisherModel, self).__init__() + + self._controller = controller + self._product_items = [] + + def set_current_directory(self, directory): + self._populate_from_directory(directory) + + def get_product_items(self): + return list(self._product_items) + + def rowCount(self, parent=None): + if parent is None: + parent = QtCore.QModelIndex() + return len(self._product_items) + + def columnCount(self, parent=None): + if parent is None: + parent = QtCore.QModelIndex() + return len(BatchPublisherModel.HEADER_LABELS) + + def headerData(self, section, orientation, role=None): + if role is None: + role = QtCore.Qt.DisplayRole + + if role != QtCore.Qt.DisplayRole: + return None + if orientation == QtCore.Qt.Horizontal: + return BatchPublisherModel.HEADER_LABELS[section] + + def setData(self, index, value, role=None): + column = index.column() + row = index.row() + product_item = self._product_items[row] + if role == QtCore.Qt.EditRole: + if column == BatchPublisherModel.COLUMN_OF_FILEPATH: + product_item.filepath = value + elif column == BatchPublisherModel.COLUMN_OF_FOLDER: + # Check folder path is valid in available docs. + # Folder path might also be reset to None. + asset_docs_by_path = self._controller._get_asset_docs() + if value is None or value in asset_docs_by_path: + # Update folder path + product_item.folder_path = value + # Update task name + product_item.task_name = None + task_names = self._controller.get_task_names(value) + if not product_item.task_name and task_names: + product_item.task_name = task_names[0] + elif column == BatchPublisherModel.COLUMN_OF_TASK: + # Check task is valid in availble task names. + # Task name might also be reset to None. + if value is None or value in self._controller.get_task_names( + product_item.folder_path): + product_item.task_name = value + elif column == BatchPublisherModel.COLUMN_OF_PRODUCT_TYPE: + # Check family is valid in available families + # Product type might also be reset to None. + if value is None or value in integrate.IntegrateAsset.families: + product_item.product_type = value + # Update the product name based on product type + product_item.derive_product_name() + roles = [QtCore.Qt.DisplayRole] + self.dataChanged.emit( + self.index( + row, BatchPublisherModel.COLUMN_OF_ENABLED), + self.index( + row, BatchPublisherModel.COLUMN_OF_COMMENT), + roles) + elif column == BatchPublisherModel.COLUMN_OF_PRODUCT_NAME: + product_item.product_name = value + elif column == BatchPublisherModel.COLUMN_OF_REPRESENTATION: + product_item.representation_name = value + elif column == BatchPublisherModel.COLUMN_OF_VERSION: + try: + product_item.version = int(value) + except Exception: + product_item.version = None + elif column == BatchPublisherModel.COLUMN_OF_COMMENT: + product_item.comment = value + return True + elif role == QtCore.Qt.CheckStateRole: + if column == BatchPublisherModel.COLUMN_OF_ENABLED: + enabled = True if value == QtCore.Qt.Checked else False + product_item.enabled = enabled + roles = [QtCore.Qt.ForegroundRole] + self.dataChanged.emit( + self.index(row, column), + self.index(row, BatchPublisherModel.COLUMN_OF_VERSION), + roles) + return True + + def data(self, index, role=QtCore.Qt.DisplayRole): + column = index.column() + row = index.row() + product_item = self._product_items[row] + if role == QtCore.Qt.DisplayRole or role == QtCore.Qt.EditRole: + if column == BatchPublisherModel.COLUMN_OF_FILEPATH: + return product_item.filepath + elif column == BatchPublisherModel.COLUMN_OF_FOLDER: + return product_item.folder_path + elif column == BatchPublisherModel.COLUMN_OF_TASK: + return product_item.task_name + elif column == BatchPublisherModel.COLUMN_OF_PRODUCT_TYPE: + return product_item.product_type + elif column == BatchPublisherModel.COLUMN_OF_PRODUCT_NAME: + return product_item.product_name + elif column == BatchPublisherModel.COLUMN_OF_REPRESENTATION: + return product_item.representation_name + elif column == BatchPublisherModel.COLUMN_OF_VERSION: + return str(product_item.version or "") + elif column == BatchPublisherModel.COLUMN_OF_COMMENT: + return product_item.comment + elif role == QtCore.Qt.ForegroundRole: + if product_item.defined and product_item.enabled: + return QtGui.QColor(240, 240, 240) + else: + return QtGui.QColor(120, 120, 120) + # elif role == QtCore.Qt.BackgroundRole: + # return QtGui.QColor(QtCore.Qt.white) + # elif role == QtCore.Qt.TextAlignmentRole: + # return QtCore.Qt.AlignRight + elif role == QtCore.Qt.ToolTipRole: + project_name = self._controller.get_selected_project_name() + task_names = self._controller.get_task_names( + product_item.folder_path) + tooltip = f""" +Enabled: {product_item.enabled} +
Filepath: {product_item.filepath} +
Folder (Asset): {product_item.folder_path} +
Task: {product_item.task_name} +
Product Type (Family): {product_item.product_type} +
Product Name (Subset): {product_item.product_name} +
Representation: {product_item.representation_name} +
Version: {product_item.version} +
Comment: {product_item.comment} +
Frame start: {product_item.frame_start} +
Frame end: {product_item.frame_end} +
Defined: {product_item.defined} +
Task Names: {task_names} +
Project: {project_name} +""" + return tooltip + + elif role == QtCore.Qt.CheckStateRole: + if column == BatchPublisherModel.COLUMN_OF_ENABLED: + return QtCore.Qt.Checked if product_item.enabled \ + else QtCore.Qt.Unchecked + elif role == QtCore.Qt.FontRole: + # if column in [ + # BatchPublisherModel.COLUMN_OF_FILEPATH, + # BatchPublisherModel.COLUMN_OF_PRODUCT_TYPE, + # BatchPublisherModel.COLUMN_OF_PRODUCT_NAME]: + font = QtGui.QFont() + font.setPointSize(9) + return font + + # return None + + def flags(self, index): + flags = QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable + if index.column() == BatchPublisherModel.COLUMN_OF_ENABLED: + flags |= QtCore.Qt.ItemIsUserCheckable + elif index.column() > BatchPublisherModel.COLUMN_OF_FILEPATH: + flags |= QtCore.Qt.ItemIsEditable + return flags + + def _populate_from_directory(self, directory): + print("model set_current_directory", directory) + self.beginResetModel() + self._product_items = self._controller.get_product_items( + directory + ) + self.endResetModel() + + def _change_project(self, project_name): + """Clear the existing picked folder names, since project changed""" + for row in range(self.rowCount()): + product_item = self._product_items[row] + product_item.folder_path = None + product_item.task_name = None + roles = [QtCore.Qt.DisplayRole] + self.dataChanged.emit( + self.index(row, self.COLUMN_OF_ENABLED), + self.index(row, self.COLUMN_OF_COMMENT), + roles) \ No newline at end of file diff --git a/client/ayon_core/hosts/batchpublisher/ui/batch_publisher_view.py b/client/ayon_core/hosts/batchpublisher/ui/batch_publisher_view.py new file mode 100644 index 0000000000..cfad2e88fe --- /dev/null +++ b/client/ayon_core/hosts/batchpublisher/ui/batch_publisher_view.py @@ -0,0 +1,140 @@ +import functools + +from qtpy import QtWidgets, QtCore, QtGui + +from .batch_publisher_model import BatchPublisherModel + + +class BatchPublisherTableView(QtWidgets.QTableView): + + def __init__(self, controller, parent=None): + super(BatchPublisherTableView, self).__init__(parent) + + model = BatchPublisherModel(controller) + self.setModel(model) + + # self.setEditTriggers(self.NoEditTriggers) + self.setSelectionMode(self.ExtendedSelection) + # self.setSelectionBehavior(self.SelectRows) + + self.setColumnWidth(model.COLUMN_OF_ENABLED, 22) + self.setColumnWidth(model.COLUMN_OF_FILEPATH, 700) + self.setColumnWidth(model.COLUMN_OF_FOLDER, 200) + self.setColumnWidth(model.COLUMN_OF_TASK, 90) + self.setColumnWidth(model.COLUMN_OF_PRODUCT_TYPE, 140) + self.setColumnWidth(model.COLUMN_OF_PRODUCT_NAME, 275) + self.setColumnWidth(model.COLUMN_OF_REPRESENTATION, 120) + self.setColumnWidth(model.COLUMN_OF_VERSION, 70) + self.setColumnWidth(model.COLUMN_OF_COMMENT, 120) + + self.setTextElideMode(QtCore.Qt.ElideNone) + self.setWordWrap(False) + + header = self.horizontalHeader() + header.setSectionResizeMode( + BatchPublisherModel.COLUMN_OF_FILEPATH, + header.Stretch) + self.verticalHeader().hide() + + self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + self.customContextMenuRequested.connect(self._open_menu) + + self._model = model + self._controller = controller + + def set_current_directory(self, directory): + print("view set_current_directory", directory) + self._model.set_current_directory(directory) + + def get_product_items(self): + return self._model.get_product_items() + + def commitData(self, editor): + super(BatchPublisherTableView, self).commitData(editor) + current_index = self.currentIndex() + model = self.currentIndex().model() + + # Apply edit role to every other row of selection + value = model.data(current_index, QtCore.Qt.EditRole) + for qmodelindex in self.selectedIndexes(): + # row = qmodelindex.row() + # product_item = model.product_items[row] + model.setData(qmodelindex, value, role=QtCore.Qt.EditRole) + + # When changing folder we need to propagate + # the chosen task value to every other row + if current_index.column() == BatchPublisherModel.COLUMN_OF_FOLDER: + qmodelindex_task = model.index( + current_index.row(), + BatchPublisherModel.COLUMN_OF_TASK) + value_task = model.data( + qmodelindex_task, + QtCore.Qt.DisplayRole) + for qmodelindex in self.selectedIndexes(): + qmodelindex_task = model.index( + qmodelindex.row(), + BatchPublisherModel.COLUMN_OF_TASK) + model.setData( + qmodelindex_task, + value_task, + QtCore.Qt.EditRole) + + def _open_menu(self, pos): + index = self.indexAt(pos) + if not index.isValid(): + return + product_items = self._model.get_product_items() + product_item = product_items[index.row()] + enabled = not product_item.enabled + + menu = QtWidgets.QMenu() + + action_copy = QtWidgets.QAction() + action_copy.setText("Copy selected text") + action_copy.triggered.connect( + functools.partial(self.__copy_selected_text, pos)) + menu.addAction(action_copy) + + action_paste = QtWidgets.QAction() + action_paste.setText("Paste text into selected cells") + action_paste.triggered.connect( + functools.partial(self.__paste_selected_text, pos)) + menu.addAction(action_paste) + + action_toggle_enabled = QtWidgets.QAction() + action_toggle_enabled.setText("Toggle enabled") + action_toggle_enabled.triggered.connect( + functools.partial(self.__toggle_selected_enabled, enabled)) + menu.addAction(action_toggle_enabled) + + menu.exec_(self.viewport().mapToGlobal(pos)) + + def __toggle_selected_enabled(self, enabled): + product_items = self._model.get_product_items() + for _index in self.selectedIndexes(): + product_item = product_items[_index.row()] + product_item.enabled = enabled + roles = [QtCore.Qt.DisplayRole] + self._model.dataChanged.emit( + self._model.index(_index.row(), self._model.COLUMN_OF_ENABLED), + self._model.index(_index.row(), self._model.COLUMN_OF_COMMENT), + roles) + + def __copy_selected_text(self, pos): + index = self.indexAt(pos) + if not index.isValid(): + return + value = self._model.data(index) + clipboard = QtWidgets.QApplication.clipboard() + clipboard.setText(value, QtGui.QClipboard.Clipboard) + + def __paste_selected_text(self, pos): + index = self.indexAt(pos) + if not index.isValid(): + return + value = QtWidgets.QApplication.clipboard().text() + column = index.column() + for index in self.selectedIndexes(): + if column == self._model.COLUMN_OF_FILEPATH: + continue + self._model.setData(index, value, QtCore.Qt.EditRole) \ No newline at end of file diff --git a/client/ayon_core/hosts/batchpublisher/ui/window.py b/client/ayon_core/hosts/batchpublisher/ui/window.py new file mode 100644 index 0000000000..48be7f54e4 --- /dev/null +++ b/client/ayon_core/hosts/batchpublisher/ui/window.py @@ -0,0 +1,181 @@ +from openpype import style + +from qtpy import QtWidgets + +from openpype.hosts.batchpublisher import controller +from .batch_publisher_model import BatchPublisherModel +from .batch_publisher_delegate import BatchPublisherTableDelegate +from .batch_publisher_view import BatchPublisherTableView + + +class BatchPublisherWindow(QtWidgets.QMainWindow): + + def __init__(self, parent=None): + super(BatchPublisherWindow, self).__init__(parent) + + self.setWindowTitle("AYON Batch Publisher") + self.resize(1750, 900) + + main_widget = QtWidgets.QWidget(self) + self.setCentralWidget(main_widget) + + # --- Top inputs (project, directory) --- + top_inputs_widget = QtWidgets.QWidget(self) + + self._project_combobox = QtWidgets.QComboBox(top_inputs_widget) + self._project_combobox.setSizePolicy( + QtWidgets.QSizePolicy.Expanding, + QtWidgets.QSizePolicy.Fixed) + + dir_inputs_widget = QtWidgets.QWidget(top_inputs_widget) + dir_input = QtWidgets.QLineEdit(dir_inputs_widget) + dir_browse_btn = QtWidgets.QPushButton("Browse", dir_inputs_widget) + + dir_inputs_layout = QtWidgets.QHBoxLayout(dir_inputs_widget) + dir_inputs_layout.setContentsMargins(0, 0, 0, 0) + dir_inputs_layout.addWidget(dir_input, 1) + dir_inputs_layout.addWidget(dir_browse_btn, 0) + + top_inputs_layout = QtWidgets.QFormLayout(top_inputs_widget) + top_inputs_layout.setContentsMargins(0, 0, 0, 0) + top_inputs_layout.addRow("Choose project", self._project_combobox) + # pushbutton_change_project = QtWidgets.QPushButton("Change project") + # top_inputs_layout.addRow(pushbutton_change_project) + top_inputs_layout.addRow("Directory to ingest", dir_inputs_widget) + + self._controller = controller.BatchPublisherController() + + # --- Main view --- + table_view = BatchPublisherTableView(self._controller, main_widget) + + # --- Footer --- + footer_widget = QtWidgets.QWidget(main_widget) + + publish_btn = QtWidgets.QPushButton("Publish", footer_widget) + + footer_layout = QtWidgets.QHBoxLayout(footer_widget) + footer_layout.setContentsMargins(0, 0, 0, 0) + footer_layout.addStretch(1) + footer_layout.addWidget(publish_btn, 0) + + # --- Main layout --- + main_layout = QtWidgets.QVBoxLayout(main_widget) + main_layout.setContentsMargins(12, 12, 12, 12) + main_layout.setSpacing(12) + main_layout.addWidget(top_inputs_widget, 0) + main_layout.addWidget(table_view, 1) + main_layout.addWidget(footer_widget, 0) + + self.setStyleSheet(style.load_stylesheet()) + + self._project_combobox.currentIndexChanged.connect( + self._on_project_changed) + # pushbutton_change_project.clicked.connect(self._on_project_changed) + dir_browse_btn.clicked.connect(self._on_browse_button_clicked) + publish_btn.clicked.connect(self._on_publish_button_clicked) + + editors_delegate = BatchPublisherTableDelegate(self._controller) + table_view.setItemDelegateForColumn( + BatchPublisherModel.COLUMN_OF_FOLDER, + editors_delegate) + table_view.setItemDelegateForColumn( + BatchPublisherModel.COLUMN_OF_TASK, + editors_delegate) + table_view.setItemDelegateForColumn( + BatchPublisherModel.COLUMN_OF_PRODUCT_TYPE, + editors_delegate) + dir_input.textChanged.connect(self._on_dir_change) + + # self._project_combobox = project_combobox + self._dir_input = dir_input + self._table_view = table_view + self._editors_delegate = editors_delegate + self._pushbutton_publish = publish_btn + + self._first_show = True + + def showEvent(self, event): + super(BatchPublisherWindow, self).showEvent(event) + if self._first_show: + self._first_show = False + self._on_first_show() + + def _on_first_show(self): + project_names = sorted(self._controller.get_project_names()) + for project_name in project_names: + self._project_combobox.addItem(project_name) + + def _on_project_changed(self): + project_name = str(self._project_combobox.currentText()) + self._controller.set_selected_project_name(project_name) + self._table_view._model._change_project(project_name) + + def _on_browse_button_clicked(self): + directory = self._dir_input.text() + directory = QtWidgets.QFileDialog.getExistingDirectory( + self, + dir=directory) + if not directory: + return + # Lets insure text changes even if the directory picked + # is the same as before + self._dir_input.blockSignals(True) + self._dir_input.setText(directory) + self._dir_input.blockSignals(False) + self._dir_input.textChanged.emit(directory) + + def _on_dir_change(self, directory): + print("_on_dir_changed") + self._table_view.set_current_directory(directory) + + def _on_publish_button_clicked(self): + product_items = self._table_view.get_product_items() + publish_count = 0 + enabled_count = 0 + defined_count = 0 + for product_item in product_items: + if product_item.enabled and product_item.defined: + publish_count += 1 + if product_item.enabled: + enabled_count += 1 + if product_item.defined: + defined_count += 1 + + if publish_count == 0: + msg = "You must provide asset, task, family, " + msg += "subset etc and they must be enabled" + QtWidgets.QMessageBox.warning( + None, + "No enabled and defined ingest items!", + msg) + return + elif publish_count > 0: + msg = "Are you sure you want to publish " + msg += "{} products".format(publish_count) + result = QtWidgets.QMessageBox.question( + None, + "Okay to publish?", + msg, + QtWidgets.QMessageBox.Yes | QtWidgets.QMessageBox.No) + if result == QtWidgets.QMessageBox.No: + print("User cancelled publishing") + return + elif enabled_count == 0: + QtWidgets.QMessageBox.warning( + None, + "Nothing enabled for publish!", + "There is no items enabled for publish") + return + elif defined_count == 0: + QtWidgets.QMessageBox.warning( + None, + "No defined ingest items!", + "You must provide asset, task, family, subset etc") + return + + self._controller.publish_product_items(product_items) + + +def main(): + batch_publisher = BatchPublisherWindow() + batch_publisher.show() \ No newline at end of file