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