diff --git a/openpype/pipeline/create/__init__.py b/openpype/pipeline/create/__init__.py index b0877d0a29..c89fb04c42 100644 --- a/openpype/pipeline/create/__init__.py +++ b/openpype/pipeline/create/__init__.py @@ -6,6 +6,7 @@ from .constants import ( from .subset_name import ( TaskNotSetError, + get_subset_name_template, get_subset_name, ) @@ -46,6 +47,7 @@ __all__ = ( "PRE_CREATE_THUMBNAIL_KEY", "TaskNotSetError", + "get_subset_name_template", "get_subset_name", "CreatorError", diff --git a/openpype/pipeline/create/subset_name.py b/openpype/pipeline/create/subset_name.py index f508263708..ed05dd6083 100644 --- a/openpype/pipeline/create/subset_name.py +++ b/openpype/pipeline/create/subset_name.py @@ -14,6 +14,53 @@ class TaskNotSetError(KeyError): super(TaskNotSetError, self).__init__(msg) +def get_subset_name_template( + project_name, + family, + task_name, + task_type, + host_name, + default_template=None, + project_settings=None +): + """Get subset name template based on passed context. + + Args: + project_name (str): Project on which the context lives. + family (str): Family (subset type) for which the subset name is + calculated. + host_name (str): Name of host in which the subset name is calculated. + task_name (str): Name of task in which context the subset is created. + task_type (str): Type of task in which context the subset is created. + default_template (Union[str, None]): Default template which is used if + settings won't find any matching possitibility. Constant + 'DEFAULT_SUBSET_TEMPLATE' is used if not defined. + project_settings (Union[Dict[str, Any], None]): Prepared settings for + project. Settings are queried if not passed. + """ + + if project_settings is None: + project_settings = get_project_settings(project_name) + tools_settings = project_settings["global"]["tools"] + profiles = tools_settings["creator"]["subset_name_profiles"] + filtering_criteria = { + "families": family, + "hosts": host_name, + "tasks": task_name, + "task_types": task_type + } + + matching_profile = filter_profiles(profiles, filtering_criteria) + template = None + if matching_profile: + template = matching_profile["template"] + + # Make sure template is set (matching may have empty string) + if not template: + template = default_template or DEFAULT_SUBSET_TEMPLATE + return template + + def get_subset_name( family, variant, @@ -37,9 +84,9 @@ def get_subset_name( Args: family (str): Instance family. - variant (str): In most of cases it is user input during creation. + variant (str): In most of the cases it is user input during creation. task_name (str): Task name on which context is instance created. - asset_doc (dict): Queried asset document with it's tasks in data. + asset_doc (dict): Queried asset document with its tasks in data. Used to get task type. project_name (str): Name of project on which is instance created. Important for project settings that are loaded. @@ -50,15 +97,15 @@ def get_subset_name( is not passed. dynamic_data (dict): Dynamic data specific for a creator which creates instance. - dbcon (AvalonMongoDB): Mongo connection to be able query asset document - if 'asset_doc' is not passed. + project_settings (Union[Dict[str, Any], None]): Prepared settings for + project. Settings are queried if not passed. """ if not family: return "" if not host_name: - host_name = os.environ["AVALON_APP"] + host_name = os.environ.get("AVALON_APP") # Use only last part of class family value split by dot (`.`) family = family.rsplit(".", 1)[-1] @@ -70,27 +117,15 @@ def get_subset_name( task_info = asset_tasks.get(task_name) or {} task_type = task_info.get("type") - # Get settings - if not project_settings: - project_settings = get_project_settings(project_name) - tools_settings = project_settings["global"]["tools"] - profiles = tools_settings["creator"]["subset_name_profiles"] - filtering_criteria = { - "families": family, - "hosts": host_name, - "tasks": task_name, - "task_types": task_type - } - - matching_profile = filter_profiles(profiles, filtering_criteria) - template = None - if matching_profile: - template = matching_profile["template"] - - # Make sure template is set (matching may have empty string) - if not template: - template = default_template or DEFAULT_SUBSET_TEMPLATE - + template = get_subset_name_template( + project_name, + family, + task_name, + task_type, + host_name, + default_template=default_template, + project_settings=project_settings + ) # Simple check of task name existence for template with {task} in # - missing task should be possible only in Standalone publisher if not task_name and "{task" in template.lower(): diff --git a/openpype/pipeline/load/__init__.py b/openpype/pipeline/load/__init__.py index e96f64f2a4..8bd09876bf 100644 --- a/openpype/pipeline/load/__init__.py +++ b/openpype/pipeline/load/__init__.py @@ -1,6 +1,7 @@ from .utils import ( HeroVersionType, + LoadError, IncompatibleLoaderError, InvalidRepresentationContext, diff --git a/openpype/pipeline/load/utils.py b/openpype/pipeline/load/utils.py index 784d4628f3..e2b3675115 100644 --- a/openpype/pipeline/load/utils.py +++ b/openpype/pipeline/load/utils.py @@ -60,6 +60,16 @@ class HeroVersionType(object): return self.version.__format__(format_spec) +class LoadError(Exception): + """Known error that happened during loading. + + A message is shown to user (without traceback). Make sure an artist can + understand the problem. + """ + + pass + + class IncompatibleLoaderError(ValueError): """Error when Loader is incompatible with a representation.""" pass diff --git a/openpype/plugins/load/push_to_library.py b/openpype/plugins/load/push_to_library.py new file mode 100644 index 0000000000..dd7291e686 --- /dev/null +++ b/openpype/plugins/load/push_to_library.py @@ -0,0 +1,52 @@ +import os + +from openpype import PACKAGE_DIR +from openpype.lib import get_openpype_execute_args, run_detached_process +from openpype.pipeline import load +from openpype.pipeline.load import LoadError + + +class PushToLibraryProject(load.SubsetLoaderPlugin): + """Export selected versions to folder structure from Template""" + + is_multiple_contexts_compatible = True + + representations = ["*"] + families = ["*"] + + label = "Push to Library project" + order = 35 + icon = "send" + color = "#d8d8d8" + + def load(self, contexts, name=None, namespace=None, options=None): + filtered_contexts = [ + context + for context in contexts + if context.get("project") and context.get("version") + ] + if not filtered_contexts: + raise LoadError("Nothing to push for your selection") + + if len(filtered_contexts) > 1: + raise LoadError("Please select only one item") + + context = tuple(filtered_contexts)[0] + push_tool_script_path = os.path.join( + PACKAGE_DIR, + "tools", + "push_to_project", + "app.py" + ) + project_doc = context["project"] + version_doc = context["version"] + project_name = project_doc["name"] + version_id = str(version_doc["_id"]) + + args = get_openpype_execute_args( + "run", + push_tool_script_path, + "--project", project_name, + "--version", version_id + ) + run_detached_process(args) diff --git a/openpype/style/style.css b/openpype/style/style.css index a7a48cdb9d..da477eeefa 100644 --- a/openpype/style/style.css +++ b/openpype/style/style.css @@ -148,6 +148,10 @@ QPushButton::menu-indicator { padding-right: 5px; } +QPushButton[state="error"] { + background: {color:publisher:error}; +} + QToolButton { border: 0px solid transparent; background: {color:bg-buttons}; @@ -1416,6 +1420,13 @@ CreateNextPageOverlay { } /* Globally used names */ +#ValidatedLineEdit[state="valid"], #ValidatedLineEdit[state="valid"]:focus, #ValidatedLineEdit[state="valid"]:hover { + border-color: {color:publisher:success}; +} +#ValidatedLineEdit[state="invalid"], #ValidatedLineEdit[state="invalid"]:focus, #ValidatedLineEdit[state="invalid"]:hover { + border-color: {color:publisher:error}; +} + #Separator { background: {color:bg-menu-separator}; } diff --git a/openpype/tools/loader/widgets.py b/openpype/tools/loader/widgets.py index b1619ca02b..faef6c8a26 100644 --- a/openpype/tools/loader/widgets.py +++ b/openpype/tools/loader/widgets.py @@ -29,6 +29,7 @@ from openpype.pipeline.load import ( load_with_repre_context, load_with_subset_context, load_with_subset_contexts, + LoadError, IncompatibleLoaderError, ) from openpype.tools.utils import ( @@ -1581,6 +1582,7 @@ def _load_representations_by_loader(loader, repre_contexts, repre_context, options=options ) + except IncompatibleLoaderError as exc: print(exc) error_info.append(( @@ -1592,10 +1594,13 @@ def _load_representations_by_loader(loader, repre_contexts, )) except Exception as exc: - exc_type, exc_value, exc_traceback = sys.exc_info() - formatted_traceback = "".join(traceback.format_exception( - exc_type, exc_value, exc_traceback - )) + formatted_traceback = None + if not isinstance(exc, LoadError): + exc_type, exc_value, exc_traceback = sys.exc_info() + formatted_traceback = "".join(traceback.format_exception( + exc_type, exc_value, exc_traceback + )) + error_info.append(( str(exc), formatted_traceback, @@ -1620,7 +1625,7 @@ def _load_subsets_by_loader(loader, subset_contexts, options, error_info = [] if options is None: # not load when cancelled - return + return error_info if loader.is_multiple_contexts_compatible: subset_names = [] @@ -1635,13 +1640,14 @@ def _load_subsets_by_loader(loader, subset_contexts, options, subset_contexts, options=options ) + except Exception as exc: - exc_type, exc_value, exc_traceback = sys.exc_info() - formatted_traceback = "".join( - traceback.format_exception( + formatted_traceback = None + if not isinstance(exc, LoadError): + exc_type, exc_value, exc_traceback = sys.exc_info() + formatted_traceback = "".join(traceback.format_exception( exc_type, exc_value, exc_traceback - ) - ) + )) error_info.append(( str(exc), formatted_traceback, @@ -1661,13 +1667,15 @@ def _load_subsets_by_loader(loader, subset_contexts, options, subset_context, options=options ) + except Exception as exc: - exc_type, exc_value, exc_traceback = sys.exc_info() - formatted_traceback = "\n".join( - traceback.format_exception( + formatted_traceback = None + if not isinstance(exc, LoadError): + exc_type, exc_value, exc_traceback = sys.exc_info() + formatted_traceback = "".join(traceback.format_exception( exc_type, exc_value, exc_traceback - ) - ) + )) + error_info.append(( str(exc), formatted_traceback, diff --git a/openpype/tools/push_to_project/__init__.py b/openpype/tools/push_to_project/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openpype/tools/push_to_project/app.py b/openpype/tools/push_to_project/app.py new file mode 100644 index 0000000000..9ca5fd83e9 --- /dev/null +++ b/openpype/tools/push_to_project/app.py @@ -0,0 +1,41 @@ +import click +from qtpy import QtWidgets, QtCore + +from openpype.tools.push_to_project.window import PushToContextSelectWindow + + +@click.command() +@click.option("--project", help="Source project name") +@click.option("--version", help="Source version id") +def main(project, version): + """Run PushToProject tool to integrate version in different project. + + Args: + project (str): Source project name. + version (str): Version id. + """ + + app = QtWidgets.QApplication.instance() + if not app: + # 'AA_EnableHighDpiScaling' must be set before app instance creation + high_dpi_scale_attr = getattr( + QtCore.Qt, "AA_EnableHighDpiScaling", None + ) + if high_dpi_scale_attr is not None: + QtWidgets.QApplication.setAttribute(high_dpi_scale_attr) + + app = QtWidgets.QApplication([]) + + attr = getattr(QtCore.Qt, "AA_UseHighDpiPixmaps", None) + if attr is not None: + app.setAttribute(attr) + + window = PushToContextSelectWindow() + window.show() + window.controller.set_source(project, version) + + app.exec_() + + +if __name__ == "__main__": + main() diff --git a/openpype/tools/push_to_project/control_context.py b/openpype/tools/push_to_project/control_context.py new file mode 100644 index 0000000000..e4058893d5 --- /dev/null +++ b/openpype/tools/push_to_project/control_context.py @@ -0,0 +1,678 @@ +import re +import collections +import threading + +from openpype.client import ( + get_projects, + get_assets, + get_asset_by_id, + get_subset_by_id, + get_version_by_id, + get_representations, +) +from openpype.settings import get_project_settings +from openpype.lib import prepare_template_data +from openpype.lib.events import EventSystem +from openpype.pipeline.create import ( + SUBSET_NAME_ALLOWED_SYMBOLS, + get_subset_name_template, +) + +from .control_integrate import ( + ProjectPushItem, + ProjectPushItemProcess, + ProjectPushItemStatus, +) + + +class AssetItem: + def __init__( + self, + entity_id, + name, + icon_name, + icon_color, + parent_id, + has_children + ): + self.id = entity_id + self.name = name + self.icon_name = icon_name + self.icon_color = icon_color + self.parent_id = parent_id + self.has_children = has_children + + @classmethod + def from_doc(cls, asset_doc, has_children=True): + parent_id = asset_doc["data"].get("visualParent") + if parent_id is not None: + parent_id = str(parent_id) + return cls( + str(asset_doc["_id"]), + asset_doc["name"], + asset_doc["data"].get("icon"), + asset_doc["data"].get("color"), + parent_id, + has_children + ) + + +class TaskItem: + def __init__(self, asset_id, name, task_type, short_name): + self.asset_id = asset_id + self.name = name + self.task_type = task_type + self.short_name = short_name + + @classmethod + def from_asset_doc(cls, asset_doc, project_doc): + asset_tasks = asset_doc["data"].get("tasks") or {} + project_task_types = project_doc["config"]["tasks"] + output = [] + for task_name, task_info in asset_tasks.items(): + task_type = task_info.get("type") + task_type_info = project_task_types.get(task_type) or {} + output.append(cls( + asset_doc["_id"], + task_name, + task_type, + task_type_info.get("short_name") + )) + return output + + +class EntitiesModel: + def __init__(self, event_system): + self._event_system = event_system + self._project_names = None + self._project_docs_by_name = {} + self._assets_by_project = {} + self._tasks_by_asset_id = collections.defaultdict(dict) + + def has_cached_projects(self): + return self._project_names is None + + def has_cached_assets(self, project_name): + if not project_name: + return True + return project_name in self._assets_by_project + + def has_cached_tasks(self, project_name): + return self.has_cached_assets(project_name) + + def get_projects(self): + if self._project_names is None: + self.refresh_projects() + return list(self._project_names) + + def get_assets(self, project_name): + if project_name not in self._assets_by_project: + self.refresh_assets(project_name) + return dict(self._assets_by_project[project_name]) + + def get_asset_by_id(self, project_name, asset_id): + return self._assets_by_project[project_name].get(asset_id) + + def get_tasks(self, project_name, asset_id): + if not project_name or not asset_id: + return [] + + if project_name not in self._tasks_by_asset_id: + self.refresh_assets(project_name) + + all_task_items = self._tasks_by_asset_id[project_name] + asset_task_items = all_task_items.get(asset_id) + if not asset_task_items: + return [] + return list(asset_task_items) + + def refresh_projects(self, force=False): + self._event_system.emit( + "projects.refresh.started", {}, "entities.model" + ) + if force or self._project_names is None: + project_names = [] + project_docs_by_name = {} + for project_doc in get_projects(): + library_project = project_doc["data"].get("library_project") + if not library_project: + continue + project_name = project_doc["name"] + project_names.append(project_name) + project_docs_by_name[project_name] = project_doc + self._project_names = project_names + self._project_docs_by_name = project_docs_by_name + self._event_system.emit( + "projects.refresh.finished", {}, "entities.model" + ) + + def _refresh_assets(self, project_name): + asset_items_by_id = {} + task_items_by_asset_id = {} + self._assets_by_project[project_name] = asset_items_by_id + self._tasks_by_asset_id[project_name] = task_items_by_asset_id + if not project_name: + return + + project_doc = self._project_docs_by_name[project_name] + asset_docs_by_parent_id = collections.defaultdict(list) + for asset_doc in get_assets(project_name): + parent_id = asset_doc["data"].get("visualParent") + asset_docs_by_parent_id[parent_id].append(asset_doc) + + hierarchy_queue = collections.deque() + for asset_doc in asset_docs_by_parent_id[None]: + hierarchy_queue.append(asset_doc) + + while hierarchy_queue: + asset_doc = hierarchy_queue.popleft() + children = asset_docs_by_parent_id[asset_doc["_id"]] + asset_item = AssetItem.from_doc(asset_doc, len(children) > 0) + asset_items_by_id[asset_item.id] = asset_item + task_items_by_asset_id[asset_item.id] = ( + TaskItem.from_asset_doc(asset_doc, project_doc) + ) + for child in children: + hierarchy_queue.append(child) + + def refresh_assets(self, project_name, force=False): + self._event_system.emit( + "assets.refresh.started", + {"project_name": project_name}, + "entities.model" + ) + + if force or project_name not in self._assets_by_project: + self._refresh_assets(project_name) + + self._event_system.emit( + "assets.refresh.finished", + {"project_name": project_name}, + "entities.model" + ) + + +class SelectionModel: + def __init__(self, event_system): + self._event_system = event_system + + self.project_name = None + self.asset_id = None + self.task_name = None + + def select_project(self, project_name): + if self.project_name == project_name: + return + + self.project_name = project_name + self._event_system.emit( + "project.changed", + {"project_name": project_name}, + "selection.model" + ) + + def select_asset(self, asset_id): + if self.asset_id == asset_id: + return + self.asset_id = asset_id + self._event_system.emit( + "asset.changed", + { + "project_name": self.project_name, + "asset_id": asset_id + }, + "selection.model" + ) + + def select_task(self, task_name): + if self.task_name == task_name: + return + self.task_name = task_name + self._event_system.emit( + "task.changed", + { + "project_name": self.project_name, + "asset_id": self.asset_id, + "task_name": task_name + }, + "selection.model" + ) + + +class UserPublishValues: + """Helper object to validate values required for push to different project. + + Args: + event_system (EventSystem): Event system to catch and emit events. + new_asset_name (str): Name of new asset name. + variant (str): Variant for new subset name in new project. + """ + + asset_name_regex = re.compile("^[a-zA-Z0-9_.]+$") + variant_regex = re.compile("^[{}]+$".format(SUBSET_NAME_ALLOWED_SYMBOLS)) + + def __init__(self, event_system): + self._event_system = event_system + self._new_asset_name = None + self._variant = None + self._comment = None + self._is_variant_valid = False + self._is_new_asset_name_valid = False + + self.set_new_asset("") + self.set_variant("") + self.set_comment("") + + @property + def new_asset_name(self): + return self._new_asset_name + + @property + def variant(self): + return self._variant + + @property + def comment(self): + return self._comment + + @property + def is_variant_valid(self): + return self._is_variant_valid + + @property + def is_new_asset_name_valid(self): + return self._is_new_asset_name_valid + + @property + def is_valid(self): + return self.is_variant_valid and self.is_new_asset_name_valid + + def set_variant(self, variant): + if variant == self._variant: + return + + old_variant = self._variant + old_is_valid = self._is_variant_valid + + self._variant = variant + is_valid = False + if variant: + is_valid = self.variant_regex.match(variant) is not None + self._is_variant_valid = is_valid + + changes = { + key: {"new": new, "old": old} + for key, old, new in ( + ("variant", old_variant, variant), + ("is_valid", old_is_valid, is_valid) + ) + } + + self._event_system.emit( + "variant.changed", + { + "variant": variant, + "is_valid": self._is_variant_valid, + "changes": changes + }, + "user_values" + ) + + def set_new_asset(self, asset_name): + if self._new_asset_name == asset_name: + return + old_asset_name = self._new_asset_name + old_is_valid = self._is_new_asset_name_valid + self._new_asset_name = asset_name + is_valid = True + if asset_name: + is_valid = ( + self.asset_name_regex.match(asset_name) is not None + ) + self._is_new_asset_name_valid = is_valid + changes = { + key: {"new": new, "old": old} + for key, old, new in ( + ("new_asset_name", old_asset_name, asset_name), + ("is_valid", old_is_valid, is_valid) + ) + } + + self._event_system.emit( + "new_asset_name.changed", + { + "new_asset_name": self._new_asset_name, + "is_valid": self._is_new_asset_name_valid, + "changes": changes + }, + "user_values" + ) + + def set_comment(self, comment): + if comment == self._comment: + return + old_comment = self._comment + self._comment = comment + self._event_system.emit( + "comment.changed", + { + "comment": comment, + "changes": { + "comment": {"new": comment, "old": old_comment} + } + }, + "user_values" + ) + + +class PushToContextController: + def __init__(self, project_name=None, version_id=None): + self._src_project_name = None + self._src_version_id = None + self._src_asset_doc = None + self._src_subset_doc = None + self._src_version_doc = None + + event_system = EventSystem() + entities_model = EntitiesModel(event_system) + selection_model = SelectionModel(event_system) + user_values = UserPublishValues(event_system) + + self._event_system = event_system + self._entities_model = entities_model + self._selection_model = selection_model + self._user_values = user_values + + event_system.add_callback("project.changed", self._on_project_change) + event_system.add_callback("asset.changed", self._invalidate) + event_system.add_callback("variant.changed", self._invalidate) + event_system.add_callback("new_asset_name.changed", self._invalidate) + + self._submission_enabled = False + self._process_thread = None + self._process_item = None + + self.set_source(project_name, version_id) + + def _get_task_info_from_repre_docs(self, asset_doc, repre_docs): + asset_tasks = asset_doc["data"].get("tasks") or {} + found_comb = [] + for repre_doc in repre_docs: + context = repre_doc["context"] + task_info = context.get("task") + if task_info is None: + continue + + task_name = None + task_type = None + if isinstance(task_info, str): + task_name = task_info + asset_task_info = asset_tasks.get(task_info) or {} + task_type = asset_task_info.get("type") + + elif isinstance(task_info, dict): + task_name = task_info.get("name") + task_type = task_info.get("type") + + if task_name and task_type: + return task_name, task_type + + if task_name: + found_comb.append((task_name, task_type)) + + for task_name, task_type in found_comb: + return task_name, task_type + return None, None + + def _get_src_variant(self): + project_name = self._src_project_name + version_doc = self._src_version_doc + asset_doc = self._src_asset_doc + repre_docs = get_representations( + project_name, version_ids=[version_doc["_id"]] + ) + task_name, task_type = self._get_task_info_from_repre_docs( + asset_doc, repre_docs + ) + + project_settings = get_project_settings(project_name) + subset_doc = self.src_subset_doc + family = subset_doc["data"].get("family") + if not family: + family = subset_doc["data"]["families"][0] + template = get_subset_name_template( + self._src_project_name, + family, + task_name, + task_type, + None, + project_settings=project_settings + ) + template_low = template.lower() + variant_placeholder = "{variant}" + if ( + variant_placeholder not in template_low + or (not task_name and "{task" in template_low) + ): + return "" + + idx = template_low.index(variant_placeholder) + template_s = template[:idx] + template_e = template[idx + len(variant_placeholder):] + fill_data = prepare_template_data({ + "family": family, + "task": task_name + }) + try: + subset_s = template_s.format(**fill_data) + subset_e = template_e.format(**fill_data) + except Exception as exc: + print("Failed format", exc) + return "" + + subset_name = self.src_subset_doc["name"] + if ( + (subset_s and not subset_name.startswith(subset_s)) + or (subset_e and not subset_name.endswith(subset_e)) + ): + return "" + + if subset_s: + subset_name = subset_name[len(subset_s):] + if subset_e: + subset_name = subset_name[:len(subset_e)] + return subset_name + + def set_source(self, project_name, version_id): + if ( + project_name == self._src_project_name + and version_id == self._src_version_id + ): + return + + self._src_project_name = project_name + self._src_version_id = version_id + asset_doc = None + subset_doc = None + version_doc = None + if project_name and version_id: + version_doc = get_version_by_id(project_name, version_id) + + if version_doc: + subset_doc = get_subset_by_id(project_name, version_doc["parent"]) + + if subset_doc: + asset_doc = get_asset_by_id(project_name, subset_doc["parent"]) + + self._src_asset_doc = asset_doc + self._src_subset_doc = subset_doc + self._src_version_doc = version_doc + if asset_doc: + self.user_values.set_new_asset(asset_doc["name"]) + variant = self._get_src_variant() + if variant: + self.user_values.set_variant(variant) + + comment = version_doc["data"].get("comment") + if comment: + self.user_values.set_comment(comment) + + self._event_system.emit( + "source.changed", { + "project_name": project_name, + "version_id": version_id + }, + "controller" + ) + + @property + def src_project_name(self): + return self._src_project_name + + @property + def src_version_id(self): + return self._src_version_id + + @property + def src_label(self): + if not self._src_project_name or not self._src_version_id: + return "Source is not defined" + + asset_doc = self.src_asset_doc + if not asset_doc: + return "Source is invalid" + + asset_path_parts = list(asset_doc["data"]["parents"]) + asset_path_parts.append(asset_doc["name"]) + asset_path = "/".join(asset_path_parts) + subset_doc = self.src_subset_doc + version_doc = self.src_version_doc + return "Source: {}/{}/{}/v{:0>3}".format( + self._src_project_name, + asset_path, + subset_doc["name"], + version_doc["name"] + ) + + @property + def src_version_doc(self): + return self._src_version_doc + + @property + def src_subset_doc(self): + return self._src_subset_doc + + @property + def src_asset_doc(self): + return self._src_asset_doc + + @property + def event_system(self): + return self._event_system + + @property + def model(self): + return self._entities_model + + @property + def selection_model(self): + return self._selection_model + + @property + def user_values(self): + return self._user_values + + @property + def submission_enabled(self): + return self._submission_enabled + + def _on_project_change(self, event): + project_name = event["project_name"] + self.model.refresh_assets(project_name) + self._invalidate() + + def _invalidate(self): + submission_enabled = self._check_submit_validations() + if submission_enabled == self._submission_enabled: + return + self._submission_enabled = submission_enabled + self._event_system.emit( + "submission.enabled.changed", + {"enabled": submission_enabled}, + "controller" + ) + + def _check_submit_validations(self): + if not self._user_values.is_valid: + return False + + if not self.selection_model.project_name: + return False + + if ( + not self._user_values.new_asset_name + and not self.selection_model.asset_id + ): + return False + + return True + + def get_selected_asset_name(self): + project_name = self._selection_model.project_name + asset_id = self._selection_model.asset_id + if not project_name or not asset_id: + return None + asset_item = self._entities_model.get_asset_by_id( + project_name, asset_id + ) + if asset_item: + return asset_item.name + return None + + def submit(self, wait=True): + if not self.submission_enabled: + return + + if self._process_thread is not None: + return + + item = ProjectPushItem( + self.src_project_name, + self.src_version_id, + self.selection_model.project_name, + self.selection_model.asset_id, + self.selection_model.task_name, + self.user_values.variant, + comment=self.user_values.comment, + new_asset_name=self.user_values.new_asset_name, + dst_version=1 + ) + + status_item = ProjectPushItemStatus(event_system=self._event_system) + process_item = ProjectPushItemProcess(item, status_item) + self._process_item = process_item + self._event_system.emit("submit.started", {}, "controller") + if wait: + self._submit_callback() + self._process_item = None + return process_item + + thread = threading.Thread(target=self._submit_callback) + self._process_thread = thread + thread.start() + return process_item + + def wait_for_process_thread(self): + if self._process_thread is None: + return + self._process_thread.join() + self._process_thread = None + + def _submit_callback(self): + process_item = self._process_item + if process_item is None: + return + process_item.process() + self._event_system.emit("submit.finished", {}, "controller") + if process_item is self._process_item: + self._process_item = None diff --git a/openpype/tools/push_to_project/control_integrate.py b/openpype/tools/push_to_project/control_integrate.py new file mode 100644 index 0000000000..819724ad4c --- /dev/null +++ b/openpype/tools/push_to_project/control_integrate.py @@ -0,0 +1,1184 @@ +import os +import re +import copy +import socket +import itertools +import datetime +import sys +import traceback + +from bson.objectid import ObjectId + +from openpype.client import ( + get_project, + get_assets, + get_asset_by_id, + get_subset_by_id, + get_subset_by_name, + get_version_by_id, + get_last_version_by_subset_id, + get_version_by_name, + get_representations, +) +from openpype.client.operations import ( + OperationsSession, + new_asset_document, + new_subset_document, + new_version_doc, + new_representation_doc, + prepare_version_update_data, + prepare_representation_update_data, +) +from openpype.modules import ModulesManager +from openpype.lib import ( + StringTemplate, + get_openpype_username, + get_formatted_current_time, + source_hash, +) + +from openpype.lib.file_transaction import FileTransaction +from openpype.settings import get_project_settings +from openpype.pipeline import Anatomy +from openpype.pipeline.template_data import get_template_data +from openpype.pipeline.publish import get_publish_template_name +from openpype.pipeline.create import get_subset_name + +UNKNOWN = object() + + +class PushToProjectError(Exception): + pass + + +class FileItem(object): + def __init__(self, path): + self.path = path + + @property + def is_valid_file(self): + return os.path.exists(self.path) and os.path.isfile(self.path) + + +class SourceFile(FileItem): + def __init__(self, path, frame=None, udim=None): + super(SourceFile, self).__init__(path) + self.frame = frame + self.udim = udim + + def __repr__(self): + subparts = [self.__class__.__name__] + if self.frame is not None: + subparts.append("frame: {}".format(self.frame)) + if self.udim is not None: + subparts.append("UDIM: {}".format(self.udim)) + + return "<{}> '{}'".format(" - ".join(subparts), self.path) + + +class ResourceFile(FileItem): + def __init__(self, path, relative_path): + super(ResourceFile, self).__init__(path) + self.relative_path = relative_path + + def __repr__(self): + return "<{}> '{}'".format(self.__class__.__name__, self.relative_path) + + +class ProjectPushItem: + def __init__( + self, + src_project_name, + src_version_id, + dst_project_name, + dst_asset_id, + dst_task_name, + variant, + comment=None, + new_asset_name=None, + dst_version=None + ): + self.src_project_name = src_project_name + self.src_version_id = src_version_id + self.dst_project_name = dst_project_name + self.dst_asset_id = dst_asset_id + self.dst_task_name = dst_task_name + self.dst_version = dst_version + self.variant = variant + self.new_asset_name = new_asset_name + self.comment = comment or "" + self._id = "|".join([ + src_project_name, + src_version_id, + dst_project_name, + str(dst_asset_id), + str(new_asset_name), + str(dst_task_name), + str(dst_version) + ]) + + @property + def id(self): + return self._id + + def __repr__(self): + return "<{} - {}>".format(self.__class__.__name__, self.id) + + +class StatusMessage: + def __init__(self, message, level): + self.message = message + self.level = level + + def __str__(self): + return "{}: {}".format(self.level.upper(), self.message) + + def __repr__(self): + return "<{} - {}> {}".format( + self.__class__.__name__, self.level.upper, self.message + ) + + +class ProjectPushItemStatus: + def __init__( + self, + failed=False, + finished=False, + fail_reason=None, + formatted_traceback=None, + messages=None, + event_system=None + ): + if messages is None: + messages = [] + self._failed = failed + self._finished = finished + self._fail_reason = fail_reason + self._traceback = formatted_traceback + self._messages = messages + self._event_system = event_system + + def emit_event(self, topic, data=None): + if self._event_system is None: + return + + self._event_system.emit(topic, data or {}, "push.status") + + def get_finished(self): + """Processing of push to project finished. + + Returns: + bool: Finished. + """ + + return self._finished + + def set_finished(self, finished=True): + """Mark status as finished. + + Args: + finished (bool): Processing finished (failed or not). + """ + + if finished != self._finished: + self._finished = finished + self.emit_event("push.finished.changed", {"finished": finished}) + + finished = property(get_finished, set_finished) + + def set_failed(self, fail_reason, exc_info=None): + """Set status as failed. + + Attribute 'fail_reason' can change automatically based on passed value. + Reason is unset if 'failed' is 'False' and is set do default reason if + is set to 'True' and reason is not set. + + Args: + failed (bool): Push to project failed. + fail_reason (str): Reason why failed. + """ + + failed = True + if not fail_reason and not exc_info: + failed = False + + full_traceback = None + if exc_info is not None: + full_traceback = "".join(traceback.format_exception(*exc_info)) + if not fail_reason: + fail_reason = "Failed without specified reason" + + if ( + self._failed == failed + and self._traceback == full_traceback + and self._fail_reason == fail_reason + ): + return + + self._failed = failed + self._fail_reason = fail_reason or None + self._traceback = full_traceback + + self.emit_event( + "push.failed.changed", + { + "failed": failed, + "reason": fail_reason, + "traceback": full_traceback + } + ) + + @property + def failed(self): + """Processing failed. + + Returns: + bool: Processing failed. + """ + + return self._failed + + @property + def fail_reason(self): + """Reason why push to process failed. + + Returns: + Union[str, None]: Reason why push failed or None. + """ + + return self._fail_reason + + @property + def traceback(self): + """Traceback of failed process. + + Traceback is available only if unhandled exception happened. + + Returns: + Union[str, None]: Formatted traceback. + """ + + return self._traceback + + # Loggin helpers + # TODO better logging + def add_message(self, message, level): + message_obj = StatusMessage(message, level) + self._messages.append(message_obj) + self.emit_event( + "push.message.added", + {"message": message, "level": level} + ) + print(message_obj) + return message_obj + + def debug(self, message): + return self.add_message(message, "debug") + + def info(self, message): + return self.add_message(message, "info") + + def warning(self, message): + return self.add_message(message, "warning") + + def error(self, message): + return self.add_message(message, "error") + + def critical(self, message): + return self.add_message(message, "critical") + + +class ProjectPushRepreItem: + """Representation item. + + Representation item based on representation document and project roots. + + Representation document may have reference to: + - source files: Files defined with publish template + - resource files: Files that should be in publish directory + but filenames are not template based. + + Args: + repre_doc (Dict[str, Ant]): Representation document. + roots (Dict[str, str]): Project roots (based on project anatomy). + """ + + def __init__(self, repre_doc, roots): + self._repre_doc = repre_doc + self._roots = roots + self._src_files = None + self._resource_files = None + self._frame = UNKNOWN + + @property + def repre_doc(self): + return self._repre_doc + + @property + def src_files(self): + if self._src_files is None: + self.get_source_files() + return self._src_files + + @property + def resource_files(self): + if self._resource_files is None: + self.get_source_files() + return self._resource_files + + @property + def frame(self): + """First frame of representation files. + + This value will be in representation document context if is sequence. + + Returns: + Union[int, None]: First frame in representation files based on + source files or None if frame is not part of filename. + """ + + if self._frame is UNKNOWN: + frame = None + for src_file in self.src_files: + src_frame = src_file.frame + if ( + src_frame is not None + and (frame is None or src_frame < frame) + ): + frame = src_frame + self._frame = frame + return self._frame + + @staticmethod + def validate_source_files(src_files, resource_files): + if not src_files: + raise AssertionError(( + "Couldn't figure out source files from representation." + " Found resource files {}" + ).format(", ".join(str(i) for i in resource_files))) + + invalid_items = [ + item + for item in itertools.chain(src_files, resource_files) + if not item.is_valid_file + ] + if invalid_items: + raise AssertionError(( + "Source files that were not found on disk: {}" + ).format(", ".join(str(i) for i in invalid_items))) + + def get_source_files(self): + if self._src_files is not None: + return self._src_files, self._resource_files + + repre_context = self._repre_doc["context"] + if "frame" in repre_context or "udim" in repre_context: + src_files, resource_files = self._get_source_files_with_frames() + else: + src_files, resource_files = self._get_source_files() + + self.validate_source_files(src_files, resource_files) + + self._src_files = src_files + self._resource_files = resource_files + return self._src_files, self._resource_files + + def _get_source_files_with_frames(self): + frame_placeholder = "__frame__" + udim_placeholder = "__udim__" + src_files = [] + resource_files = [] + template = self._repre_doc["data"]["template"] + # Remove padding from 'udim' and 'frame' formatting keys + # - "{frame:0>4}" -> "{frame}" + for key in ("udim", "frame"): + sub_part = "{" + key + "[^}]*}" + replacement = "{{{}}}".format(key) + template = re.sub(sub_part, replacement, template) + + repre_context = self._repre_doc["context"] + fill_repre_context = copy.deepcopy(repre_context) + if "frame" in fill_repre_context: + fill_repre_context["frame"] = frame_placeholder + + if "udim" in fill_repre_context: + fill_repre_context["udim"] = udim_placeholder + + fill_roots = fill_repre_context["root"] + for root_name in tuple(fill_roots.keys()): + fill_roots[root_name] = "{{root[{}]}}".format(root_name) + repre_path = StringTemplate.format_template(template, + fill_repre_context) + repre_path = repre_path.replace("\\", "/") + src_dirpath, src_basename = os.path.split(repre_path) + src_basename = ( + re.escape(src_basename) + .replace(frame_placeholder, "(?P[0-9]+)") + .replace(udim_placeholder, "(?P[0-9]+)") + ) + src_basename_regex = re.compile("^{}$".format(src_basename)) + for file_info in self._repre_doc["files"]: + filepath_template = file_info["path"].replace("\\", "/") + filepath = filepath_template.format(root=self._roots) + dirpath, basename = os.path.split(filepath_template) + if ( + dirpath != src_dirpath + or not src_basename_regex.match(basename) + ): + relative_dir = dirpath.replace(src_dirpath, "") + if relative_dir: + relative_path = "/".join([relative_dir, basename]) + else: + relative_path = basename + resource_files.append(ResourceFile(filepath, relative_path)) + continue + + frame = None + udim = None + for item in src_basename_regex.finditer(basename): + group_name = item.lastgroup + value = item.group(group_name) + if group_name == "frame": + frame = int(value) + elif group_name == "udim": + udim = value + + src_files.append(SourceFile(filepath, frame, udim)) + + return src_files, resource_files + + def _get_source_files(self): + src_files = [] + resource_files = [] + template = self._repre_doc["data"]["template"] + repre_context = self._repre_doc["context"] + fill_repre_context = copy.deepcopy(repre_context) + fill_roots = fill_repre_context["root"] + for root_name in tuple(fill_roots.keys()): + fill_roots[root_name] = "{{root[{}]}}".format(root_name) + repre_path = StringTemplate.format_template(template, + fill_repre_context) + repre_path = repre_path.replace("\\", "/") + src_dirpath = os.path.dirname(repre_path) + for file_info in self._repre_doc["files"]: + filepath_template = file_info["path"].replace("\\", "/") + filepath = filepath_template.format(root=self._roots) + if filepath_template == repre_path: + src_files.append(SourceFile(filepath)) + else: + dirpath, basename = os.path.split(filepath_template) + relative_dir = dirpath.replace(src_dirpath, "") + if relative_dir: + relative_path = "/".join([relative_dir, basename]) + else: + relative_path = basename + + resource_files.append( + ResourceFile(filepath, relative_path) + ) + return src_files, resource_files + + +class ProjectPushItemProcess: + """ + Args: + item (ProjectPushItem): Item which is being processed. + item_status (ProjectPushItemStatus): Object to store status. + """ + + # TODO where to get host?!!! + host_name = "republisher" + + def __init__(self, item, item_status=None): + self._item = item + + self._src_project_doc = None + self._src_asset_doc = None + self._src_subset_doc = None + self._src_version_doc = None + self._src_repre_items = None + self._src_anatomy = None + + self._project_doc = None + self._anatomy = None + self._asset_doc = None + self._created_asset_doc = None + self._task_info = None + self._subset_doc = None + self._version_doc = None + + self._family = None + self._subset_name = None + + self._project_settings = None + self._template_name = None + + if item_status is None: + item_status = ProjectPushItemStatus() + self._status = item_status + self._operations = OperationsSession() + self._file_transaction = FileTransaction() + + @property + def status(self): + return self._status + + @property + def src_project_doc(self): + return self._src_project_doc + + @property + def src_anatomy(self): + return self._src_anatomy + + @property + def src_asset_doc(self): + return self._src_asset_doc + + @property + def src_subset_doc(self): + return self._src_subset_doc + + @property + def src_version_doc(self): + return self._src_version_doc + + @property + def src_repre_items(self): + return self._src_repre_items + + @property + def project_doc(self): + return self._project_doc + + @property + def anatomy(self): + return self._anatomy + + @property + def project_settings(self): + return self._project_settings + + @property + def asset_doc(self): + return self._asset_doc + + @property + def task_info(self): + return self._task_info + + @property + def subset_doc(self): + return self._subset_doc + + @property + def version_doc(self): + return self._version_doc + + @property + def variant(self): + return self._item.variant + + @property + def family(self): + return self._family + + @property + def subset_name(self): + return self._subset_name + + @property + def template_name(self): + return self._template_name + + def fill_source_variables(self): + src_project_name = self._item.src_project_name + src_version_id = self._item.src_version_id + + project_doc = get_project(src_project_name) + if not project_doc: + self._status.set_failed( + f"Source project \"{src_project_name}\" was not found" + ) + raise PushToProjectError(self._status.fail_reason) + + self._status.debug(f"Project '{src_project_name}' found") + + version_doc = get_version_by_id(src_project_name, src_version_id) + if not version_doc: + self._status.set_failed(( + f"Source version with id \"{src_version_id}\"" + f" was not found in project \"{src_project_name}\"" + )) + raise PushToProjectError(self._status.fail_reason) + + subset_id = version_doc["parent"] + subset_doc = get_subset_by_id(src_project_name, subset_id) + if not subset_doc: + self._status.set_failed(( + f"Could find subset with id \"{subset_id}\"" + f" in project \"{src_project_name}\"" + )) + raise PushToProjectError(self._status.fail_reason) + + asset_id = subset_doc["parent"] + asset_doc = get_asset_by_id(src_project_name, asset_id) + if not asset_doc: + self._status.set_failed(( + f"Could find asset with id \"{asset_id}\"" + f" in project \"{src_project_name}\"" + )) + raise PushToProjectError(self._status.fail_reason) + + anatomy = Anatomy(src_project_name) + + repre_docs = get_representations( + src_project_name, + version_ids=[src_version_id] + ) + repre_items = [ + ProjectPushRepreItem(repre_doc, anatomy.roots) + for repre_doc in repre_docs + ] + self._status.debug(( + f"Found {len(repre_items)} representations on" + f" version {src_version_id} in project '{src_project_name}'" + )) + if not repre_items: + self._status.set_failed( + "Source version does not have representations" + f" (Version id: {src_version_id})" + ) + raise PushToProjectError(self._status.fail_reason) + + self._src_anatomy = anatomy + self._src_project_doc = project_doc + self._src_asset_doc = asset_doc + self._src_subset_doc = subset_doc + self._src_version_doc = version_doc + self._src_repre_items = repre_items + + def fill_destination_project(self): + # --- Destination entities --- + dst_project_name = self._item.dst_project_name + # Validate project existence + dst_project_doc = get_project(dst_project_name) + if not dst_project_doc: + self._status.set_failed( + f"Destination project '{dst_project_name}' was not found" + ) + raise PushToProjectError(self._status.fail_reason) + + self._status.debug( + f"Destination project '{dst_project_name}' found" + ) + self._project_doc = dst_project_doc + self._anatomy = Anatomy(dst_project_name) + self._project_settings = get_project_settings( + self._item.dst_project_name + ) + + def _create_asset( + self, + src_asset_doc, + project_doc, + parent_asset_doc, + asset_name + ): + parent_id = None + parents = [] + tools = [] + if parent_asset_doc: + parent_id = parent_asset_doc["_id"] + parents = list(parent_asset_doc["data"]["parents"]) + parents.append(parent_asset_doc["name"]) + _tools = parent_asset_doc["data"].get("tools_env") + if _tools: + tools = list(_tools) + + asset_name_low = asset_name.lower() + other_asset_docs = get_assets( + project_doc["name"], fields=["_id", "name", "data.visualParent"] + ) + for other_asset_doc in other_asset_docs: + other_name = other_asset_doc["name"] + other_parent_id = other_asset_doc["data"].get("visualParent") + if other_name.lower() != asset_name_low: + continue + + if other_parent_id != parent_id: + self._status.set_failed(( + f"Asset with name \"{other_name}\" already" + " exists in different hierarchy." + )) + raise PushToProjectError(self._status.fail_reason) + + self._status.debug(( + f"Found already existing asset with name \"{other_name}\"" + f" which match requested name \"{asset_name}\"" + )) + return get_asset_by_id(project_doc["name"], other_asset_doc["_id"]) + + data_keys = ( + "clipIn", + "clipOut", + "frameStart", + "frameEnd", + "handleStart", + "handleEnd", + "resolutionWidth", + "resolutionHeight", + "fps", + "pixelAspect", + ) + asset_data = { + "visualParent": parent_id, + "parents": parents, + "tasks": {}, + "tools_env": tools + } + src_asset_data = src_asset_doc["data"] + for key in data_keys: + if key in src_asset_data: + asset_data[key] = src_asset_data[key] + + asset_doc = new_asset_document( + asset_name, + project_doc["_id"], + parent_id, + parents, + data=asset_data + ) + self._operations.create_entity( + project_doc["name"], + asset_doc["type"], + asset_doc + ) + self._status.info( + f"Creating new asset with name \"{asset_name}\"" + ) + self._created_asset_doc = asset_doc + return asset_doc + + def fill_or_create_destination_asset(self): + dst_project_name = self._item.dst_project_name + dst_asset_id = self._item.dst_asset_id + dst_task_name = self._item.dst_task_name + new_asset_name = self._item.new_asset_name + if not dst_asset_id and not new_asset_name: + self._status.set_failed( + "Push item does not have defined destination asset" + ) + raise PushToProjectError(self._status.fail_reason) + + # Get asset document + parent_asset_doc = None + if dst_asset_id: + parent_asset_doc = get_asset_by_id( + self._item.dst_project_name, self._item.dst_asset_id + ) + if not parent_asset_doc: + self._status.set_failed( + f"Could find asset with id \"{dst_asset_id}\"" + f" in project \"{dst_project_name}\"" + ) + raise PushToProjectError(self._status.fail_reason) + + if not new_asset_name: + asset_doc = parent_asset_doc + else: + asset_doc = self._create_asset( + self.src_asset_doc, + self.project_doc, + parent_asset_doc, + new_asset_name + ) + self._asset_doc = asset_doc + if not dst_task_name: + self._task_info = {} + return + + asset_path_parts = list(asset_doc["data"]["parents"]) + asset_path_parts.append(asset_doc["name"]) + asset_path = "/".join(asset_path_parts) + asset_tasks = asset_doc.get("data", {}).get("tasks") or {} + task_info = asset_tasks.get(dst_task_name) + if not task_info: + self._status.set_failed( + f"Could find task with name \"{dst_task_name}\"" + f" on asset \"{asset_path}\"" + f" in project \"{dst_project_name}\"" + ) + raise PushToProjectError(self._status.fail_reason) + + # Create copy of task info to avoid changing data in asset document + task_info = copy.deepcopy(task_info) + task_info["name"] = dst_task_name + # Fill rest of task information based on task type + task_type = task_info["type"] + task_type_info = self.project_doc["config"]["tasks"].get(task_type, {}) + task_info.update(task_type_info) + self._task_info = task_info + + def determine_family(self): + subset_doc = self.src_subset_doc + family = subset_doc["data"].get("family") + families = subset_doc["data"].get("families") + if not family and families: + family = families[0] + + if not family: + self._status.set_failed( + "Couldn't figure out family from source subset" + ) + raise PushToProjectError(self._status.fail_reason) + + self._status.debug( + f"Publishing family is '{family}' (Based on source subset)" + ) + self._family = family + + def determine_publish_template_name(self): + template_name = get_publish_template_name( + self._item.dst_project_name, + self.host_name, + self.family, + self.task_info.get("name"), + self.task_info.get("type"), + project_settings=self.project_settings + ) + self._status.debug( + f"Using template '{template_name}' for integration" + ) + self._template_name = template_name + + def determine_subset_name(self): + family = self.family + asset_doc = self.asset_doc + task_info = self.task_info + subset_name = get_subset_name( + family, + self.variant, + task_info.get("name"), + asset_doc, + project_name=self._item.dst_project_name, + host_name=self.host_name, + project_settings=self.project_settings + ) + self._status.info( + f"Push will be integrating to subset with name '{subset_name}'" + ) + self._subset_name = subset_name + + def make_sure_subset_exists(self): + project_name = self._item.dst_project_name + asset_id = self.asset_doc["_id"] + subset_name = self.subset_name + family = self.family + subset_doc = get_subset_by_name(project_name, subset_name, asset_id) + if subset_doc: + self._subset_doc = subset_doc + return subset_doc + + data = { + "families": [family] + } + subset_doc = new_subset_document( + subset_name, family, asset_id, data + ) + self._operations.create_entity(project_name, "subset", subset_doc) + self._subset_doc = subset_doc + + def make_sure_version_exists(self): + """Make sure version document exits in database.""" + + project_name = self._item.dst_project_name + version = self._item.dst_version + src_version_doc = self.src_version_doc + subset_doc = self.subset_doc + subset_id = subset_doc["_id"] + src_data = src_version_doc["data"] + families = subset_doc["data"].get("families") + if not families: + families = [subset_doc["data"]["family"]] + + version_data = { + "families": list(families), + "fps": src_data.get("fps"), + "source": src_data.get("source"), + "machine": socket.gethostname(), + "comment": self._item.comment or "", + "author": get_openpype_username(), + "time": get_formatted_current_time(), + } + if version is None: + last_version_doc = get_last_version_by_subset_id( + project_name, subset_id + ) + version = 1 + if last_version_doc: + version += int(last_version_doc["name"]) + + existing_version_doc = get_version_by_name( + project_name, version, subset_id + ) + # Update existing version + if existing_version_doc: + version_doc = new_version_doc( + version, subset_id, version_data, existing_version_doc["_id"] + ) + update_data = prepare_version_update_data( + existing_version_doc, version_doc + ) + if update_data: + self._operations.update_entity( + project_name, + "version", + existing_version_doc["_id"], + update_data + ) + self._version_doc = version_doc + + return + + if version is None: + last_version_doc = get_last_version_by_subset_id( + project_name, subset_id + ) + version = 1 + if last_version_doc: + version += int(last_version_doc["name"]) + + version_doc = new_version_doc( + version, subset_id, version_data + ) + self._operations.create_entity(project_name, "version", version_doc) + + self._version_doc = version_doc + + def integrate_representations(self): + try: + self._integrate_representations() + except Exception: + self._operations.clear() + self._file_transaction.rollback() + raise + + def _integrate_representations(self): + version_doc = self.version_doc + version_id = version_doc["_id"] + existing_repres = get_representations( + self._item.dst_project_name, + version_ids=[version_id] + ) + existing_repres_by_low_name = { + repre_doc["name"].lower(): repre_doc + for repre_doc in existing_repres + } + template_name = self.template_name + anatomy = self.anatomy + formatting_data = get_template_data( + self.project_doc, + self.asset_doc, + self.task_info.get("name"), + self.host_name + ) + formatting_data.update({ + "subset": self.subset_name, + "family": self.family, + "version": version_doc["name"] + }) + + path_template = anatomy.templates[template_name]["path"].replace( + "\\", "/" + ) + file_template = StringTemplate( + anatomy.templates[template_name]["file"] + ) + self._status.info("Preparing files to transfer") + processed_repre_items = self._prepare_file_transactions( + anatomy, template_name, formatting_data, file_template + ) + self._file_transaction.process() + self._status.info("Preparing database changes") + self._prepare_database_operations( + version_id, + processed_repre_items, + path_template, + existing_repres_by_low_name + ) + self._status.info("Finalization") + self._operations.commit() + self._file_transaction.finalize() + + def _prepare_file_transactions( + self, anatomy, template_name, formatting_data, file_template + ): + processed_repre_items = [] + for repre_item in self.src_repre_items: + repre_doc = repre_item.repre_doc + repre_name = repre_doc["name"] + repre_format_data = copy.deepcopy(formatting_data) + repre_format_data["representation"] = repre_name + for src_file in repre_item.src_files: + ext = os.path.splitext(src_file.path)[-1] + repre_format_data["ext"] = ext[1:] + break + + tmp_result = anatomy.format(formatting_data) + folder_path = tmp_result[template_name]["folder"] + repre_context = folder_path.used_values + folder_path_rootless = folder_path.rootless + repre_filepaths = [] + published_path = None + for src_file in repre_item.src_files: + file_data = copy.deepcopy(repre_format_data) + frame = src_file.frame + if frame is not None: + file_data["frame"] = frame + + udim = src_file.udim + if udim is not None: + file_data["udim"] = udim + + filename = file_template.format_strict(file_data) + dst_filepath = os.path.normpath( + os.path.join(folder_path, filename) + ) + dst_rootless_path = os.path.normpath( + os.path.join(folder_path_rootless, filename) + ) + if published_path is None or frame == repre_item.frame: + published_path = dst_filepath + repre_context.update(filename.used_values) + + repre_filepaths.append((dst_filepath, dst_rootless_path)) + self._file_transaction.add(src_file.path, dst_filepath) + + for resource_file in repre_item.resource_files: + dst_filepath = os.path.normpath( + os.path.join(folder_path, resource_file.relative_path) + ) + dst_rootless_path = os.path.normpath( + os.path.join( + folder_path_rootless, resource_file.relative_path + ) + ) + repre_filepaths.append((dst_filepath, dst_rootless_path)) + self._file_transaction.add(resource_file.path, dst_filepath) + processed_repre_items.append( + (repre_item, repre_filepaths, repre_context, published_path) + ) + return processed_repre_items + + def _prepare_database_operations( + self, + version_id, + processed_repre_items, + path_template, + existing_repres_by_low_name + ): + modules_manager = ModulesManager() + sync_server_module = modules_manager.get("sync_server") + if sync_server_module is None or not sync_server_module.enabled: + sites = [{ + "name": "studio", + "created_dt": datetime.datetime.now() + }] + else: + sites = sync_server_module.compute_resource_sync_sites( + project_name=self._item.dst_project_name + ) + + added_repre_names = set() + for item in processed_repre_items: + (repre_item, repre_filepaths, repre_context, published_path) = item + repre_name = repre_item.repre_doc["name"] + added_repre_names.add(repre_name.lower()) + new_repre_data = { + "path": published_path, + "template": path_template + } + new_repre_files = [] + for (path, rootless_path) in repre_filepaths: + new_repre_files.append({ + "_id": ObjectId(), + "path": rootless_path, + "size": os.path.getsize(path), + "hash": source_hash(path), + "sites": sites + }) + + existing_repre = existing_repres_by_low_name.get( + repre_name.lower() + ) + entity_id = None + if existing_repre: + entity_id = existing_repre["_id"] + new_repre_doc = new_representation_doc( + repre_name, + version_id, + repre_context, + data=new_repre_data, + entity_id=entity_id + ) + new_repre_doc["files"] = new_repre_files + if not existing_repre: + self._operations.create_entity( + self._item.dst_project_name, + new_repre_doc["type"], + new_repre_doc + ) + else: + update_data = prepare_representation_update_data( + existing_repre, new_repre_doc + ) + if update_data: + self._operations.update_entity( + self._item.dst_project_name, + new_repre_doc["type"], + new_repre_doc["_id"], + update_data + ) + + existing_repre_names = set(existing_repres_by_low_name.keys()) + for repre_name in (existing_repre_names - added_repre_names): + repre_doc = existing_repres_by_low_name[repre_name] + self._operations.update_entity( + self._item.dst_project_name, + repre_doc["type"], + repre_doc["_id"], + {"type": "archived_representation"} + ) + + def process(self): + try: + self._status.info("Process started") + self.fill_source_variables() + self._status.info("Source entities were found") + self.fill_destination_project() + self._status.info("Destination project was found") + self.fill_or_create_destination_asset() + self._status.info("Destination asset was determined") + self.determine_family() + self.determine_publish_template_name() + self.determine_subset_name() + self.make_sure_subset_exists() + self.make_sure_version_exists() + self._status.info("Prerequirements were prepared") + self.integrate_representations() + self._status.info("Integration finished") + + except PushToProjectError as exc: + if not self._status.failed: + self._status.set_failed(str(exc)) + + except Exception as exc: + _exc, _value, _tb = sys.exc_info() + self._status.set_failed( + "Unhandled error happened: {}".format(str(exc)), + (_exc, _value, _tb) + ) + + finally: + self._status.set_finished() diff --git a/openpype/tools/push_to_project/window.py b/openpype/tools/push_to_project/window.py new file mode 100644 index 0000000000..e62650ec53 --- /dev/null +++ b/openpype/tools/push_to_project/window.py @@ -0,0 +1,826 @@ +import collections + +from qtpy import QtWidgets, QtGui, QtCore + +from openpype.style import load_stylesheet, get_app_icon_path +from openpype.tools.utils import ( + PlaceholderLineEdit, + SeparatorWidget, + get_asset_icon_by_name, + set_style_property, +) +from openpype.tools.utils.views import DeselectableTreeView + +from .control_context import PushToContextController + +PROJECT_NAME_ROLE = QtCore.Qt.UserRole + 1 +ASSET_NAME_ROLE = QtCore.Qt.UserRole + 2 +ASSET_ID_ROLE = QtCore.Qt.UserRole + 3 +TASK_NAME_ROLE = QtCore.Qt.UserRole + 4 +TASK_TYPE_ROLE = QtCore.Qt.UserRole + 5 + + +class ProjectsModel(QtGui.QStandardItemModel): + empty_text = "< Empty >" + refreshing_text = "< Refreshing >" + select_project_text = "< Select Project >" + + refreshed = QtCore.Signal() + + def __init__(self, controller): + super(ProjectsModel, self).__init__() + self._controller = controller + + self.event_system.add_callback( + "projects.refresh.finished", self._on_refresh_finish + ) + + placeholder_item = QtGui.QStandardItem(self.empty_text) + + root_item = self.invisibleRootItem() + root_item.appendRows([placeholder_item]) + items = {None: placeholder_item} + + self._placeholder_item = placeholder_item + self._items = items + + @property + def event_system(self): + return self._controller.event_system + + def _on_refresh_finish(self): + root_item = self.invisibleRootItem() + project_names = self._controller.model.get_projects() + + if not project_names: + placeholder_text = self.empty_text + else: + placeholder_text = self.select_project_text + self._placeholder_item.setData(placeholder_text, QtCore.Qt.DisplayRole) + + new_items = [] + if None not in self._items: + new_items.append(self._placeholder_item) + + current_project_names = set(self._items.keys()) + for project_name in current_project_names - set(project_names): + if project_name is None: + continue + item = self._items.pop(project_name) + root_item.takeRow(item.row()) + + for project_name in project_names: + if project_name in self._items: + continue + item = QtGui.QStandardItem(project_name) + item.setData(project_name, PROJECT_NAME_ROLE) + new_items.append(item) + + if new_items: + root_item.appendRows(new_items) + self.refreshed.emit() + + +class ProjectProxyModel(QtCore.QSortFilterProxyModel): + def __init__(self): + super(ProjectProxyModel, self).__init__() + self._filter_empty_projects = False + + def set_filter_empty_project(self, filter_empty_projects): + if filter_empty_projects == self._filter_empty_projects: + return + self._filter_empty_projects = filter_empty_projects + self.invalidate() + + def filterAcceptsRow(self, row, parent): + if not self._filter_empty_projects: + return True + model = self.sourceModel() + source_index = model.index(row, self.filterKeyColumn(), parent) + if model.data(source_index, PROJECT_NAME_ROLE) is None: + return False + return True + + +class AssetsModel(QtGui.QStandardItemModel): + items_changed = QtCore.Signal() + empty_text = "< Empty >" + + def __init__(self, controller): + super(AssetsModel, self).__init__() + self._controller = controller + + placeholder_item = QtGui.QStandardItem(self.empty_text) + placeholder_item.setFlags(QtCore.Qt.ItemIsEnabled) + + root_item = self.invisibleRootItem() + root_item.appendRows([placeholder_item]) + + self.event_system.add_callback( + "project.changed", self._on_project_change + ) + self.event_system.add_callback( + "assets.refresh.started", self._on_refresh_start + ) + self.event_system.add_callback( + "assets.refresh.finished", self._on_refresh_finish + ) + + self._items = {None: placeholder_item} + + self._placeholder_item = placeholder_item + self._last_project = None + + @property + def event_system(self): + return self._controller.event_system + + def _clear(self): + placeholder_in = False + root_item = self.invisibleRootItem() + for row in reversed(range(root_item.rowCount())): + item = root_item.child(row) + asset_id = item.data(ASSET_ID_ROLE) + if asset_id is None: + placeholder_in = True + continue + root_item.removeRow(item.row()) + + for key in tuple(self._items.keys()): + if key is not None: + self._items.pop(key) + + if not placeholder_in: + root_item.appendRows([self._placeholder_item]) + self._items[None] = self._placeholder_item + + def _on_project_change(self, event): + project_name = event["project_name"] + if project_name == self._last_project: + return + + self._last_project = project_name + self._clear() + self.items_changed.emit() + + def _on_refresh_start(self, event): + pass + + def _on_refresh_finish(self, event): + event_project_name = event["project_name"] + project_name = self._controller.selection_model.project_name + if event_project_name != project_name: + return + + self._last_project = event["project_name"] + if project_name is None: + if None not in self._items: + self._clear() + self.items_changed.emit() + return + + asset_items_by_id = self._controller.model.get_assets(project_name) + if not asset_items_by_id: + self._clear() + self.items_changed.emit() + return + + assets_by_parent_id = collections.defaultdict(list) + for asset_item in asset_items_by_id.values(): + assets_by_parent_id[asset_item.parent_id].append(asset_item) + + root_item = self.invisibleRootItem() + if None in self._items: + self._items.pop(None) + root_item.takeRow(self._placeholder_item.row()) + + items_to_remove = set(self._items) - set(asset_items_by_id.keys()) + hierarchy_queue = collections.deque() + hierarchy_queue.append((None, root_item)) + while hierarchy_queue: + parent_id, parent_item = hierarchy_queue.popleft() + new_items = [] + for asset_item in assets_by_parent_id[parent_id]: + item = self._items.get(asset_item.id) + if item is None: + item = QtGui.QStandardItem() + item.setFlags( + QtCore.Qt.ItemIsSelectable + | QtCore.Qt.ItemIsEnabled + ) + new_items.append(item) + self._items[asset_item.id] = item + + elif item.parent() is not parent_item: + new_items.append(item) + + icon = get_asset_icon_by_name( + asset_item.icon_name, asset_item.icon_color + ) + item.setData(asset_item.name, QtCore.Qt.DisplayRole) + item.setData(icon, QtCore.Qt.DecorationRole) + item.setData(asset_item.id, ASSET_ID_ROLE) + + hierarchy_queue.append((asset_item.id, item)) + + if new_items: + parent_item.appendRows(new_items) + + for item_id in items_to_remove: + item = self._items.pop(item_id, None) + if item is None: + continue + parent = item.parent() + if parent is not None: + parent.takeRow(item.row()) + + self.items_changed.emit() + + +class TasksModel(QtGui.QStandardItemModel): + items_changed = QtCore.Signal() + empty_text = "< Empty >" + + def __init__(self, controller): + super(TasksModel, self).__init__() + self._controller = controller + + placeholder_item = QtGui.QStandardItem(self.empty_text) + placeholder_item.setFlags(QtCore.Qt.ItemIsEnabled) + + root_item = self.invisibleRootItem() + root_item.appendRows([placeholder_item]) + + self.event_system.add_callback( + "project.changed", self._on_project_change + ) + self.event_system.add_callback( + "assets.refresh.finished", self._on_asset_refresh_finish + ) + self.event_system.add_callback( + "asset.changed", self._on_asset_change + ) + + self._items = {None: placeholder_item} + + self._placeholder_item = placeholder_item + self._last_project = None + + @property + def event_system(self): + return self._controller.event_system + + def _clear(self): + placeholder_in = False + root_item = self.invisibleRootItem() + for row in reversed(range(root_item.rowCount())): + item = root_item.child(row) + task_name = item.data(TASK_NAME_ROLE) + if task_name is None: + placeholder_in = True + continue + root_item.removeRow(item.row()) + + for key in tuple(self._items.keys()): + if key is not None: + self._items.pop(key) + + if not placeholder_in: + root_item.appendRows([self._placeholder_item]) + self._items[None] = self._placeholder_item + + def _on_project_change(self, event): + project_name = event["project_name"] + if project_name == self._last_project: + return + + self._last_project = project_name + self._clear() + self.items_changed.emit() + + def _on_asset_refresh_finish(self, event): + self._refresh(event["project_name"]) + + def _on_asset_change(self, event): + self._refresh(event["project_name"]) + + def _refresh(self, new_project_name): + project_name = self._controller.selection_model.project_name + if new_project_name != project_name: + return + + self._last_project = project_name + if project_name is None: + if None not in self._items: + self._clear() + self.items_changed.emit() + return + + asset_id = self._controller.selection_model.asset_id + task_items = self._controller.model.get_tasks( + project_name, asset_id + ) + if not task_items: + self._clear() + self.items_changed.emit() + return + + root_item = self.invisibleRootItem() + if None in self._items: + self._items.pop(None) + root_item.takeRow(self._placeholder_item.row()) + + new_items = [] + task_names = set() + for task_item in task_items: + task_name = task_item.name + item = self._items.get(task_name) + if item is None: + item = QtGui.QStandardItem() + item.setFlags( + QtCore.Qt.ItemIsSelectable + | QtCore.Qt.ItemIsEnabled + ) + new_items.append(item) + self._items[task_name] = item + + item.setData(task_name, QtCore.Qt.DisplayRole) + item.setData(task_name, TASK_NAME_ROLE) + item.setData(task_item.task_type, TASK_TYPE_ROLE) + + if new_items: + root_item.appendRows(new_items) + + items_to_remove = set(self._items) - task_names + for item_id in items_to_remove: + item = self._items.pop(item_id, None) + if item is None: + continue + parent = item.parent() + if parent is not None: + parent.removeRow(item.row()) + + self.items_changed.emit() + + +class PushToContextSelectWindow(QtWidgets.QWidget): + def __init__(self, controller=None): + super(PushToContextSelectWindow, self).__init__() + if controller is None: + controller = PushToContextController() + self._controller = controller + + self.setWindowTitle("Push to project (select context)") + self.setWindowIcon(QtGui.QIcon(get_app_icon_path())) + + main_context_widget = QtWidgets.QWidget(self) + + header_widget = QtWidgets.QWidget(main_context_widget) + + header_label = QtWidgets.QLabel(controller.src_label, header_widget) + + header_layout = QtWidgets.QHBoxLayout(header_widget) + header_layout.setContentsMargins(0, 0, 0, 0) + header_layout.addWidget(header_label) + + main_splitter = QtWidgets.QSplitter( + QtCore.Qt.Horizontal, main_context_widget + ) + + context_widget = QtWidgets.QWidget(main_splitter) + + project_combobox = QtWidgets.QComboBox(context_widget) + project_model = ProjectsModel(controller) + project_proxy = ProjectProxyModel() + project_proxy.setSourceModel(project_model) + project_proxy.setDynamicSortFilter(True) + project_delegate = QtWidgets.QStyledItemDelegate() + project_combobox.setItemDelegate(project_delegate) + project_combobox.setModel(project_proxy) + + asset_task_splitter = QtWidgets.QSplitter( + QtCore.Qt.Vertical, context_widget + ) + + asset_view = DeselectableTreeView(asset_task_splitter) + asset_view.setHeaderHidden(True) + asset_model = AssetsModel(controller) + asset_proxy = QtCore.QSortFilterProxyModel() + asset_proxy.setSourceModel(asset_model) + asset_proxy.setDynamicSortFilter(True) + asset_view.setModel(asset_proxy) + + task_view = QtWidgets.QListView(asset_task_splitter) + task_proxy = QtCore.QSortFilterProxyModel() + task_model = TasksModel(controller) + task_proxy.setSourceModel(task_model) + task_proxy.setDynamicSortFilter(True) + task_view.setModel(task_proxy) + + asset_task_splitter.addWidget(asset_view) + asset_task_splitter.addWidget(task_view) + + context_layout = QtWidgets.QVBoxLayout(context_widget) + context_layout.setContentsMargins(0, 0, 0, 0) + context_layout.addWidget(project_combobox, 0) + context_layout.addWidget(asset_task_splitter, 1) + + # --- Inputs widget --- + inputs_widget = QtWidgets.QWidget(main_splitter) + + asset_name_input = PlaceholderLineEdit(inputs_widget) + asset_name_input.setPlaceholderText("< Name of new asset >") + asset_name_input.setObjectName("ValidatedLineEdit") + + variant_input = PlaceholderLineEdit(inputs_widget) + variant_input.setPlaceholderText("< Variant >") + variant_input.setObjectName("ValidatedLineEdit") + + comment_input = PlaceholderLineEdit(inputs_widget) + comment_input.setPlaceholderText("< Publish comment >") + + inputs_layout = QtWidgets.QFormLayout(inputs_widget) + inputs_layout.setContentsMargins(0, 0, 0, 0) + inputs_layout.addRow("New asset name", asset_name_input) + inputs_layout.addRow("Variant", variant_input) + inputs_layout.addRow("Comment", comment_input) + + main_splitter.addWidget(context_widget) + main_splitter.addWidget(inputs_widget) + + # --- Buttons widget --- + btns_widget = QtWidgets.QWidget(self) + cancel_btn = QtWidgets.QPushButton("Cancel", btns_widget) + publish_btn = QtWidgets.QPushButton("Publish", btns_widget) + + btns_layout = QtWidgets.QHBoxLayout(btns_widget) + btns_layout.setContentsMargins(0, 0, 0, 0) + btns_layout.addStretch(1) + btns_layout.addWidget(cancel_btn, 0) + btns_layout.addWidget(publish_btn, 0) + + sep_1 = SeparatorWidget(parent=main_context_widget) + sep_2 = SeparatorWidget(parent=main_context_widget) + main_context_layout = QtWidgets.QVBoxLayout(main_context_widget) + main_context_layout.addWidget(header_widget, 0) + main_context_layout.addWidget(sep_1, 0) + main_context_layout.addWidget(main_splitter, 1) + main_context_layout.addWidget(sep_2, 0) + main_context_layout.addWidget(btns_widget, 0) + + # NOTE This was added in hurry + # - should be reorganized and changed styles + overlay_widget = QtWidgets.QFrame(self) + overlay_widget.setObjectName("OverlayFrame") + + overlay_label = QtWidgets.QLabel(overlay_widget) + overlay_label.setAlignment(QtCore.Qt.AlignCenter) + + overlay_btns_widget = QtWidgets.QWidget(overlay_widget) + overlay_btns_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + # Add try again button (requires changes in controller) + overlay_try_btn = QtWidgets.QPushButton( + "Try again", overlay_btns_widget + ) + overlay_close_btn = QtWidgets.QPushButton( + "Close", overlay_btns_widget + ) + + overlay_btns_layout = QtWidgets.QHBoxLayout(overlay_btns_widget) + overlay_btns_layout.addStretch(1) + overlay_btns_layout.addWidget(overlay_try_btn, 0) + overlay_btns_layout.addWidget(overlay_close_btn, 0) + overlay_btns_layout.addStretch(1) + + overlay_layout = QtWidgets.QVBoxLayout(overlay_widget) + overlay_layout.addWidget(overlay_label, 0) + overlay_layout.addWidget(overlay_btns_widget, 0) + overlay_layout.setAlignment(QtCore.Qt.AlignCenter) + + main_layout = QtWidgets.QStackedLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(main_context_widget) + main_layout.addWidget(overlay_widget) + main_layout.setStackingMode(QtWidgets.QStackedLayout.StackAll) + main_layout.setCurrentWidget(main_context_widget) + + show_timer = QtCore.QTimer() + show_timer.setInterval(1) + + main_thread_timer = QtCore.QTimer() + main_thread_timer.setInterval(10) + + user_input_changed_timer = QtCore.QTimer() + user_input_changed_timer.setInterval(200) + user_input_changed_timer.setSingleShot(True) + + main_thread_timer.timeout.connect(self._on_main_thread_timer) + show_timer.timeout.connect(self._on_show_timer) + user_input_changed_timer.timeout.connect(self._on_user_input_timer) + asset_name_input.textChanged.connect(self._on_new_asset_change) + variant_input.textChanged.connect(self._on_variant_change) + comment_input.textChanged.connect(self._on_comment_change) + project_model.refreshed.connect(self._on_projects_refresh) + project_combobox.currentIndexChanged.connect(self._on_project_change) + asset_view.selectionModel().selectionChanged.connect( + self._on_asset_change + ) + asset_model.items_changed.connect(self._on_asset_model_change) + task_view.selectionModel().selectionChanged.connect( + self._on_task_change + ) + task_model.items_changed.connect(self._on_task_model_change) + publish_btn.clicked.connect(self._on_select_click) + cancel_btn.clicked.connect(self._on_close_click) + overlay_close_btn.clicked.connect(self._on_close_click) + overlay_try_btn.clicked.connect(self._on_try_again_click) + + controller.event_system.add_callback( + "new_asset_name.changed", self._on_controller_new_asset_change + ) + controller.event_system.add_callback( + "variant.changed", self._on_controller_variant_change + ) + controller.event_system.add_callback( + "comment.changed", self._on_controller_comment_change + ) + controller.event_system.add_callback( + "submission.enabled.changed", self._on_submission_change + ) + controller.event_system.add_callback( + "source.changed", self._on_controller_source_change + ) + controller.event_system.add_callback( + "submit.started", self._on_controller_submit_start + ) + controller.event_system.add_callback( + "submit.finished", self._on_controller_submit_end + ) + controller.event_system.add_callback( + "push.message.added", self._on_push_message + ) + + self._main_layout = main_layout + + self._main_context_widget = main_context_widget + + self._header_label = header_label + self._main_splitter = main_splitter + + self._project_combobox = project_combobox + self._project_model = project_model + self._project_proxy = project_proxy + self._project_delegate = project_delegate + + self._asset_view = asset_view + self._asset_model = asset_model + self._asset_proxy_model = asset_proxy + + self._task_view = task_view + self._task_proxy_model = task_proxy + + self._variant_input = variant_input + self._asset_name_input = asset_name_input + self._comment_input = comment_input + + self._publish_btn = publish_btn + + self._overlay_widget = overlay_widget + self._overlay_close_btn = overlay_close_btn + self._overlay_try_btn = overlay_try_btn + self._overlay_label = overlay_label + + self._user_input_changed_timer = user_input_changed_timer + # Store current value on input text change + # The value is unset when is passed to controller + # The goal is to have controll over changes happened during user change + # in UI and controller auto-changes + self._variant_input_text = None + self._new_asset_name_input_text = None + self._comment_input_text = None + self._show_timer = show_timer + self._show_counter = 2 + self._first_show = True + + self._main_thread_timer = main_thread_timer + self._main_thread_timer_can_stop = True + self._last_submit_message = None + self._process_item = None + + publish_btn.setEnabled(False) + overlay_close_btn.setVisible(False) + overlay_try_btn.setVisible(False) + + if controller.user_values.new_asset_name: + asset_name_input.setText(controller.user_values.new_asset_name) + if controller.user_values.variant: + variant_input.setText(controller.user_values.variant) + self._invalidate_variant() + self._invalidate_new_asset_name() + + @property + def controller(self): + return self._controller + + def showEvent(self, event): + super(PushToContextSelectWindow, self).showEvent(event) + if self._first_show: + self._first_show = False + self.setStyleSheet(load_stylesheet()) + self._invalidate_variant() + self._show_timer.start() + + def _on_show_timer(self): + if self._show_counter == 0: + self._show_timer.stop() + return + + self._show_counter -= 1 + if self._show_counter == 1: + width = 740 + height = 640 + inputs_width = 360 + self.resize(width, height) + self._main_splitter.setSizes([width - inputs_width, inputs_width]) + + if self._show_counter > 0: + return + + self._controller.model.refresh_projects() + + def _on_new_asset_change(self, text): + self._new_asset_name_input_text = text + self._user_input_changed_timer.start() + + def _on_variant_change(self, text): + self._variant_input_text = text + self._user_input_changed_timer.start() + + def _on_comment_change(self, text): + self._comment_input_text = text + self._user_input_changed_timer.start() + + def _on_user_input_timer(self): + asset_name = self._new_asset_name_input_text + if asset_name is not None: + self._new_asset_name_input_text = None + self._controller.user_values.set_new_asset(asset_name) + + variant = self._variant_input_text + if variant is not None: + self._variant_input_text = None + self._controller.user_values.set_variant(variant) + + comment = self._comment_input_text + if comment is not None: + self._comment_input_text = None + self._controller.user_values.set_comment(comment) + + def _on_controller_new_asset_change(self, event): + asset_name = event["changes"]["new_asset_name"]["new"] + if ( + self._new_asset_name_input_text is None + and asset_name != self._asset_name_input.text() + ): + self._asset_name_input.setText(asset_name) + + self._invalidate_new_asset_name() + + def _on_controller_variant_change(self, event): + is_valid_changes = event["changes"]["is_valid"] + variant = event["changes"]["variant"]["new"] + if ( + self._variant_input_text is None + and variant != self._variant_input.text() + ): + self._variant_input.setText(variant) + + if is_valid_changes["old"] != is_valid_changes["new"]: + self._invalidate_variant() + + def _on_controller_comment_change(self, event): + comment = event["comment"] + if ( + self._comment_input_text is None + and comment != self._comment_input.text() + ): + self._comment_input.setText(comment) + + def _on_controller_source_change(self): + self._header_label.setText(self._controller.src_label) + + def _invalidate_new_asset_name(self): + asset_name = self._controller.user_values.new_asset_name + self._task_view.setVisible(not asset_name) + + valid = None + if asset_name: + valid = self._controller.user_values.is_new_asset_name_valid + + state = "" + if valid is True: + state = "valid" + elif valid is False: + state = "invalid" + set_style_property(self._asset_name_input, "state", state) + + def _invalidate_variant(self): + valid = self._controller.user_values.is_variant_valid + state = "invalid" + if valid is True: + state = "valid" + set_style_property(self._variant_input, "state", state) + + def _on_projects_refresh(self): + self._project_proxy.sort(0, QtCore.Qt.AscendingOrder) + + def _on_project_change(self): + idx = self._project_combobox.currentIndex() + if idx < 0: + self._project_proxy.set_filter_empty_project(False) + return + + project_name = self._project_combobox.itemData(idx, PROJECT_NAME_ROLE) + self._project_proxy.set_filter_empty_project(project_name is not None) + self._controller.selection_model.select_project(project_name) + + def _on_asset_change(self): + indexes = self._asset_view.selectedIndexes() + index = next(iter(indexes), None) + asset_id = None + if index is not None: + model = self._asset_view.model() + asset_id = model.data(index, ASSET_ID_ROLE) + self._controller.selection_model.select_asset(asset_id) + + def _on_asset_model_change(self): + self._asset_proxy_model.sort(0, QtCore.Qt.AscendingOrder) + + def _on_task_model_change(self): + self._task_proxy_model.sort(0, QtCore.Qt.AscendingOrder) + + def _on_task_change(self): + indexes = self._task_view.selectedIndexes() + index = next(iter(indexes), None) + task_name = None + if index is not None: + model = self._task_view.model() + task_name = model.data(index, TASK_NAME_ROLE) + self._controller.selection_model.select_task(task_name) + + def _on_submission_change(self, event): + self._publish_btn.setEnabled(event["enabled"]) + + def _on_close_click(self): + self.close() + + def _on_select_click(self): + self._process_item = self._controller.submit(wait=False) + + def _on_try_again_click(self): + self._process_item = None + self._last_submit_message = None + + self._overlay_close_btn.setVisible(False) + self._overlay_try_btn.setVisible(False) + self._main_layout.setCurrentWidget(self._main_context_widget) + + def _on_main_thread_timer(self): + if self._last_submit_message: + self._overlay_label.setText(self._last_submit_message) + self._last_submit_message = None + + process_status = self._process_item.status + push_failed = process_status.failed + fail_traceback = process_status.traceback + if self._main_thread_timer_can_stop: + self._main_thread_timer.stop() + self._overlay_close_btn.setVisible(True) + if push_failed and not fail_traceback: + self._overlay_try_btn.setVisible(True) + + if push_failed: + message = "Push Failed:\n{}".format(process_status.fail_reason) + if fail_traceback: + message += "\n{}".format(fail_traceback) + self._overlay_label.setText(message) + set_style_property(self._overlay_close_btn, "state", "error") + + if self._main_thread_timer_can_stop: + # Join thread in controller + self._controller.wait_for_process_thread() + # Reset process item to None + self._process_item = None + + def _on_controller_submit_start(self): + self._main_thread_timer_can_stop = False + self._main_thread_timer.start() + self._main_layout.setCurrentWidget(self._overlay_widget) + self._overlay_label.setText("Submittion started") + + def _on_controller_submit_end(self): + self._main_thread_timer_can_stop = True + + def _on_push_message(self, event): + self._last_submit_message = event["message"] diff --git a/openpype/tools/utils/__init__.py b/openpype/tools/utils/__init__.py index 31c8232f47..d51ebb5744 100644 --- a/openpype/tools/utils/__init__.py +++ b/openpype/tools/utils/__init__.py @@ -20,6 +20,9 @@ from .lib import ( DynamicQThread, qt_app_context, get_asset_icon, + get_asset_icon_by_name, + get_asset_icon_name_from_doc, + get_asset_icon_color_from_doc, ) from .models import ( @@ -53,6 +56,9 @@ __all__ = ( "DynamicQThread", "qt_app_context", "get_asset_icon", + "get_asset_icon_by_name", + "get_asset_icon_name_from_doc", + "get_asset_icon_color_from_doc", "RecursiveSortFilterProxyModel", diff --git a/openpype/tools/utils/lib.py b/openpype/tools/utils/lib.py index 5302946c28..04ab2d028f 100644 --- a/openpype/tools/utils/lib.py +++ b/openpype/tools/utils/lib.py @@ -168,20 +168,52 @@ def get_project_icon(project_doc): def get_asset_icon_name(asset_doc, has_children=True): - icon_name = asset_doc["data"].get("icon") + icon_name = get_asset_icon_name_from_doc(asset_doc) if icon_name: return icon_name + return get_default_asset_icon_name(has_children) + +def get_asset_icon_color(asset_doc): + icon_color = get_asset_icon_color_from_doc(asset_doc) + if icon_color: + return icon_color + return get_default_entity_icon_color() + + +def get_default_asset_icon_name(has_children): if has_children: return "fa.folder" return "fa.folder-o" -def get_asset_icon_color(asset_doc): - icon_color = asset_doc["data"].get("color") +def get_asset_icon_name_from_doc(asset_doc): + if asset_doc: + return asset_doc["data"].get("icon") + return None + + +def get_asset_icon_color_from_doc(asset_doc): + if asset_doc: + return asset_doc["data"].get("color") + return None + + +def get_asset_icon_by_name(icon_name, icon_color, has_children=False): + if not icon_name: + icon_name = get_default_asset_icon_name(has_children) + if icon_color: - return icon_color - return get_default_entity_icon_color() + icon_color = QtGui.QColor(icon_color) + else: + icon_color = get_default_entity_icon_color() + icon = get_qta_icon_by_name_and_color(icon_name, icon_color) + if icon is not None: + return icon + return get_qta_icon_by_name_and_color( + get_default_asset_icon_name(has_children), + icon_color + ) def get_asset_icon(asset_doc, has_children=False):