diff --git a/openpype/cli.py b/openpype/cli.py index 0597c387d0..b9c80ca065 100644 --- a/openpype/cli.py +++ b/openpype/cli.py @@ -42,6 +42,12 @@ def standalonepublisher(): PypeCommands().launch_standalone_publisher() +@main.command() +def traypublisher(): + """Show new OpenPype Standalone publisher UI.""" + PypeCommands().launch_traypublisher() + + @main.command() @click.option("-d", "--debug", is_flag=True, help=("Run pype tray in debug mode")) diff --git a/openpype/hosts/testhost/api/instances.json b/openpype/hosts/testhost/api/instances.json index 84021eff91..d955012514 100644 --- a/openpype/hosts/testhost/api/instances.json +++ b/openpype/hosts/testhost/api/instances.json @@ -8,7 +8,7 @@ "asset": "sq01_sh0010", "task": "Compositing", "variant": "myVariant", - "uuid": "a485f148-9121-46a5-8157-aa64df0fb449", + "instance_id": "a485f148-9121-46a5-8157-aa64df0fb449", "creator_attributes": { "number_key": 10, "ha": 10 @@ -29,8 +29,8 @@ "asset": "sq01_sh0010", "task": "Compositing", "variant": "myVariant2", - "uuid": "a485f148-9121-46a5-8157-aa64df0fb444", "creator_attributes": {}, + "instance_id": "a485f148-9121-46a5-8157-aa64df0fb444", "publish_attributes": { "CollectFtrackApi": { "add_ftrack_family": true @@ -47,8 +47,8 @@ "asset": "sq01_sh0010", "task": "Compositing", "variant": "Main", - "uuid": "3607bc95-75f6-4648-a58d-e699f413d09f", "creator_attributes": {}, + "instance_id": "3607bc95-75f6-4648-a58d-e699f413d09f", "publish_attributes": { "CollectFtrackApi": { "add_ftrack_family": true @@ -65,7 +65,7 @@ "asset": "sq01_sh0020", "task": "Compositing", "variant": "Main2", - "uuid": "4ccf56f6-9982-4837-967c-a49695dbe8eb", + "instance_id": "4ccf56f6-9982-4837-967c-a49695dbe8eb", "creator_attributes": {}, "publish_attributes": { "CollectFtrackApi": { @@ -83,7 +83,7 @@ "asset": "sq01_sh0020", "task": "Compositing", "variant": "Main2", - "uuid": "4ccf56f6-9982-4837-967c-a49695dbe8ec", + "instance_id": "4ccf56f6-9982-4837-967c-a49695dbe8ec", "creator_attributes": {}, "publish_attributes": { "CollectFtrackApi": { @@ -101,7 +101,7 @@ "asset": "Alpaca_01", "task": "modeling", "variant": "Main", - "uuid": "7c9ddfc7-9f9c-4c1c-b233-38c966735fb6", + "instance_id": "7c9ddfc7-9f9c-4c1c-b233-38c966735fb6", "creator_attributes": {}, "publish_attributes": {} } diff --git a/openpype/hosts/testhost/api/pipeline.py b/openpype/hosts/testhost/api/pipeline.py index 49f1d3f33d..1f5d680705 100644 --- a/openpype/hosts/testhost/api/pipeline.py +++ b/openpype/hosts/testhost/api/pipeline.py @@ -114,7 +114,7 @@ def update_instances(update_list): instances = HostContext.get_instances() for instance_data in instances: - instance_id = instance_data["uuid"] + instance_id = instance_data["instance_id"] if instance_id in updated_instances: new_instance_data = updated_instances[instance_id] old_keys = set(instance_data.keys()) @@ -132,10 +132,10 @@ def remove_instances(instances): current_instances = HostContext.get_instances() for instance in instances: - instance_id = instance.data["uuid"] + instance_id = instance.data["instance_id"] found_idx = None for idx, _instance in enumerate(current_instances): - if instance_id == _instance["uuid"]: + if instance_id == _instance["instance_id"]: found_idx = idx break diff --git a/openpype/hosts/traypublisher/api/__init__.py b/openpype/hosts/traypublisher/api/__init__.py new file mode 100644 index 0000000000..c461c0c526 --- /dev/null +++ b/openpype/hosts/traypublisher/api/__init__.py @@ -0,0 +1,20 @@ +from .pipeline import ( + install, + ls, + + set_project_name, + get_context_title, + get_context_data, + update_context_data, +) + + +__all__ = ( + "install", + "ls", + + "set_project_name", + "get_context_title", + "get_context_data", + "update_context_data", +) diff --git a/openpype/hosts/traypublisher/api/pipeline.py b/openpype/hosts/traypublisher/api/pipeline.py new file mode 100644 index 0000000000..a39e5641ae --- /dev/null +++ b/openpype/hosts/traypublisher/api/pipeline.py @@ -0,0 +1,180 @@ +import os +import json +import tempfile +import atexit + +from avalon import io +import avalon.api +import pyblish.api + +from openpype.pipeline import BaseCreator + +ROOT_DIR = os.path.dirname(os.path.dirname( + os.path.abspath(__file__) +)) +PUBLISH_PATH = os.path.join(ROOT_DIR, "plugins", "publish") +CREATE_PATH = os.path.join(ROOT_DIR, "plugins", "create") + + +class HostContext: + _context_json_path = None + + @staticmethod + def _on_exit(): + if ( + HostContext._context_json_path + and os.path.exists(HostContext._context_json_path) + ): + os.remove(HostContext._context_json_path) + + @classmethod + def get_context_json_path(cls): + if cls._context_json_path is None: + output_file = tempfile.NamedTemporaryFile( + mode="w", prefix="traypub_", suffix=".json" + ) + output_file.close() + cls._context_json_path = output_file.name + atexit.register(HostContext._on_exit) + print(cls._context_json_path) + return cls._context_json_path + + @classmethod + def _get_data(cls, group=None): + json_path = cls.get_context_json_path() + data = {} + if not os.path.exists(json_path): + with open(json_path, "w") as json_stream: + json.dump(data, json_stream) + else: + with open(json_path, "r") as json_stream: + content = json_stream.read() + if content: + data = json.loads(content) + if group is None: + return data + return data.get(group) + + @classmethod + def _save_data(cls, group, new_data): + json_path = cls.get_context_json_path() + data = cls._get_data() + data[group] = new_data + with open(json_path, "w") as json_stream: + json.dump(data, json_stream) + + @classmethod + def add_instance(cls, instance): + instances = cls.get_instances() + instances.append(instance) + cls.save_instances(instances) + + @classmethod + def get_instances(cls): + return cls._get_data("instances") or [] + + @classmethod + def save_instances(cls, instances): + cls._save_data("instances", instances) + + @classmethod + def get_context_data(cls): + return cls._get_data("context") or {} + + @classmethod + def save_context_data(cls, data): + cls._save_data("context", data) + + @classmethod + def get_project_name(cls): + return cls._get_data("project_name") + + @classmethod + def set_project_name(cls, project_name): + cls._save_data("project_name", project_name) + + @classmethod + def get_data_to_store(cls): + return { + "project_name": cls.get_project_name(), + "instances": cls.get_instances(), + "context": cls.get_context_data(), + } + + +def list_instances(): + return HostContext.get_instances() + + +def update_instances(update_list): + updated_instances = {} + for instance, _changes in update_list: + updated_instances[instance.id] = instance.data_to_store() + + instances = HostContext.get_instances() + for instance_data in instances: + instance_id = instance_data["instance_id"] + if instance_id in updated_instances: + new_instance_data = updated_instances[instance_id] + old_keys = set(instance_data.keys()) + new_keys = set(new_instance_data.keys()) + instance_data.update(new_instance_data) + for key in (old_keys - new_keys): + instance_data.pop(key) + + HostContext.save_instances(instances) + + +def remove_instances(instances): + if not isinstance(instances, (tuple, list)): + instances = [instances] + + current_instances = HostContext.get_instances() + for instance in instances: + instance_id = instance.data["instance_id"] + found_idx = None + for idx, _instance in enumerate(current_instances): + if instance_id == _instance["instance_id"]: + found_idx = idx + break + + if found_idx is not None: + current_instances.pop(found_idx) + HostContext.save_instances(current_instances) + + +def get_context_data(): + return HostContext.get_context_data() + + +def update_context_data(data, changes): + HostContext.save_context_data(data) + + +def get_context_title(): + return HostContext.get_project_name() + + +def ls(): + """Probably will never return loaded containers.""" + return [] + + +def install(): + """This is called before a project is known. + + Project is defined with 'set_project_name'. + """ + os.environ["AVALON_APP"] = "traypublisher" + + pyblish.api.register_host("traypublisher") + pyblish.api.register_plugin_path(PUBLISH_PATH) + avalon.api.register_plugin_path(BaseCreator, CREATE_PATH) + + +def set_project_name(project_name): + # TODO Deregister project specific plugins and register new project plugins + os.environ["AVALON_PROJECT"] = project_name + avalon.api.Session["AVALON_PROJECT"] = project_name + io.install() + HostContext.set_project_name(project_name) diff --git a/openpype/hosts/traypublisher/plugins/create/create_workfile.py b/openpype/hosts/traypublisher/plugins/create/create_workfile.py new file mode 100644 index 0000000000..2db4770bbc --- /dev/null +++ b/openpype/hosts/traypublisher/plugins/create/create_workfile.py @@ -0,0 +1,97 @@ +from openpype.hosts.traypublisher.api import pipeline +from openpype.pipeline import ( + Creator, + CreatedInstance, + lib +) + + +class WorkfileCreator(Creator): + identifier = "workfile" + label = "Workfile" + family = "workfile" + description = "Publish backup of workfile" + + create_allow_context_change = True + + extensions = [ + # Maya + ".ma", ".mb", + # Nuke + ".nk", + # Hiero + ".hrox", + # Houdini + ".hip", ".hiplc", ".hipnc", + # Blender + ".blend", + # Celaction + ".scn", + # TVPaint + ".tvpp", + # Fusion + ".comp", + # Harmony + ".zip", + # Premiere + ".prproj", + # Resolve + ".drp", + # Photoshop + ".psd", ".psb", + # Aftereffects + ".aep" + ] + + def get_icon(self): + return "fa.file" + + def collect_instances(self): + for instance_data in pipeline.list_instances(): + creator_id = instance_data.get("creator_identifier") + if creator_id == self.identifier: + instance = CreatedInstance.from_existing( + instance_data, self + ) + self._add_instance_to_context(instance) + + def update_instances(self, update_list): + pipeline.update_instances(update_list) + + def remove_instances(self, instances): + pipeline.remove_instances(instances) + for instance in instances: + self._remove_instance_from_context(instance) + + def create(self, subset_name, data, pre_create_data): + # Pass precreate data to creator attributes + data["creator_attributes"] = pre_create_data + # Create new instance + new_instance = CreatedInstance(self.family, subset_name, data, self) + # Host implementation of storing metadata about instance + pipeline.HostContext.add_instance(new_instance.data_to_store()) + # Add instance to current context + self._add_instance_to_context(new_instance) + + def get_default_variants(self): + return [ + "Main" + ] + + def get_instance_attr_defs(self): + output = [ + lib.FileDef( + "filepath", + folders=False, + extensions=self.extensions, + label="Filepath" + ) + ] + return output + + def get_pre_create_attr_defs(self): + # Use same attributes as for instance attrobites + return self.get_instance_attr_defs() + + def get_detail_description(self): + return """# Publish workfile backup""" diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_source.py b/openpype/hosts/traypublisher/plugins/publish/collect_source.py new file mode 100644 index 0000000000..6ff22be13a --- /dev/null +++ b/openpype/hosts/traypublisher/plugins/publish/collect_source.py @@ -0,0 +1,24 @@ +import pyblish.api + + +class CollectSource(pyblish.api.ContextPlugin): + """Collecting instances from traypublisher host.""" + + label = "Collect source" + order = pyblish.api.CollectorOrder - 0.49 + hosts = ["traypublisher"] + + def process(self, context): + # get json paths from os and load them + source_name = "traypublisher" + for instance in context: + source = instance.data.get("source") + if not source: + instance.data["source"] = source_name + self.log.info(( + "Source of instance \"{}\" is changed to \"{}\"" + ).format(instance.data["name"], source_name)) + else: + self.log.info(( + "Source of instance \"{}\" was already set to \"{}\"" + ).format(instance.data["name"], source)) diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_workfile.py b/openpype/hosts/traypublisher/plugins/publish/collect_workfile.py new file mode 100644 index 0000000000..d48bace047 --- /dev/null +++ b/openpype/hosts/traypublisher/plugins/publish/collect_workfile.py @@ -0,0 +1,31 @@ +import os +import pyblish.api + + +class CollectWorkfile(pyblish.api.InstancePlugin): + """Collect representation of workfile instances.""" + + label = "Collect Workfile" + order = pyblish.api.CollectorOrder - 0.49 + families = ["workfile"] + hosts = ["traypublisher"] + + def process(self, instance): + if "representations" not in instance.data: + instance.data["representations"] = [] + repres = instance.data["representations"] + + creator_attributes = instance.data["creator_attributes"] + filepath = creator_attributes["filepath"] + instance.data["sourceFilepath"] = filepath + + staging_dir = os.path.dirname(filepath) + filename = os.path.basename(filepath) + ext = os.path.splitext(filename)[-1] + + repres.append({ + "ext": ext, + "name": ext, + "stagingDir": staging_dir, + "files": filename + }) diff --git a/openpype/hosts/traypublisher/plugins/publish/validate_workfile.py b/openpype/hosts/traypublisher/plugins/publish/validate_workfile.py new file mode 100644 index 0000000000..88339d2aac --- /dev/null +++ b/openpype/hosts/traypublisher/plugins/publish/validate_workfile.py @@ -0,0 +1,24 @@ +import os +import pyblish.api +from openpype.pipeline import PublishValidationError + + +class ValidateWorkfilePath(pyblish.api.InstancePlugin): + """Validate existence of workfile instance existence.""" + + label = "Collect Workfile" + order = pyblish.api.ValidatorOrder - 0.49 + families = ["workfile"] + hosts = ["traypublisher"] + + def process(self, instance): + filepath = instance.data["sourceFilepath"] + if not filepath: + raise PublishValidationError(( + "Filepath of 'workfile' instance \"{}\" is not set" + ).format(instance.data["name"])) + + if not os.path.exists(filepath): + raise PublishValidationError(( + "Filepath of 'workfile' instance \"{}\" does not exist: {}" + ).format(instance.data["name"], filepath)) diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index f79c03ed57..882ff03e61 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -29,6 +29,7 @@ from .execute import ( get_linux_launcher_args, execute, run_subprocess, + run_detached_process, run_openpype_process, clean_envs_for_openpype_process, path_to_subprocess_arg, @@ -188,6 +189,7 @@ __all__ = [ "get_linux_launcher_args", "execute", "run_subprocess", + "run_detached_process", "run_openpype_process", "clean_envs_for_openpype_process", "path_to_subprocess_arg", diff --git a/openpype/lib/execute.py b/openpype/lib/execute.py index afde844f2d..f2eb97c5f5 100644 --- a/openpype/lib/execute.py +++ b/openpype/lib/execute.py @@ -1,5 +1,9 @@ import os +import sys import subprocess +import platform +import json +import tempfile import distutils.spawn from .log import PypeLogger as Logger @@ -181,6 +185,80 @@ def run_openpype_process(*args, **kwargs): return run_subprocess(args, env=env, **kwargs) +def run_detached_process(args, **kwargs): + """Execute process with passed arguments as separated process. + + Values from 'os.environ' are used for environments if are not passed. + They are cleaned using 'clean_envs_for_openpype_process' function. + + Example: + ``` + run_detached_openpype_process("run", "") + ``` + + Args: + *args (tuple): OpenPype cli arguments. + **kwargs (dict): Keyword arguments for for subprocess.Popen. + + Returns: + subprocess.Popen: Pointer to launched process but it is possible that + launched process is already killed (on linux). + """ + env = kwargs.pop("env", None) + # Keep env untouched if are passed and not empty + if not env: + env = os.environ + + # Create copy of passed env + kwargs["env"] = {k: v for k, v in env.items()} + + low_platform = platform.system().lower() + if low_platform == "darwin": + new_args = ["open", "-na", args.pop(0), "--args"] + new_args.extend(args) + args = new_args + + elif low_platform == "windows": + flags = ( + subprocess.CREATE_NEW_PROCESS_GROUP + | subprocess.DETACHED_PROCESS + ) + kwargs["creationflags"] = flags + + if not sys.stdout: + kwargs["stdout"] = subprocess.DEVNULL + kwargs["stderr"] = subprocess.DEVNULL + + elif low_platform == "linux" and get_linux_launcher_args() is not None: + json_data = { + "args": args, + "env": kwargs.pop("env") + } + json_temp = tempfile.NamedTemporaryFile( + mode="w", prefix="op_app_args", suffix=".json", delete=False + ) + json_temp.close() + json_temp_filpath = json_temp.name + with open(json_temp_filpath, "w") as stream: + json.dump(json_data, stream) + + new_args = get_linux_launcher_args() + new_args.append(json_temp_filpath) + + # Create mid-process which will launch application + process = subprocess.Popen(new_args, **kwargs) + # Wait until the process finishes + # - This is important! The process would stay in "open" state. + process.wait() + # Remove the temp file + os.remove(json_temp_filpath) + # Return process which is already terminated + return process + + process = subprocess.Popen(args, **kwargs) + return process + + def path_to_subprocess_arg(path): """Prepare path for subprocess arguments. diff --git a/openpype/modules/base.py b/openpype/modules/base.py index 213a7681f5..c7078475df 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -44,6 +44,7 @@ DEFAULT_OPENPYPE_MODULES = ( "project_manager_action", "settings_action", "standalonepublish_action", + "traypublish_action", "job_queue", "timers_manager", "sync_server", @@ -846,6 +847,7 @@ class TrayModulesManager(ModulesManager): "avalon", "clockify", "standalonepublish_tool", + "traypublish_tool", "log_viewer", "local_settings", "settings" diff --git a/openpype/modules/ftrack/plugins/publish/collect_ftrack_api.py b/openpype/modules/ftrack/plugins/publish/collect_ftrack_api.py index a348617cfc..07af217fb6 100644 --- a/openpype/modules/ftrack/plugins/publish/collect_ftrack_api.py +++ b/openpype/modules/ftrack/plugins/publish/collect_ftrack_api.py @@ -1,4 +1,3 @@ -import os import logging import pyblish.api import avalon.api @@ -43,37 +42,48 @@ class CollectFtrackApi(pyblish.api.ContextPlugin): ).format(project_name)) project_entity = project_entities[0] + self.log.debug("Project found: {0}".format(project_entity)) - # Find asset entity - entity_query = ( - 'TypedContext where project_id is "{0}"' - ' and name is "{1}"' - ).format(project_entity["id"], asset_name) - self.log.debug("Asset entity query: < {0} >".format(entity_query)) - asset_entities = [] - for entity in session.query(entity_query).all(): - # Skip tasks - if entity.entity_type.lower() != "task": - asset_entities.append(entity) + asset_entity = None + if asset_name: + # Find asset entity + entity_query = ( + 'TypedContext where project_id is "{0}"' + ' and name is "{1}"' + ).format(project_entity["id"], asset_name) + self.log.debug("Asset entity query: < {0} >".format(entity_query)) + asset_entities = [] + for entity in session.query(entity_query).all(): + # Skip tasks + if entity.entity_type.lower() != "task": + asset_entities.append(entity) - if len(asset_entities) == 0: - raise AssertionError(( - "Entity with name \"{0}\" not found" - " in Ftrack project \"{1}\"." - ).format(asset_name, project_name)) + if len(asset_entities) == 0: + raise AssertionError(( + "Entity with name \"{0}\" not found" + " in Ftrack project \"{1}\"." + ).format(asset_name, project_name)) - elif len(asset_entities) > 1: - raise AssertionError(( - "Found more than one entity with name \"{0}\"" - " in Ftrack project \"{1}\"." - ).format(asset_name, project_name)) + elif len(asset_entities) > 1: + raise AssertionError(( + "Found more than one entity with name \"{0}\"" + " in Ftrack project \"{1}\"." + ).format(asset_name, project_name)) + + asset_entity = asset_entities[0] - asset_entity = asset_entities[0] self.log.debug("Asset found: {0}".format(asset_entity)) + task_entity = None # Find task entity if task is set - if task_name: + if not asset_entity: + self.log.warning( + "Asset entity is not set. Skipping query of task entity." + ) + elif not task_name: + self.log.warning("Task name is not set.") + else: task_query = ( 'Task where name is "{0}" and parent_id is "{1}"' ).format(task_name, asset_entity["id"]) @@ -88,10 +98,6 @@ class CollectFtrackApi(pyblish.api.ContextPlugin): else: self.log.debug("Task entity found: {0}".format(task_entity)) - else: - task_entity = None - self.log.warning("Task name is not set.") - context.data["ftrackSession"] = session context.data["ftrackPythonModule"] = ftrack_api context.data["ftrackProject"] = project_entity diff --git a/openpype/modules/interfaces.py b/openpype/modules/interfaces.py index 7c301c15b4..13cbea690b 100644 --- a/openpype/modules/interfaces.py +++ b/openpype/modules/interfaces.py @@ -122,6 +122,7 @@ class ITrayAction(ITrayModule): admin_action = False _admin_submenu = None + _action_item = None @property @abstractmethod @@ -149,6 +150,7 @@ class ITrayAction(ITrayModule): tray_menu.addAction(action) action.triggered.connect(self.on_action_trigger) + self._action_item = action def tray_start(self): return diff --git a/openpype/modules/traypublish_action.py b/openpype/modules/traypublish_action.py new file mode 100644 index 0000000000..39163b8eb8 --- /dev/null +++ b/openpype/modules/traypublish_action.py @@ -0,0 +1,49 @@ +import os +from openpype.lib import get_openpype_execute_args +from openpype.lib.execute import run_detached_process +from openpype.modules import OpenPypeModule +from openpype_interfaces import ITrayAction + + +class TrayPublishAction(OpenPypeModule, ITrayAction): + label = "New Publish (beta)" + name = "traypublish_tool" + + def initialize(self, modules_settings): + import openpype + self.enabled = True + self.publish_paths = [ + os.path.join( + openpype.PACKAGE_DIR, + "hosts", + "traypublisher", + "plugins", + "publish" + ) + ] + self._experimental_tools = None + + def tray_init(self): + from openpype.tools.experimental_tools import ExperimentalTools + + self._experimental_tools = ExperimentalTools() + + def tray_menu(self, *args, **kwargs): + super(TrayPublishAction, self).tray_menu(*args, **kwargs) + traypublisher = self._experimental_tools.get("traypublisher") + visible = False + if traypublisher and traypublisher.enabled: + visible = True + self._action_item.setVisible(visible) + + def on_action_trigger(self): + self.run_traypublisher() + + def connect_with_modules(self, enabled_modules): + """Collect publish paths from other modules.""" + publish_paths = self.manager.collect_plugin_paths()["publish"] + self.publish_paths.extend(publish_paths) + + def run_traypublisher(self): + args = get_openpype_execute_args("traypublisher") + run_detached_process(args) diff --git a/openpype/pipeline/create/README.md b/openpype/pipeline/create/README.md index 9eef7c72a7..02b64e52ea 100644 --- a/openpype/pipeline/create/README.md +++ b/openpype/pipeline/create/README.md @@ -14,7 +14,7 @@ Except creating and removing instances are all changes not automatically propaga ## CreatedInstance -Product of creation is "instance" which holds basic data defying it. Core data are `creator_identifier`, `family` and `subset`. Other data can be keys used to fill subset name or metadata modifying publishing process of the instance (more described later). All instances have `id` which holds constant `pyblish.avalon.instance` and `uuid` which is identifier of the instance. +Product of creation is "instance" which holds basic data defying it. Core data are `creator_identifier`, `family` and `subset`. Other data can be keys used to fill subset name or metadata modifying publishing process of the instance (more described later). All instances have `id` which holds constant `pyblish.avalon.instance` and `instance_id` which is identifier of the instance. Family tells how should be instance processed and subset what name will published item have. - There are cases when subset is not fully filled during creation and may change during publishing. That is in most of cases caused because instance is related to other instance or instance data do not represent final product. @@ -26,7 +26,7 @@ Family tells how should be instance processed and subset what name will publishe ## Identifier that this data represents instance for publishing (automatically assigned) "id": "pyblish.avalon.instance", ## Identifier of this specific instance (automatically assigned) - "uuid": , + "instance_id": , ## Instance family (used from Creator) "family": , diff --git a/openpype/pipeline/create/context.py b/openpype/pipeline/create/context.py index 4454d31d83..e11d32091f 100644 --- a/openpype/pipeline/create/context.py +++ b/openpype/pipeline/create/context.py @@ -361,7 +361,7 @@ class CreatedInstance: # their individual children but not on their own __immutable_keys = ( "id", - "uuid", + "instance_id", "family", "creator_identifier", "creator_attributes", @@ -434,8 +434,8 @@ class CreatedInstance: if data: self._data.update(data) - if not self._data.get("uuid"): - self._data["uuid"] = str(uuid4()) + if not self._data.get("instance_id"): + self._data["instance_id"] = str(uuid4()) self._asset_is_valid = self.has_set_asset self._task_is_valid = self.has_set_task @@ -551,7 +551,7 @@ class CreatedInstance: @property def id(self): """Instance identifier.""" - return self._data["uuid"] + return self._data["instance_id"] @property def data(self): diff --git a/openpype/plugins/publish/collect_anatomy_context_data.py b/openpype/plugins/publish/collect_anatomy_context_data.py index b0474b93ce..bd8d9e50c4 100644 --- a/openpype/plugins/publish/collect_anatomy_context_data.py +++ b/openpype/plugins/publish/collect_anatomy_context_data.py @@ -44,42 +44,18 @@ class CollectAnatomyContextData(pyblish.api.ContextPlugin): label = "Collect Anatomy Context Data" def process(self, context): - - task_name = api.Session["AVALON_TASK"] - project_entity = context.data["projectEntity"] - asset_entity = context.data["assetEntity"] - - asset_tasks = asset_entity["data"]["tasks"] - task_type = asset_tasks.get(task_name, {}).get("type") - - project_task_types = project_entity["config"]["tasks"] - task_code = project_task_types.get(task_type, {}).get("short_name") - - asset_parents = asset_entity["data"]["parents"] - hierarchy = "/".join(asset_parents) - - parent_name = project_entity["name"] - if asset_parents: - parent_name = asset_parents[-1] - context_data = { "project": { "name": project_entity["name"], "code": project_entity["data"].get("code") }, - "asset": asset_entity["name"], - "parent": parent_name, - "hierarchy": hierarchy, - "task": { - "name": task_name, - "type": task_type, - "short": task_code, - }, "username": context.data["user"], "app": context.data["hostName"] } + context.data["anatomyData"] = context_data + # add system general settings anatomy data system_general_data = get_system_general_anatomy_data() context_data.update(system_general_data) @@ -87,7 +63,33 @@ class CollectAnatomyContextData(pyblish.api.ContextPlugin): datetime_data = context.data.get("datetimeData") or {} context_data.update(datetime_data) - context.data["anatomyData"] = context_data + asset_entity = context.data.get("assetEntity") + if asset_entity: + task_name = api.Session["AVALON_TASK"] + + asset_tasks = asset_entity["data"]["tasks"] + task_type = asset_tasks.get(task_name, {}).get("type") + + project_task_types = project_entity["config"]["tasks"] + task_code = project_task_types.get(task_type, {}).get("short_name") + + asset_parents = asset_entity["data"]["parents"] + hierarchy = "/".join(asset_parents) + + parent_name = project_entity["name"] + if asset_parents: + parent_name = asset_parents[-1] + + context_data.update({ + "asset": asset_entity["name"], + "parent": parent_name, + "hierarchy": hierarchy, + "task": { + "name": task_name, + "type": task_type, + "short": task_code, + } + }) self.log.info("Global anatomy Data collected") self.log.debug(json.dumps(context_data, indent=4)) diff --git a/openpype/plugins/publish/collect_anatomy_instance_data.py b/openpype/plugins/publish/collect_anatomy_instance_data.py index 74b556e28a..42836e796b 100644 --- a/openpype/plugins/publish/collect_anatomy_instance_data.py +++ b/openpype/plugins/publish/collect_anatomy_instance_data.py @@ -52,7 +52,7 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): def fill_missing_asset_docs(self, context): self.log.debug("Qeurying asset documents for instances.") - context_asset_doc = context.data["assetEntity"] + context_asset_doc = context.data.get("assetEntity") instances_with_missing_asset_doc = collections.defaultdict(list) for instance in context: @@ -69,7 +69,7 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): # Check if asset name is the same as what is in context # - they may be different, e.g. in NukeStudio - if context_asset_doc["name"] == _asset_name: + if context_asset_doc and context_asset_doc["name"] == _asset_name: instance.data["assetEntity"] = context_asset_doc else: @@ -212,7 +212,7 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): self.log.debug("Storing anatomy data to instance data.") project_doc = context.data["projectEntity"] - context_asset_doc = context.data["assetEntity"] + context_asset_doc = context.data.get("assetEntity") project_task_types = project_doc["config"]["tasks"] @@ -240,7 +240,13 @@ class CollectAnatomyInstanceData(pyblish.api.ContextPlugin): # Hiearchy asset_doc = instance.data.get("assetEntity") - if asset_doc and asset_doc["_id"] != context_asset_doc["_id"]: + if ( + asset_doc + and ( + not context_asset_doc + or asset_doc["_id"] != context_asset_doc["_id"] + ) + ): parents = asset_doc["data"].get("parents") or list() parent_name = project_doc["name"] if parents: diff --git a/openpype/plugins/publish/collect_avalon_entities.py b/openpype/plugins/publish/collect_avalon_entities.py index a6120d42fe..c099a2cf75 100644 --- a/openpype/plugins/publish/collect_avalon_entities.py +++ b/openpype/plugins/publish/collect_avalon_entities.py @@ -33,6 +33,11 @@ class CollectAvalonEntities(pyblish.api.ContextPlugin): ).format(project_name) self.log.debug("Collected Project \"{}\"".format(project_entity)) + context.data["projectEntity"] = project_entity + + if not asset_name: + self.log.info("Context is not set. Can't collect global data.") + return asset_entity = io.find_one({ "type": "asset", "name": asset_name, @@ -44,7 +49,6 @@ class CollectAvalonEntities(pyblish.api.ContextPlugin): self.log.debug("Collected Asset \"{}\"".format(asset_entity)) - context.data["projectEntity"] = project_entity context.data["assetEntity"] = asset_entity data = asset_entity['data'] diff --git a/openpype/plugins/publish/integrate_new.py b/openpype/plugins/publish/integrate_new.py index 486718d8c4..6e0940d459 100644 --- a/openpype/plugins/publish/integrate_new.py +++ b/openpype/plugins/publish/integrate_new.py @@ -148,7 +148,10 @@ class IntegrateAssetNew(pyblish.api.InstancePlugin): project_entity = instance.data["projectEntity"] - context_asset_name = context.data["assetEntity"]["name"] + context_asset_name = None + context_asset_doc = context.data.get("assetEntity") + if context_asset_doc: + context_asset_name = context_asset_doc["name"] asset_name = instance.data["asset"] asset_entity = instance.data.get("assetEntity") diff --git a/openpype/plugins/publish/validate_aseset_docs.py b/openpype/plugins/publish/validate_aseset_docs.py new file mode 100644 index 0000000000..eed75cdf8a --- /dev/null +++ b/openpype/plugins/publish/validate_aseset_docs.py @@ -0,0 +1,31 @@ +import pyblish.api +from openpype.pipeline import PublishValidationError + + +class ValidateContainers(pyblish.api.InstancePlugin): + """Validate existence of asset asset documents on instances. + + Without asset document it is not possible to publish the instance. + + If context has set asset document the validation is skipped. + + Plugin was added because there are cases when context asset is not defined + e.g. in tray publisher. + """ + + label = "Validate Asset docs" + order = pyblish.api.ValidatorOrder + + def process(self, instance): + context_asset_doc = instance.context.data.get("assetEntity") + if context_asset_doc: + return + + if instance.data.get("assetEntity"): + self.log.info("Instance have set asset document in it's data.") + + else: + raise PublishValidationError(( + "Instance \"{}\" don't have set asset" + " document which is needed for publishing." + ).format(instance.data["name"])) diff --git a/openpype/pype_commands.py b/openpype/pype_commands.py index 47f5e7fcc0..9dc3e29337 100644 --- a/openpype/pype_commands.py +++ b/openpype/pype_commands.py @@ -80,6 +80,11 @@ class PypeCommands: from openpype.tools import standalonepublish standalonepublish.main() + @staticmethod + def launch_traypublisher(): + from openpype.tools import traypublisher + traypublisher.main() + @staticmethod def publish(paths, targets=None, gui=False): """Start headless publishing. diff --git a/openpype/style/style.css b/openpype/style/style.css index c96e87aa02..ba40b780ab 100644 --- a/openpype/style/style.css +++ b/openpype/style/style.css @@ -1261,6 +1261,12 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { background: {color:restart-btn-bg}; } +/* Tray publisher */ +#ChooseProjectLabel { + font-size: 15pt; + font-weight: 750; +} + /* Globally used names */ #Separator { background: {color:bg-menu-separator}; diff --git a/openpype/tools/experimental_tools/dialog.py b/openpype/tools/experimental_tools/dialog.py index 295afbe68d..0099492207 100644 --- a/openpype/tools/experimental_tools/dialog.py +++ b/openpype/tools/experimental_tools/dialog.py @@ -82,7 +82,7 @@ class ExperimentalToolsDialog(QtWidgets.QDialog): tool_btns_layout.addWidget(tool_btns_label, 0) experimental_tools = ExperimentalTools( - parent=parent, filter_hosts=True + parent_widget=parent, refresh=False ) # Main layout @@ -116,7 +116,8 @@ class ExperimentalToolsDialog(QtWidgets.QDialog): self._experimental_tools.refresh_availability() buttons_to_remove = set(self._buttons_by_tool_identifier.keys()) - for idx, tool in enumerate(self._experimental_tools.tools): + tools = self._experimental_tools.get_tools_for_host() + for idx, tool in enumerate(tools): identifier = tool.identifier if identifier in buttons_to_remove: buttons_to_remove.remove(identifier) diff --git a/openpype/tools/experimental_tools/tools_def.py b/openpype/tools/experimental_tools/tools_def.py index 316359c0f3..fa2971dc1d 100644 --- a/openpype/tools/experimental_tools/tools_def.py +++ b/openpype/tools/experimental_tools/tools_def.py @@ -5,7 +5,32 @@ from openpype.settings import get_local_settings LOCAL_EXPERIMENTAL_KEY = "experimental_tools" -class ExperimentalTool: +class ExperimentalTool(object): + """Definition of experimental tool. + + Definition is used in local settings. + + Args: + identifier (str): String identifier of tool (unique). + label (str): Label shown in UI. + """ + def __init__(self, identifier, label, tooltip): + self.identifier = identifier + self.label = label + self.tooltip = tooltip + self._enabled = True + + @property + def enabled(self): + """Is tool enabled and button is clickable.""" + return self._enabled + + def set_enabled(self, enabled=True): + """Change if tool is enabled.""" + self._enabled = enabled + + +class ExperimentalHostTool(ExperimentalTool): """Definition of experimental tool. Definition is used in local settings and in experimental tools dialog. @@ -19,12 +44,10 @@ class ExperimentalTool: Some tools may not be available in all hosts. """ def __init__( - self, identifier, label, callback, tooltip, hosts_filter=None + self, identifier, label, tooltip, callback, hosts_filter=None ): - self.identifier = identifier - self.label = label + super(ExperimentalHostTool, self).__init__(identifier, label, tooltip) self.callback = callback - self.tooltip = tooltip self.hosts_filter = hosts_filter self._enabled = True @@ -33,18 +56,9 @@ class ExperimentalTool: return host_name in self.hosts_filter return True - @property - def enabled(self): - """Is tool enabled and button is clickable.""" - return self._enabled - - def set_enabled(self, enabled=True): - """Change if tool is enabled.""" - self._enabled = enabled - - def execute(self): + def execute(self, *args, **kwargs): """Trigger registered callback.""" - self.callback() + self.callback(*args, **kwargs) class ExperimentalTools: @@ -53,57 +67,36 @@ class ExperimentalTools: To add/remove experimental tool just add/remove tool to `experimental_tools` variable in __init__ function. - Args: - parent (QtWidgets.QWidget): Parent widget for tools. - host_name (str): Name of host in which context we're now. Environment - value 'AVALON_APP' is used when not passed. - filter_hosts (bool): Should filter tools. By default is set to 'True' - when 'host_name' is passed. Is always set to 'False' if 'host_name' - is not defined. + --- Example tool (callback will just print on click) --- + def example_callback(*args): + print("Triggered tool") + + experimental_tools = [ + ExperimentalHostTool( + "example", + "Example experimental tool", + example_callback, + "Example tool tooltip." + ) + ] + --- """ - def __init__(self, parent=None, host_name=None, filter_hosts=None): + def __init__(self, parent_widget=None, refresh=True): # Definition of experimental tools experimental_tools = [ - ExperimentalTool( + ExperimentalHostTool( "publisher", "New publisher", - self._show_publisher, - "Combined creation and publishing into one tool." + "Combined creation and publishing into one tool.", + self._show_publisher + ), + ExperimentalTool( + "traypublisher", + "New Standalone Publisher", + "Standalone publisher using new publisher. Requires restart" ) ] - # --- Example tool (callback will just print on click) --- - # def example_callback(*args): - # print("Triggered tool") - # - # experimental_tools = [ - # ExperimentalTool( - # "example", - # "Example experimental tool", - # example_callback, - # "Example tool tooltip." - # ) - # ] - - # Try to get host name from env variable `AVALON_APP` - if not host_name: - host_name = os.environ.get("AVALON_APP") - - # Decide if filtering by host name should happen - if filter_hosts is None: - filter_hosts = host_name is not None - - if filter_hosts and not host_name: - filter_hosts = False - - # Filter tools by host name - if filter_hosts: - experimental_tools = [ - tool - for tool in experimental_tools - if tool.is_available_for_host(host_name) - ] - # Store tools by identifier tools_by_identifier = {} for tool in experimental_tools: @@ -115,10 +108,13 @@ class ExperimentalTools: self._tools_by_identifier = tools_by_identifier self._tools = experimental_tools - self._parent_widget = parent + self._parent_widget = parent_widget self._publisher_tool = None + if refresh: + self.refresh_availability() + @property def tools(self): """Tools in list. @@ -139,6 +135,22 @@ class ExperimentalTools: """ return self._tools_by_identifier + def get(self, tool_identifier): + """Get tool by identifier.""" + return self.tools_by_identifier.get(tool_identifier) + + def get_tools_for_host(self, host_name=None): + if not host_name: + host_name = os.environ.get("AVALON_APP") + tools = [] + for tool in self.tools: + if ( + isinstance(tool, ExperimentalHostTool) + and tool.is_available_for_host(host_name) + ): + tools.append(tool) + return tools + def refresh_availability(self): """Reload local settings and check if any tool changed ability.""" local_settings = get_local_settings() diff --git a/openpype/tools/publisher/control.py b/openpype/tools/publisher/control.py index 2ce0eaad62..5a84b1d8ca 100644 --- a/openpype/tools/publisher/control.py +++ b/openpype/tools/publisher/control.py @@ -42,18 +42,23 @@ class MainThreadProcess(QtCore.QObject): This approach gives ability to update UI meanwhile plugin is in progress. """ - timer_interval = 3 + count_timeout = 2 def __init__(self): super(MainThreadProcess, self).__init__() self._items_to_process = collections.deque() timer = QtCore.QTimer() - timer.setInterval(self.timer_interval) + timer.setInterval(0) timer.timeout.connect(self._execute) self._timer = timer + self._switch_counter = self.count_timeout + + def process(self, func, *args, **kwargs): + item = MainThreadItem(func, *args, **kwargs) + self.add_item(item) def add_item(self, item): self._items_to_process.append(item) @@ -62,6 +67,12 @@ class MainThreadProcess(QtCore.QObject): if not self._items_to_process: return + if self._switch_counter > 0: + self._switch_counter -= 1 + return + + self._switch_counter = self.count_timeout + item = self._items_to_process.popleft() item.process() @@ -173,11 +184,21 @@ class PublishReport: self._stored_plugins.append(plugin) + plugin_data_item = self._create_plugin_data_item(plugin) + + self._plugin_data_with_plugin.append({ + "plugin": plugin, + "data": plugin_data_item + }) + self._plugin_data.append(plugin_data_item) + return plugin_data_item + + def _create_plugin_data_item(self, plugin): label = None if hasattr(plugin, "label"): label = plugin.label - plugin_data_item = { + return { "name": plugin.__name__, "label": label, "order": plugin.order, @@ -186,12 +207,6 @@ class PublishReport: "skipped": False, "passed": False } - self._plugin_data_with_plugin.append({ - "plugin": plugin, - "data": plugin_data_item - }) - self._plugin_data.append(plugin_data_item) - return plugin_data_item def set_plugin_skipped(self): """Set that current plugin has been skipped.""" @@ -241,7 +256,7 @@ class PublishReport: if publish_plugins: for plugin in publish_plugins: if plugin not in self._stored_plugins: - plugins_data.append(self._add_plugin_data_item(plugin)) + plugins_data.append(self._create_plugin_data_item(plugin)) crashed_file_paths = {} if self._publish_discover_result is not None: @@ -971,6 +986,9 @@ class PublisherController: self._publish_next_process() + def reset_project_data_cache(self): + self._asset_docs_cache.reset() + def collect_families_from_instances(instances, only_active=False): """Collect all families for passed publish instances. diff --git a/openpype/tools/publisher/publish_report_viewer/__init__.py b/openpype/tools/publisher/publish_report_viewer/__init__.py index 3cfaaa5a05..ce1cc3729c 100644 --- a/openpype/tools/publisher/publish_report_viewer/__init__.py +++ b/openpype/tools/publisher/publish_report_viewer/__init__.py @@ -1,3 +1,6 @@ +from .report_items import ( + PublishReport +) from .widgets import ( PublishReportViewerWidget ) @@ -8,6 +11,8 @@ from .window import ( __all__ = ( + "PublishReport", + "PublishReportViewerWidget", "PublishReportViewerWindow", diff --git a/openpype/tools/publisher/publish_report_viewer/model.py b/openpype/tools/publisher/publish_report_viewer/model.py index 460d3e12d1..a88129a358 100644 --- a/openpype/tools/publisher/publish_report_viewer/model.py +++ b/openpype/tools/publisher/publish_report_viewer/model.py @@ -28,6 +28,8 @@ class InstancesModel(QtGui.QStandardItemModel): self.clear() self._items_by_id.clear() self._plugin_items_by_id.clear() + if not report_item: + return root_item = self.invisibleRootItem() @@ -119,6 +121,8 @@ class PluginsModel(QtGui.QStandardItemModel): self.clear() self._items_by_id.clear() self._plugin_items_by_id.clear() + if not report_item: + return root_item = self.invisibleRootItem() diff --git a/openpype/tools/publisher/publish_report_viewer/report_items.py b/openpype/tools/publisher/publish_report_viewer/report_items.py new file mode 100644 index 0000000000..b47d14da25 --- /dev/null +++ b/openpype/tools/publisher/publish_report_viewer/report_items.py @@ -0,0 +1,126 @@ +import uuid +import collections +import copy + + +class PluginItem: + def __init__(self, plugin_data): + self._id = uuid.uuid4() + + self.name = plugin_data["name"] + self.label = plugin_data["label"] + self.order = plugin_data["order"] + self.skipped = plugin_data["skipped"] + self.passed = plugin_data["passed"] + + errored = False + for instance_data in plugin_data["instances_data"]: + for log_item in instance_data["logs"]: + errored = log_item["type"] == "error" + if errored: + break + if errored: + break + + self.errored = errored + + @property + def id(self): + return self._id + + +class InstanceItem: + def __init__(self, instance_id, instance_data, logs_by_instance_id): + self._id = instance_id + self.label = instance_data.get("label") or instance_data.get("name") + self.family = instance_data.get("family") + self.removed = not instance_data.get("exists", True) + + logs = logs_by_instance_id.get(instance_id) or [] + errored = False + for log_item in logs: + if log_item.errored: + errored = True + break + + self.errored = errored + + @property + def id(self): + return self._id + + +class LogItem: + def __init__(self, log_item_data, plugin_id, instance_id): + self._instance_id = instance_id + self._plugin_id = plugin_id + self._errored = log_item_data["type"] == "error" + self.data = log_item_data + + def __getitem__(self, key): + return self.data[key] + + @property + def errored(self): + return self._errored + + @property + def instance_id(self): + return self._instance_id + + @property + def plugin_id(self): + return self._plugin_id + + +class PublishReport: + def __init__(self, report_data): + data = copy.deepcopy(report_data) + + context_data = data["context"] + context_data["name"] = "context" + context_data["label"] = context_data["label"] or "Context" + + logs = [] + plugins_items_by_id = {} + plugins_id_order = [] + for plugin_data in data["plugins_data"]: + item = PluginItem(plugin_data) + plugins_id_order.append(item.id) + plugins_items_by_id[item.id] = item + for instance_data_item in plugin_data["instances_data"]: + instance_id = instance_data_item["id"] + for log_item_data in instance_data_item["logs"]: + log_item = LogItem( + copy.deepcopy(log_item_data), item.id, instance_id + ) + logs.append(log_item) + + logs_by_instance_id = collections.defaultdict(list) + for log_item in logs: + logs_by_instance_id[log_item.instance_id].append(log_item) + + instance_items_by_id = {} + instance_items_by_family = {} + context_item = InstanceItem(None, context_data, logs_by_instance_id) + instance_items_by_id[context_item.id] = context_item + instance_items_by_family[context_item.family] = [context_item] + + for instance_id, instance_data in data["instances"].items(): + item = InstanceItem( + instance_id, instance_data, logs_by_instance_id + ) + instance_items_by_id[item.id] = item + if item.family not in instance_items_by_family: + instance_items_by_family[item.family] = [] + instance_items_by_family[item.family].append(item) + + self.instance_items_by_id = instance_items_by_id + self.instance_items_by_family = instance_items_by_family + + self.plugins_id_order = plugins_id_order + self.plugins_items_by_id = plugins_items_by_id + + self.logs = logs + + self.crashed_plugin_paths = report_data["crashed_file_paths"] diff --git a/openpype/tools/publisher/publish_report_viewer/widgets.py b/openpype/tools/publisher/publish_report_viewer/widgets.py index 24f1d33d0e..fd226ea0e4 100644 --- a/openpype/tools/publisher/publish_report_viewer/widgets.py +++ b/openpype/tools/publisher/publish_report_viewer/widgets.py @@ -1,10 +1,8 @@ -import copy -import uuid - -from Qt import QtWidgets, QtCore +from Qt import QtWidgets, QtCore, QtGui from openpype.widgets.nice_checkbox import NiceCheckbox +# from openpype.tools.utils import DeselectableTreeView from .constants import ( ITEM_ID_ROLE, ITEM_IS_GROUP_ROLE @@ -16,98 +14,127 @@ from .model import ( PluginsModel, PluginProxyModel ) +from .report_items import PublishReport + +FILEPATH_ROLE = QtCore.Qt.UserRole + 1 +TRACEBACK_ROLE = QtCore.Qt.UserRole + 2 +IS_DETAIL_ITEM_ROLE = QtCore.Qt.UserRole + 3 -class PluginItem: - def __init__(self, plugin_data): - self._id = uuid.uuid4() +class PluginLoadReportModel(QtGui.QStandardItemModel): + def set_report(self, report): + parent = self.invisibleRootItem() + parent.removeRows(0, parent.rowCount()) - self.name = plugin_data["name"] - self.label = plugin_data["label"] - self.order = plugin_data["order"] - self.skipped = plugin_data["skipped"] - self.passed = plugin_data["passed"] + new_items = [] + new_items_by_filepath = {} + for filepath in report.crashed_plugin_paths.keys(): + item = QtGui.QStandardItem(filepath) + new_items.append(item) + new_items_by_filepath[filepath] = item - logs = [] - errored = False - for instance_data in plugin_data["instances_data"]: - for log_item in instance_data["logs"]: - if not errored: - errored = log_item["type"] == "error" - logs.append(copy.deepcopy(log_item)) + if not new_items: + return - self.errored = errored - self.logs = logs - - @property - def id(self): - return self._id + parent.appendRows(new_items) + for filepath, item in new_items_by_filepath.items(): + traceback_txt = report.crashed_plugin_paths[filepath] + detail_item = QtGui.QStandardItem() + detail_item.setData(filepath, FILEPATH_ROLE) + detail_item.setData(traceback_txt, TRACEBACK_ROLE) + detail_item.setData(True, IS_DETAIL_ITEM_ROLE) + item.appendRow(detail_item) -class InstanceItem: - def __init__(self, instance_id, instance_data, report_data): - self._id = instance_id - self.label = instance_data.get("label") or instance_data.get("name") - self.family = instance_data.get("family") - self.removed = not instance_data.get("exists", True) +class DetailWidget(QtWidgets.QTextEdit): + def __init__(self, text, *args, **kwargs): + super(DetailWidget, self).__init__(*args, **kwargs) - logs = [] - for plugin_data in report_data["plugins_data"]: - for instance_data_item in plugin_data["instances_data"]: - if instance_data_item["id"] == self._id: - logs.extend(copy.deepcopy(instance_data_item["logs"])) + self.setReadOnly(True) + self.setHtml(text) + self.setTextInteractionFlags(QtCore.Qt.TextBrowserInteraction) + self.setWordWrapMode( + QtGui.QTextOption.WrapAtWordBoundaryOrAnywhere + ) - errored = False - for log in logs: - if log["type"] == "error": - errored = True - break - - self.errored = errored - self.logs = logs - - @property - def id(self): - return self._id + def sizeHint(self): + content_margins = ( + self.contentsMargins().top() + + self.contentsMargins().bottom() + ) + size = self.document().documentLayout().documentSize().toSize() + size.setHeight(size.height() + content_margins) + return size -class PublishReport: - def __init__(self, report_data): - data = copy.deepcopy(report_data) +class PluginLoadReportWidget(QtWidgets.QWidget): + def __init__(self, parent): + super(PluginLoadReportWidget, self).__init__(parent) - context_data = data["context"] - context_data["name"] = "context" - context_data["label"] = context_data["label"] or "Context" + view = QtWidgets.QTreeView(self) + view.setEditTriggers(view.NoEditTriggers) + view.setTextElideMode(QtCore.Qt.ElideLeft) + view.setHeaderHidden(True) + view.setAlternatingRowColors(True) + view.setVerticalScrollMode(view.ScrollPerPixel) - instance_items_by_id = {} - instance_items_by_family = {} - context_item = InstanceItem(None, context_data, data) - instance_items_by_id[context_item.id] = context_item - instance_items_by_family[context_item.family] = [context_item] + model = PluginLoadReportModel() + view.setModel(model) - for instance_id, instance_data in data["instances"].items(): - item = InstanceItem(instance_id, instance_data, data) - instance_items_by_id[item.id] = item - if item.family not in instance_items_by_family: - instance_items_by_family[item.family] = [] - instance_items_by_family[item.family].append(item) + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(view, 1) - all_logs = [] - plugins_items_by_id = {} - plugins_id_order = [] - for plugin_data in data["plugins_data"]: - item = PluginItem(plugin_data) - plugins_id_order.append(item.id) - plugins_items_by_id[item.id] = item - all_logs.extend(copy.deepcopy(item.logs)) + view.expanded.connect(self._on_expand) - self.instance_items_by_id = instance_items_by_id - self.instance_items_by_family = instance_items_by_family + self._view = view + self._model = model + self._widgets_by_filepath = {} - self.plugins_id_order = plugins_id_order - self.plugins_items_by_id = plugins_items_by_id + def _on_expand(self, index): + for row in range(self._model.rowCount(index)): + child_index = self._model.index(row, index.column(), index) + self._create_widget(child_index) - self.logs = all_logs + def showEvent(self, event): + super(PluginLoadReportWidget, self).showEvent(event) + self._update_widgets_size_hints() + + def resizeEvent(self, event): + super(PluginLoadReportWidget, self).resizeEvent(event) + self._update_widgets_size_hints() + + def _update_widgets_size_hints(self): + for item in self._widgets_by_filepath.values(): + widget, index = item + if not widget.isVisible(): + continue + self._model.setData( + index, widget.sizeHint(), QtCore.Qt.SizeHintRole + ) + + def _create_widget(self, index): + if not index.data(IS_DETAIL_ITEM_ROLE): + return + + filepath = index.data(FILEPATH_ROLE) + if filepath in self._widgets_by_filepath: + return + + traceback_txt = index.data(TRACEBACK_ROLE) + detail_text = ( + "Filepath:
" + "{}

" + "Traceback:
" + "{}" + ).format(filepath, traceback_txt.replace("\n", "
")) + widget = DetailWidget(detail_text, self) + self._view.setIndexWidget(index, widget) + self._widgets_by_filepath[filepath] = (widget, index) + + def set_report(self, report): + self._widgets_by_filepath = {} + self._model.set_report(report) class DetailsWidget(QtWidgets.QWidget): @@ -123,11 +150,50 @@ class DetailsWidget(QtWidgets.QWidget): layout.addWidget(output_widget) self._output_widget = output_widget + self._report_item = None + self._instance_filter = set() + self._plugin_filter = set() def clear(self): self._output_widget.setPlainText("") - def set_logs(self, logs): + def set_report(self, report): + self._report_item = report + self._plugin_filter = set() + self._instance_filter = set() + self._update_logs() + + def set_plugin_filter(self, plugin_filter): + self._plugin_filter = plugin_filter + self._update_logs() + + def set_instance_filter(self, instance_filter): + self._instance_filter = instance_filter + self._update_logs() + + def _update_logs(self): + if not self._report_item: + self._output_widget.setPlainText("") + return + + filtered_logs = [] + for log in self._report_item.logs: + if ( + self._instance_filter + and log.instance_id not in self._instance_filter + ): + continue + + if ( + self._plugin_filter + and log.plugin_id not in self._plugin_filter + ): + continue + filtered_logs.append(log) + + self._set_logs(filtered_logs) + + def _set_logs(self, logs): lines = [] for log in logs: if log["type"] == "record": @@ -148,6 +214,60 @@ class DetailsWidget(QtWidgets.QWidget): self._output_widget.setPlainText(text) +class DeselectableTreeView(QtWidgets.QTreeView): + """A tree view that deselects on clicking on an empty area in the view""" + + def mousePressEvent(self, event): + index = self.indexAt(event.pos()) + clear_selection = False + if not index.isValid(): + modifiers = QtWidgets.QApplication.keyboardModifiers() + if modifiers == QtCore.Qt.ShiftModifier: + return + elif modifiers == QtCore.Qt.ControlModifier: + return + clear_selection = True + else: + indexes = self.selectedIndexes() + if len(indexes) == 1 and index in indexes: + clear_selection = True + + if clear_selection: + # clear the selection + self.clearSelection() + # clear the current index + self.setCurrentIndex(QtCore.QModelIndex()) + event.accept() + return + + QtWidgets.QTreeView.mousePressEvent(self, event) + + +class DetailsPopup(QtWidgets.QDialog): + closed = QtCore.Signal() + + def __init__(self, parent, center_widget): + super(DetailsPopup, self).__init__(parent) + self.setWindowTitle("Report Details") + layout = QtWidgets.QHBoxLayout(self) + + self._center_widget = center_widget + self._first_show = True + self._layout = layout + + def showEvent(self, event): + layout = self.layout() + layout.insertWidget(0, self._center_widget) + super(DetailsPopup, self).showEvent(event) + if self._first_show: + self._first_show = False + self.resize(700, 400) + + def closeEvent(self, event): + super(DetailsPopup, self).closeEvent(event) + self.closed.emit() + + class PublishReportViewerWidget(QtWidgets.QWidget): def __init__(self, parent=None): super(PublishReportViewerWidget, self).__init__(parent) @@ -171,12 +291,13 @@ class PublishReportViewerWidget(QtWidgets.QWidget): removed_instances_layout.addWidget(removed_instances_check, 0) removed_instances_layout.addWidget(removed_instances_label, 1) - instances_view = QtWidgets.QTreeView(self) + instances_view = DeselectableTreeView(self) instances_view.setObjectName("PublishDetailViews") instances_view.setModel(instances_proxy) instances_view.setIndentation(0) instances_view.setHeaderHidden(True) instances_view.setEditTriggers(QtWidgets.QTreeView.NoEditTriggers) + instances_view.setSelectionMode(QtWidgets.QTreeView.ExtendedSelection) instances_view.setExpandsOnDoubleClick(False) instances_delegate = GroupItemDelegate(instances_view) @@ -191,29 +312,49 @@ class PublishReportViewerWidget(QtWidgets.QWidget): skipped_plugins_layout.addWidget(skipped_plugins_check, 0) skipped_plugins_layout.addWidget(skipped_plugins_label, 1) - plugins_view = QtWidgets.QTreeView(self) + plugins_view = DeselectableTreeView(self) plugins_view.setObjectName("PublishDetailViews") plugins_view.setModel(plugins_proxy) plugins_view.setIndentation(0) plugins_view.setHeaderHidden(True) + plugins_view.setSelectionMode(QtWidgets.QTreeView.ExtendedSelection) plugins_view.setEditTriggers(QtWidgets.QTreeView.NoEditTriggers) plugins_view.setExpandsOnDoubleClick(False) plugins_delegate = GroupItemDelegate(plugins_view) plugins_view.setItemDelegate(plugins_delegate) - details_widget = DetailsWidget(self) + details_widget = QtWidgets.QWidget(self) + details_tab_widget = QtWidgets.QTabWidget(details_widget) + details_popup_btn = QtWidgets.QPushButton("PopUp", details_widget) - layout = QtWidgets.QGridLayout(self) + details_layout = QtWidgets.QVBoxLayout(details_widget) + details_layout.setContentsMargins(0, 0, 0, 0) + details_layout.addWidget(details_tab_widget, 1) + details_layout.addWidget(details_popup_btn, 0) + + details_popup = DetailsPopup(self, details_tab_widget) + + logs_text_widget = DetailsWidget(details_tab_widget) + plugin_load_report_widget = PluginLoadReportWidget(details_tab_widget) + + details_tab_widget.addTab(logs_text_widget, "Logs") + details_tab_widget.addTab(plugin_load_report_widget, "Crashed plugins") + + middle_widget = QtWidgets.QWidget(self) + middle_layout = QtWidgets.QGridLayout(middle_widget) + middle_layout.setContentsMargins(0, 0, 0, 0) # Row 1 - layout.addLayout(removed_instances_layout, 0, 0) - layout.addLayout(skipped_plugins_layout, 0, 1) + middle_layout.addLayout(removed_instances_layout, 0, 0) + middle_layout.addLayout(skipped_plugins_layout, 0, 1) # Row 2 - layout.addWidget(instances_view, 1, 0) - layout.addWidget(plugins_view, 1, 1) - layout.addWidget(details_widget, 1, 2) + middle_layout.addWidget(instances_view, 1, 0) + middle_layout.addWidget(plugins_view, 1, 1) - layout.setColumnStretch(2, 1) + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(middle_widget, 0) + layout.addWidget(details_widget, 1) instances_view.selectionModel().selectionChanged.connect( self._on_instance_change @@ -230,10 +371,13 @@ class PublishReportViewerWidget(QtWidgets.QWidget): removed_instances_check.stateChanged.connect( self._on_removed_instances_check ) + details_popup_btn.clicked.connect(self._on_details_popup) + details_popup.closed.connect(self._on_popup_close) self._ignore_selection_changes = False self._report_item = None - self._details_widget = details_widget + self._logs_text_widget = logs_text_widget + self._plugin_load_report_widget = plugin_load_report_widget self._removed_instances_check = removed_instances_check self._instances_view = instances_view @@ -248,6 +392,10 @@ class PublishReportViewerWidget(QtWidgets.QWidget): self._plugins_model = plugins_model self._plugins_proxy = plugins_proxy + self._details_widget = details_widget + self._details_tab_widget = details_tab_widget + self._details_popup = details_popup + def _on_instance_view_clicked(self, index): if not index.isValid() or not index.data(ITEM_IS_GROUP_ROLE): return @@ -266,62 +414,46 @@ class PublishReportViewerWidget(QtWidgets.QWidget): else: self._plugins_view.expand(index) - def set_report(self, report_data): + def set_report_data(self, report_data): + report = PublishReport(report_data) + self.set_report(report) + + def set_report(self, report): self._ignore_selection_changes = True - report_item = PublishReport(report_data) - self._report_item = report_item + self._report_item = report - self._instances_model.set_report(report_item) - self._plugins_model.set_report(report_item) - self._details_widget.set_logs(report_item.logs) + self._instances_model.set_report(report) + self._plugins_model.set_report(report) + self._logs_text_widget.set_report(report) + self._plugin_load_report_widget.set_report(report) self._ignore_selection_changes = False + self._instances_view.expandAll() + self._plugins_view.expandAll() + def _on_instance_change(self, *_args): if self._ignore_selection_changes: return - valid_index = None + instance_ids = set() for index in self._instances_view.selectedIndexes(): if index.isValid(): - valid_index = index - break + instance_ids.add(index.data(ITEM_ID_ROLE)) - if valid_index is None: - return - - if self._plugins_view.selectedIndexes(): - self._ignore_selection_changes = True - self._plugins_view.selectionModel().clearSelection() - self._ignore_selection_changes = False - - plugin_id = valid_index.data(ITEM_ID_ROLE) - instance_item = self._report_item.instance_items_by_id[plugin_id] - self._details_widget.set_logs(instance_item.logs) + self._logs_text_widget.set_instance_filter(instance_ids) def _on_plugin_change(self, *_args): if self._ignore_selection_changes: return - valid_index = None + plugin_ids = set() for index in self._plugins_view.selectedIndexes(): if index.isValid(): - valid_index = index - break + plugin_ids.add(index.data(ITEM_ID_ROLE)) - if valid_index is None: - self._details_widget.set_logs(self._report_item.logs) - return - - if self._instances_view.selectedIndexes(): - self._ignore_selection_changes = True - self._instances_view.selectionModel().clearSelection() - self._ignore_selection_changes = False - - plugin_id = valid_index.data(ITEM_ID_ROLE) - plugin_item = self._report_item.plugins_items_by_id[plugin_id] - self._details_widget.set_logs(plugin_item.logs) + self._logs_text_widget.set_plugin_filter(plugin_ids) def _on_skipped_plugin_check(self): self._plugins_proxy.set_ignore_skipped( @@ -332,3 +464,16 @@ class PublishReportViewerWidget(QtWidgets.QWidget): self._instances_proxy.set_ignore_removed( self._removed_instances_check.isChecked() ) + + def _on_details_popup(self): + self._details_widget.setVisible(False) + self._details_popup.show() + + def _on_popup_close(self): + self._details_widget.setVisible(True) + layout = self._details_widget.layout() + layout.insertWidget(0, self._details_tab_widget) + + def close_details_popup(self): + if self._details_popup.isVisible(): + self._details_popup.close() diff --git a/openpype/tools/publisher/publish_report_viewer/window.py b/openpype/tools/publisher/publish_report_viewer/window.py index 7a0fef7d91..678884677c 100644 --- a/openpype/tools/publisher/publish_report_viewer/window.py +++ b/openpype/tools/publisher/publish_report_viewer/window.py @@ -1,29 +1,355 @@ -from Qt import QtWidgets +import os +import json +import six +import appdirs +from Qt import QtWidgets, QtCore, QtGui from openpype import style +from openpype.lib import JSONSettingRegistry +from openpype.resources import get_openpype_icon_filepath +from openpype.tools import resources +from openpype.tools.utils import ( + IconButton, + paint_image_with_color +) + +from openpype.tools.utils.delegates import PrettyTimeDelegate + if __package__: from .widgets import PublishReportViewerWidget + from .report_items import PublishReport else: from widgets import PublishReportViewerWidget + from report_items import PublishReport + + +FILEPATH_ROLE = QtCore.Qt.UserRole + 1 +MODIFIED_ROLE = QtCore.Qt.UserRole + 2 + + +class PublisherReportRegistry(JSONSettingRegistry): + """Class handling storing publish report tool. + + Attributes: + vendor (str): Name used for path construction. + product (str): Additional name used for path construction. + + """ + + def __init__(self): + self.vendor = "pypeclub" + self.product = "openpype" + name = "publish_report_viewer" + path = appdirs.user_data_dir(self.product, self.vendor) + super(PublisherReportRegistry, self).__init__(name, path) + + +class LoadedFilesMopdel(QtGui.QStandardItemModel): + def __init__(self, *args, **kwargs): + super(LoadedFilesMopdel, self).__init__(*args, **kwargs) + self.setColumnCount(2) + self._items_by_filepath = {} + self._reports_by_filepath = {} + + self._registry = PublisherReportRegistry() + + self._loading_registry = False + self._load_registry() + + def headerData(self, section, orientation, role): + if role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole): + if section == 0: + return "Exports" + if section == 1: + return "Modified" + return "" + super(LoadedFilesMopdel, self).headerData(section, orientation, role) + + def _load_registry(self): + self._loading_registry = True + try: + filepaths = self._registry.get_item("filepaths") + self.add_filepaths(filepaths) + except ValueError: + pass + self._loading_registry = False + + def _store_registry(self): + if self._loading_registry: + return + filepaths = list(self._items_by_filepath.keys()) + self._registry.set_item("filepaths", filepaths) + + def data(self, index, role=None): + if role is None: + role = QtCore.Qt.DisplayRole + + col = index.column() + if col != 0: + index = self.index(index.row(), 0, index.parent()) + + if role == QtCore.Qt.ToolTipRole: + if col == 0: + role = FILEPATH_ROLE + elif col == 1: + return "File modified" + return None + + elif role == QtCore.Qt.DisplayRole: + if col == 1: + role = MODIFIED_ROLE + return super(LoadedFilesMopdel, self).data(index, role) + + def add_filepaths(self, filepaths): + if not filepaths: + return + + if isinstance(filepaths, six.string_types): + filepaths = [filepaths] + + filtered_paths = [] + for filepath in filepaths: + normalized_path = os.path.normpath(filepath) + if normalized_path in self._items_by_filepath: + continue + + if ( + os.path.exists(normalized_path) + and normalized_path not in filtered_paths + ): + filtered_paths.append(normalized_path) + + if not filtered_paths: + return + + new_items = [] + for normalized_path in filtered_paths: + try: + with open(normalized_path, "r") as stream: + data = json.load(stream) + report = PublishReport(data) + except Exception: + # TODO handle errors + continue + + modified = os.path.getmtime(normalized_path) + item = QtGui.QStandardItem(os.path.basename(normalized_path)) + item.setColumnCount(self.columnCount()) + item.setData(normalized_path, FILEPATH_ROLE) + item.setData(modified, MODIFIED_ROLE) + new_items.append(item) + self._items_by_filepath[normalized_path] = item + self._reports_by_filepath[normalized_path] = report + + if not new_items: + return + + parent = self.invisibleRootItem() + parent.appendRows(new_items) + + self._store_registry() + + def remove_filepaths(self, filepaths): + if not filepaths: + return + + if isinstance(filepaths, six.string_types): + filepaths = [filepaths] + + filtered_paths = [] + for filepath in filepaths: + normalized_path = os.path.normpath(filepath) + if normalized_path in self._items_by_filepath: + filtered_paths.append(normalized_path) + + if not filtered_paths: + return + + parent = self.invisibleRootItem() + for filepath in filtered_paths: + self._reports_by_filepath.pop(normalized_path) + item = self._items_by_filepath.pop(filepath) + parent.removeRow(item.row()) + + self._store_registry() + + def get_report_by_filepath(self, filepath): + return self._reports_by_filepath.get(filepath) + + +class LoadedFilesView(QtWidgets.QTreeView): + selection_changed = QtCore.Signal() + + def __init__(self, *args, **kwargs): + super(LoadedFilesView, self).__init__(*args, **kwargs) + self.setEditTriggers(self.NoEditTriggers) + self.setIndentation(0) + self.setAlternatingRowColors(True) + + model = LoadedFilesMopdel() + self.setModel(model) + + time_delegate = PrettyTimeDelegate() + self.setItemDelegateForColumn(1, time_delegate) + + remove_btn = IconButton(self) + remove_icon_path = resources.get_icon_path("delete") + loaded_remove_image = QtGui.QImage(remove_icon_path) + pix = paint_image_with_color(loaded_remove_image, QtCore.Qt.white) + icon = QtGui.QIcon(pix) + remove_btn.setIcon(icon) + + model.rowsInserted.connect(self._on_rows_inserted) + remove_btn.clicked.connect(self._on_remove_clicked) + self.selectionModel().selectionChanged.connect( + self._on_selection_change + ) + + self._model = model + self._time_delegate = time_delegate + self._remove_btn = remove_btn + + def _update_remove_btn(self): + viewport = self.viewport() + height = viewport.height() + self.header().height() + pos_x = viewport.width() - self._remove_btn.width() - 5 + pos_y = height - self._remove_btn.height() - 5 + self._remove_btn.move(max(0, pos_x), max(0, pos_y)) + + def _on_rows_inserted(self): + header = self.header() + header.resizeSections(header.ResizeToContents) + + def resizeEvent(self, event): + super(LoadedFilesView, self).resizeEvent(event) + self._update_remove_btn() + + def showEvent(self, event): + super(LoadedFilesView, self).showEvent(event) + self._update_remove_btn() + header = self.header() + header.resizeSections(header.ResizeToContents) + + def _on_selection_change(self): + self.selection_changed.emit() + + def add_filepaths(self, filepaths): + self._model.add_filepaths(filepaths) + self._fill_selection() + + def remove_filepaths(self, filepaths): + self._model.remove_filepaths(filepaths) + self._fill_selection() + + def _on_remove_clicked(self): + index = self.currentIndex() + filepath = index.data(FILEPATH_ROLE) + self.remove_filepaths(filepath) + + def _fill_selection(self): + index = self.currentIndex() + if index.isValid(): + return + + index = self._model.index(0, 0) + if index.isValid(): + self.setCurrentIndex(index) + + def get_current_report(self): + index = self.currentIndex() + filepath = index.data(FILEPATH_ROLE) + return self._model.get_report_by_filepath(filepath) + + +class LoadedFilesWidget(QtWidgets.QWidget): + report_changed = QtCore.Signal() + + def __init__(self, parent): + super(LoadedFilesWidget, self).__init__(parent) + + self.setAcceptDrops(True) + + view = LoadedFilesView(self) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(view, 1) + + view.selection_changed.connect(self._on_report_change) + + self._view = view + + def dragEnterEvent(self, event): + mime_data = event.mimeData() + if mime_data.hasUrls(): + event.setDropAction(QtCore.Qt.CopyAction) + event.accept() + + def dragLeaveEvent(self, event): + event.accept() + + def dropEvent(self, event): + mime_data = event.mimeData() + if mime_data.hasUrls(): + filepaths = [] + for url in mime_data.urls(): + filepath = url.toLocalFile() + ext = os.path.splitext(filepath)[-1] + if os.path.exists(filepath) and ext == ".json": + filepaths.append(filepath) + self._add_filepaths(filepaths) + event.accept() + + def _on_report_change(self): + self.report_changed.emit() + + def _add_filepaths(self, filepaths): + self._view.add_filepaths(filepaths) + + def get_current_report(self): + return self._view.get_current_report() class PublishReportViewerWindow(QtWidgets.QWidget): - # TODO add buttons to be able load report file or paste content of report default_width = 1200 default_height = 600 def __init__(self, parent=None): super(PublishReportViewerWindow, self).__init__(parent) + self.setWindowTitle("Publish report viewer") + icon = QtGui.QIcon(get_openpype_icon_filepath()) + self.setWindowIcon(icon) - main_widget = PublishReportViewerWidget(self) + body = QtWidgets.QSplitter(self) + body.setContentsMargins(0, 0, 0, 0) + body.setSizePolicy( + QtWidgets.QSizePolicy.Expanding, + QtWidgets.QSizePolicy.Expanding + ) + body.setOrientation(QtCore.Qt.Horizontal) + + loaded_files_widget = LoadedFilesWidget(body) + main_widget = PublishReportViewerWidget(body) + + body.addWidget(loaded_files_widget) + body.addWidget(main_widget) + body.setStretchFactor(0, 70) + body.setStretchFactor(1, 65) layout = QtWidgets.QHBoxLayout(self) - layout.addWidget(main_widget) + layout.addWidget(body, 1) + loaded_files_widget.report_changed.connect(self._on_report_change) + + self._loaded_files_widget = loaded_files_widget self._main_widget = main_widget self.resize(self.default_width, self.default_height) self.setStyleSheet(style.load_stylesheet()) + def _on_report_change(self): + report = self._loaded_files_widget.get_current_report() + self.set_report(report) + def set_report(self, report_data): self._main_widget.set_report(report_data) diff --git a/openpype/tools/publisher/widgets/create_dialog.py b/openpype/tools/publisher/widgets/create_dialog.py index f9f8310e09..c5b77eca8b 100644 --- a/openpype/tools/publisher/widgets/create_dialog.py +++ b/openpype/tools/publisher/widgets/create_dialog.py @@ -174,6 +174,8 @@ class CreatorDescriptionWidget(QtWidgets.QWidget): class CreateDialog(QtWidgets.QDialog): + default_size = (900, 500) + def __init__( self, controller, asset_name=None, task_name=None, parent=None ): @@ -262,11 +264,16 @@ class CreateDialog(QtWidgets.QDialog): mid_layout.addLayout(form_layout, 0) mid_layout.addWidget(create_btn, 0) + splitter_widget = QtWidgets.QSplitter(self) + splitter_widget.addWidget(context_widget) + splitter_widget.addWidget(mid_widget) + splitter_widget.addWidget(pre_create_widget) + splitter_widget.setStretchFactor(0, 1) + splitter_widget.setStretchFactor(1, 1) + splitter_widget.setStretchFactor(2, 1) + layout = QtWidgets.QHBoxLayout(self) - layout.setSpacing(10) - layout.addWidget(context_widget, 1) - layout.addWidget(mid_widget, 1) - layout.addWidget(pre_create_widget, 1) + layout.addWidget(splitter_widget, 1) prereq_timer = QtCore.QTimer() prereq_timer.setInterval(50) @@ -289,6 +296,8 @@ class CreateDialog(QtWidgets.QDialog): controller.add_plugins_refresh_callback(self._on_plugins_refresh) + self._splitter_widget = splitter_widget + self._pre_create_widget = pre_create_widget self._context_widget = context_widget @@ -308,6 +317,7 @@ class CreateDialog(QtWidgets.QDialog): self.create_btn = create_btn self._prereq_timer = prereq_timer + self._first_show = True def _context_change_is_enabled(self): return self._context_widget.isEnabled() @@ -643,6 +653,16 @@ class CreateDialog(QtWidgets.QDialog): def showEvent(self, event): super(CreateDialog, self).showEvent(event) + if self._first_show: + self._first_show = False + width, height = self.default_size + self.resize(width, height) + + third_size = int(width / 3) + self._splitter_widget.setSizes( + [third_size, third_size, width - (2 * third_size)] + ) + if self._last_pos is not None: self.move(self._last_pos) diff --git a/openpype/tools/publisher/widgets/publish_widget.py b/openpype/tools/publisher/widgets/publish_widget.py index e4f3579978..80d0265dd3 100644 --- a/openpype/tools/publisher/widgets/publish_widget.py +++ b/openpype/tools/publisher/widgets/publish_widget.py @@ -213,7 +213,6 @@ class PublishFrame(QtWidgets.QFrame): close_report_btn.setIcon(close_report_icon) details_layout = QtWidgets.QVBoxLayout(details_widget) - details_layout.setContentsMargins(0, 0, 0, 0) details_layout.addWidget(report_view) details_layout.addWidget(close_report_btn) @@ -495,10 +494,11 @@ class PublishFrame(QtWidgets.QFrame): def _on_show_details(self): self._change_bg_property(2) self._main_layout.setCurrentWidget(self._details_widget) - logs = self.controller.get_publish_report() - self._report_view.set_report(logs) + report_data = self.controller.get_publish_report() + self._report_view.set_report_data(report_data) def _on_close_report_clicked(self): + self._report_view.close_details_popup() if self.controller.get_publish_crash_error(): self._change_bg_property() diff --git a/openpype/tools/publisher/widgets/validations_widget.py b/openpype/tools/publisher/widgets/validations_widget.py index bb88e1783c..798c1f9d92 100644 --- a/openpype/tools/publisher/widgets/validations_widget.py +++ b/openpype/tools/publisher/widgets/validations_widget.py @@ -10,6 +10,9 @@ from openpype.tools.utils import BaseClickableFrame from .widgets import ( IconValuePixmapLabel ) +from ..constants import ( + INSTANCE_ID_ROLE +) class ValidationErrorInstanceList(QtWidgets.QListView): @@ -22,19 +25,20 @@ class ValidationErrorInstanceList(QtWidgets.QListView): self.setObjectName("ValidationErrorInstanceList") + self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) self.setSelectionMode(QtWidgets.QListView.ExtendedSelection) def minimumSizeHint(self): - result = super(ValidationErrorInstanceList, self).minimumSizeHint() - result.setHeight(self.sizeHint().height()) - return result + return self.sizeHint() def sizeHint(self): + result = super(ValidationErrorInstanceList, self).sizeHint() row_count = self.model().rowCount() height = 0 if row_count > 0: height = self.sizeHintForRow(0) * row_count - return QtCore.QSize(self.width(), height) + result.setHeight(height) + return result class ValidationErrorTitleWidget(QtWidgets.QWidget): @@ -47,6 +51,7 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget): if there is a list (Valdation error may happen on context). """ selected = QtCore.Signal(int) + instance_changed = QtCore.Signal(int) def __init__(self, index, error_info, parent): super(ValidationErrorTitleWidget, self).__init__(parent) @@ -64,32 +69,38 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget): toggle_instance_btn.setArrowType(QtCore.Qt.RightArrow) toggle_instance_btn.setMaximumWidth(14) - exception = error_info["exception"] - label_widget = QtWidgets.QLabel(exception.title, title_frame) + label_widget = QtWidgets.QLabel(error_info["title"], title_frame) title_frame_layout = QtWidgets.QHBoxLayout(title_frame) title_frame_layout.addWidget(toggle_instance_btn) title_frame_layout.addWidget(label_widget) instances_model = QtGui.QStandardItemModel() - instances = error_info["instances"] + error_info = error_info["error_info"] + + help_text_by_instance_id = {} context_validation = False if ( - not instances - or (len(instances) == 1 and instances[0] is None) + not error_info + or (len(error_info) == 1 and error_info[0][0] is None) ): context_validation = True toggle_instance_btn.setArrowType(QtCore.Qt.NoArrow) + description = self._prepare_description(error_info[0][1]) + help_text_by_instance_id[None] = description else: items = [] - for instance in instances: + for instance, exception in error_info: label = instance.data.get("label") or instance.data.get("name") item = QtGui.QStandardItem(label) item.setFlags( QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable ) - item.setData(instance.id) + item.setData(label, QtCore.Qt.ToolTipRole) + item.setData(instance.id, INSTANCE_ID_ROLE) items.append(item) + description = self._prepare_description(exception) + help_text_by_instance_id[instance.id] = description instances_model.invisibleRootItem().appendRows(items) @@ -114,17 +125,64 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget): if not context_validation: toggle_instance_btn.clicked.connect(self._on_toggle_btn_click) + instances_view.selectionModel().selectionChanged.connect( + self._on_seleciton_change + ) + self._title_frame = title_frame self._toggle_instance_btn = toggle_instance_btn + self._view_layout = view_layout + self._instances_model = instances_model self._instances_view = instances_view + self._context_validation = context_validation + self._help_text_by_instance_id = help_text_by_instance_id + + def sizeHint(self): + result = super().sizeHint() + expected_width = 0 + for idx in range(self._view_layout.count()): + expected_width += self._view_layout.itemAt(idx).sizeHint().width() + + if expected_width < 200: + expected_width = 200 + + if result.width() < expected_width: + result.setWidth(expected_width) + return result + + def minimumSizeHint(self): + return self.sizeHint() + + def _prepare_description(self, exception): + dsc = exception.description + detail = exception.detail + if detail: + dsc += "

{}".format(detail) + + description = dsc + if commonmark: + description = commonmark.commonmark(dsc) + return description + def _mouse_release_callback(self): """Mark this widget as selected on click.""" self.set_selected(True) + def current_desctiption_text(self): + if self._context_validation: + return self._help_text_by_instance_id[None] + index = self._instances_view.currentIndex() + # TODO make sure instance is selected + if not index.isValid(): + index = self._instances_model.index(0, 0) + + indence_id = index.data(INSTANCE_ID_ROLE) + return self._help_text_by_instance_id[indence_id] + @property def is_selected(self): """Is widget marked a selected""" @@ -167,6 +225,9 @@ class ValidationErrorTitleWidget(QtWidgets.QWidget): else: self._toggle_instance_btn.setArrowType(QtCore.Qt.RightArrow) + def _on_seleciton_change(self): + self.instance_changed.emit(self._index) + class ActionButton(BaseClickableFrame): """Plugin's action callback button. @@ -185,13 +246,15 @@ class ActionButton(BaseClickableFrame): action_label = action.label or action.__name__ action_icon = getattr(action, "icon", None) label_widget = QtWidgets.QLabel(action_label, self) + icon_label = None if action_icon: icon_label = IconValuePixmapLabel(action_icon, self) layout = QtWidgets.QHBoxLayout(self) layout.setContentsMargins(5, 0, 5, 0) layout.addWidget(label_widget, 1) - layout.addWidget(icon_label, 0) + if icon_label: + layout.addWidget(icon_label, 0) self.setSizePolicy( QtWidgets.QSizePolicy.Minimum, @@ -231,6 +294,7 @@ class ValidateActionsWidget(QtWidgets.QFrame): item = self._content_layout.takeAt(0) widget = item.widget() if widget: + widget.setVisible(False) widget.deleteLater() self._actions_mapping = {} @@ -363,24 +427,23 @@ class ValidationsWidget(QtWidgets.QWidget): errors_scroll.setWidgetResizable(True) errors_widget = QtWidgets.QWidget(errors_scroll) - errors_widget.setFixedWidth(200) errors_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) errors_layout = QtWidgets.QVBoxLayout(errors_widget) errors_layout.setContentsMargins(0, 0, 0, 0) errors_scroll.setWidget(errors_widget) - error_details_widget = QtWidgets.QWidget(self) - error_details_input = QtWidgets.QTextEdit(error_details_widget) + error_details_frame = QtWidgets.QFrame(self) + error_details_input = QtWidgets.QTextEdit(error_details_frame) error_details_input.setObjectName("InfoText") error_details_input.setTextInteractionFlags( QtCore.Qt.TextBrowserInteraction ) actions_widget = ValidateActionsWidget(controller, self) - actions_widget.setFixedWidth(140) + actions_widget.setMinimumWidth(140) - error_details_layout = QtWidgets.QHBoxLayout(error_details_widget) + error_details_layout = QtWidgets.QHBoxLayout(error_details_frame) error_details_layout.addWidget(error_details_input, 1) error_details_layout.addWidget(actions_widget, 0) @@ -389,7 +452,7 @@ class ValidationsWidget(QtWidgets.QWidget): content_layout.setContentsMargins(0, 0, 0, 0) content_layout.addWidget(errors_scroll, 0) - content_layout.addWidget(error_details_widget, 1) + content_layout.addWidget(error_details_frame, 1) top_label = QtWidgets.QLabel("Publish validation report", self) top_label.setObjectName("PublishInfoMainLabel") @@ -403,7 +466,7 @@ class ValidationsWidget(QtWidgets.QWidget): self._top_label = top_label self._errors_widget = errors_widget self._errors_layout = errors_layout - self._error_details_widget = error_details_widget + self._error_details_frame = error_details_frame self._error_details_input = error_details_input self._actions_widget = actions_widget @@ -423,7 +486,7 @@ class ValidationsWidget(QtWidgets.QWidget): widget.deleteLater() self._top_label.setVisible(False) - self._error_details_widget.setVisible(False) + self._error_details_frame.setVisible(False) self._errors_widget.setVisible(False) self._actions_widget.setVisible(False) @@ -434,34 +497,35 @@ class ValidationsWidget(QtWidgets.QWidget): return self._top_label.setVisible(True) - self._error_details_widget.setVisible(True) + self._error_details_frame.setVisible(True) self._errors_widget.setVisible(True) errors_by_title = [] for plugin_info in errors: titles = [] - exception_by_title = {} - instances_by_title = {} + error_info_by_title = {} for error_info in plugin_info["errors"]: exception = error_info["exception"] title = exception.title if title not in titles: titles.append(title) - instances_by_title[title] = [] - exception_by_title[title] = exception - instances_by_title[title].append(error_info["instance"]) + error_info_by_title[title] = [] + error_info_by_title[title].append( + (error_info["instance"], exception) + ) for title in titles: errors_by_title.append({ "plugin": plugin_info["plugin"], - "exception": exception_by_title[title], - "instances": instances_by_title[title] + "error_info": error_info_by_title[title], + "title": title }) for idx, item in enumerate(errors_by_title): widget = ValidationErrorTitleWidget(idx, item, self) widget.selected.connect(self._on_select) + widget.instance_changed.connect(self._on_instance_change) self._errors_layout.addWidget(widget) self._title_widgets[idx] = widget self._error_info[idx] = item @@ -471,6 +535,8 @@ class ValidationsWidget(QtWidgets.QWidget): if self._title_widgets: self._title_widgets[0].set_selected(True) + self.updateGeometry() + def _on_select(self, index): if self._previous_select: if self._previous_select.index == index: @@ -481,10 +547,19 @@ class ValidationsWidget(QtWidgets.QWidget): error_item = self._error_info[index] - dsc = error_item["exception"].description + self._actions_widget.set_plugin(error_item["plugin"]) + + self._update_description() + + def _on_instance_change(self, index): + if self._previous_select and self._previous_select.index != index: + return + self._update_description() + + def _update_description(self): + description = self._previous_select.current_desctiption_text() if commonmark: - html = commonmark.commonmark(dsc) + html = commonmark.commonmark(description) self._error_details_input.setHtml(html) else: - self._error_details_input.setMarkdown(dsc) - self._actions_widget.set_plugin(error_item["plugin"]) + self._error_details_input.setMarkdown(description) diff --git a/openpype/tools/publisher/widgets/widgets.py b/openpype/tools/publisher/widgets/widgets.py index a63258efb7..fb1f0e54aa 100644 --- a/openpype/tools/publisher/widgets/widgets.py +++ b/openpype/tools/publisher/widgets/widgets.py @@ -535,6 +535,7 @@ class TasksCombobox(QtWidgets.QComboBox): return self._text = text + self.repaint() def paintEvent(self, event): """Paint custom text without using QLineEdit. @@ -548,6 +549,7 @@ class TasksCombobox(QtWidgets.QComboBox): self.initStyleOption(opt) if self._text is not None: opt.currentText = self._text + style = self.style() style.drawComplexControl( QtWidgets.QStyle.CC_ComboBox, opt, painter, self @@ -609,11 +611,15 @@ class TasksCombobox(QtWidgets.QComboBox): if self._selected_items: is_valid = True + valid_task_names = [] for task_name in self._selected_items: - is_valid = self._model.is_task_name_valid(asset_name, task_name) - if not is_valid: - break + _is_valid = self._model.is_task_name_valid(asset_name, task_name) + if _is_valid: + valid_task_names.append(task_name) + else: + is_valid = _is_valid + self._selected_items = valid_task_names if len(self._selected_items) == 0: self.set_selected_item("") @@ -625,6 +631,7 @@ class TasksCombobox(QtWidgets.QComboBox): if multiselection_text is None: multiselection_text = "|".join(self._selected_items) self.set_selected_item(multiselection_text) + self._set_is_valid(is_valid) def set_selected_items(self, asset_task_combinations=None): @@ -708,8 +715,7 @@ class TasksCombobox(QtWidgets.QComboBox): idx = self.findText(item_name) # Set current index (must be set to -1 if is invalid) self.setCurrentIndex(idx) - if idx < 0: - self.set_text(item_name) + self.set_text(item_name) def reset_to_origin(self): """Change to task names set with last `set_selected_items` call.""" diff --git a/openpype/tools/publisher/window.py b/openpype/tools/publisher/window.py index 642bd17589..b74e95b227 100644 --- a/openpype/tools/publisher/window.py +++ b/openpype/tools/publisher/window.py @@ -84,7 +84,7 @@ class PublisherWindow(QtWidgets.QDialog): # Content # Subset widget - subset_frame = QtWidgets.QWidget(self) + subset_frame = QtWidgets.QFrame(self) subset_views_widget = BorderedLabelWidget( "Subsets to publish", subset_frame @@ -225,6 +225,9 @@ class PublisherWindow(QtWidgets.QDialog): controller.add_publish_validated_callback(self._on_publish_validated) controller.add_publish_stopped_callback(self._on_publish_stop) + # Store header for TrayPublisher + self._header_layout = header_layout + self.content_stacked_layout = content_stacked_layout self.publish_frame = publish_frame self.subset_frame = subset_frame diff --git a/openpype/tools/settings/local_settings/experimental_widget.py b/openpype/tools/settings/local_settings/experimental_widget.py index e863d9afb0..22ef952356 100644 --- a/openpype/tools/settings/local_settings/experimental_widget.py +++ b/openpype/tools/settings/local_settings/experimental_widget.py @@ -28,7 +28,7 @@ class LocalExperimentalToolsWidgets(QtWidgets.QWidget): layout.addRow(empty_label) - experimental_defs = ExperimentalTools(filter_hosts=False) + experimental_defs = ExperimentalTools(refresh=False) checkboxes_by_identifier = {} for tool in experimental_defs.tools: checkbox = QtWidgets.QCheckBox(self) diff --git a/openpype/tools/traypublisher/__init__.py b/openpype/tools/traypublisher/__init__.py new file mode 100644 index 0000000000..188a234a9e --- /dev/null +++ b/openpype/tools/traypublisher/__init__.py @@ -0,0 +1,6 @@ +from .window import main + + +__all__ = ( + "main", +) diff --git a/openpype/tools/traypublisher/window.py b/openpype/tools/traypublisher/window.py new file mode 100644 index 0000000000..53f8ca450a --- /dev/null +++ b/openpype/tools/traypublisher/window.py @@ -0,0 +1,158 @@ +"""Tray publisher is extending publisher tool. + +Adds ability to select project using overlay widget with list of projects. + +Tray publisher can be considered as host implementeation with creators and +publishing plugins. +""" + +from Qt import QtWidgets, QtCore + +import avalon.api +from avalon.api import AvalonMongoDB +from openpype.hosts.traypublisher import ( + api as traypublisher +) +from openpype.tools.publisher import PublisherWindow +from openpype.tools.utils.constants import PROJECT_NAME_ROLE +from openpype.tools.utils.models import ( + ProjectModel, + ProjectSortFilterProxy +) + + +class StandaloneOverlayWidget(QtWidgets.QFrame): + project_selected = QtCore.Signal(str) + + def __init__(self, publisher_window): + super(StandaloneOverlayWidget, self).__init__(publisher_window) + self.setObjectName("OverlayFrame") + + # Create db connection for projects model + dbcon = AvalonMongoDB() + dbcon.install() + + header_label = QtWidgets.QLabel("Choose project", self) + header_label.setObjectName("ChooseProjectLabel") + # Create project models and view + projects_model = ProjectModel(dbcon) + projects_proxy = ProjectSortFilterProxy() + projects_proxy.setSourceModel(projects_model) + + projects_view = QtWidgets.QListView(self) + projects_view.setModel(projects_proxy) + projects_view.setEditTriggers( + QtWidgets.QAbstractItemView.NoEditTriggers + ) + + confirm_btn = QtWidgets.QPushButton("Choose", self) + btns_layout = QtWidgets.QHBoxLayout() + btns_layout.addStretch(1) + btns_layout.addWidget(confirm_btn, 0) + + layout = QtWidgets.QGridLayout(self) + layout.addWidget(header_label, 0, 1, alignment=QtCore.Qt.AlignCenter) + layout.addWidget(projects_view, 1, 1) + layout.addLayout(btns_layout, 2, 1) + layout.setColumnStretch(0, 1) + layout.setColumnStretch(1, 0) + layout.setColumnStretch(2, 1) + layout.setRowStretch(0, 0) + layout.setRowStretch(1, 1) + layout.setRowStretch(2, 0) + + projects_view.doubleClicked.connect(self._on_double_click) + confirm_btn.clicked.connect(self._on_confirm_click) + + self._projects_view = projects_view + self._projects_model = projects_model + self._confirm_btn = confirm_btn + + self._publisher_window = publisher_window + + def showEvent(self, event): + self._projects_model.refresh() + super(StandaloneOverlayWidget, self).showEvent(event) + + def _on_double_click(self): + self.set_selected_project() + + def _on_confirm_click(self): + self.set_selected_project() + + def set_selected_project(self): + index = self._projects_view.currentIndex() + + project_name = index.data(PROJECT_NAME_ROLE) + if not project_name: + return + + traypublisher.set_project_name(project_name) + self.setVisible(False) + self.project_selected.emit(project_name) + + +class TrayPublishWindow(PublisherWindow): + def __init__(self, *args, **kwargs): + super(TrayPublishWindow, self).__init__(reset_on_show=False) + + overlay_widget = StandaloneOverlayWidget(self) + + btns_widget = QtWidgets.QWidget(self) + + back_to_overlay_btn = QtWidgets.QPushButton( + "Change project", btns_widget + ) + save_btn = QtWidgets.QPushButton("Save", btns_widget) + # TODO implement save mechanism of tray publisher + save_btn.setVisible(False) + + btns_layout = QtWidgets.QHBoxLayout(btns_widget) + btns_layout.setContentsMargins(0, 0, 0, 0) + + btns_layout.addWidget(save_btn, 0) + btns_layout.addWidget(back_to_overlay_btn, 0) + + self._header_layout.addWidget(btns_widget, 0) + + overlay_widget.project_selected.connect(self._on_project_select) + back_to_overlay_btn.clicked.connect(self._on_back_to_overlay) + save_btn.clicked.connect(self._on_tray_publish_save) + + self._back_to_overlay_btn = back_to_overlay_btn + self._overlay_widget = overlay_widget + + def _on_back_to_overlay(self): + self._overlay_widget.setVisible(True) + self._resize_overlay() + + def _resize_overlay(self): + self._overlay_widget.resize( + self.width(), + self.height() + ) + + def resizeEvent(self, event): + super(TrayPublishWindow, self).resizeEvent(event) + self._resize_overlay() + + def _on_project_select(self, project_name): + # TODO register project specific plugin paths + self.controller.save_changes() + self.controller.reset_project_data_cache() + + self.reset() + if not self.controller.instances: + self._on_create_clicked() + + def _on_tray_publish_save(self): + self.controller.save_changes() + print("NOT YET IMPLEMENTED") + + +def main(): + avalon.api.install(traypublisher) + app = QtWidgets.QApplication([]) + window = TrayPublishWindow() + window.show() + app.exec_() diff --git a/openpype/tools/utils/__init__.py b/openpype/tools/utils/__init__.py index 46af051069..b4b0af106e 100644 --- a/openpype/tools/utils/__init__.py +++ b/openpype/tools/utils/__init__.py @@ -2,11 +2,12 @@ from .widgets import ( PlaceholderLineEdit, BaseClickableFrame, ClickableFrame, + ClickableLabel, ExpandBtn, PixmapLabel, IconButton, ) - +from .views import DeselectableTreeView from .error_dialog import ErrorMessageBox from .lib import ( WrappedCallbackItem, @@ -24,10 +25,13 @@ __all__ = ( "PlaceholderLineEdit", "BaseClickableFrame", "ClickableFrame", + "ClickableLabel", "ExpandBtn", "PixmapLabel", "IconButton", + "DeselectableTreeView", + "ErrorMessageBox", "WrappedCallbackItem", diff --git a/openpype/tools/utils/widgets.py b/openpype/tools/utils/widgets.py index c62b838231..a4e172ea5c 100644 --- a/openpype/tools/utils/widgets.py +++ b/openpype/tools/utils/widgets.py @@ -63,6 +63,29 @@ class ClickableFrame(BaseClickableFrame): self.clicked.emit() +class ClickableLabel(QtWidgets.QLabel): + """Label that catch left mouse click and can trigger 'clicked' signal.""" + clicked = QtCore.Signal() + + def __init__(self, parent): + super(ClickableLabel, self).__init__(parent) + + self._mouse_pressed = False + + def mousePressEvent(self, event): + if event.button() == QtCore.Qt.LeftButton: + self._mouse_pressed = True + super(ClickableLabel, self).mousePressEvent(event) + + def mouseReleaseEvent(self, event): + if self._mouse_pressed: + self._mouse_pressed = False + if self.rect().contains(event.pos()): + self.clicked.emit() + + super(ClickableLabel, self).mouseReleaseEvent(event) + + class ExpandBtnLabel(QtWidgets.QLabel): """Label showing expand icon meant for ExpandBtn.""" def __init__(self, parent): diff --git a/openpype/widgets/attribute_defs/files_widget.py b/openpype/widgets/attribute_defs/files_widget.py index fb48528bdc..87b98e2378 100644 --- a/openpype/widgets/attribute_defs/files_widget.py +++ b/openpype/widgets/attribute_defs/files_widget.py @@ -433,7 +433,7 @@ class MultiFilesWidget(QtWidgets.QFrame): filenames = index.data(FILENAMES_ROLE) for filename in filenames: filepaths.add(os.path.join(dirpath, filename)) - return filepaths + return list(filepaths) def set_filters(self, folders_allowed, exts_filter): self._files_proxy_model.set_allow_folders(folders_allowed) @@ -552,7 +552,7 @@ class MultiFilesWidget(QtWidgets.QFrame): self._update_visibility() def _update_visibility(self): - files_exists = self._files_model.rowCount() > 0 + files_exists = self._files_proxy_model.rowCount() > 0 self._files_view.setVisible(files_exists) self._empty_widget.setVisible(not files_exists)