From 47a02c2386f79bbbd63ac7328154bd1943499849 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 11 Oct 2023 14:34:10 +0100 Subject: [PATCH 01/46] Change pointcache creator to be in line with other creators --- .../plugins/create/create_pointcache.py | 42 +++++++++++++------ 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/openpype/hosts/blender/plugins/create/create_pointcache.py b/openpype/hosts/blender/plugins/create/create_pointcache.py index 6220f68dc5..65cf18472d 100644 --- a/openpype/hosts/blender/plugins/create/create_pointcache.py +++ b/openpype/hosts/blender/plugins/create/create_pointcache.py @@ -3,11 +3,11 @@ import bpy from openpype.pipeline import get_current_task_name -import openpype.hosts.blender.api.plugin -from openpype.hosts.blender.api import lib +from openpype.hosts.blender.api import plugin, lib, ops +from openpype.hosts.blender.api.pipeline import AVALON_INSTANCES -class CreatePointcache(openpype.hosts.blender.api.plugin.Creator): +class CreatePointcache(plugin.Creator): """Polygonal static geometry""" name = "pointcacheMain" @@ -16,20 +16,36 @@ class CreatePointcache(openpype.hosts.blender.api.plugin.Creator): icon = "gears" def process(self): + """ Run the creator on Blender main thread""" + mti = ops.MainThreadItem(self._process) + ops.execute_in_main_thread(mti) + def _process(self): + # Get Instance Container or create it if it does not exist + instances = bpy.data.collections.get(AVALON_INSTANCES) + if not instances: + instances = bpy.data.collections.new(name=AVALON_INSTANCES) + bpy.context.scene.collection.children.link(instances) + + # Create instance object asset = self.data["asset"] subset = self.data["subset"] - name = openpype.hosts.blender.api.plugin.asset_name(asset, subset) - collection = bpy.data.collections.new(name=name) - bpy.context.scene.collection.children.link(collection) + name = plugin.asset_name(asset, subset) + asset_group = bpy.data.objects.new(name=name, object_data=None) + asset_group.empty_display_type = 'SINGLE_ARROW' + instances.objects.link(asset_group) self.data['task'] = get_current_task_name() - lib.imprint(collection, self.data) + lib.imprint(asset_group, self.data) + # Add selected objects to instance if (self.options or {}).get("useSelection"): - objects = lib.get_selection() - for obj in objects: - collection.objects.link(obj) - if obj.type == 'EMPTY': - objects.extend(obj.children) + bpy.context.view_layer.objects.active = asset_group + selected = lib.get_selection() + for obj in selected: + if obj.parent in selected: + obj.select_set(False) + continue + selected.append(asset_group) + bpy.ops.object.parent_set(keep_transform=True) - return collection + return asset_group From 7fec582a2d472c8a956621a53d556a8ee784e52a Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 11 Oct 2023 14:34:55 +0100 Subject: [PATCH 02/46] Improve instance collector --- .../plugins/publish/collect_instances.py | 101 +++++++----------- 1 file changed, 40 insertions(+), 61 deletions(-) diff --git a/openpype/hosts/blender/plugins/publish/collect_instances.py b/openpype/hosts/blender/plugins/publish/collect_instances.py index bc4b5ab092..4915e4a7cf 100644 --- a/openpype/hosts/blender/plugins/publish/collect_instances.py +++ b/openpype/hosts/blender/plugins/publish/collect_instances.py @@ -1,4 +1,5 @@ import json +from itertools import chain from typing import Generator import bpy @@ -19,85 +20,63 @@ class CollectInstances(pyblish.api.ContextPlugin): @staticmethod def get_asset_groups() -> Generator: - """Return all 'model' collections. - - Check if the family is 'model' and if it doesn't have the - representation set. If the representation is set, it is a loaded model - and we don't want to publish it. + """Return all instances that are empty objects asset groups. """ instances = bpy.data.collections.get(AVALON_INSTANCES) for obj in instances.objects: - avalon_prop = obj.get(AVALON_PROPERTY) or dict() + avalon_prop = obj.get(AVALON_PROPERTY) or {} if avalon_prop.get('id') == 'pyblish.avalon.instance': yield obj @staticmethod def get_collections() -> Generator: - """Return all 'model' collections. - - Check if the family is 'model' and if it doesn't have the - representation set. If the representation is set, it is a loaded model - and we don't want to publish it. + """Return all instances that are collections. """ - for collection in bpy.data.collections: - avalon_prop = collection.get(AVALON_PROPERTY) or dict() + instances = bpy.data.collections.get(AVALON_INSTANCES) + for collection in instances.children: + avalon_prop = collection.get(AVALON_PROPERTY) or {} if avalon_prop.get('id') == 'pyblish.avalon.instance': yield collection + @staticmethod + def create_instance(context, group): + avalon_prop = group[AVALON_PROPERTY] + asset = avalon_prop['asset'] + family = avalon_prop['family'] + subset = avalon_prop['subset'] + task = avalon_prop['task'] + name = f"{asset}_{subset}" + return context.create_instance( + name=name, + family=family, + families=[family], + subset=subset, + asset=asset, + task=task, + ), family + def process(self, context): """Collect the models from the current Blender scene.""" asset_groups = self.get_asset_groups() collections = self.get_collections() - for group in asset_groups: - avalon_prop = group[AVALON_PROPERTY] - asset = avalon_prop['asset'] - family = avalon_prop['family'] - subset = avalon_prop['subset'] - task = avalon_prop['task'] - name = f"{asset}_{subset}" - instance = context.create_instance( - name=name, - family=family, - families=[family], - subset=subset, - asset=asset, - task=task, - ) - objects = list(group.children) - members = set() - for obj in objects: - objects.extend(list(obj.children)) - members.add(obj) - members.add(group) - instance[:] = list(members) - self.log.debug(json.dumps(instance.data, indent=4)) - for obj in instance: - self.log.debug(obj) + instances = chain(asset_groups, collections) - for collection in collections: - avalon_prop = collection[AVALON_PROPERTY] - asset = avalon_prop['asset'] - family = avalon_prop['family'] - subset = avalon_prop['subset'] - task = avalon_prop['task'] - name = f"{asset}_{subset}" - instance = context.create_instance( - name=name, - family=family, - families=[family], - subset=subset, - asset=asset, - task=task, - ) - members = list(collection.objects) - if family == "animation": - for obj in collection.objects: - if obj.type == 'EMPTY' and obj.get(AVALON_PROPERTY): - for child in obj.children: - if child.type == 'ARMATURE': - members.append(child) - members.append(collection) + for group in instances: + instance, family = self.create_instance(context, group) + members = [] + if type(group) == bpy.types.Collection: + members = list(group.objects) + if family == "animation": + for obj in group.objects: + if obj.type == 'EMPTY' and obj.get(AVALON_PROPERTY): + members.extend( + child for child in obj.children + if child.type == 'ARMATURE') + else: + members = group.children_recursive + + members.append(group) instance[:] = members self.log.debug(json.dumps(instance.data, indent=4)) for obj in instance: From 9f82f8ee2ff35aa66f0c3447a8aefb0adff101c6 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 11 Oct 2023 14:38:23 +0100 Subject: [PATCH 03/46] Changed how alembic files are extracted --- .../plugins/publish/collect_instances.py | 3 +++ .../blender/plugins/publish/extract_abc.py | 3 +-- .../plugins/publish/extract_abc_model.py | 17 +++++++++++++++++ .../defaults/project_settings/blender.json | 2 +- .../schemas/schema_blender_publish.json | 8 ++++---- .../blender/server/settings/publish_plugins.py | 4 ++-- 6 files changed, 28 insertions(+), 9 deletions(-) create mode 100644 openpype/hosts/blender/plugins/publish/extract_abc_model.py diff --git a/openpype/hosts/blender/plugins/publish/collect_instances.py b/openpype/hosts/blender/plugins/publish/collect_instances.py index 4915e4a7cf..1e0db9d9ce 100644 --- a/openpype/hosts/blender/plugins/publish/collect_instances.py +++ b/openpype/hosts/blender/plugins/publish/collect_instances.py @@ -76,6 +76,9 @@ class CollectInstances(pyblish.api.ContextPlugin): else: members = group.children_recursive + if family == "pointcache": + instance.data["families"].append("abc.export") + members.append(group) instance[:] = members self.log.debug(json.dumps(instance.data, indent=4)) diff --git a/openpype/hosts/blender/plugins/publish/extract_abc.py b/openpype/hosts/blender/plugins/publish/extract_abc.py index 87159e53f0..b113685842 100644 --- a/openpype/hosts/blender/plugins/publish/extract_abc.py +++ b/openpype/hosts/blender/plugins/publish/extract_abc.py @@ -12,8 +12,7 @@ class ExtractABC(publish.Extractor): label = "Extract ABC" hosts = ["blender"] - families = ["model", "pointcache"] - optional = True + families = ["abc.export"] def process(self, instance): # Define extract output file path diff --git a/openpype/hosts/blender/plugins/publish/extract_abc_model.py b/openpype/hosts/blender/plugins/publish/extract_abc_model.py new file mode 100644 index 0000000000..b31e36c681 --- /dev/null +++ b/openpype/hosts/blender/plugins/publish/extract_abc_model.py @@ -0,0 +1,17 @@ +import pyblish.api +from openpype.pipeline import publish + + +class ExtractModelABC(publish.Extractor): + """Extract model as ABC.""" + + order = pyblish.api.ExtractorOrder - 0.1 + label = "Extract Model ABC" + hosts = ["blender"] + families = ["model"] + optional = True + + def process(self, instance): + # Add abc.export family to the instance, to allow the extraction + # as alembic of the asset. + instance.data["families"].append("abc.export") diff --git a/openpype/settings/defaults/project_settings/blender.json b/openpype/settings/defaults/project_settings/blender.json index f3eb31174f..2bc518e329 100644 --- a/openpype/settings/defaults/project_settings/blender.json +++ b/openpype/settings/defaults/project_settings/blender.json @@ -89,7 +89,7 @@ "optional": true, "active": false }, - "ExtractABC": { + "ExtractModelABC": { "enabled": true, "optional": true, "active": false diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_blender_publish.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_blender_publish.json index 7f1a8a915b..b84c663e6c 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_blender_publish.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_blender_publish.json @@ -181,12 +181,12 @@ "name": "template_publish_plugin", "template_data": [ { - "key": "ExtractFBX", - "label": "Extract FBX (model and rig)" + "key": "ExtractModelABC", + "label": "Extract ABC (model)" }, { - "key": "ExtractABC", - "label": "Extract ABC (model and pointcache)" + "key": "ExtractFBX", + "label": "Extract FBX (model and rig)" }, { "key": "ExtractBlendAnimation", diff --git a/server_addon/blender/server/settings/publish_plugins.py b/server_addon/blender/server/settings/publish_plugins.py index 5e047b7013..102320cfed 100644 --- a/server_addon/blender/server/settings/publish_plugins.py +++ b/server_addon/blender/server/settings/publish_plugins.py @@ -103,7 +103,7 @@ class PublishPuginsModel(BaseSettingsModel): default_factory=ValidatePluginModel, title="Extract FBX" ) - ExtractABC: ValidatePluginModel = Field( + ExtractModelABC: ValidatePluginModel = Field( default_factory=ValidatePluginModel, title="Extract ABC" ) @@ -197,7 +197,7 @@ DEFAULT_BLENDER_PUBLISH_SETTINGS = { "optional": True, "active": False }, - "ExtractABC": { + "ExtractModelABC": { "enabled": True, "optional": True, "active": False From 08e10fd59af02725011886e0e133fc0a70b084d1 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 11 Oct 2023 14:43:38 +0100 Subject: [PATCH 04/46] Make extraction of models as alembic on by default --- openpype/settings/defaults/project_settings/blender.json | 2 +- server_addon/blender/server/settings/publish_plugins.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/settings/defaults/project_settings/blender.json b/openpype/settings/defaults/project_settings/blender.json index 2bc518e329..7fb8c333a6 100644 --- a/openpype/settings/defaults/project_settings/blender.json +++ b/openpype/settings/defaults/project_settings/blender.json @@ -92,7 +92,7 @@ "ExtractModelABC": { "enabled": true, "optional": true, - "active": false + "active": true }, "ExtractBlendAnimation": { "enabled": true, diff --git a/server_addon/blender/server/settings/publish_plugins.py b/server_addon/blender/server/settings/publish_plugins.py index 102320cfed..27dc0b232f 100644 --- a/server_addon/blender/server/settings/publish_plugins.py +++ b/server_addon/blender/server/settings/publish_plugins.py @@ -200,7 +200,7 @@ DEFAULT_BLENDER_PUBLISH_SETTINGS = { "ExtractModelABC": { "enabled": True, "optional": True, - "active": False + "active": True }, "ExtractBlendAnimation": { "enabled": True, From 23b29c947cb59fc626047846d86cf5d714d515f5 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 11 Oct 2023 15:17:33 +0100 Subject: [PATCH 05/46] Improved function to create the instance --- openpype/hosts/blender/plugins/publish/collect_instances.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/blender/plugins/publish/collect_instances.py b/openpype/hosts/blender/plugins/publish/collect_instances.py index 1e0db9d9ce..cc163fc97e 100644 --- a/openpype/hosts/blender/plugins/publish/collect_instances.py +++ b/openpype/hosts/blender/plugins/publish/collect_instances.py @@ -53,7 +53,7 @@ class CollectInstances(pyblish.api.ContextPlugin): subset=subset, asset=asset, task=task, - ), family + ) def process(self, context): """Collect the models from the current Blender scene.""" @@ -63,7 +63,8 @@ class CollectInstances(pyblish.api.ContextPlugin): instances = chain(asset_groups, collections) for group in instances: - instance, family = self.create_instance(context, group) + instance = self.create_instance(context, group) + family = instance.data["family"] members = [] if type(group) == bpy.types.Collection: members = list(group.objects) From 70abe6b7b7576699918ef6cb818c019b888567bf Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Wed, 11 Oct 2023 15:21:44 +0100 Subject: [PATCH 06/46] Merged the two functions to get asset groups --- .../plugins/publish/collect_instances.py | 18 ++---------------- 1 file changed, 2 insertions(+), 16 deletions(-) diff --git a/openpype/hosts/blender/plugins/publish/collect_instances.py b/openpype/hosts/blender/plugins/publish/collect_instances.py index cc163fc97e..b4fc167638 100644 --- a/openpype/hosts/blender/plugins/publish/collect_instances.py +++ b/openpype/hosts/blender/plugins/publish/collect_instances.py @@ -1,5 +1,4 @@ import json -from itertools import chain from typing import Generator import bpy @@ -23,21 +22,11 @@ class CollectInstances(pyblish.api.ContextPlugin): """Return all instances that are empty objects asset groups. """ instances = bpy.data.collections.get(AVALON_INSTANCES) - for obj in instances.objects: + for obj in list(instances.objects) + list(instances.children): avalon_prop = obj.get(AVALON_PROPERTY) or {} if avalon_prop.get('id') == 'pyblish.avalon.instance': yield obj - @staticmethod - def get_collections() -> Generator: - """Return all instances that are collections. - """ - instances = bpy.data.collections.get(AVALON_INSTANCES) - for collection in instances.children: - avalon_prop = collection.get(AVALON_PROPERTY) or {} - if avalon_prop.get('id') == 'pyblish.avalon.instance': - yield collection - @staticmethod def create_instance(context, group): avalon_prop = group[AVALON_PROPERTY] @@ -58,11 +47,8 @@ class CollectInstances(pyblish.api.ContextPlugin): def process(self, context): """Collect the models from the current Blender scene.""" asset_groups = self.get_asset_groups() - collections = self.get_collections() - instances = chain(asset_groups, collections) - - for group in instances: + for group in asset_groups: instance = self.create_instance(context, group) family = instance.data["family"] members = [] From 68b281fdedad5df6339452418335e5f7f771ca07 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 12 Oct 2023 10:10:29 +0100 Subject: [PATCH 07/46] Improved how models abc extractor is implemented Co-authored-by: Kayla Man --- .../plugins/publish/collect_instances.py | 5 +---- .../blender/plugins/publish/extract_abc.py | 10 +++++++++- .../plugins/publish/extract_abc_model.py | 17 ----------------- 3 files changed, 10 insertions(+), 22 deletions(-) delete mode 100644 openpype/hosts/blender/plugins/publish/extract_abc_model.py diff --git a/openpype/hosts/blender/plugins/publish/collect_instances.py b/openpype/hosts/blender/plugins/publish/collect_instances.py index b4fc167638..c95d718187 100644 --- a/openpype/hosts/blender/plugins/publish/collect_instances.py +++ b/openpype/hosts/blender/plugins/publish/collect_instances.py @@ -50,10 +50,10 @@ class CollectInstances(pyblish.api.ContextPlugin): for group in asset_groups: instance = self.create_instance(context, group) - family = instance.data["family"] members = [] if type(group) == bpy.types.Collection: members = list(group.objects) + family = instance.data["family"] if family == "animation": for obj in group.objects: if obj.type == 'EMPTY' and obj.get(AVALON_PROPERTY): @@ -63,9 +63,6 @@ class CollectInstances(pyblish.api.ContextPlugin): else: members = group.children_recursive - if family == "pointcache": - instance.data["families"].append("abc.export") - members.append(group) instance[:] = members self.log.debug(json.dumps(instance.data, indent=4)) diff --git a/openpype/hosts/blender/plugins/publish/extract_abc.py b/openpype/hosts/blender/plugins/publish/extract_abc.py index b113685842..a603366f30 100644 --- a/openpype/hosts/blender/plugins/publish/extract_abc.py +++ b/openpype/hosts/blender/plugins/publish/extract_abc.py @@ -12,7 +12,7 @@ class ExtractABC(publish.Extractor): label = "Extract ABC" hosts = ["blender"] - families = ["abc.export"] + families = ["pointcache"] def process(self, instance): # Define extract output file path @@ -61,3 +61,11 @@ class ExtractABC(publish.Extractor): self.log.info("Extracted instance '%s' to: %s", instance.name, representation) + +class ExtractModelABC(ExtractABC): + """Extract model as ABC.""" + + label = "Extract Model ABC" + hosts = ["blender"] + families = ["model"] + optional = True diff --git a/openpype/hosts/blender/plugins/publish/extract_abc_model.py b/openpype/hosts/blender/plugins/publish/extract_abc_model.py deleted file mode 100644 index b31e36c681..0000000000 --- a/openpype/hosts/blender/plugins/publish/extract_abc_model.py +++ /dev/null @@ -1,17 +0,0 @@ -import pyblish.api -from openpype.pipeline import publish - - -class ExtractModelABC(publish.Extractor): - """Extract model as ABC.""" - - order = pyblish.api.ExtractorOrder - 0.1 - label = "Extract Model ABC" - hosts = ["blender"] - families = ["model"] - optional = True - - def process(self, instance): - # Add abc.export family to the instance, to allow the extraction - # as alembic of the asset. - instance.data["families"].append("abc.export") From 7cce128c2027f88cb5dc54d526ff3f944dc3a14f Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 12 Oct 2023 10:11:28 +0100 Subject: [PATCH 08/46] Hound fixes --- openpype/hosts/blender/plugins/publish/extract_abc.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hosts/blender/plugins/publish/extract_abc.py b/openpype/hosts/blender/plugins/publish/extract_abc.py index a603366f30..7b6c4d7ae7 100644 --- a/openpype/hosts/blender/plugins/publish/extract_abc.py +++ b/openpype/hosts/blender/plugins/publish/extract_abc.py @@ -62,6 +62,7 @@ class ExtractABC(publish.Extractor): self.log.info("Extracted instance '%s' to: %s", instance.name, representation) + class ExtractModelABC(ExtractABC): """Extract model as ABC.""" From 6259687b32c9216f0f111e07b5c6228242d1d25e Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 12 Oct 2023 10:14:30 +0100 Subject: [PATCH 09/46] Increment workfile version when publishing pointcache --- .../hosts/blender/plugins/publish/increment_workfile_version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/blender/plugins/publish/increment_workfile_version.py b/openpype/hosts/blender/plugins/publish/increment_workfile_version.py index 3d176f9c30..6ace14d77c 100644 --- a/openpype/hosts/blender/plugins/publish/increment_workfile_version.py +++ b/openpype/hosts/blender/plugins/publish/increment_workfile_version.py @@ -10,7 +10,7 @@ class IncrementWorkfileVersion(pyblish.api.ContextPlugin): optional = True hosts = ["blender"] families = ["animation", "model", "rig", "action", "layout", "blendScene", - "render"] + "pointcache", "render"] def process(self, context): From 95fefaaa169a7404d3fae9ed5906b1227cde7c95 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 12 Oct 2023 10:45:16 +0100 Subject: [PATCH 10/46] Fix wrong hierarchy when loading --- .../hosts/blender/plugins/load/load_abc.py | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/blender/plugins/load/load_abc.py b/openpype/hosts/blender/plugins/load/load_abc.py index 9b3d940536..a7077f98f2 100644 --- a/openpype/hosts/blender/plugins/load/load_abc.py +++ b/openpype/hosts/blender/plugins/load/load_abc.py @@ -60,16 +60,30 @@ class CacheModelLoader(plugin.AssetLoader): imported = lib.get_selection() + empties = [obj for obj in imported if obj.type == 'EMPTY'] + + container = None + + for empty in empties: + if not empty.parent: + container = empty + break + + assert container, "No asset group found" + # Children must be linked before parents, # otherwise the hierarchy will break objects = [] + nodes = list(container.children) - for obj in imported: + for obj in nodes: obj.parent = asset_group - for obj in imported: + bpy.data.objects.remove(container) + + for obj in nodes: objects.append(obj) - imported.extend(list(obj.children)) + nodes.extend(list(obj.children)) objects.reverse() From 7589de5aa14cb595f7646f68e7f9d8eaf373a0b5 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 12 Oct 2023 11:33:18 +0100 Subject: [PATCH 11/46] Improved loop to get all loaded objects --- openpype/hosts/blender/plugins/load/load_abc.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/blender/plugins/load/load_abc.py b/openpype/hosts/blender/plugins/load/load_abc.py index a7077f98f2..91d7356a2c 100644 --- a/openpype/hosts/blender/plugins/load/load_abc.py +++ b/openpype/hosts/blender/plugins/load/load_abc.py @@ -83,7 +83,7 @@ class CacheModelLoader(plugin.AssetLoader): for obj in nodes: objects.append(obj) - nodes.extend(list(obj.children)) + objects.extend(list(obj.children_recursive)) objects.reverse() From 251740891980ad37a7d3d326bbb833b36ced2f24 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 12 Oct 2023 11:40:21 +0100 Subject: [PATCH 12/46] Fixed handling of missing container in the abc file being loaded --- .../hosts/blender/plugins/load/load_abc.py | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/openpype/hosts/blender/plugins/load/load_abc.py b/openpype/hosts/blender/plugins/load/load_abc.py index 91d7356a2c..531a820436 100644 --- a/openpype/hosts/blender/plugins/load/load_abc.py +++ b/openpype/hosts/blender/plugins/load/load_abc.py @@ -69,23 +69,26 @@ class CacheModelLoader(plugin.AssetLoader): container = empty break - assert container, "No asset group found" - - # Children must be linked before parents, - # otherwise the hierarchy will break objects = [] - nodes = list(container.children) + if container: + # Children must be linked before parents, + # otherwise the hierarchy will break + nodes = list(container.children) - for obj in nodes: - obj.parent = asset_group + for obj in nodes: + obj.parent = asset_group - bpy.data.objects.remove(container) + bpy.data.objects.remove(container) - for obj in nodes: - objects.append(obj) - objects.extend(list(obj.children_recursive)) + for obj in nodes: + objects.append(obj) + objects.extend(list(obj.children_recursive)) - objects.reverse() + objects.reverse() + else: + for obj in imported: + obj.parent = asset_group + objects = imported for obj in objects: # Unlink the object from all collections From dcfad64320085041cf6b91577b3de605acde1f02 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 13 Oct 2023 16:32:34 +0800 Subject: [PATCH 13/46] add families with frame range back to the frame range validator --- openpype/hosts/max/plugins/publish/validate_frame_range.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/max/plugins/publish/validate_frame_range.py b/openpype/hosts/max/plugins/publish/validate_frame_range.py index 21e847405e..43692d0401 100644 --- a/openpype/hosts/max/plugins/publish/validate_frame_range.py +++ b/openpype/hosts/max/plugins/publish/validate_frame_range.py @@ -27,7 +27,9 @@ class ValidateFrameRange(pyblish.api.InstancePlugin, label = "Validate Frame Range" order = ValidateContentsOrder - families = ["maxrender"] + families = ["camera", "maxrender", + "pointcache", "pointcloud", + "review", "redshiftproxy"] hosts = ["max"] optional = True actions = [RepairAction] From 1b79767e7bbd76f93ca8ba8bf0f2ef434239509c Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 13 Oct 2023 22:15:14 +0800 Subject: [PATCH 14/46] add families into frame range collector and improve the validation report in frame range validator --- .../plugins/publish/collect_frame_range.py | 23 +++++++++ .../max/plugins/publish/collect_review.py | 2 - .../max/plugins/publish/extract_camera_abc.py | 4 +- .../max/plugins/publish/extract_pointcache.py | 4 +- .../max/plugins/publish/extract_pointcloud.py | 4 +- .../plugins/publish/extract_redshift_proxy.py | 4 +- .../publish/validate_animation_timeline.py | 48 ------------------- .../plugins/publish/validate_frame_range.py | 45 +++++++++++------ 8 files changed, 62 insertions(+), 72 deletions(-) create mode 100644 openpype/hosts/max/plugins/publish/collect_frame_range.py delete mode 100644 openpype/hosts/max/plugins/publish/validate_animation_timeline.py diff --git a/openpype/hosts/max/plugins/publish/collect_frame_range.py b/openpype/hosts/max/plugins/publish/collect_frame_range.py new file mode 100644 index 0000000000..197ecff0b1 --- /dev/null +++ b/openpype/hosts/max/plugins/publish/collect_frame_range.py @@ -0,0 +1,23 @@ +# -*- coding: utf-8 -*- +"""Collect instance members.""" +import pyblish.api +from pymxs import runtime as rt + + +class CollectFrameRange(pyblish.api.InstancePlugin): + """Collect Set Members.""" + + order = pyblish.api.CollectorOrder + 0.01 + label = "Collect Frame Range" + hosts = ['max'] + families = ["camera", "maxrender", + "pointcache", "pointcloud", + "review"] + + def process(self, instance): + if instance.data["family"] == "maxrender": + instance.data["frameStart"] = int(rt.rendStart) + instance.data["frameEnd"] = int(rt.rendEnd) + else: + instance.data["frameStart"] = int(rt.animationRange.start) + instance.data["frameEnd"] = int(rt.animationRange.end) diff --git a/openpype/hosts/max/plugins/publish/collect_review.py b/openpype/hosts/max/plugins/publish/collect_review.py index 8e27a857d7..cc4caae497 100644 --- a/openpype/hosts/max/plugins/publish/collect_review.py +++ b/openpype/hosts/max/plugins/publish/collect_review.py @@ -29,8 +29,6 @@ class CollectReview(pyblish.api.InstancePlugin, attr_values = self.get_attr_values_from_data(instance.data) data = { "review_camera": camera_name, - "frameStart": instance.context.data["frameStart"], - "frameEnd": instance.context.data["frameEnd"], "fps": instance.context.data["fps"], "dspGeometry": attr_values.get("dspGeometry"), "dspShapes": attr_values.get("dspShapes"), diff --git a/openpype/hosts/max/plugins/publish/extract_camera_abc.py b/openpype/hosts/max/plugins/publish/extract_camera_abc.py index b1918c53e0..ea33bc67ed 100644 --- a/openpype/hosts/max/plugins/publish/extract_camera_abc.py +++ b/openpype/hosts/max/plugins/publish/extract_camera_abc.py @@ -19,8 +19,8 @@ class ExtractCameraAlembic(publish.Extractor, OptionalPyblishPluginMixin): def process(self, instance): if not self.is_active(instance.data): return - start = float(instance.data.get("frameStartHandle", 1)) - end = float(instance.data.get("frameEndHandle", 1)) + start = instance.data["frameStart"] + end = instance.data["frameEnd"] self.log.info("Extracting Camera ...") diff --git a/openpype/hosts/max/plugins/publish/extract_pointcache.py b/openpype/hosts/max/plugins/publish/extract_pointcache.py index c3de623bc0..a5480ff0dc 100644 --- a/openpype/hosts/max/plugins/publish/extract_pointcache.py +++ b/openpype/hosts/max/plugins/publish/extract_pointcache.py @@ -51,8 +51,8 @@ class ExtractAlembic(publish.Extractor): families = ["pointcache"] def process(self, instance): - start = float(instance.data.get("frameStartHandle", 1)) - end = float(instance.data.get("frameEndHandle", 1)) + start = instance.data["frameStart"] + end = instance.data["frameEnd"] self.log.debug("Extracting pointcache ...") diff --git a/openpype/hosts/max/plugins/publish/extract_pointcloud.py b/openpype/hosts/max/plugins/publish/extract_pointcloud.py index 583bbb6dbd..de90229c59 100644 --- a/openpype/hosts/max/plugins/publish/extract_pointcloud.py +++ b/openpype/hosts/max/plugins/publish/extract_pointcloud.py @@ -39,8 +39,8 @@ class ExtractPointCloud(publish.Extractor): def process(self, instance): self.settings = self.get_setting(instance) - start = int(instance.context.data.get("frameStart")) - end = int(instance.context.data.get("frameEnd")) + start = instance.data["frameStart"] + end = instance.data["frameEnd"] self.log.info("Extracting PRT...") stagingdir = self.staging_dir(instance) diff --git a/openpype/hosts/max/plugins/publish/extract_redshift_proxy.py b/openpype/hosts/max/plugins/publish/extract_redshift_proxy.py index f67ed30c6b..4f64e88584 100644 --- a/openpype/hosts/max/plugins/publish/extract_redshift_proxy.py +++ b/openpype/hosts/max/plugins/publish/extract_redshift_proxy.py @@ -16,8 +16,8 @@ class ExtractRedshiftProxy(publish.Extractor): families = ["redshiftproxy"] def process(self, instance): - start = int(instance.context.data.get("frameStart")) - end = int(instance.context.data.get("frameEnd")) + start = instance.data["frameStart"] + end = instance.data["frameEnd"] self.log.debug("Extracting Redshift Proxy...") stagingdir = self.staging_dir(instance) diff --git a/openpype/hosts/max/plugins/publish/validate_animation_timeline.py b/openpype/hosts/max/plugins/publish/validate_animation_timeline.py deleted file mode 100644 index 2a9483c763..0000000000 --- a/openpype/hosts/max/plugins/publish/validate_animation_timeline.py +++ /dev/null @@ -1,48 +0,0 @@ -import pyblish.api - -from pymxs import runtime as rt -from openpype.pipeline.publish import ( - RepairAction, - ValidateContentsOrder, - PublishValidationError -) -from openpype.hosts.max.api.lib import get_frame_range, set_timeline - - -class ValidateAnimationTimeline(pyblish.api.InstancePlugin): - """ - Validates Animation Timeline for Preview Animation in Max - """ - - label = "Animation Timeline for Review" - order = ValidateContentsOrder - families = ["review"] - hosts = ["max"] - actions = [RepairAction] - - def process(self, instance): - frame_range = get_frame_range() - frame_start_handle = frame_range["frameStart"] - int( - frame_range["handleStart"] - ) - frame_end_handle = frame_range["frameEnd"] + int( - frame_range["handleEnd"] - ) - if rt.animationRange.start != frame_start_handle or ( - rt.animationRange.end != frame_end_handle - ): - raise PublishValidationError("Incorrect animation timeline " - "set for preview animation.. " - "\nYou can use repair action to " - "the correct animation timeline") - - @classmethod - def repair(cls, instance): - frame_range = get_frame_range() - frame_start_handle = frame_range["frameStart"] - int( - frame_range["handleStart"] - ) - frame_end_handle = frame_range["frameEnd"] + int( - frame_range["handleEnd"] - ) - set_timeline(frame_start_handle, frame_end_handle) diff --git a/openpype/hosts/max/plugins/publish/validate_frame_range.py b/openpype/hosts/max/plugins/publish/validate_frame_range.py index 43692d0401..a50a3910c7 100644 --- a/openpype/hosts/max/plugins/publish/validate_frame_range.py +++ b/openpype/hosts/max/plugins/publish/validate_frame_range.py @@ -9,6 +9,7 @@ from openpype.pipeline.publish import ( ValidateContentsOrder, PublishValidationError ) +from openpype.hosts.max.api.lib import get_frame_range, set_timeline class ValidateFrameRange(pyblish.api.InstancePlugin, @@ -29,7 +30,7 @@ class ValidateFrameRange(pyblish.api.InstancePlugin, order = ValidateContentsOrder families = ["camera", "maxrender", "pointcache", "pointcloud", - "review", "redshiftproxy"] + "review"] hosts = ["max"] optional = True actions = [RepairAction] @@ -38,29 +39,45 @@ class ValidateFrameRange(pyblish.api.InstancePlugin, if not self.is_active(instance.data): self.log.info("Skipping validation...") return - context = instance.context - frame_start = int(context.data.get("frameStart")) - frame_end = int(context.data.get("frameEnd")) - - inst_frame_start = int(instance.data.get("frameStart")) - inst_frame_end = int(instance.data.get("frameEnd")) + frame_range = get_frame_range() + inst_frame_start = instance.data.get("frameStart") + inst_frame_end = instance.data.get("frameEnd") + frame_start_handle = frame_range["frameStart"] - int( + frame_range["handleStart"] + ) + frame_end_handle = frame_range["frameEnd"] + int( + frame_range["handleEnd"] + ) errors = [] - if frame_start != inst_frame_start: + if frame_start_handle != inst_frame_start: errors.append( f"Start frame ({inst_frame_start}) on instance does not match " # noqa - f"with the start frame ({frame_start}) set on the asset data. ") # noqa - if frame_end != inst_frame_end: + f"with the start frame ({frame_start_handle}) set on the asset data. ") # noqa + if frame_end_handle != inst_frame_end: errors.append( f"End frame ({inst_frame_end}) on instance does not match " - f"with the end frame ({frame_start}) from the asset data. ") + f"with the end frame ({frame_end_handle}) from the asset data. ") if errors: errors.append("You can use repair action to fix it.") - raise PublishValidationError("\n".join(errors)) + report = "Frame range settings are incorrect.\n\n" + for error in errors: + report += "- {}\n\n".format(error) + raise PublishValidationError(report, title="Frame Range incorrect") @classmethod def repair(cls, instance): - rt.rendStart = instance.context.data.get("frameStart") - rt.rendEnd = instance.context.data.get("frameEnd") + frame_range = get_frame_range() + frame_start_handle = frame_range["frameStart"] - int( + frame_range["handleStart"] + ) + frame_end_handle = frame_range["frameEnd"] + int( + frame_range["handleEnd"] + ) + if instance.data["family"] == "maxrender": + rt.rendStart = frame_start_handle + rt.rendEnd = frame_end_handle + else: + set_timeline(frame_start_handle, frame_end_handle) From f45c603da29da954a44aade1e23d5ce304ccc8f1 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 13 Oct 2023 22:16:21 +0800 Subject: [PATCH 15/46] add redshift proxy family --- openpype/hosts/max/plugins/publish/collect_frame_range.py | 2 +- openpype/hosts/max/plugins/publish/validate_frame_range.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/collect_frame_range.py b/openpype/hosts/max/plugins/publish/collect_frame_range.py index 197ecff0b1..6e5f928a8e 100644 --- a/openpype/hosts/max/plugins/publish/collect_frame_range.py +++ b/openpype/hosts/max/plugins/publish/collect_frame_range.py @@ -12,7 +12,7 @@ class CollectFrameRange(pyblish.api.InstancePlugin): hosts = ['max'] families = ["camera", "maxrender", "pointcache", "pointcloud", - "review"] + "review", "redshiftproxy"] def process(self, instance): if instance.data["family"] == "maxrender": diff --git a/openpype/hosts/max/plugins/publish/validate_frame_range.py b/openpype/hosts/max/plugins/publish/validate_frame_range.py index a50a3910c7..cf4d02c830 100644 --- a/openpype/hosts/max/plugins/publish/validate_frame_range.py +++ b/openpype/hosts/max/plugins/publish/validate_frame_range.py @@ -30,7 +30,7 @@ class ValidateFrameRange(pyblish.api.InstancePlugin, order = ValidateContentsOrder families = ["camera", "maxrender", "pointcache", "pointcloud", - "review"] + "review", "redshiftproxy"] hosts = ["max"] optional = True actions = [RepairAction] From f9dcd4bce67dd35c111f184a07cf489b07ed3537 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 13 Oct 2023 22:18:22 +0800 Subject: [PATCH 16/46] hound --- openpype/hosts/max/plugins/publish/validate_frame_range.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/max/plugins/publish/validate_frame_range.py b/openpype/hosts/max/plugins/publish/validate_frame_range.py index cf4d02c830..b1e8aafbb7 100644 --- a/openpype/hosts/max/plugins/publish/validate_frame_range.py +++ b/openpype/hosts/max/plugins/publish/validate_frame_range.py @@ -58,7 +58,8 @@ class ValidateFrameRange(pyblish.api.InstancePlugin, if frame_end_handle != inst_frame_end: errors.append( f"End frame ({inst_frame_end}) on instance does not match " - f"with the end frame ({frame_end_handle}) from the asset data. ") + f"with the end frame ({frame_end_handle}) " + "from the asset data. ") if errors: errors.append("You can use repair action to fix it.") From 59b7c61b3da7cb95b6b62c731ea0496dc83bac8a Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 13 Oct 2023 22:28:45 +0800 Subject: [PATCH 17/46] docstring for collect frane rabge --- openpype/hosts/max/plugins/publish/collect_frame_range.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/hosts/max/plugins/publish/collect_frame_range.py b/openpype/hosts/max/plugins/publish/collect_frame_range.py index 6e5f928a8e..2dd39b5b50 100644 --- a/openpype/hosts/max/plugins/publish/collect_frame_range.py +++ b/openpype/hosts/max/plugins/publish/collect_frame_range.py @@ -5,7 +5,7 @@ from pymxs import runtime as rt class CollectFrameRange(pyblish.api.InstancePlugin): - """Collect Set Members.""" + """Collect Frame Range.""" order = pyblish.api.CollectorOrder + 0.01 label = "Collect Frame Range" From 4418d1116477add6da68e33abeca492c669bba73 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 19 Oct 2023 11:18:59 +0100 Subject: [PATCH 18/46] Code improvements from suggestions Co-authored-by: Roy Nieterau --- .../hosts/blender/plugins/load/load_abc.py | 21 +++++++------------ .../plugins/publish/collect_instances.py | 2 +- 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/openpype/hosts/blender/plugins/load/load_abc.py b/openpype/hosts/blender/plugins/load/load_abc.py index 531a820436..af28cff7fe 100644 --- a/openpype/hosts/blender/plugins/load/load_abc.py +++ b/openpype/hosts/blender/plugins/load/load_abc.py @@ -60,19 +60,14 @@ class CacheModelLoader(plugin.AssetLoader): imported = lib.get_selection() - empties = [obj for obj in imported if obj.type == 'EMPTY'] - - container = None - - for empty in empties: - if not empty.parent: - container = empty - break + # Use first EMPTY without parent as container + container = next( + (obj for obj in imported if obj.type == "EMPTY" and not obj.parent), + None + ) objects = [] if container: - # Children must be linked before parents, - # otherwise the hierarchy will break nodes = list(container.children) for obj in nodes: @@ -80,11 +75,9 @@ class CacheModelLoader(plugin.AssetLoader): bpy.data.objects.remove(container) + objects.extend(nodes) for obj in nodes: - objects.append(obj) - objects.extend(list(obj.children_recursive)) - - objects.reverse() + objects.extend(obj.children_recursive) else: for obj in imported: obj.parent = asset_group diff --git a/openpype/hosts/blender/plugins/publish/collect_instances.py b/openpype/hosts/blender/plugins/publish/collect_instances.py index c95d718187..ad2ce54147 100644 --- a/openpype/hosts/blender/plugins/publish/collect_instances.py +++ b/openpype/hosts/blender/plugins/publish/collect_instances.py @@ -51,7 +51,7 @@ class CollectInstances(pyblish.api.ContextPlugin): for group in asset_groups: instance = self.create_instance(context, group) members = [] - if type(group) == bpy.types.Collection: + if isinstance(group, bpy.types.Collection): members = list(group.objects) family = instance.data["family"] if family == "animation": From 0b69ce120a23cb2393f4a281030b8696d357d780 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 19 Oct 2023 11:21:13 +0100 Subject: [PATCH 19/46] Hound fixes --- openpype/hosts/blender/plugins/load/load_abc.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/openpype/hosts/blender/plugins/load/load_abc.py b/openpype/hosts/blender/plugins/load/load_abc.py index af28cff7fe..73f08fcc98 100644 --- a/openpype/hosts/blender/plugins/load/load_abc.py +++ b/openpype/hosts/blender/plugins/load/load_abc.py @@ -62,7 +62,8 @@ class CacheModelLoader(plugin.AssetLoader): # Use first EMPTY without parent as container container = next( - (obj for obj in imported if obj.type == "EMPTY" and not obj.parent), + (obj for obj in imported + if obj.type == "EMPTY" and not obj.parent), None ) From a37c7539bb6477ce26f7f5c816230a4783e2ccf0 Mon Sep 17 00:00:00 2001 From: Simone Barbieri Date: Thu, 19 Oct 2023 12:52:48 +0100 Subject: [PATCH 20/46] Changed empty type to Single Arrow --- openpype/hosts/blender/plugins/load/load_abc.py | 1 + 1 file changed, 1 insertion(+) diff --git a/openpype/hosts/blender/plugins/load/load_abc.py b/openpype/hosts/blender/plugins/load/load_abc.py index 73f08fcc98..8d1863d4d5 100644 --- a/openpype/hosts/blender/plugins/load/load_abc.py +++ b/openpype/hosts/blender/plugins/load/load_abc.py @@ -148,6 +148,7 @@ class CacheModelLoader(plugin.AssetLoader): bpy.context.scene.collection.children.link(containers) asset_group = bpy.data.objects.new(group_name, object_data=None) + asset_group.empty_display_type = 'SINGLE_ARROW' containers.objects.link(asset_group) objects = self._process(libpath, asset_group, group_name) From 0016f8aa3d4dea931ac89ce4c977bff9a442cf45 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 13 Oct 2023 15:33:00 +0200 Subject: [PATCH 21/46] created copy of push to project tool --- .../tools/ayon_push_to_project/__init__.py | 0 openpype/tools/ayon_push_to_project/app.py | 28 + .../ayon_push_to_project/control_context.py | 678 +++++++++ .../ayon_push_to_project/control_integrate.py | 1210 +++++++++++++++++ openpype/tools/ayon_push_to_project/window.py | 829 +++++++++++ 5 files changed, 2745 insertions(+) create mode 100644 openpype/tools/ayon_push_to_project/__init__.py create mode 100644 openpype/tools/ayon_push_to_project/app.py create mode 100644 openpype/tools/ayon_push_to_project/control_context.py create mode 100644 openpype/tools/ayon_push_to_project/control_integrate.py create mode 100644 openpype/tools/ayon_push_to_project/window.py diff --git a/openpype/tools/ayon_push_to_project/__init__.py b/openpype/tools/ayon_push_to_project/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openpype/tools/ayon_push_to_project/app.py b/openpype/tools/ayon_push_to_project/app.py new file mode 100644 index 0000000000..b3ec33f353 --- /dev/null +++ b/openpype/tools/ayon_push_to_project/app.py @@ -0,0 +1,28 @@ +import click + +from openpype.tools.utils import get_openpype_qt_app +from openpype.tools.push_to_project.window import PushToContextSelectWindow + + +@click.command() +@click.option("--project", help="Source project name") +@click.option("--version", help="Source version id") +def main(project, version): + """Run PushToProject tool to integrate version in different project. + + Args: + project (str): Source project name. + version (str): Version id. + """ + + app = get_openpype_qt_app() + + window = PushToContextSelectWindow() + window.show() + window.controller.set_source(project, version) + + app.exec_() + + +if __name__ == "__main__": + main() diff --git a/openpype/tools/ayon_push_to_project/control_context.py b/openpype/tools/ayon_push_to_project/control_context.py new file mode 100644 index 0000000000..e4058893d5 --- /dev/null +++ b/openpype/tools/ayon_push_to_project/control_context.py @@ -0,0 +1,678 @@ +import re +import collections +import threading + +from openpype.client import ( + get_projects, + get_assets, + get_asset_by_id, + get_subset_by_id, + get_version_by_id, + get_representations, +) +from openpype.settings import get_project_settings +from openpype.lib import prepare_template_data +from openpype.lib.events import EventSystem +from openpype.pipeline.create import ( + SUBSET_NAME_ALLOWED_SYMBOLS, + get_subset_name_template, +) + +from .control_integrate import ( + ProjectPushItem, + ProjectPushItemProcess, + ProjectPushItemStatus, +) + + +class AssetItem: + def __init__( + self, + entity_id, + name, + icon_name, + icon_color, + parent_id, + has_children + ): + self.id = entity_id + self.name = name + self.icon_name = icon_name + self.icon_color = icon_color + self.parent_id = parent_id + self.has_children = has_children + + @classmethod + def from_doc(cls, asset_doc, has_children=True): + parent_id = asset_doc["data"].get("visualParent") + if parent_id is not None: + parent_id = str(parent_id) + return cls( + str(asset_doc["_id"]), + asset_doc["name"], + asset_doc["data"].get("icon"), + asset_doc["data"].get("color"), + parent_id, + has_children + ) + + +class TaskItem: + def __init__(self, asset_id, name, task_type, short_name): + self.asset_id = asset_id + self.name = name + self.task_type = task_type + self.short_name = short_name + + @classmethod + def from_asset_doc(cls, asset_doc, project_doc): + asset_tasks = asset_doc["data"].get("tasks") or {} + project_task_types = project_doc["config"]["tasks"] + output = [] + for task_name, task_info in asset_tasks.items(): + task_type = task_info.get("type") + task_type_info = project_task_types.get(task_type) or {} + output.append(cls( + asset_doc["_id"], + task_name, + task_type, + task_type_info.get("short_name") + )) + return output + + +class EntitiesModel: + def __init__(self, event_system): + self._event_system = event_system + self._project_names = None + self._project_docs_by_name = {} + self._assets_by_project = {} + self._tasks_by_asset_id = collections.defaultdict(dict) + + def has_cached_projects(self): + return self._project_names is None + + def has_cached_assets(self, project_name): + if not project_name: + return True + return project_name in self._assets_by_project + + def has_cached_tasks(self, project_name): + return self.has_cached_assets(project_name) + + def get_projects(self): + if self._project_names is None: + self.refresh_projects() + return list(self._project_names) + + def get_assets(self, project_name): + if project_name not in self._assets_by_project: + self.refresh_assets(project_name) + return dict(self._assets_by_project[project_name]) + + def get_asset_by_id(self, project_name, asset_id): + return self._assets_by_project[project_name].get(asset_id) + + def get_tasks(self, project_name, asset_id): + if not project_name or not asset_id: + return [] + + if project_name not in self._tasks_by_asset_id: + self.refresh_assets(project_name) + + all_task_items = self._tasks_by_asset_id[project_name] + asset_task_items = all_task_items.get(asset_id) + if not asset_task_items: + return [] + return list(asset_task_items) + + def refresh_projects(self, force=False): + self._event_system.emit( + "projects.refresh.started", {}, "entities.model" + ) + if force or self._project_names is None: + project_names = [] + project_docs_by_name = {} + for project_doc in get_projects(): + library_project = project_doc["data"].get("library_project") + if not library_project: + continue + project_name = project_doc["name"] + project_names.append(project_name) + project_docs_by_name[project_name] = project_doc + self._project_names = project_names + self._project_docs_by_name = project_docs_by_name + self._event_system.emit( + "projects.refresh.finished", {}, "entities.model" + ) + + def _refresh_assets(self, project_name): + asset_items_by_id = {} + task_items_by_asset_id = {} + self._assets_by_project[project_name] = asset_items_by_id + self._tasks_by_asset_id[project_name] = task_items_by_asset_id + if not project_name: + return + + project_doc = self._project_docs_by_name[project_name] + asset_docs_by_parent_id = collections.defaultdict(list) + for asset_doc in get_assets(project_name): + parent_id = asset_doc["data"].get("visualParent") + asset_docs_by_parent_id[parent_id].append(asset_doc) + + hierarchy_queue = collections.deque() + for asset_doc in asset_docs_by_parent_id[None]: + hierarchy_queue.append(asset_doc) + + while hierarchy_queue: + asset_doc = hierarchy_queue.popleft() + children = asset_docs_by_parent_id[asset_doc["_id"]] + asset_item = AssetItem.from_doc(asset_doc, len(children) > 0) + asset_items_by_id[asset_item.id] = asset_item + task_items_by_asset_id[asset_item.id] = ( + TaskItem.from_asset_doc(asset_doc, project_doc) + ) + for child in children: + hierarchy_queue.append(child) + + def refresh_assets(self, project_name, force=False): + self._event_system.emit( + "assets.refresh.started", + {"project_name": project_name}, + "entities.model" + ) + + if force or project_name not in self._assets_by_project: + self._refresh_assets(project_name) + + self._event_system.emit( + "assets.refresh.finished", + {"project_name": project_name}, + "entities.model" + ) + + +class SelectionModel: + def __init__(self, event_system): + self._event_system = event_system + + self.project_name = None + self.asset_id = None + self.task_name = None + + def select_project(self, project_name): + if self.project_name == project_name: + return + + self.project_name = project_name + self._event_system.emit( + "project.changed", + {"project_name": project_name}, + "selection.model" + ) + + def select_asset(self, asset_id): + if self.asset_id == asset_id: + return + self.asset_id = asset_id + self._event_system.emit( + "asset.changed", + { + "project_name": self.project_name, + "asset_id": asset_id + }, + "selection.model" + ) + + def select_task(self, task_name): + if self.task_name == task_name: + return + self.task_name = task_name + self._event_system.emit( + "task.changed", + { + "project_name": self.project_name, + "asset_id": self.asset_id, + "task_name": task_name + }, + "selection.model" + ) + + +class UserPublishValues: + """Helper object to validate values required for push to different project. + + Args: + event_system (EventSystem): Event system to catch and emit events. + new_asset_name (str): Name of new asset name. + variant (str): Variant for new subset name in new project. + """ + + asset_name_regex = re.compile("^[a-zA-Z0-9_.]+$") + variant_regex = re.compile("^[{}]+$".format(SUBSET_NAME_ALLOWED_SYMBOLS)) + + def __init__(self, event_system): + self._event_system = event_system + self._new_asset_name = None + self._variant = None + self._comment = None + self._is_variant_valid = False + self._is_new_asset_name_valid = False + + self.set_new_asset("") + self.set_variant("") + self.set_comment("") + + @property + def new_asset_name(self): + return self._new_asset_name + + @property + def variant(self): + return self._variant + + @property + def comment(self): + return self._comment + + @property + def is_variant_valid(self): + return self._is_variant_valid + + @property + def is_new_asset_name_valid(self): + return self._is_new_asset_name_valid + + @property + def is_valid(self): + return self.is_variant_valid and self.is_new_asset_name_valid + + def set_variant(self, variant): + if variant == self._variant: + return + + old_variant = self._variant + old_is_valid = self._is_variant_valid + + self._variant = variant + is_valid = False + if variant: + is_valid = self.variant_regex.match(variant) is not None + self._is_variant_valid = is_valid + + changes = { + key: {"new": new, "old": old} + for key, old, new in ( + ("variant", old_variant, variant), + ("is_valid", old_is_valid, is_valid) + ) + } + + self._event_system.emit( + "variant.changed", + { + "variant": variant, + "is_valid": self._is_variant_valid, + "changes": changes + }, + "user_values" + ) + + def set_new_asset(self, asset_name): + if self._new_asset_name == asset_name: + return + old_asset_name = self._new_asset_name + old_is_valid = self._is_new_asset_name_valid + self._new_asset_name = asset_name + is_valid = True + if asset_name: + is_valid = ( + self.asset_name_regex.match(asset_name) is not None + ) + self._is_new_asset_name_valid = is_valid + changes = { + key: {"new": new, "old": old} + for key, old, new in ( + ("new_asset_name", old_asset_name, asset_name), + ("is_valid", old_is_valid, is_valid) + ) + } + + self._event_system.emit( + "new_asset_name.changed", + { + "new_asset_name": self._new_asset_name, + "is_valid": self._is_new_asset_name_valid, + "changes": changes + }, + "user_values" + ) + + def set_comment(self, comment): + if comment == self._comment: + return + old_comment = self._comment + self._comment = comment + self._event_system.emit( + "comment.changed", + { + "comment": comment, + "changes": { + "comment": {"new": comment, "old": old_comment} + } + }, + "user_values" + ) + + +class PushToContextController: + def __init__(self, project_name=None, version_id=None): + self._src_project_name = None + self._src_version_id = None + self._src_asset_doc = None + self._src_subset_doc = None + self._src_version_doc = None + + event_system = EventSystem() + entities_model = EntitiesModel(event_system) + selection_model = SelectionModel(event_system) + user_values = UserPublishValues(event_system) + + self._event_system = event_system + self._entities_model = entities_model + self._selection_model = selection_model + self._user_values = user_values + + event_system.add_callback("project.changed", self._on_project_change) + event_system.add_callback("asset.changed", self._invalidate) + event_system.add_callback("variant.changed", self._invalidate) + event_system.add_callback("new_asset_name.changed", self._invalidate) + + self._submission_enabled = False + self._process_thread = None + self._process_item = None + + self.set_source(project_name, version_id) + + def _get_task_info_from_repre_docs(self, asset_doc, repre_docs): + asset_tasks = asset_doc["data"].get("tasks") or {} + found_comb = [] + for repre_doc in repre_docs: + context = repre_doc["context"] + task_info = context.get("task") + if task_info is None: + continue + + task_name = None + task_type = None + if isinstance(task_info, str): + task_name = task_info + asset_task_info = asset_tasks.get(task_info) or {} + task_type = asset_task_info.get("type") + + elif isinstance(task_info, dict): + task_name = task_info.get("name") + task_type = task_info.get("type") + + if task_name and task_type: + return task_name, task_type + + if task_name: + found_comb.append((task_name, task_type)) + + for task_name, task_type in found_comb: + return task_name, task_type + return None, None + + def _get_src_variant(self): + project_name = self._src_project_name + version_doc = self._src_version_doc + asset_doc = self._src_asset_doc + repre_docs = get_representations( + project_name, version_ids=[version_doc["_id"]] + ) + task_name, task_type = self._get_task_info_from_repre_docs( + asset_doc, repre_docs + ) + + project_settings = get_project_settings(project_name) + subset_doc = self.src_subset_doc + family = subset_doc["data"].get("family") + if not family: + family = subset_doc["data"]["families"][0] + template = get_subset_name_template( + self._src_project_name, + family, + task_name, + task_type, + None, + project_settings=project_settings + ) + template_low = template.lower() + variant_placeholder = "{variant}" + if ( + variant_placeholder not in template_low + or (not task_name and "{task" in template_low) + ): + return "" + + idx = template_low.index(variant_placeholder) + template_s = template[:idx] + template_e = template[idx + len(variant_placeholder):] + fill_data = prepare_template_data({ + "family": family, + "task": task_name + }) + try: + subset_s = template_s.format(**fill_data) + subset_e = template_e.format(**fill_data) + except Exception as exc: + print("Failed format", exc) + return "" + + subset_name = self.src_subset_doc["name"] + if ( + (subset_s and not subset_name.startswith(subset_s)) + or (subset_e and not subset_name.endswith(subset_e)) + ): + return "" + + if subset_s: + subset_name = subset_name[len(subset_s):] + if subset_e: + subset_name = subset_name[:len(subset_e)] + return subset_name + + def set_source(self, project_name, version_id): + if ( + project_name == self._src_project_name + and version_id == self._src_version_id + ): + return + + self._src_project_name = project_name + self._src_version_id = version_id + asset_doc = None + subset_doc = None + version_doc = None + if project_name and version_id: + version_doc = get_version_by_id(project_name, version_id) + + if version_doc: + subset_doc = get_subset_by_id(project_name, version_doc["parent"]) + + if subset_doc: + asset_doc = get_asset_by_id(project_name, subset_doc["parent"]) + + self._src_asset_doc = asset_doc + self._src_subset_doc = subset_doc + self._src_version_doc = version_doc + if asset_doc: + self.user_values.set_new_asset(asset_doc["name"]) + variant = self._get_src_variant() + if variant: + self.user_values.set_variant(variant) + + comment = version_doc["data"].get("comment") + if comment: + self.user_values.set_comment(comment) + + self._event_system.emit( + "source.changed", { + "project_name": project_name, + "version_id": version_id + }, + "controller" + ) + + @property + def src_project_name(self): + return self._src_project_name + + @property + def src_version_id(self): + return self._src_version_id + + @property + def src_label(self): + if not self._src_project_name or not self._src_version_id: + return "Source is not defined" + + asset_doc = self.src_asset_doc + if not asset_doc: + return "Source is invalid" + + asset_path_parts = list(asset_doc["data"]["parents"]) + asset_path_parts.append(asset_doc["name"]) + asset_path = "/".join(asset_path_parts) + subset_doc = self.src_subset_doc + version_doc = self.src_version_doc + return "Source: {}/{}/{}/v{:0>3}".format( + self._src_project_name, + asset_path, + subset_doc["name"], + version_doc["name"] + ) + + @property + def src_version_doc(self): + return self._src_version_doc + + @property + def src_subset_doc(self): + return self._src_subset_doc + + @property + def src_asset_doc(self): + return self._src_asset_doc + + @property + def event_system(self): + return self._event_system + + @property + def model(self): + return self._entities_model + + @property + def selection_model(self): + return self._selection_model + + @property + def user_values(self): + return self._user_values + + @property + def submission_enabled(self): + return self._submission_enabled + + def _on_project_change(self, event): + project_name = event["project_name"] + self.model.refresh_assets(project_name) + self._invalidate() + + def _invalidate(self): + submission_enabled = self._check_submit_validations() + if submission_enabled == self._submission_enabled: + return + self._submission_enabled = submission_enabled + self._event_system.emit( + "submission.enabled.changed", + {"enabled": submission_enabled}, + "controller" + ) + + def _check_submit_validations(self): + if not self._user_values.is_valid: + return False + + if not self.selection_model.project_name: + return False + + if ( + not self._user_values.new_asset_name + and not self.selection_model.asset_id + ): + return False + + return True + + def get_selected_asset_name(self): + project_name = self._selection_model.project_name + asset_id = self._selection_model.asset_id + if not project_name or not asset_id: + return None + asset_item = self._entities_model.get_asset_by_id( + project_name, asset_id + ) + if asset_item: + return asset_item.name + return None + + def submit(self, wait=True): + if not self.submission_enabled: + return + + if self._process_thread is not None: + return + + item = ProjectPushItem( + self.src_project_name, + self.src_version_id, + self.selection_model.project_name, + self.selection_model.asset_id, + self.selection_model.task_name, + self.user_values.variant, + comment=self.user_values.comment, + new_asset_name=self.user_values.new_asset_name, + dst_version=1 + ) + + status_item = ProjectPushItemStatus(event_system=self._event_system) + process_item = ProjectPushItemProcess(item, status_item) + self._process_item = process_item + self._event_system.emit("submit.started", {}, "controller") + if wait: + self._submit_callback() + self._process_item = None + return process_item + + thread = threading.Thread(target=self._submit_callback) + self._process_thread = thread + thread.start() + return process_item + + def wait_for_process_thread(self): + if self._process_thread is None: + return + self._process_thread.join() + self._process_thread = None + + def _submit_callback(self): + process_item = self._process_item + if process_item is None: + return + process_item.process() + self._event_system.emit("submit.finished", {}, "controller") + if process_item is self._process_item: + self._process_item = None diff --git a/openpype/tools/ayon_push_to_project/control_integrate.py b/openpype/tools/ayon_push_to_project/control_integrate.py new file mode 100644 index 0000000000..a822339ccf --- /dev/null +++ b/openpype/tools/ayon_push_to_project/control_integrate.py @@ -0,0 +1,1210 @@ +import os +import re +import copy +import socket +import itertools +import datetime +import sys +import traceback + +from bson.objectid import ObjectId + +from openpype.client import ( + get_project, + get_assets, + get_asset_by_id, + get_subset_by_id, + get_subset_by_name, + get_version_by_id, + get_last_version_by_subset_id, + get_version_by_name, + get_representations, +) +from openpype.client.operations import ( + OperationsSession, + new_asset_document, + new_subset_document, + new_version_doc, + new_representation_doc, + prepare_version_update_data, + prepare_representation_update_data, +) +from openpype.modules import ModulesManager +from openpype.lib import ( + StringTemplate, + get_openpype_username, + get_formatted_current_time, + source_hash, +) + +from openpype.lib.file_transaction import FileTransaction +from openpype.settings import get_project_settings +from openpype.pipeline import Anatomy +from openpype.pipeline.version_start import get_versioning_start +from openpype.pipeline.template_data import get_template_data +from openpype.pipeline.publish import get_publish_template_name +from openpype.pipeline.create import get_subset_name + +UNKNOWN = object() + + +class PushToProjectError(Exception): + pass + + +class FileItem(object): + def __init__(self, path): + self.path = path + + @property + def is_valid_file(self): + return os.path.exists(self.path) and os.path.isfile(self.path) + + +class SourceFile(FileItem): + def __init__(self, path, frame=None, udim=None): + super(SourceFile, self).__init__(path) + self.frame = frame + self.udim = udim + + def __repr__(self): + subparts = [self.__class__.__name__] + if self.frame is not None: + subparts.append("frame: {}".format(self.frame)) + if self.udim is not None: + subparts.append("UDIM: {}".format(self.udim)) + + return "<{}> '{}'".format(" - ".join(subparts), self.path) + + +class ResourceFile(FileItem): + def __init__(self, path, relative_path): + super(ResourceFile, self).__init__(path) + self.relative_path = relative_path + + def __repr__(self): + return "<{}> '{}'".format(self.__class__.__name__, self.relative_path) + + @property + def is_valid_file(self): + if not self.relative_path: + return False + return super(ResourceFile, self).is_valid_file + + +class ProjectPushItem: + def __init__( + self, + src_project_name, + src_version_id, + dst_project_name, + dst_asset_id, + dst_task_name, + variant, + comment=None, + new_asset_name=None, + dst_version=None + ): + self.src_project_name = src_project_name + self.src_version_id = src_version_id + self.dst_project_name = dst_project_name + self.dst_asset_id = dst_asset_id + self.dst_task_name = dst_task_name + self.dst_version = dst_version + self.variant = variant + self.new_asset_name = new_asset_name + self.comment = comment or "" + self._id = "|".join([ + src_project_name, + src_version_id, + dst_project_name, + str(dst_asset_id), + str(new_asset_name), + str(dst_task_name), + str(dst_version) + ]) + + @property + def id(self): + return self._id + + def __repr__(self): + return "<{} - {}>".format(self.__class__.__name__, self.id) + + +class StatusMessage: + def __init__(self, message, level): + self.message = message + self.level = level + + def __str__(self): + return "{}: {}".format(self.level.upper(), self.message) + + def __repr__(self): + return "<{} - {}> {}".format( + self.__class__.__name__, self.level.upper, self.message + ) + + +class ProjectPushItemStatus: + def __init__( + self, + failed=False, + finished=False, + fail_reason=None, + formatted_traceback=None, + messages=None, + event_system=None + ): + if messages is None: + messages = [] + self._failed = failed + self._finished = finished + self._fail_reason = fail_reason + self._traceback = formatted_traceback + self._messages = messages + self._event_system = event_system + + def emit_event(self, topic, data=None): + if self._event_system is None: + return + + self._event_system.emit(topic, data or {}, "push.status") + + def get_finished(self): + """Processing of push to project finished. + + Returns: + bool: Finished. + """ + + return self._finished + + def set_finished(self, finished=True): + """Mark status as finished. + + Args: + finished (bool): Processing finished (failed or not). + """ + + if finished != self._finished: + self._finished = finished + self.emit_event("push.finished.changed", {"finished": finished}) + + finished = property(get_finished, set_finished) + + def set_failed(self, fail_reason, exc_info=None): + """Set status as failed. + + Attribute 'fail_reason' can change automatically based on passed value. + Reason is unset if 'failed' is 'False' and is set do default reason if + is set to 'True' and reason is not set. + + Args: + failed (bool): Push to project failed. + fail_reason (str): Reason why failed. + """ + + failed = True + if not fail_reason and not exc_info: + failed = False + + full_traceback = None + if exc_info is not None: + full_traceback = "".join(traceback.format_exception(*exc_info)) + if not fail_reason: + fail_reason = "Failed without specified reason" + + if ( + self._failed == failed + and self._traceback == full_traceback + and self._fail_reason == fail_reason + ): + return + + self._failed = failed + self._fail_reason = fail_reason or None + self._traceback = full_traceback + + self.emit_event( + "push.failed.changed", + { + "failed": failed, + "reason": fail_reason, + "traceback": full_traceback + } + ) + + @property + def failed(self): + """Processing failed. + + Returns: + bool: Processing failed. + """ + + return self._failed + + @property + def fail_reason(self): + """Reason why push to process failed. + + Returns: + Union[str, None]: Reason why push failed or None. + """ + + return self._fail_reason + + @property + def traceback(self): + """Traceback of failed process. + + Traceback is available only if unhandled exception happened. + + Returns: + Union[str, None]: Formatted traceback. + """ + + return self._traceback + + # Loggin helpers + # TODO better logging + def add_message(self, message, level): + message_obj = StatusMessage(message, level) + self._messages.append(message_obj) + self.emit_event( + "push.message.added", + {"message": message, "level": level} + ) + print(message_obj) + return message_obj + + def debug(self, message): + return self.add_message(message, "debug") + + def info(self, message): + return self.add_message(message, "info") + + def warning(self, message): + return self.add_message(message, "warning") + + def error(self, message): + return self.add_message(message, "error") + + def critical(self, message): + return self.add_message(message, "critical") + + +class ProjectPushRepreItem: + """Representation item. + + Representation item based on representation document and project roots. + + Representation document may have reference to: + - source files: Files defined with publish template + - resource files: Files that should be in publish directory + but filenames are not template based. + + Args: + repre_doc (Dict[str, Ant]): Representation document. + roots (Dict[str, str]): Project roots (based on project anatomy). + """ + + def __init__(self, repre_doc, roots): + self._repre_doc = repre_doc + self._roots = roots + self._src_files = None + self._resource_files = None + self._frame = UNKNOWN + + @property + def repre_doc(self): + return self._repre_doc + + @property + def src_files(self): + if self._src_files is None: + self.get_source_files() + return self._src_files + + @property + def resource_files(self): + if self._resource_files is None: + self.get_source_files() + return self._resource_files + + @staticmethod + def _clean_path(path): + new_value = path.replace("\\", "/") + while "//" in new_value: + new_value = new_value.replace("//", "/") + return new_value + + @staticmethod + def _get_relative_path(path, src_dirpath): + dirpath, basename = os.path.split(path) + if not dirpath.lower().startswith(src_dirpath.lower()): + return None + + relative_dir = dirpath[len(src_dirpath):].lstrip("/") + if relative_dir: + relative_path = "/".join([relative_dir, basename]) + else: + relative_path = basename + return relative_path + + @property + def frame(self): + """First frame of representation files. + + This value will be in representation document context if is sequence. + + Returns: + Union[int, None]: First frame in representation files based on + source files or None if frame is not part of filename. + """ + + if self._frame is UNKNOWN: + frame = None + for src_file in self.src_files: + src_frame = src_file.frame + if ( + src_frame is not None + and (frame is None or src_frame < frame) + ): + frame = src_frame + self._frame = frame + return self._frame + + @staticmethod + def validate_source_files(src_files, resource_files): + if not src_files: + raise AssertionError(( + "Couldn't figure out source files from representation." + " Found resource files {}" + ).format(", ".join(str(i) for i in resource_files))) + + invalid_items = [ + item + for item in itertools.chain(src_files, resource_files) + if not item.is_valid_file + ] + if invalid_items: + raise AssertionError(( + "Source files that were not found on disk: {}" + ).format(", ".join(str(i) for i in invalid_items))) + + def get_source_files(self): + if self._src_files is not None: + return self._src_files, self._resource_files + + repre_context = self._repre_doc["context"] + if "frame" in repre_context or "udim" in repre_context: + src_files, resource_files = self._get_source_files_with_frames() + else: + src_files, resource_files = self._get_source_files() + + self.validate_source_files(src_files, resource_files) + + self._src_files = src_files + self._resource_files = resource_files + return self._src_files, self._resource_files + + def _get_source_files_with_frames(self): + frame_placeholder = "__frame__" + udim_placeholder = "__udim__" + src_files = [] + resource_files = [] + template = self._repre_doc["data"]["template"] + # Remove padding from 'udim' and 'frame' formatting keys + # - "{frame:0>4}" -> "{frame}" + for key in ("udim", "frame"): + sub_part = "{" + key + "[^}]*}" + replacement = "{{{}}}".format(key) + template = re.sub(sub_part, replacement, template) + + repre_context = self._repre_doc["context"] + fill_repre_context = copy.deepcopy(repre_context) + if "frame" in fill_repre_context: + fill_repre_context["frame"] = frame_placeholder + + if "udim" in fill_repre_context: + fill_repre_context["udim"] = udim_placeholder + + fill_roots = fill_repre_context["root"] + for root_name in tuple(fill_roots.keys()): + fill_roots[root_name] = "{{root[{}]}}".format(root_name) + repre_path = StringTemplate.format_template( + template, fill_repre_context) + repre_path = self._clean_path(repre_path) + src_dirpath, src_basename = os.path.split(repre_path) + src_basename = ( + re.escape(src_basename) + .replace(frame_placeholder, "(?P[0-9]+)") + .replace(udim_placeholder, "(?P[0-9]+)") + ) + src_basename_regex = re.compile("^{}$".format(src_basename)) + for file_info in self._repre_doc["files"]: + filepath_template = self._clean_path(file_info["path"]) + filepath = self._clean_path( + filepath_template.format(root=self._roots) + ) + dirpath, basename = os.path.split(filepath_template) + if ( + dirpath.lower() != src_dirpath.lower() + or not src_basename_regex.match(basename) + ): + relative_path = self._get_relative_path(filepath, src_dirpath) + resource_files.append(ResourceFile(filepath, relative_path)) + continue + + filepath = os.path.join(src_dirpath, basename) + frame = None + udim = None + for item in src_basename_regex.finditer(basename): + group_name = item.lastgroup + value = item.group(group_name) + if group_name == "frame": + frame = int(value) + elif group_name == "udim": + udim = value + + src_files.append(SourceFile(filepath, frame, udim)) + + return src_files, resource_files + + def _get_source_files(self): + src_files = [] + resource_files = [] + template = self._repre_doc["data"]["template"] + repre_context = self._repre_doc["context"] + fill_repre_context = copy.deepcopy(repre_context) + fill_roots = fill_repre_context["root"] + for root_name in tuple(fill_roots.keys()): + fill_roots[root_name] = "{{root[{}]}}".format(root_name) + repre_path = StringTemplate.format_template(template, + fill_repre_context) + repre_path = self._clean_path(repre_path) + src_dirpath = os.path.dirname(repre_path) + for file_info in self._repre_doc["files"]: + filepath_template = self._clean_path(file_info["path"]) + filepath = self._clean_path( + filepath_template.format(root=self._roots)) + + if filepath_template.lower() == repre_path.lower(): + src_files.append( + SourceFile(repre_path.format(root=self._roots)) + ) + else: + relative_path = self._get_relative_path( + filepath_template, src_dirpath + ) + resource_files.append( + ResourceFile(filepath, relative_path) + ) + return src_files, resource_files + + +class ProjectPushItemProcess: + """ + Args: + item (ProjectPushItem): Item which is being processed. + item_status (ProjectPushItemStatus): Object to store status. + """ + + # TODO where to get host?!!! + host_name = "republisher" + + def __init__(self, item, item_status=None): + self._item = item + + self._src_project_doc = None + self._src_asset_doc = None + self._src_subset_doc = None + self._src_version_doc = None + self._src_repre_items = None + self._src_anatomy = None + + self._project_doc = None + self._anatomy = None + self._asset_doc = None + self._created_asset_doc = None + self._task_info = None + self._subset_doc = None + self._version_doc = None + + self._family = None + self._subset_name = None + + self._project_settings = None + self._template_name = None + + if item_status is None: + item_status = ProjectPushItemStatus() + self._status = item_status + self._operations = OperationsSession() + self._file_transaction = FileTransaction() + + @property + def status(self): + return self._status + + @property + def src_project_doc(self): + return self._src_project_doc + + @property + def src_anatomy(self): + return self._src_anatomy + + @property + def src_asset_doc(self): + return self._src_asset_doc + + @property + def src_subset_doc(self): + return self._src_subset_doc + + @property + def src_version_doc(self): + return self._src_version_doc + + @property + def src_repre_items(self): + return self._src_repre_items + + @property + def project_doc(self): + return self._project_doc + + @property + def anatomy(self): + return self._anatomy + + @property + def project_settings(self): + return self._project_settings + + @property + def asset_doc(self): + return self._asset_doc + + @property + def task_info(self): + return self._task_info + + @property + def subset_doc(self): + return self._subset_doc + + @property + def version_doc(self): + return self._version_doc + + @property + def variant(self): + return self._item.variant + + @property + def family(self): + return self._family + + @property + def subset_name(self): + return self._subset_name + + @property + def template_name(self): + return self._template_name + + def fill_source_variables(self): + src_project_name = self._item.src_project_name + src_version_id = self._item.src_version_id + + project_doc = get_project(src_project_name) + if not project_doc: + self._status.set_failed( + f"Source project \"{src_project_name}\" was not found" + ) + raise PushToProjectError(self._status.fail_reason) + + self._status.debug(f"Project '{src_project_name}' found") + + version_doc = get_version_by_id(src_project_name, src_version_id) + if not version_doc: + self._status.set_failed(( + f"Source version with id \"{src_version_id}\"" + f" was not found in project \"{src_project_name}\"" + )) + raise PushToProjectError(self._status.fail_reason) + + subset_id = version_doc["parent"] + subset_doc = get_subset_by_id(src_project_name, subset_id) + if not subset_doc: + self._status.set_failed(( + f"Could find subset with id \"{subset_id}\"" + f" in project \"{src_project_name}\"" + )) + raise PushToProjectError(self._status.fail_reason) + + asset_id = subset_doc["parent"] + asset_doc = get_asset_by_id(src_project_name, asset_id) + if not asset_doc: + self._status.set_failed(( + f"Could find asset with id \"{asset_id}\"" + f" in project \"{src_project_name}\"" + )) + raise PushToProjectError(self._status.fail_reason) + + anatomy = Anatomy(src_project_name) + + repre_docs = get_representations( + src_project_name, + version_ids=[src_version_id] + ) + repre_items = [ + ProjectPushRepreItem(repre_doc, anatomy.roots) + for repre_doc in repre_docs + ] + self._status.debug(( + f"Found {len(repre_items)} representations on" + f" version {src_version_id} in project '{src_project_name}'" + )) + if not repre_items: + self._status.set_failed( + "Source version does not have representations" + f" (Version id: {src_version_id})" + ) + raise PushToProjectError(self._status.fail_reason) + + self._src_anatomy = anatomy + self._src_project_doc = project_doc + self._src_asset_doc = asset_doc + self._src_subset_doc = subset_doc + self._src_version_doc = version_doc + self._src_repre_items = repre_items + + def fill_destination_project(self): + # --- Destination entities --- + dst_project_name = self._item.dst_project_name + # Validate project existence + dst_project_doc = get_project(dst_project_name) + if not dst_project_doc: + self._status.set_failed( + f"Destination project '{dst_project_name}' was not found" + ) + raise PushToProjectError(self._status.fail_reason) + + self._status.debug( + f"Destination project '{dst_project_name}' found" + ) + self._project_doc = dst_project_doc + self._anatomy = Anatomy(dst_project_name) + self._project_settings = get_project_settings( + self._item.dst_project_name + ) + + def _create_asset( + self, + src_asset_doc, + project_doc, + parent_asset_doc, + asset_name + ): + parent_id = None + parents = [] + tools = [] + if parent_asset_doc: + parent_id = parent_asset_doc["_id"] + parents = list(parent_asset_doc["data"]["parents"]) + parents.append(parent_asset_doc["name"]) + _tools = parent_asset_doc["data"].get("tools_env") + if _tools: + tools = list(_tools) + + asset_name_low = asset_name.lower() + other_asset_docs = get_assets( + project_doc["name"], fields=["_id", "name", "data.visualParent"] + ) + for other_asset_doc in other_asset_docs: + other_name = other_asset_doc["name"] + other_parent_id = other_asset_doc["data"].get("visualParent") + if other_name.lower() != asset_name_low: + continue + + if other_parent_id != parent_id: + self._status.set_failed(( + f"Asset with name \"{other_name}\" already" + " exists in different hierarchy." + )) + raise PushToProjectError(self._status.fail_reason) + + self._status.debug(( + f"Found already existing asset with name \"{other_name}\"" + f" which match requested name \"{asset_name}\"" + )) + return get_asset_by_id(project_doc["name"], other_asset_doc["_id"]) + + data_keys = ( + "clipIn", + "clipOut", + "frameStart", + "frameEnd", + "handleStart", + "handleEnd", + "resolutionWidth", + "resolutionHeight", + "fps", + "pixelAspect", + ) + asset_data = { + "visualParent": parent_id, + "parents": parents, + "tasks": {}, + "tools_env": tools + } + src_asset_data = src_asset_doc["data"] + for key in data_keys: + if key in src_asset_data: + asset_data[key] = src_asset_data[key] + + asset_doc = new_asset_document( + asset_name, + project_doc["_id"], + parent_id, + parents, + data=asset_data + ) + self._operations.create_entity( + project_doc["name"], + asset_doc["type"], + asset_doc + ) + self._status.info( + f"Creating new asset with name \"{asset_name}\"" + ) + self._created_asset_doc = asset_doc + return asset_doc + + def fill_or_create_destination_asset(self): + dst_project_name = self._item.dst_project_name + dst_asset_id = self._item.dst_asset_id + dst_task_name = self._item.dst_task_name + new_asset_name = self._item.new_asset_name + if not dst_asset_id and not new_asset_name: + self._status.set_failed( + "Push item does not have defined destination asset" + ) + raise PushToProjectError(self._status.fail_reason) + + # Get asset document + parent_asset_doc = None + if dst_asset_id: + parent_asset_doc = get_asset_by_id( + self._item.dst_project_name, self._item.dst_asset_id + ) + if not parent_asset_doc: + self._status.set_failed( + f"Could find asset with id \"{dst_asset_id}\"" + f" in project \"{dst_project_name}\"" + ) + raise PushToProjectError(self._status.fail_reason) + + if not new_asset_name: + asset_doc = parent_asset_doc + else: + asset_doc = self._create_asset( + self.src_asset_doc, + self.project_doc, + parent_asset_doc, + new_asset_name + ) + self._asset_doc = asset_doc + if not dst_task_name: + self._task_info = {} + return + + asset_path_parts = list(asset_doc["data"]["parents"]) + asset_path_parts.append(asset_doc["name"]) + asset_path = "/".join(asset_path_parts) + asset_tasks = asset_doc.get("data", {}).get("tasks") or {} + task_info = asset_tasks.get(dst_task_name) + if not task_info: + self._status.set_failed( + f"Could find task with name \"{dst_task_name}\"" + f" on asset \"{asset_path}\"" + f" in project \"{dst_project_name}\"" + ) + raise PushToProjectError(self._status.fail_reason) + + # Create copy of task info to avoid changing data in asset document + task_info = copy.deepcopy(task_info) + task_info["name"] = dst_task_name + # Fill rest of task information based on task type + task_type = task_info["type"] + task_type_info = self.project_doc["config"]["tasks"].get(task_type, {}) + task_info.update(task_type_info) + self._task_info = task_info + + def determine_family(self): + subset_doc = self.src_subset_doc + family = subset_doc["data"].get("family") + families = subset_doc["data"].get("families") + if not family and families: + family = families[0] + + if not family: + self._status.set_failed( + "Couldn't figure out family from source subset" + ) + raise PushToProjectError(self._status.fail_reason) + + self._status.debug( + f"Publishing family is '{family}' (Based on source subset)" + ) + self._family = family + + def determine_publish_template_name(self): + template_name = get_publish_template_name( + self._item.dst_project_name, + self.host_name, + self.family, + self.task_info.get("name"), + self.task_info.get("type"), + project_settings=self.project_settings + ) + self._status.debug( + f"Using template '{template_name}' for integration" + ) + self._template_name = template_name + + def determine_subset_name(self): + family = self.family + asset_doc = self.asset_doc + task_info = self.task_info + subset_name = get_subset_name( + family, + self.variant, + task_info.get("name"), + asset_doc, + project_name=self._item.dst_project_name, + host_name=self.host_name, + project_settings=self.project_settings + ) + self._status.info( + f"Push will be integrating to subset with name '{subset_name}'" + ) + self._subset_name = subset_name + + def make_sure_subset_exists(self): + project_name = self._item.dst_project_name + asset_id = self.asset_doc["_id"] + subset_name = self.subset_name + family = self.family + subset_doc = get_subset_by_name(project_name, subset_name, asset_id) + if subset_doc: + self._subset_doc = subset_doc + return subset_doc + + data = { + "families": [family] + } + subset_doc = new_subset_document( + subset_name, family, asset_id, data + ) + self._operations.create_entity(project_name, "subset", subset_doc) + self._subset_doc = subset_doc + + def make_sure_version_exists(self): + """Make sure version document exits in database.""" + + project_name = self._item.dst_project_name + version = self._item.dst_version + src_version_doc = self.src_version_doc + subset_doc = self.subset_doc + subset_id = subset_doc["_id"] + src_data = src_version_doc["data"] + families = subset_doc["data"].get("families") + if not families: + families = [subset_doc["data"]["family"]] + + version_data = { + "families": list(families), + "fps": src_data.get("fps"), + "source": src_data.get("source"), + "machine": socket.gethostname(), + "comment": self._item.comment or "", + "author": get_openpype_username(), + "time": get_formatted_current_time(), + } + if version is None: + last_version_doc = get_last_version_by_subset_id( + project_name, subset_id + ) + if last_version_doc: + version = int(last_version_doc["name"]) + 1 + else: + version = get_versioning_start( + project_name, + self.host_name, + task_name=self.task_info["name"], + task_type=self.task_info["type"], + family=families[0], + subset=subset_doc["name"] + ) + + existing_version_doc = get_version_by_name( + project_name, version, subset_id + ) + # Update existing version + if existing_version_doc: + version_doc = new_version_doc( + version, subset_id, version_data, existing_version_doc["_id"] + ) + update_data = prepare_version_update_data( + existing_version_doc, version_doc + ) + if update_data: + self._operations.update_entity( + project_name, + "version", + existing_version_doc["_id"], + update_data + ) + self._version_doc = version_doc + + return + + version_doc = new_version_doc( + version, subset_id, version_data + ) + self._operations.create_entity(project_name, "version", version_doc) + + self._version_doc = version_doc + + def integrate_representations(self): + try: + self._integrate_representations() + except Exception: + self._operations.clear() + self._file_transaction.rollback() + raise + + def _integrate_representations(self): + version_doc = self.version_doc + version_id = version_doc["_id"] + existing_repres = get_representations( + self._item.dst_project_name, + version_ids=[version_id] + ) + existing_repres_by_low_name = { + repre_doc["name"].lower(): repre_doc + for repre_doc in existing_repres + } + template_name = self.template_name + anatomy = self.anatomy + formatting_data = get_template_data( + self.project_doc, + self.asset_doc, + self.task_info.get("name"), + self.host_name + ) + formatting_data.update({ + "subset": self.subset_name, + "family": self.family, + "version": version_doc["name"] + }) + + path_template = anatomy.templates[template_name]["path"].replace( + "\\", "/" + ) + file_template = StringTemplate( + anatomy.templates[template_name]["file"] + ) + self._status.info("Preparing files to transfer") + processed_repre_items = self._prepare_file_transactions( + anatomy, template_name, formatting_data, file_template + ) + self._file_transaction.process() + self._status.info("Preparing database changes") + self._prepare_database_operations( + version_id, + processed_repre_items, + path_template, + existing_repres_by_low_name + ) + self._status.info("Finalization") + self._operations.commit() + self._file_transaction.finalize() + + def _prepare_file_transactions( + self, anatomy, template_name, formatting_data, file_template + ): + processed_repre_items = [] + for repre_item in self.src_repre_items: + repre_doc = repre_item.repre_doc + repre_name = repre_doc["name"] + repre_format_data = copy.deepcopy(formatting_data) + repre_format_data["representation"] = repre_name + for src_file in repre_item.src_files: + ext = os.path.splitext(src_file.path)[-1] + repre_format_data["ext"] = ext[1:] + break + + template_obj = anatomy.templates_obj[template_name]["folder"] + folder_path = template_obj.format_strict(formatting_data) + repre_context = folder_path.used_values + folder_path_rootless = folder_path.rootless + repre_filepaths = [] + published_path = None + for src_file in repre_item.src_files: + file_data = copy.deepcopy(repre_format_data) + frame = src_file.frame + if frame is not None: + file_data["frame"] = frame + + udim = src_file.udim + if udim is not None: + file_data["udim"] = udim + + filename = file_template.format_strict(file_data) + dst_filepath = os.path.normpath( + os.path.join(folder_path, filename) + ) + dst_rootless_path = os.path.normpath( + os.path.join(folder_path_rootless, filename) + ) + if published_path is None or frame == repre_item.frame: + published_path = dst_filepath + repre_context.update(filename.used_values) + + repre_filepaths.append((dst_filepath, dst_rootless_path)) + self._file_transaction.add(src_file.path, dst_filepath) + + for resource_file in repre_item.resource_files: + dst_filepath = os.path.normpath( + os.path.join(folder_path, resource_file.relative_path) + ) + dst_rootless_path = os.path.normpath( + os.path.join( + folder_path_rootless, resource_file.relative_path + ) + ) + repre_filepaths.append((dst_filepath, dst_rootless_path)) + self._file_transaction.add(resource_file.path, dst_filepath) + processed_repre_items.append( + (repre_item, repre_filepaths, repre_context, published_path) + ) + return processed_repre_items + + def _prepare_database_operations( + self, + version_id, + processed_repre_items, + path_template, + existing_repres_by_low_name + ): + modules_manager = ModulesManager() + sync_server_module = modules_manager.get("sync_server") + if sync_server_module is None or not sync_server_module.enabled: + sites = [{ + "name": "studio", + "created_dt": datetime.datetime.now() + }] + else: + sites = sync_server_module.compute_resource_sync_sites( + project_name=self._item.dst_project_name + ) + + added_repre_names = set() + for item in processed_repre_items: + (repre_item, repre_filepaths, repre_context, published_path) = item + repre_name = repre_item.repre_doc["name"] + added_repre_names.add(repre_name.lower()) + new_repre_data = { + "path": published_path, + "template": path_template + } + new_repre_files = [] + for (path, rootless_path) in repre_filepaths: + new_repre_files.append({ + "_id": ObjectId(), + "path": rootless_path, + "size": os.path.getsize(path), + "hash": source_hash(path), + "sites": sites + }) + + existing_repre = existing_repres_by_low_name.get( + repre_name.lower() + ) + entity_id = None + if existing_repre: + entity_id = existing_repre["_id"] + new_repre_doc = new_representation_doc( + repre_name, + version_id, + repre_context, + data=new_repre_data, + entity_id=entity_id + ) + new_repre_doc["files"] = new_repre_files + if not existing_repre: + self._operations.create_entity( + self._item.dst_project_name, + new_repre_doc["type"], + new_repre_doc + ) + else: + update_data = prepare_representation_update_data( + existing_repre, new_repre_doc + ) + if update_data: + self._operations.update_entity( + self._item.dst_project_name, + new_repre_doc["type"], + new_repre_doc["_id"], + update_data + ) + + existing_repre_names = set(existing_repres_by_low_name.keys()) + for repre_name in (existing_repre_names - added_repre_names): + repre_doc = existing_repres_by_low_name[repre_name] + self._operations.update_entity( + self._item.dst_project_name, + repre_doc["type"], + repre_doc["_id"], + {"type": "archived_representation"} + ) + + def process(self): + try: + self._status.info("Process started") + self.fill_source_variables() + self._status.info("Source entities were found") + self.fill_destination_project() + self._status.info("Destination project was found") + self.fill_or_create_destination_asset() + self._status.info("Destination asset was determined") + self.determine_family() + self.determine_publish_template_name() + self.determine_subset_name() + self.make_sure_subset_exists() + self.make_sure_version_exists() + self._status.info("Prerequirements were prepared") + self.integrate_representations() + self._status.info("Integration finished") + + except PushToProjectError as exc: + if not self._status.failed: + self._status.set_failed(str(exc)) + + except Exception as exc: + _exc, _value, _tb = sys.exc_info() + self._status.set_failed( + "Unhandled error happened: {}".format(str(exc)), + (_exc, _value, _tb) + ) + + finally: + self._status.set_finished() diff --git a/openpype/tools/ayon_push_to_project/window.py b/openpype/tools/ayon_push_to_project/window.py new file mode 100644 index 0000000000..dc5eab5787 --- /dev/null +++ b/openpype/tools/ayon_push_to_project/window.py @@ -0,0 +1,829 @@ +import collections + +from qtpy import QtWidgets, QtGui, QtCore + +from openpype.style import load_stylesheet, get_app_icon_path +from openpype.tools.utils import ( + PlaceholderLineEdit, + SeparatorWidget, + get_asset_icon_by_name, + set_style_property, +) +from openpype.tools.utils.views import DeselectableTreeView + +from .control_context import PushToContextController + +PROJECT_NAME_ROLE = QtCore.Qt.UserRole + 1 +ASSET_NAME_ROLE = QtCore.Qt.UserRole + 2 +ASSET_ID_ROLE = QtCore.Qt.UserRole + 3 +TASK_NAME_ROLE = QtCore.Qt.UserRole + 4 +TASK_TYPE_ROLE = QtCore.Qt.UserRole + 5 + + +class ProjectsModel(QtGui.QStandardItemModel): + empty_text = "< Empty >" + select_project_text = "< Select Project >" + + refreshed = QtCore.Signal() + + def __init__(self, controller): + super(ProjectsModel, self).__init__() + self._controller = controller + + self.event_system.add_callback( + "projects.refresh.finished", self._on_refresh_finish + ) + + placeholder_item = QtGui.QStandardItem(self.empty_text) + + root_item = self.invisibleRootItem() + root_item.appendRows([placeholder_item]) + items = {None: placeholder_item} + + self._placeholder_item = placeholder_item + self._items = items + + @property + def event_system(self): + return self._controller.event_system + + def _on_refresh_finish(self): + root_item = self.invisibleRootItem() + project_names = self._controller.model.get_projects() + + if not project_names: + placeholder_text = self.empty_text + else: + placeholder_text = self.select_project_text + self._placeholder_item.setData(placeholder_text, QtCore.Qt.DisplayRole) + + new_items = [] + if None not in self._items: + new_items.append(self._placeholder_item) + + current_project_names = set(self._items.keys()) + for project_name in current_project_names - set(project_names): + if project_name is None: + continue + item = self._items.pop(project_name) + root_item.takeRow(item.row()) + + for project_name in project_names: + if project_name in self._items: + continue + item = QtGui.QStandardItem(project_name) + item.setData(project_name, PROJECT_NAME_ROLE) + new_items.append(item) + + if new_items: + root_item.appendRows(new_items) + self.refreshed.emit() + + +class ProjectProxyModel(QtCore.QSortFilterProxyModel): + def __init__(self): + super(ProjectProxyModel, self).__init__() + self._filter_empty_projects = False + + def set_filter_empty_project(self, filter_empty_projects): + if filter_empty_projects == self._filter_empty_projects: + return + self._filter_empty_projects = filter_empty_projects + self.invalidate() + + def filterAcceptsRow(self, row, parent): + if not self._filter_empty_projects: + return True + model = self.sourceModel() + source_index = model.index(row, self.filterKeyColumn(), parent) + if model.data(source_index, PROJECT_NAME_ROLE) is None: + return False + return True + + +class AssetsModel(QtGui.QStandardItemModel): + items_changed = QtCore.Signal() + empty_text = "< Empty >" + + def __init__(self, controller): + super(AssetsModel, self).__init__() + self._controller = controller + + placeholder_item = QtGui.QStandardItem(self.empty_text) + placeholder_item.setFlags(QtCore.Qt.ItemIsEnabled) + + root_item = self.invisibleRootItem() + root_item.appendRows([placeholder_item]) + + self.event_system.add_callback( + "project.changed", self._on_project_change + ) + self.event_system.add_callback( + "assets.refresh.started", self._on_refresh_start + ) + self.event_system.add_callback( + "assets.refresh.finished", self._on_refresh_finish + ) + + self._items = {None: placeholder_item} + + self._placeholder_item = placeholder_item + self._last_project = None + + @property + def event_system(self): + return self._controller.event_system + + def _clear(self): + placeholder_in = False + root_item = self.invisibleRootItem() + for row in reversed(range(root_item.rowCount())): + item = root_item.child(row) + asset_id = item.data(ASSET_ID_ROLE) + if asset_id is None: + placeholder_in = True + continue + root_item.removeRow(item.row()) + + for key in tuple(self._items.keys()): + if key is not None: + self._items.pop(key) + + if not placeholder_in: + root_item.appendRows([self._placeholder_item]) + self._items[None] = self._placeholder_item + + def _on_project_change(self, event): + project_name = event["project_name"] + if project_name == self._last_project: + return + + self._last_project = project_name + self._clear() + self.items_changed.emit() + + def _on_refresh_start(self, event): + pass + + def _on_refresh_finish(self, event): + event_project_name = event["project_name"] + project_name = self._controller.selection_model.project_name + if event_project_name != project_name: + return + + self._last_project = event["project_name"] + if project_name is None: + if None not in self._items: + self._clear() + self.items_changed.emit() + return + + asset_items_by_id = self._controller.model.get_assets(project_name) + if not asset_items_by_id: + self._clear() + self.items_changed.emit() + return + + assets_by_parent_id = collections.defaultdict(list) + for asset_item in asset_items_by_id.values(): + assets_by_parent_id[asset_item.parent_id].append(asset_item) + + root_item = self.invisibleRootItem() + if None in self._items: + self._items.pop(None) + root_item.takeRow(self._placeholder_item.row()) + + items_to_remove = set(self._items) - set(asset_items_by_id.keys()) + hierarchy_queue = collections.deque() + hierarchy_queue.append((None, root_item)) + while hierarchy_queue: + parent_id, parent_item = hierarchy_queue.popleft() + new_items = [] + for asset_item in assets_by_parent_id[parent_id]: + item = self._items.get(asset_item.id) + if item is None: + item = QtGui.QStandardItem() + item.setFlags( + QtCore.Qt.ItemIsSelectable + | QtCore.Qt.ItemIsEnabled + ) + new_items.append(item) + self._items[asset_item.id] = item + + elif item.parent() is not parent_item: + new_items.append(item) + + icon = get_asset_icon_by_name( + asset_item.icon_name, asset_item.icon_color + ) + item.setData(asset_item.name, QtCore.Qt.DisplayRole) + item.setData(icon, QtCore.Qt.DecorationRole) + item.setData(asset_item.id, ASSET_ID_ROLE) + + hierarchy_queue.append((asset_item.id, item)) + + if new_items: + parent_item.appendRows(new_items) + + for item_id in items_to_remove: + item = self._items.pop(item_id, None) + if item is None: + continue + row = item.row() + if row < 0: + continue + parent = item.parent() + if parent is None: + parent = root_item + parent.takeRow(row) + + self.items_changed.emit() + + +class TasksModel(QtGui.QStandardItemModel): + items_changed = QtCore.Signal() + empty_text = "< Empty >" + + def __init__(self, controller): + super(TasksModel, self).__init__() + self._controller = controller + + placeholder_item = QtGui.QStandardItem(self.empty_text) + placeholder_item.setFlags(QtCore.Qt.ItemIsEnabled) + + root_item = self.invisibleRootItem() + root_item.appendRows([placeholder_item]) + + self.event_system.add_callback( + "project.changed", self._on_project_change + ) + self.event_system.add_callback( + "assets.refresh.finished", self._on_asset_refresh_finish + ) + self.event_system.add_callback( + "asset.changed", self._on_asset_change + ) + + self._items = {None: placeholder_item} + + self._placeholder_item = placeholder_item + self._last_project = None + + @property + def event_system(self): + return self._controller.event_system + + def _clear(self): + placeholder_in = False + root_item = self.invisibleRootItem() + for row in reversed(range(root_item.rowCount())): + item = root_item.child(row) + task_name = item.data(TASK_NAME_ROLE) + if task_name is None: + placeholder_in = True + continue + root_item.removeRow(item.row()) + + for key in tuple(self._items.keys()): + if key is not None: + self._items.pop(key) + + if not placeholder_in: + root_item.appendRows([self._placeholder_item]) + self._items[None] = self._placeholder_item + + def _on_project_change(self, event): + project_name = event["project_name"] + if project_name == self._last_project: + return + + self._last_project = project_name + self._clear() + self.items_changed.emit() + + def _on_asset_refresh_finish(self, event): + self._refresh(event["project_name"]) + + def _on_asset_change(self, event): + self._refresh(event["project_name"]) + + def _refresh(self, new_project_name): + project_name = self._controller.selection_model.project_name + if new_project_name != project_name: + return + + self._last_project = project_name + if project_name is None: + if None not in self._items: + self._clear() + self.items_changed.emit() + return + + asset_id = self._controller.selection_model.asset_id + task_items = self._controller.model.get_tasks( + project_name, asset_id + ) + if not task_items: + self._clear() + self.items_changed.emit() + return + + root_item = self.invisibleRootItem() + if None in self._items: + self._items.pop(None) + root_item.takeRow(self._placeholder_item.row()) + + new_items = [] + task_names = set() + for task_item in task_items: + task_name = task_item.name + item = self._items.get(task_name) + if item is None: + item = QtGui.QStandardItem() + item.setFlags( + QtCore.Qt.ItemIsSelectable + | QtCore.Qt.ItemIsEnabled + ) + new_items.append(item) + self._items[task_name] = item + + item.setData(task_name, QtCore.Qt.DisplayRole) + item.setData(task_name, TASK_NAME_ROLE) + item.setData(task_item.task_type, TASK_TYPE_ROLE) + + if new_items: + root_item.appendRows(new_items) + + items_to_remove = set(self._items) - task_names + for item_id in items_to_remove: + item = self._items.pop(item_id, None) + if item is None: + continue + parent = item.parent() + if parent is not None: + parent.removeRow(item.row()) + + self.items_changed.emit() + + +class PushToContextSelectWindow(QtWidgets.QWidget): + def __init__(self, controller=None): + super(PushToContextSelectWindow, self).__init__() + if controller is None: + controller = PushToContextController() + self._controller = controller + + self.setWindowTitle("Push to project (select context)") + self.setWindowIcon(QtGui.QIcon(get_app_icon_path())) + + main_context_widget = QtWidgets.QWidget(self) + + header_widget = QtWidgets.QWidget(main_context_widget) + + header_label = QtWidgets.QLabel(controller.src_label, header_widget) + + header_layout = QtWidgets.QHBoxLayout(header_widget) + header_layout.setContentsMargins(0, 0, 0, 0) + header_layout.addWidget(header_label) + + main_splitter = QtWidgets.QSplitter( + QtCore.Qt.Horizontal, main_context_widget + ) + + context_widget = QtWidgets.QWidget(main_splitter) + + project_combobox = QtWidgets.QComboBox(context_widget) + project_model = ProjectsModel(controller) + project_proxy = ProjectProxyModel() + project_proxy.setSourceModel(project_model) + project_proxy.setDynamicSortFilter(True) + project_delegate = QtWidgets.QStyledItemDelegate() + project_combobox.setItemDelegate(project_delegate) + project_combobox.setModel(project_proxy) + + asset_task_splitter = QtWidgets.QSplitter( + QtCore.Qt.Vertical, context_widget + ) + + asset_view = DeselectableTreeView(asset_task_splitter) + asset_view.setHeaderHidden(True) + asset_model = AssetsModel(controller) + asset_proxy = QtCore.QSortFilterProxyModel() + asset_proxy.setSourceModel(asset_model) + asset_proxy.setDynamicSortFilter(True) + asset_view.setModel(asset_proxy) + + task_view = QtWidgets.QListView(asset_task_splitter) + task_proxy = QtCore.QSortFilterProxyModel() + task_model = TasksModel(controller) + task_proxy.setSourceModel(task_model) + task_proxy.setDynamicSortFilter(True) + task_view.setModel(task_proxy) + + asset_task_splitter.addWidget(asset_view) + asset_task_splitter.addWidget(task_view) + + context_layout = QtWidgets.QVBoxLayout(context_widget) + context_layout.setContentsMargins(0, 0, 0, 0) + context_layout.addWidget(project_combobox, 0) + context_layout.addWidget(asset_task_splitter, 1) + + # --- Inputs widget --- + inputs_widget = QtWidgets.QWidget(main_splitter) + + asset_name_input = PlaceholderLineEdit(inputs_widget) + asset_name_input.setPlaceholderText("< Name of new asset >") + asset_name_input.setObjectName("ValidatedLineEdit") + + variant_input = PlaceholderLineEdit(inputs_widget) + variant_input.setPlaceholderText("< Variant >") + variant_input.setObjectName("ValidatedLineEdit") + + comment_input = PlaceholderLineEdit(inputs_widget) + comment_input.setPlaceholderText("< Publish comment >") + + inputs_layout = QtWidgets.QFormLayout(inputs_widget) + inputs_layout.setContentsMargins(0, 0, 0, 0) + inputs_layout.addRow("New asset name", asset_name_input) + inputs_layout.addRow("Variant", variant_input) + inputs_layout.addRow("Comment", comment_input) + + main_splitter.addWidget(context_widget) + main_splitter.addWidget(inputs_widget) + + # --- Buttons widget --- + btns_widget = QtWidgets.QWidget(self) + cancel_btn = QtWidgets.QPushButton("Cancel", btns_widget) + publish_btn = QtWidgets.QPushButton("Publish", btns_widget) + + btns_layout = QtWidgets.QHBoxLayout(btns_widget) + btns_layout.setContentsMargins(0, 0, 0, 0) + btns_layout.addStretch(1) + btns_layout.addWidget(cancel_btn, 0) + btns_layout.addWidget(publish_btn, 0) + + sep_1 = SeparatorWidget(parent=main_context_widget) + sep_2 = SeparatorWidget(parent=main_context_widget) + main_context_layout = QtWidgets.QVBoxLayout(main_context_widget) + main_context_layout.addWidget(header_widget, 0) + main_context_layout.addWidget(sep_1, 0) + main_context_layout.addWidget(main_splitter, 1) + main_context_layout.addWidget(sep_2, 0) + main_context_layout.addWidget(btns_widget, 0) + + # NOTE This was added in hurry + # - should be reorganized and changed styles + overlay_widget = QtWidgets.QFrame(self) + overlay_widget.setObjectName("OverlayFrame") + + overlay_label = QtWidgets.QLabel(overlay_widget) + overlay_label.setAlignment(QtCore.Qt.AlignCenter) + + overlay_btns_widget = QtWidgets.QWidget(overlay_widget) + overlay_btns_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + # Add try again button (requires changes in controller) + overlay_try_btn = QtWidgets.QPushButton( + "Try again", overlay_btns_widget + ) + overlay_close_btn = QtWidgets.QPushButton( + "Close", overlay_btns_widget + ) + + overlay_btns_layout = QtWidgets.QHBoxLayout(overlay_btns_widget) + overlay_btns_layout.addStretch(1) + overlay_btns_layout.addWidget(overlay_try_btn, 0) + overlay_btns_layout.addWidget(overlay_close_btn, 0) + overlay_btns_layout.addStretch(1) + + overlay_layout = QtWidgets.QVBoxLayout(overlay_widget) + overlay_layout.addWidget(overlay_label, 0) + overlay_layout.addWidget(overlay_btns_widget, 0) + overlay_layout.setAlignment(QtCore.Qt.AlignCenter) + + main_layout = QtWidgets.QStackedLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(main_context_widget) + main_layout.addWidget(overlay_widget) + main_layout.setStackingMode(QtWidgets.QStackedLayout.StackAll) + main_layout.setCurrentWidget(main_context_widget) + + show_timer = QtCore.QTimer() + show_timer.setInterval(1) + + main_thread_timer = QtCore.QTimer() + main_thread_timer.setInterval(10) + + user_input_changed_timer = QtCore.QTimer() + user_input_changed_timer.setInterval(200) + user_input_changed_timer.setSingleShot(True) + + main_thread_timer.timeout.connect(self._on_main_thread_timer) + show_timer.timeout.connect(self._on_show_timer) + user_input_changed_timer.timeout.connect(self._on_user_input_timer) + asset_name_input.textChanged.connect(self._on_new_asset_change) + variant_input.textChanged.connect(self._on_variant_change) + comment_input.textChanged.connect(self._on_comment_change) + project_model.refreshed.connect(self._on_projects_refresh) + project_combobox.currentIndexChanged.connect(self._on_project_change) + asset_view.selectionModel().selectionChanged.connect( + self._on_asset_change + ) + asset_model.items_changed.connect(self._on_asset_model_change) + task_view.selectionModel().selectionChanged.connect( + self._on_task_change + ) + task_model.items_changed.connect(self._on_task_model_change) + publish_btn.clicked.connect(self._on_select_click) + cancel_btn.clicked.connect(self._on_close_click) + overlay_close_btn.clicked.connect(self._on_close_click) + overlay_try_btn.clicked.connect(self._on_try_again_click) + + controller.event_system.add_callback( + "new_asset_name.changed", self._on_controller_new_asset_change + ) + controller.event_system.add_callback( + "variant.changed", self._on_controller_variant_change + ) + controller.event_system.add_callback( + "comment.changed", self._on_controller_comment_change + ) + controller.event_system.add_callback( + "submission.enabled.changed", self._on_submission_change + ) + controller.event_system.add_callback( + "source.changed", self._on_controller_source_change + ) + controller.event_system.add_callback( + "submit.started", self._on_controller_submit_start + ) + controller.event_system.add_callback( + "submit.finished", self._on_controller_submit_end + ) + controller.event_system.add_callback( + "push.message.added", self._on_push_message + ) + + self._main_layout = main_layout + + self._main_context_widget = main_context_widget + + self._header_label = header_label + self._main_splitter = main_splitter + + self._project_combobox = project_combobox + self._project_model = project_model + self._project_proxy = project_proxy + self._project_delegate = project_delegate + + self._asset_view = asset_view + self._asset_model = asset_model + self._asset_proxy_model = asset_proxy + + self._task_view = task_view + self._task_proxy_model = task_proxy + + self._variant_input = variant_input + self._asset_name_input = asset_name_input + self._comment_input = comment_input + + self._publish_btn = publish_btn + + self._overlay_widget = overlay_widget + self._overlay_close_btn = overlay_close_btn + self._overlay_try_btn = overlay_try_btn + self._overlay_label = overlay_label + + self._user_input_changed_timer = user_input_changed_timer + # Store current value on input text change + # The value is unset when is passed to controller + # The goal is to have controll over changes happened during user change + # in UI and controller auto-changes + self._variant_input_text = None + self._new_asset_name_input_text = None + self._comment_input_text = None + self._show_timer = show_timer + self._show_counter = 2 + self._first_show = True + + self._main_thread_timer = main_thread_timer + self._main_thread_timer_can_stop = True + self._last_submit_message = None + self._process_item = None + + publish_btn.setEnabled(False) + overlay_close_btn.setVisible(False) + overlay_try_btn.setVisible(False) + + if controller.user_values.new_asset_name: + asset_name_input.setText(controller.user_values.new_asset_name) + if controller.user_values.variant: + variant_input.setText(controller.user_values.variant) + self._invalidate_variant() + self._invalidate_new_asset_name() + + @property + def controller(self): + return self._controller + + def showEvent(self, event): + super(PushToContextSelectWindow, self).showEvent(event) + if self._first_show: + self._first_show = False + self.setStyleSheet(load_stylesheet()) + self._invalidate_variant() + self._show_timer.start() + + def _on_show_timer(self): + if self._show_counter == 0: + self._show_timer.stop() + return + + self._show_counter -= 1 + if self._show_counter == 1: + width = 740 + height = 640 + inputs_width = 360 + self.resize(width, height) + self._main_splitter.setSizes([width - inputs_width, inputs_width]) + + if self._show_counter > 0: + return + + self._controller.model.refresh_projects() + + def _on_new_asset_change(self, text): + self._new_asset_name_input_text = text + self._user_input_changed_timer.start() + + def _on_variant_change(self, text): + self._variant_input_text = text + self._user_input_changed_timer.start() + + def _on_comment_change(self, text): + self._comment_input_text = text + self._user_input_changed_timer.start() + + def _on_user_input_timer(self): + asset_name = self._new_asset_name_input_text + if asset_name is not None: + self._new_asset_name_input_text = None + self._controller.user_values.set_new_asset(asset_name) + + variant = self._variant_input_text + if variant is not None: + self._variant_input_text = None + self._controller.user_values.set_variant(variant) + + comment = self._comment_input_text + if comment is not None: + self._comment_input_text = None + self._controller.user_values.set_comment(comment) + + def _on_controller_new_asset_change(self, event): + asset_name = event["changes"]["new_asset_name"]["new"] + if ( + self._new_asset_name_input_text is None + and asset_name != self._asset_name_input.text() + ): + self._asset_name_input.setText(asset_name) + + self._invalidate_new_asset_name() + + def _on_controller_variant_change(self, event): + is_valid_changes = event["changes"]["is_valid"] + variant = event["changes"]["variant"]["new"] + if ( + self._variant_input_text is None + and variant != self._variant_input.text() + ): + self._variant_input.setText(variant) + + if is_valid_changes["old"] != is_valid_changes["new"]: + self._invalidate_variant() + + def _on_controller_comment_change(self, event): + comment = event["comment"] + if ( + self._comment_input_text is None + and comment != self._comment_input.text() + ): + self._comment_input.setText(comment) + + def _on_controller_source_change(self): + self._header_label.setText(self._controller.src_label) + + def _invalidate_new_asset_name(self): + asset_name = self._controller.user_values.new_asset_name + self._task_view.setVisible(not asset_name) + + valid = None + if asset_name: + valid = self._controller.user_values.is_new_asset_name_valid + + state = "" + if valid is True: + state = "valid" + elif valid is False: + state = "invalid" + set_style_property(self._asset_name_input, "state", state) + + def _invalidate_variant(self): + valid = self._controller.user_values.is_variant_valid + state = "invalid" + if valid is True: + state = "valid" + set_style_property(self._variant_input, "state", state) + + def _on_projects_refresh(self): + self._project_proxy.sort(0, QtCore.Qt.AscendingOrder) + + def _on_project_change(self): + idx = self._project_combobox.currentIndex() + if idx < 0: + self._project_proxy.set_filter_empty_project(False) + return + + project_name = self._project_combobox.itemData(idx, PROJECT_NAME_ROLE) + self._project_proxy.set_filter_empty_project(project_name is not None) + self._controller.selection_model.select_project(project_name) + + def _on_asset_change(self): + indexes = self._asset_view.selectedIndexes() + index = next(iter(indexes), None) + asset_id = None + if index is not None: + model = self._asset_view.model() + asset_id = model.data(index, ASSET_ID_ROLE) + self._controller.selection_model.select_asset(asset_id) + + def _on_asset_model_change(self): + self._asset_proxy_model.sort(0, QtCore.Qt.AscendingOrder) + + def _on_task_model_change(self): + self._task_proxy_model.sort(0, QtCore.Qt.AscendingOrder) + + def _on_task_change(self): + indexes = self._task_view.selectedIndexes() + index = next(iter(indexes), None) + task_name = None + if index is not None: + model = self._task_view.model() + task_name = model.data(index, TASK_NAME_ROLE) + self._controller.selection_model.select_task(task_name) + + def _on_submission_change(self, event): + self._publish_btn.setEnabled(event["enabled"]) + + def _on_close_click(self): + self.close() + + def _on_select_click(self): + self._process_item = self._controller.submit(wait=False) + + def _on_try_again_click(self): + self._process_item = None + self._last_submit_message = None + + self._overlay_close_btn.setVisible(False) + self._overlay_try_btn.setVisible(False) + self._main_layout.setCurrentWidget(self._main_context_widget) + + def _on_main_thread_timer(self): + if self._last_submit_message: + self._overlay_label.setText(self._last_submit_message) + self._last_submit_message = None + + process_status = self._process_item.status + push_failed = process_status.failed + fail_traceback = process_status.traceback + if self._main_thread_timer_can_stop: + self._main_thread_timer.stop() + self._overlay_close_btn.setVisible(True) + if push_failed and not fail_traceback: + self._overlay_try_btn.setVisible(True) + + if push_failed: + message = "Push Failed:\n{}".format(process_status.fail_reason) + if fail_traceback: + message += "\n{}".format(fail_traceback) + self._overlay_label.setText(message) + set_style_property(self._overlay_close_btn, "state", "error") + + if self._main_thread_timer_can_stop: + # Join thread in controller + self._controller.wait_for_process_thread() + # Reset process item to None + self._process_item = None + + def _on_controller_submit_start(self): + self._main_thread_timer_can_stop = False + self._main_thread_timer.start() + self._main_layout.setCurrentWidget(self._overlay_widget) + self._overlay_label.setText("Submittion started") + + def _on_controller_submit_end(self): + self._main_thread_timer_can_stop = True + + def _on_push_message(self, event): + self._last_submit_message = event["message"] From 065ebc389c3d8377903f814b0c76d5cc15b4429a Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 13 Oct 2023 15:35:56 +0200 Subject: [PATCH 22/46] renamed 'app.py' to 'main.py' and use it in loader action --- openpype/plugins/load/push_to_library.py | 24 +++++++++++++------ .../ayon_push_to_project/{app.py => main.py} | 0 2 files changed, 17 insertions(+), 7 deletions(-) rename openpype/tools/ayon_push_to_project/{app.py => main.py} (100%) diff --git a/openpype/plugins/load/push_to_library.py b/openpype/plugins/load/push_to_library.py index dd7291e686..5befc5eb9d 100644 --- a/openpype/plugins/load/push_to_library.py +++ b/openpype/plugins/load/push_to_library.py @@ -1,6 +1,6 @@ import os -from openpype import PACKAGE_DIR +from openpype import PACKAGE_DIR, AYON_SERVER_ENABLED from openpype.lib import get_openpype_execute_args, run_detached_process from openpype.pipeline import load from openpype.pipeline.load import LoadError @@ -32,12 +32,22 @@ class PushToLibraryProject(load.SubsetLoaderPlugin): raise LoadError("Please select only one item") context = tuple(filtered_contexts)[0] - push_tool_script_path = os.path.join( - PACKAGE_DIR, - "tools", - "push_to_project", - "app.py" - ) + + if AYON_SERVER_ENABLED: + push_tool_script_path = os.path.join( + PACKAGE_DIR, + "tools", + "ayon_push_to_project", + "main.py" + ) + else: + push_tool_script_path = os.path.join( + PACKAGE_DIR, + "tools", + "push_to_project", + "app.py" + ) + project_doc = context["project"] version_doc = context["version"] project_name = project_doc["name"] diff --git a/openpype/tools/ayon_push_to_project/app.py b/openpype/tools/ayon_push_to_project/main.py similarity index 100% rename from openpype/tools/ayon_push_to_project/app.py rename to openpype/tools/ayon_push_to_project/main.py From 178ab5d77a2e9f34ee5766e0d8a7d1bcd0cae8da Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 13 Oct 2023 18:08:16 +0200 Subject: [PATCH 23/46] moved 'window.py' to subfolder 'ui' --- openpype/tools/ayon_push_to_project/main.py | 20 +++++++++++-------- .../tools/ayon_push_to_project/ui/__init__.py | 6 ++++++ .../ayon_push_to_project/{ => ui}/window.py | 0 3 files changed, 18 insertions(+), 8 deletions(-) create mode 100644 openpype/tools/ayon_push_to_project/ui/__init__.py rename openpype/tools/ayon_push_to_project/{ => ui}/window.py (100%) diff --git a/openpype/tools/ayon_push_to_project/main.py b/openpype/tools/ayon_push_to_project/main.py index b3ec33f353..e36940e488 100644 --- a/openpype/tools/ayon_push_to_project/main.py +++ b/openpype/tools/ayon_push_to_project/main.py @@ -1,7 +1,17 @@ import click from openpype.tools.utils import get_openpype_qt_app -from openpype.tools.push_to_project.window import PushToContextSelectWindow +from openpype.tools.ayon_push_to_project.ui import PushToContextSelectWindow + + +def main_show(project_name, version_id): + app = get_openpype_qt_app() + + window = PushToContextSelectWindow() + window.show() + window.set_source(project_name, version_id) + + app.exec_() @click.command() @@ -15,13 +25,7 @@ def main(project, version): version (str): Version id. """ - app = get_openpype_qt_app() - - window = PushToContextSelectWindow() - window.show() - window.controller.set_source(project, version) - - app.exec_() + main_show(project, version) if __name__ == "__main__": diff --git a/openpype/tools/ayon_push_to_project/ui/__init__.py b/openpype/tools/ayon_push_to_project/ui/__init__.py new file mode 100644 index 0000000000..1e86475530 --- /dev/null +++ b/openpype/tools/ayon_push_to_project/ui/__init__.py @@ -0,0 +1,6 @@ +from .window import PushToContextSelectWindow + + +__all__ = ( + "PushToContextSelectWindow", +) diff --git a/openpype/tools/ayon_push_to_project/window.py b/openpype/tools/ayon_push_to_project/ui/window.py similarity index 100% rename from openpype/tools/ayon_push_to_project/window.py rename to openpype/tools/ayon_push_to_project/ui/window.py From 4481c7590b1c66fa9f36c08311adfc246f4bed6e Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 13 Oct 2023 18:11:06 +0200 Subject: [PATCH 24/46] renamed 'control_context.py' to 'control.py' --- openpype/tools/ayon_push_to_project/__init__.py | 6 ++++++ .../ayon_push_to_project/{control_context.py => control.py} | 0 openpype/tools/ayon_push_to_project/ui/window.py | 2 +- 3 files changed, 7 insertions(+), 1 deletion(-) rename openpype/tools/ayon_push_to_project/{control_context.py => control.py} (100%) diff --git a/openpype/tools/ayon_push_to_project/__init__.py b/openpype/tools/ayon_push_to_project/__init__.py index e69de29bb2..83df110c96 100644 --- a/openpype/tools/ayon_push_to_project/__init__.py +++ b/openpype/tools/ayon_push_to_project/__init__.py @@ -0,0 +1,6 @@ +from .control import PushToContextController + + +__all__ = ( + "PushToContextController", +) diff --git a/openpype/tools/ayon_push_to_project/control_context.py b/openpype/tools/ayon_push_to_project/control.py similarity index 100% rename from openpype/tools/ayon_push_to_project/control_context.py rename to openpype/tools/ayon_push_to_project/control.py diff --git a/openpype/tools/ayon_push_to_project/ui/window.py b/openpype/tools/ayon_push_to_project/ui/window.py index dc5eab5787..a1fff2d27d 100644 --- a/openpype/tools/ayon_push_to_project/ui/window.py +++ b/openpype/tools/ayon_push_to_project/ui/window.py @@ -11,7 +11,7 @@ from openpype.tools.utils import ( ) from openpype.tools.utils.views import DeselectableTreeView -from .control_context import PushToContextController +from openpype.tools.ayon_push_to_project import PushToContextController PROJECT_NAME_ROLE = QtCore.Qt.UserRole + 1 ASSET_NAME_ROLE = QtCore.Qt.UserRole + 2 From edf5c525415961fa079e3e7d2772cd0fb06a87f7 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 13 Oct 2023 18:12:00 +0200 Subject: [PATCH 25/46] initial modification of controller --- .../tools/ayon_push_to_project/control.py | 662 ++++++------------ .../ayon_push_to_project/models/__init__.py | 6 + .../ayon_push_to_project/models/selection.py | 72 ++ 3 files changed, 288 insertions(+), 452 deletions(-) create mode 100644 openpype/tools/ayon_push_to_project/models/__init__.py create mode 100644 openpype/tools/ayon_push_to_project/models/selection.py diff --git a/openpype/tools/ayon_push_to_project/control.py b/openpype/tools/ayon_push_to_project/control.py index e4058893d5..4aef09156f 100644 --- a/openpype/tools/ayon_push_to_project/control.py +++ b/openpype/tools/ayon_push_to_project/control.py @@ -1,10 +1,7 @@ import re -import collections import threading from openpype.client import ( - get_projects, - get_assets, get_asset_by_id, get_subset_by_id, get_version_by_id, @@ -12,260 +9,47 @@ from openpype.client import ( ) from openpype.settings import get_project_settings from openpype.lib import prepare_template_data -from openpype.lib.events import EventSystem +from openpype.lib.events import QueuedEventSystem from openpype.pipeline.create import ( SUBSET_NAME_ALLOWED_SYMBOLS, get_subset_name_template, ) +from openpype.tools.ayon_utils.models import ProjectsModel, HierarchyModel from .control_integrate import ( ProjectPushItem, ProjectPushItemProcess, ProjectPushItemStatus, ) - - -class AssetItem: - def __init__( - self, - entity_id, - name, - icon_name, - icon_color, - parent_id, - has_children - ): - self.id = entity_id - self.name = name - self.icon_name = icon_name - self.icon_color = icon_color - self.parent_id = parent_id - self.has_children = has_children - - @classmethod - def from_doc(cls, asset_doc, has_children=True): - parent_id = asset_doc["data"].get("visualParent") - if parent_id is not None: - parent_id = str(parent_id) - return cls( - str(asset_doc["_id"]), - asset_doc["name"], - asset_doc["data"].get("icon"), - asset_doc["data"].get("color"), - parent_id, - has_children - ) - - -class TaskItem: - def __init__(self, asset_id, name, task_type, short_name): - self.asset_id = asset_id - self.name = name - self.task_type = task_type - self.short_name = short_name - - @classmethod - def from_asset_doc(cls, asset_doc, project_doc): - asset_tasks = asset_doc["data"].get("tasks") or {} - project_task_types = project_doc["config"]["tasks"] - output = [] - for task_name, task_info in asset_tasks.items(): - task_type = task_info.get("type") - task_type_info = project_task_types.get(task_type) or {} - output.append(cls( - asset_doc["_id"], - task_name, - task_type, - task_type_info.get("short_name") - )) - return output - - -class EntitiesModel: - def __init__(self, event_system): - self._event_system = event_system - self._project_names = None - self._project_docs_by_name = {} - self._assets_by_project = {} - self._tasks_by_asset_id = collections.defaultdict(dict) - - def has_cached_projects(self): - return self._project_names is None - - def has_cached_assets(self, project_name): - if not project_name: - return True - return project_name in self._assets_by_project - - def has_cached_tasks(self, project_name): - return self.has_cached_assets(project_name) - - def get_projects(self): - if self._project_names is None: - self.refresh_projects() - return list(self._project_names) - - def get_assets(self, project_name): - if project_name not in self._assets_by_project: - self.refresh_assets(project_name) - return dict(self._assets_by_project[project_name]) - - def get_asset_by_id(self, project_name, asset_id): - return self._assets_by_project[project_name].get(asset_id) - - def get_tasks(self, project_name, asset_id): - if not project_name or not asset_id: - return [] - - if project_name not in self._tasks_by_asset_id: - self.refresh_assets(project_name) - - all_task_items = self._tasks_by_asset_id[project_name] - asset_task_items = all_task_items.get(asset_id) - if not asset_task_items: - return [] - return list(asset_task_items) - - def refresh_projects(self, force=False): - self._event_system.emit( - "projects.refresh.started", {}, "entities.model" - ) - if force or self._project_names is None: - project_names = [] - project_docs_by_name = {} - for project_doc in get_projects(): - library_project = project_doc["data"].get("library_project") - if not library_project: - continue - project_name = project_doc["name"] - project_names.append(project_name) - project_docs_by_name[project_name] = project_doc - self._project_names = project_names - self._project_docs_by_name = project_docs_by_name - self._event_system.emit( - "projects.refresh.finished", {}, "entities.model" - ) - - def _refresh_assets(self, project_name): - asset_items_by_id = {} - task_items_by_asset_id = {} - self._assets_by_project[project_name] = asset_items_by_id - self._tasks_by_asset_id[project_name] = task_items_by_asset_id - if not project_name: - return - - project_doc = self._project_docs_by_name[project_name] - asset_docs_by_parent_id = collections.defaultdict(list) - for asset_doc in get_assets(project_name): - parent_id = asset_doc["data"].get("visualParent") - asset_docs_by_parent_id[parent_id].append(asset_doc) - - hierarchy_queue = collections.deque() - for asset_doc in asset_docs_by_parent_id[None]: - hierarchy_queue.append(asset_doc) - - while hierarchy_queue: - asset_doc = hierarchy_queue.popleft() - children = asset_docs_by_parent_id[asset_doc["_id"]] - asset_item = AssetItem.from_doc(asset_doc, len(children) > 0) - asset_items_by_id[asset_item.id] = asset_item - task_items_by_asset_id[asset_item.id] = ( - TaskItem.from_asset_doc(asset_doc, project_doc) - ) - for child in children: - hierarchy_queue.append(child) - - def refresh_assets(self, project_name, force=False): - self._event_system.emit( - "assets.refresh.started", - {"project_name": project_name}, - "entities.model" - ) - - if force or project_name not in self._assets_by_project: - self._refresh_assets(project_name) - - self._event_system.emit( - "assets.refresh.finished", - {"project_name": project_name}, - "entities.model" - ) - - -class SelectionModel: - def __init__(self, event_system): - self._event_system = event_system - - self.project_name = None - self.asset_id = None - self.task_name = None - - def select_project(self, project_name): - if self.project_name == project_name: - return - - self.project_name = project_name - self._event_system.emit( - "project.changed", - {"project_name": project_name}, - "selection.model" - ) - - def select_asset(self, asset_id): - if self.asset_id == asset_id: - return - self.asset_id = asset_id - self._event_system.emit( - "asset.changed", - { - "project_name": self.project_name, - "asset_id": asset_id - }, - "selection.model" - ) - - def select_task(self, task_name): - if self.task_name == task_name: - return - self.task_name = task_name - self._event_system.emit( - "task.changed", - { - "project_name": self.project_name, - "asset_id": self.asset_id, - "task_name": task_name - }, - "selection.model" - ) +from .models import PushToProjectSelectionModel class UserPublishValues: """Helper object to validate values required for push to different project. Args: - event_system (EventSystem): Event system to catch and emit events. - new_asset_name (str): Name of new asset name. - variant (str): Variant for new subset name in new project. + controller (PushToContextController): Event system to catch + and emit events. """ - asset_name_regex = re.compile("^[a-zA-Z0-9_.]+$") + folder_name_regex = re.compile("^[a-zA-Z0-9_.]+$") variant_regex = re.compile("^[{}]+$".format(SUBSET_NAME_ALLOWED_SYMBOLS)) - def __init__(self, event_system): - self._event_system = event_system - self._new_asset_name = None + def __init__(self, controller): + self._controller = controller + self._new_folder_name = None self._variant = None self._comment = None self._is_variant_valid = False - self._is_new_asset_name_valid = False + self._is_new_folder_name_valid = False - self.set_new_asset("") + self.set_new_folder_name("") self.set_variant("") self.set_comment("") @property - def new_asset_name(self): - return self._new_asset_name + def new_folder_name(self): + return self._new_folder_name @property def variant(self): @@ -280,70 +64,58 @@ class UserPublishValues: return self._is_variant_valid @property - def is_new_asset_name_valid(self): - return self._is_new_asset_name_valid + def is_new_folder_name_valid(self): + return self._is_new_folder_name_valid @property def is_valid(self): - return self.is_variant_valid and self.is_new_asset_name_valid + return self.is_variant_valid and self.is_new_folder_name_valid + + def get_data(self): + return { + "new_folder_name": self._new_folder_name, + "variant": self._variant, + "comment": self._comment, + "is_variant_valid": self._is_variant_valid, + "is_new_folder_name_valid": self._is_new_folder_name_valid, + "is_valid": self.is_valid + } def set_variant(self, variant): if variant == self._variant: return - old_variant = self._variant - old_is_valid = self._is_variant_valid - self._variant = variant is_valid = False if variant: is_valid = self.variant_regex.match(variant) is not None self._is_variant_valid = is_valid - changes = { - key: {"new": new, "old": old} - for key, old, new in ( - ("variant", old_variant, variant), - ("is_valid", old_is_valid, is_valid) - ) - } - - self._event_system.emit( + self._controller.emit_event( "variant.changed", { "variant": variant, "is_valid": self._is_variant_valid, - "changes": changes }, "user_values" ) - def set_new_asset(self, asset_name): - if self._new_asset_name == asset_name: + def set_new_folder_name(self, folder_name): + if self._new_folder_name == folder_name: return - old_asset_name = self._new_asset_name - old_is_valid = self._is_new_asset_name_valid - self._new_asset_name = asset_name - is_valid = True - if asset_name: - is_valid = ( - self.asset_name_regex.match(asset_name) is not None - ) - self._is_new_asset_name_valid = is_valid - changes = { - key: {"new": new, "old": old} - for key, old, new in ( - ("new_asset_name", old_asset_name, asset_name), - ("is_valid", old_is_valid, is_valid) - ) - } - self._event_system.emit( - "new_asset_name.changed", + self._new_folder_name = folder_name + is_valid = True + if folder_name: + is_valid = ( + self.folder_name_regex.match(folder_name) is not None + ) + self._is_new_folder_name_valid = is_valid + self._controller.emit_event( + "new_folder_name.changed", { - "new_asset_name": self._new_asset_name, - "is_valid": self._is_new_asset_name_valid, - "changes": changes + "new_folder_name": self._new_folder_name, + "is_valid": self._is_new_folder_name_valid, }, "user_values" ) @@ -351,42 +123,30 @@ class UserPublishValues: def set_comment(self, comment): if comment == self._comment: return - old_comment = self._comment self._comment = comment - self._event_system.emit( + self._controller.emit_event( "comment.changed", - { - "comment": comment, - "changes": { - "comment": {"new": comment, "old": old_comment} - } - }, + {"comment": comment}, "user_values" ) class PushToContextController: def __init__(self, project_name=None, version_id=None): + self._event_system = self._create_event_system() + + self._projects_model = ProjectsModel(self) + self._hierarchy_model = HierarchyModel(self) + + self._selection_model = PushToProjectSelectionModel(self) + self._user_values = UserPublishValues(self) + self._src_project_name = None self._src_version_id = None self._src_asset_doc = None self._src_subset_doc = None self._src_version_doc = None - - event_system = EventSystem() - entities_model = EntitiesModel(event_system) - selection_model = SelectionModel(event_system) - user_values = UserPublishValues(event_system) - - self._event_system = event_system - self._entities_model = entities_model - self._selection_model = selection_model - self._user_values = user_values - - event_system.add_callback("project.changed", self._on_project_change) - event_system.add_callback("asset.changed", self._invalidate) - event_system.add_callback("variant.changed", self._invalidate) - event_system.add_callback("new_asset_name.changed", self._invalidate) + self._src_label = None self._submission_enabled = False self._process_thread = None @@ -394,6 +154,157 @@ class PushToContextController: self.set_source(project_name, version_id) + # Events system + def emit_event(self, topic, data=None, source=None): + """Use implemented event system to trigger event.""" + + if data is None: + data = {} + self._event_system.emit(topic, data, source) + + def register_event_callback(self, topic, callback): + self._event_system.add_callback(topic, callback) + + def set_source(self, project_name, version_id): + if ( + project_name == self._src_project_name + and version_id == self._src_version_id + ): + return + + self._src_project_name = project_name + self._src_version_id = version_id + self._src_label = None + asset_doc = None + subset_doc = None + version_doc = None + if project_name and version_id: + version_doc = get_version_by_id(project_name, version_id) + + if version_doc: + subset_doc = get_subset_by_id(project_name, version_doc["parent"]) + + if subset_doc: + asset_doc = get_asset_by_id(project_name, subset_doc["parent"]) + + self._src_asset_doc = asset_doc + self._src_subset_doc = subset_doc + self._src_version_doc = version_doc + if asset_doc: + self._user_values.set_new_folder_name(asset_doc["name"]) + variant = self._get_src_variant() + if variant: + self._user_values.set_variant(variant) + + comment = version_doc["data"].get("comment") + if comment: + self._user_values.set_comment(comment) + + self._event_system.emit( + "source.changed", { + "project_name": project_name, + "version_id": version_id + }, + "controller" + ) + + def get_source_label(self): + if self._src_label is None: + self._src_label = self._get_source_label() + return self._src_label + + def get_project_items(self, sender=None): + return self._projects_model.get_project_items(sender) + + def get_folder_items(self, project_name, sender=None): + return self._hierarchy_model.get_folder_items(project_name, sender) + + def get_task_items(self, project_name, folder_id, sender=None): + return self._hierarchy_model.get_task_items( + project_name, folder_id, sender + ) + + def get_user_values(self): + return self._user_values.get_data() + + def set_user_value_folder_name(self, folder_name): + self._user_values.set_new_folder_name(folder_name) + + def set_user_value_variant(self, variant): + self._user_values.set_variant(variant) + + def set_user_value_comment(self, comment): + self._user_values.set_comment(comment) + + def set_selected_project(self, project_name): + self._selection_model.set_selected_project(project_name) + + def set_selected_folder(self, folder_id): + self._selection_model.set_selected_folder(folder_id) + + def set_selected_task(self, task_id, task_name): + self._selection_model.set_selected_task(task_id, task_name) + + # Processing methods + def submit(self, wait=True): + if not self._submission_enabled: + return + + if self._process_thread is not None: + return + + item = ProjectPushItem( + self._src_project_name, + self._src_version_id, + self._selection_model.get_selected_project_name(), + self._selection_model.get_selected_folder_id(), + self._selection_model.get_selected_task_name(), + self._user_values.variant, + comment=self._user_values.comment, + new_folder_name=self._user_values.new_folder_name, + dst_version=1 + ) + + status_item = ProjectPushItemStatus(event_system=self._event_system) + process_item = ProjectPushItemProcess(item, status_item) + self._process_item = process_item + self._event_system.emit("submit.started", {}, "controller") + if wait: + self._submit_callback() + self._process_item = None + return process_item + + thread = threading.Thread(target=self._submit_callback) + self._process_thread = thread + thread.start() + return process_item + + def wait_for_process_thread(self): + if self._process_thread is None: + return + self._process_thread.join() + self._process_thread = None + + def _get_source_label(self): + if not self._src_project_name or not self._src_version_id: + return "Source is not defined" + + asset_doc = self._src_asset_doc + if not asset_doc: + return "Source is invalid" + + asset_path_parts = list(asset_doc["data"]["parents"]) + asset_path_parts.append(asset_doc["name"]) + asset_path = "/".join(asset_path_parts) + subset_doc = self._src_subset_doc + version_doc = self._src_version_doc + return "Source: {}/{}/{}/v{:0>3}".format( + self._src_project_name, + asset_path, + subset_doc["name"], + version_doc["name"] + ) + def _get_task_info_from_repre_docs(self, asset_doc, repre_docs): asset_tasks = asset_doc["data"].get("tasks") or {} found_comb = [] @@ -436,7 +347,7 @@ class PushToContextController: ) project_settings = get_project_settings(project_name) - subset_doc = self.src_subset_doc + subset_doc = self._src_subset_doc family = subset_doc["data"].get("family") if not family: family = subset_doc["data"]["families"][0] @@ -470,7 +381,7 @@ class PushToContextController: print("Failed format", exc) return "" - subset_name = self.src_subset_doc["name"] + subset_name = self._src_subset_doc["name"] if ( (subset_s and not subset_name.startswith(subset_s)) or (subset_e and not subset_name.endswith(subset_e)) @@ -483,112 +394,7 @@ class PushToContextController: subset_name = subset_name[:len(subset_e)] return subset_name - def set_source(self, project_name, version_id): - if ( - project_name == self._src_project_name - and version_id == self._src_version_id - ): - return - - self._src_project_name = project_name - self._src_version_id = version_id - asset_doc = None - subset_doc = None - version_doc = None - if project_name and version_id: - version_doc = get_version_by_id(project_name, version_id) - - if version_doc: - subset_doc = get_subset_by_id(project_name, version_doc["parent"]) - - if subset_doc: - asset_doc = get_asset_by_id(project_name, subset_doc["parent"]) - - self._src_asset_doc = asset_doc - self._src_subset_doc = subset_doc - self._src_version_doc = version_doc - if asset_doc: - self.user_values.set_new_asset(asset_doc["name"]) - variant = self._get_src_variant() - if variant: - self.user_values.set_variant(variant) - - comment = version_doc["data"].get("comment") - if comment: - self.user_values.set_comment(comment) - - self._event_system.emit( - "source.changed", { - "project_name": project_name, - "version_id": version_id - }, - "controller" - ) - - @property - def src_project_name(self): - return self._src_project_name - - @property - def src_version_id(self): - return self._src_version_id - - @property - def src_label(self): - if not self._src_project_name or not self._src_version_id: - return "Source is not defined" - - asset_doc = self.src_asset_doc - if not asset_doc: - return "Source is invalid" - - asset_path_parts = list(asset_doc["data"]["parents"]) - asset_path_parts.append(asset_doc["name"]) - asset_path = "/".join(asset_path_parts) - subset_doc = self.src_subset_doc - version_doc = self.src_version_doc - return "Source: {}/{}/{}/v{:0>3}".format( - self._src_project_name, - asset_path, - subset_doc["name"], - version_doc["name"] - ) - - @property - def src_version_doc(self): - return self._src_version_doc - - @property - def src_subset_doc(self): - return self._src_subset_doc - - @property - def src_asset_doc(self): - return self._src_asset_doc - - @property - def event_system(self): - return self._event_system - - @property - def model(self): - return self._entities_model - - @property - def selection_model(self): - return self._selection_model - - @property - def user_values(self): - return self._user_values - - @property - def submission_enabled(self): - return self._submission_enabled - def _on_project_change(self, event): - project_name = event["project_name"] - self.model.refresh_assets(project_name) self._invalidate() def _invalidate(self): @@ -606,68 +412,17 @@ class PushToContextController: if not self._user_values.is_valid: return False - if not self.selection_model.project_name: + if not self._selection_model.get_selected_project_name(): return False if ( - not self._user_values.new_asset_name - and not self.selection_model.asset_id + not self._user_values.new_folder_name + and not self._selection_model.get_selected_folder_id() ): return False return True - def get_selected_asset_name(self): - project_name = self._selection_model.project_name - asset_id = self._selection_model.asset_id - if not project_name or not asset_id: - return None - asset_item = self._entities_model.get_asset_by_id( - project_name, asset_id - ) - if asset_item: - return asset_item.name - return None - - def submit(self, wait=True): - if not self.submission_enabled: - return - - if self._process_thread is not None: - return - - item = ProjectPushItem( - self.src_project_name, - self.src_version_id, - self.selection_model.project_name, - self.selection_model.asset_id, - self.selection_model.task_name, - self.user_values.variant, - comment=self.user_values.comment, - new_asset_name=self.user_values.new_asset_name, - dst_version=1 - ) - - status_item = ProjectPushItemStatus(event_system=self._event_system) - process_item = ProjectPushItemProcess(item, status_item) - self._process_item = process_item - self._event_system.emit("submit.started", {}, "controller") - if wait: - self._submit_callback() - self._process_item = None - return process_item - - thread = threading.Thread(target=self._submit_callback) - self._process_thread = thread - thread.start() - return process_item - - def wait_for_process_thread(self): - if self._process_thread is None: - return - self._process_thread.join() - self._process_thread = None - def _submit_callback(self): process_item = self._process_item if process_item is None: @@ -676,3 +431,6 @@ class PushToContextController: self._event_system.emit("submit.finished", {}, "controller") if process_item is self._process_item: self._process_item = None + + def _create_event_system(self): + return QueuedEventSystem() diff --git a/openpype/tools/ayon_push_to_project/models/__init__.py b/openpype/tools/ayon_push_to_project/models/__init__.py new file mode 100644 index 0000000000..0123fc9355 --- /dev/null +++ b/openpype/tools/ayon_push_to_project/models/__init__.py @@ -0,0 +1,6 @@ +from .selection import PushToProjectSelectionModel + + +__all__ = ( + "PushToProjectSelectionModel", +) diff --git a/openpype/tools/ayon_push_to_project/models/selection.py b/openpype/tools/ayon_push_to_project/models/selection.py new file mode 100644 index 0000000000..19f1c6d37d --- /dev/null +++ b/openpype/tools/ayon_push_to_project/models/selection.py @@ -0,0 +1,72 @@ +class PushToProjectSelectionModel(object): + """Model handling selection changes. + + Triggering events: + - "selection.project.changed" + - "selection.folder.changed" + - "selection.task.changed" + """ + + event_source = "push-to-project.selection.model" + + def __init__(self, controller): + self._controller = controller + + self._project_name = None + self._folder_id = None + self._task_name = None + self._task_id = None + + def get_selected_project_name(self): + return self._project_name + + def set_selected_project(self, project_name): + if project_name == self._project_name: + return + + self._project_name = project_name + self._controller.emit_event( + "selection.project.changed", + {"project_name": project_name}, + self.event_source + ) + + def get_selected_folder_id(self): + return self._folder_id + + def set_selected_folder(self, folder_id): + if folder_id == self._folder_id: + return + + self._folder_id = folder_id + self._controller.emit_event( + "selection.folder.changed", + { + "project_name": self._project_name, + "folder_id": folder_id, + }, + self.event_source + ) + + def get_selected_task_name(self): + return self._task_name + + def get_selected_task_id(self): + return self._task_id + + def set_selected_task(self, task_id, task_name): + if task_id == self._task_id: + return + + self._task_name = task_name + self._task_id = task_id + self._controller.emit_event( + "selection.task.changed", + { + "project_name": self._project_name, + "folder_id": self._folder_id, + "task_name": task_name, + "task_id": task_id, + }, + self.event_source + ) From 78eebdaca177543235dafbda26763365678532d3 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 13 Oct 2023 18:12:21 +0200 Subject: [PATCH 26/46] initial changes of window --- .../tools/ayon_push_to_project/ui/window.py | 650 ++++-------------- 1 file changed, 126 insertions(+), 524 deletions(-) diff --git a/openpype/tools/ayon_push_to_project/ui/window.py b/openpype/tools/ayon_push_to_project/ui/window.py index a1fff2d27d..d5b2823490 100644 --- a/openpype/tools/ayon_push_to_project/ui/window.py +++ b/openpype/tools/ayon_push_to_project/ui/window.py @@ -1,369 +1,19 @@ -import collections - from qtpy import QtWidgets, QtGui, QtCore from openpype.style import load_stylesheet, get_app_icon_path from openpype.tools.utils import ( PlaceholderLineEdit, SeparatorWidget, - get_asset_icon_by_name, set_style_property, ) -from openpype.tools.utils.views import DeselectableTreeView - -from openpype.tools.ayon_push_to_project import PushToContextController - -PROJECT_NAME_ROLE = QtCore.Qt.UserRole + 1 -ASSET_NAME_ROLE = QtCore.Qt.UserRole + 2 -ASSET_ID_ROLE = QtCore.Qt.UserRole + 3 -TASK_NAME_ROLE = QtCore.Qt.UserRole + 4 -TASK_TYPE_ROLE = QtCore.Qt.UserRole + 5 - - -class ProjectsModel(QtGui.QStandardItemModel): - empty_text = "< Empty >" - select_project_text = "< Select Project >" - - refreshed = QtCore.Signal() - - def __init__(self, controller): - super(ProjectsModel, self).__init__() - self._controller = controller - - self.event_system.add_callback( - "projects.refresh.finished", self._on_refresh_finish - ) - - placeholder_item = QtGui.QStandardItem(self.empty_text) - - root_item = self.invisibleRootItem() - root_item.appendRows([placeholder_item]) - items = {None: placeholder_item} - - self._placeholder_item = placeholder_item - self._items = items - - @property - def event_system(self): - return self._controller.event_system - - def _on_refresh_finish(self): - root_item = self.invisibleRootItem() - project_names = self._controller.model.get_projects() - - if not project_names: - placeholder_text = self.empty_text - else: - placeholder_text = self.select_project_text - self._placeholder_item.setData(placeholder_text, QtCore.Qt.DisplayRole) - - new_items = [] - if None not in self._items: - new_items.append(self._placeholder_item) - - current_project_names = set(self._items.keys()) - for project_name in current_project_names - set(project_names): - if project_name is None: - continue - item = self._items.pop(project_name) - root_item.takeRow(item.row()) - - for project_name in project_names: - if project_name in self._items: - continue - item = QtGui.QStandardItem(project_name) - item.setData(project_name, PROJECT_NAME_ROLE) - new_items.append(item) - - if new_items: - root_item.appendRows(new_items) - self.refreshed.emit() - - -class ProjectProxyModel(QtCore.QSortFilterProxyModel): - def __init__(self): - super(ProjectProxyModel, self).__init__() - self._filter_empty_projects = False - - def set_filter_empty_project(self, filter_empty_projects): - if filter_empty_projects == self._filter_empty_projects: - return - self._filter_empty_projects = filter_empty_projects - self.invalidate() - - def filterAcceptsRow(self, row, parent): - if not self._filter_empty_projects: - return True - model = self.sourceModel() - source_index = model.index(row, self.filterKeyColumn(), parent) - if model.data(source_index, PROJECT_NAME_ROLE) is None: - return False - return True - - -class AssetsModel(QtGui.QStandardItemModel): - items_changed = QtCore.Signal() - empty_text = "< Empty >" - - def __init__(self, controller): - super(AssetsModel, self).__init__() - self._controller = controller - - placeholder_item = QtGui.QStandardItem(self.empty_text) - placeholder_item.setFlags(QtCore.Qt.ItemIsEnabled) - - root_item = self.invisibleRootItem() - root_item.appendRows([placeholder_item]) - - self.event_system.add_callback( - "project.changed", self._on_project_change - ) - self.event_system.add_callback( - "assets.refresh.started", self._on_refresh_start - ) - self.event_system.add_callback( - "assets.refresh.finished", self._on_refresh_finish - ) - - self._items = {None: placeholder_item} - - self._placeholder_item = placeholder_item - self._last_project = None - - @property - def event_system(self): - return self._controller.event_system - - def _clear(self): - placeholder_in = False - root_item = self.invisibleRootItem() - for row in reversed(range(root_item.rowCount())): - item = root_item.child(row) - asset_id = item.data(ASSET_ID_ROLE) - if asset_id is None: - placeholder_in = True - continue - root_item.removeRow(item.row()) - - for key in tuple(self._items.keys()): - if key is not None: - self._items.pop(key) - - if not placeholder_in: - root_item.appendRows([self._placeholder_item]) - self._items[None] = self._placeholder_item - - def _on_project_change(self, event): - project_name = event["project_name"] - if project_name == self._last_project: - return - - self._last_project = project_name - self._clear() - self.items_changed.emit() - - def _on_refresh_start(self, event): - pass - - def _on_refresh_finish(self, event): - event_project_name = event["project_name"] - project_name = self._controller.selection_model.project_name - if event_project_name != project_name: - return - - self._last_project = event["project_name"] - if project_name is None: - if None not in self._items: - self._clear() - self.items_changed.emit() - return - - asset_items_by_id = self._controller.model.get_assets(project_name) - if not asset_items_by_id: - self._clear() - self.items_changed.emit() - return - - assets_by_parent_id = collections.defaultdict(list) - for asset_item in asset_items_by_id.values(): - assets_by_parent_id[asset_item.parent_id].append(asset_item) - - root_item = self.invisibleRootItem() - if None in self._items: - self._items.pop(None) - root_item.takeRow(self._placeholder_item.row()) - - items_to_remove = set(self._items) - set(asset_items_by_id.keys()) - hierarchy_queue = collections.deque() - hierarchy_queue.append((None, root_item)) - while hierarchy_queue: - parent_id, parent_item = hierarchy_queue.popleft() - new_items = [] - for asset_item in assets_by_parent_id[parent_id]: - item = self._items.get(asset_item.id) - if item is None: - item = QtGui.QStandardItem() - item.setFlags( - QtCore.Qt.ItemIsSelectable - | QtCore.Qt.ItemIsEnabled - ) - new_items.append(item) - self._items[asset_item.id] = item - - elif item.parent() is not parent_item: - new_items.append(item) - - icon = get_asset_icon_by_name( - asset_item.icon_name, asset_item.icon_color - ) - item.setData(asset_item.name, QtCore.Qt.DisplayRole) - item.setData(icon, QtCore.Qt.DecorationRole) - item.setData(asset_item.id, ASSET_ID_ROLE) - - hierarchy_queue.append((asset_item.id, item)) - - if new_items: - parent_item.appendRows(new_items) - - for item_id in items_to_remove: - item = self._items.pop(item_id, None) - if item is None: - continue - row = item.row() - if row < 0: - continue - parent = item.parent() - if parent is None: - parent = root_item - parent.takeRow(row) - - self.items_changed.emit() - - -class TasksModel(QtGui.QStandardItemModel): - items_changed = QtCore.Signal() - empty_text = "< Empty >" - - def __init__(self, controller): - super(TasksModel, self).__init__() - self._controller = controller - - placeholder_item = QtGui.QStandardItem(self.empty_text) - placeholder_item.setFlags(QtCore.Qt.ItemIsEnabled) - - root_item = self.invisibleRootItem() - root_item.appendRows([placeholder_item]) - - self.event_system.add_callback( - "project.changed", self._on_project_change - ) - self.event_system.add_callback( - "assets.refresh.finished", self._on_asset_refresh_finish - ) - self.event_system.add_callback( - "asset.changed", self._on_asset_change - ) - - self._items = {None: placeholder_item} - - self._placeholder_item = placeholder_item - self._last_project = None - - @property - def event_system(self): - return self._controller.event_system - - def _clear(self): - placeholder_in = False - root_item = self.invisibleRootItem() - for row in reversed(range(root_item.rowCount())): - item = root_item.child(row) - task_name = item.data(TASK_NAME_ROLE) - if task_name is None: - placeholder_in = True - continue - root_item.removeRow(item.row()) - - for key in tuple(self._items.keys()): - if key is not None: - self._items.pop(key) - - if not placeholder_in: - root_item.appendRows([self._placeholder_item]) - self._items[None] = self._placeholder_item - - def _on_project_change(self, event): - project_name = event["project_name"] - if project_name == self._last_project: - return - - self._last_project = project_name - self._clear() - self.items_changed.emit() - - def _on_asset_refresh_finish(self, event): - self._refresh(event["project_name"]) - - def _on_asset_change(self, event): - self._refresh(event["project_name"]) - - def _refresh(self, new_project_name): - project_name = self._controller.selection_model.project_name - if new_project_name != project_name: - return - - self._last_project = project_name - if project_name is None: - if None not in self._items: - self._clear() - self.items_changed.emit() - return - - asset_id = self._controller.selection_model.asset_id - task_items = self._controller.model.get_tasks( - project_name, asset_id - ) - if not task_items: - self._clear() - self.items_changed.emit() - return - - root_item = self.invisibleRootItem() - if None in self._items: - self._items.pop(None) - root_item.takeRow(self._placeholder_item.row()) - - new_items = [] - task_names = set() - for task_item in task_items: - task_name = task_item.name - item = self._items.get(task_name) - if item is None: - item = QtGui.QStandardItem() - item.setFlags( - QtCore.Qt.ItemIsSelectable - | QtCore.Qt.ItemIsEnabled - ) - new_items.append(item) - self._items[task_name] = item - - item.setData(task_name, QtCore.Qt.DisplayRole) - item.setData(task_name, TASK_NAME_ROLE) - item.setData(task_item.task_type, TASK_TYPE_ROLE) - - if new_items: - root_item.appendRows(new_items) - - items_to_remove = set(self._items) - task_names - for item_id in items_to_remove: - item = self._items.pop(item_id, None) - if item is None: - continue - parent = item.parent() - if parent is not None: - parent.removeRow(item.row()) - - self.items_changed.emit() +from openpype.tools.ayon_utils.widgets import ( + ProjectsCombobox, + FoldersWidget, + TasksWidget, +) +from openpype.tools.ayon_push_to_project.control import ( + PushToContextController, +) class PushToContextSelectWindow(QtWidgets.QWidget): @@ -380,7 +30,10 @@ class PushToContextSelectWindow(QtWidgets.QWidget): header_widget = QtWidgets.QWidget(main_context_widget) - header_label = QtWidgets.QLabel(controller.src_label, header_widget) + header_label = QtWidgets.QLabel( + controller.get_source_label(), + header_widget + ) header_layout = QtWidgets.QHBoxLayout(header_widget) header_layout.setContentsMargins(0, 0, 0, 0) @@ -392,48 +45,32 @@ class PushToContextSelectWindow(QtWidgets.QWidget): context_widget = QtWidgets.QWidget(main_splitter) - project_combobox = QtWidgets.QComboBox(context_widget) - project_model = ProjectsModel(controller) - project_proxy = ProjectProxyModel() - project_proxy.setSourceModel(project_model) - project_proxy.setDynamicSortFilter(True) - project_delegate = QtWidgets.QStyledItemDelegate() - project_combobox.setItemDelegate(project_delegate) - project_combobox.setModel(project_proxy) + projects_combobox = ProjectsCombobox(controller, context_widget) + projects_combobox.set_select_item_visible(True) + projects_combobox.set_standard_filter_enabled(True) - asset_task_splitter = QtWidgets.QSplitter( + context_splitter = QtWidgets.QSplitter( QtCore.Qt.Vertical, context_widget ) - asset_view = DeselectableTreeView(asset_task_splitter) - asset_view.setHeaderHidden(True) - asset_model = AssetsModel(controller) - asset_proxy = QtCore.QSortFilterProxyModel() - asset_proxy.setSourceModel(asset_model) - asset_proxy.setDynamicSortFilter(True) - asset_view.setModel(asset_proxy) + folders_widget = FoldersWidget(controller, context_splitter) + folders_widget.set_deselectable(True) + tasks_widget = TasksWidget(controller, context_splitter) - task_view = QtWidgets.QListView(asset_task_splitter) - task_proxy = QtCore.QSortFilterProxyModel() - task_model = TasksModel(controller) - task_proxy.setSourceModel(task_model) - task_proxy.setDynamicSortFilter(True) - task_view.setModel(task_proxy) - - asset_task_splitter.addWidget(asset_view) - asset_task_splitter.addWidget(task_view) + context_splitter.addWidget(folders_widget) + context_splitter.addWidget(tasks_widget) context_layout = QtWidgets.QVBoxLayout(context_widget) context_layout.setContentsMargins(0, 0, 0, 0) - context_layout.addWidget(project_combobox, 0) - context_layout.addWidget(asset_task_splitter, 1) + context_layout.addWidget(projects_combobox, 0) + context_layout.addWidget(context_splitter, 1) # --- Inputs widget --- inputs_widget = QtWidgets.QWidget(main_splitter) - asset_name_input = PlaceholderLineEdit(inputs_widget) - asset_name_input.setPlaceholderText("< Name of new asset >") - asset_name_input.setObjectName("ValidatedLineEdit") + folder_name_input = PlaceholderLineEdit(inputs_widget) + folder_name_input.setPlaceholderText("< Name of new folder >") + folder_name_input.setObjectName("ValidatedLineEdit") variant_input = PlaceholderLineEdit(inputs_widget) variant_input.setPlaceholderText("< Variant >") @@ -444,7 +81,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget): inputs_layout = QtWidgets.QFormLayout(inputs_widget) inputs_layout.setContentsMargins(0, 0, 0, 0) - inputs_layout.addRow("New asset name", asset_name_input) + inputs_layout.addRow("New folder name", folder_name_input) inputs_layout.addRow("Variant", variant_input) inputs_layout.addRow("Comment", comment_input) @@ -509,7 +146,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget): main_layout.setCurrentWidget(main_context_widget) show_timer = QtCore.QTimer() - show_timer.setInterval(1) + show_timer.setInterval(0) main_thread_timer = QtCore.QTimer() main_thread_timer.setInterval(10) @@ -521,46 +158,38 @@ class PushToContextSelectWindow(QtWidgets.QWidget): main_thread_timer.timeout.connect(self._on_main_thread_timer) show_timer.timeout.connect(self._on_show_timer) user_input_changed_timer.timeout.connect(self._on_user_input_timer) - asset_name_input.textChanged.connect(self._on_new_asset_change) + folder_name_input.textChanged.connect(self._on_new_asset_change) variant_input.textChanged.connect(self._on_variant_change) comment_input.textChanged.connect(self._on_comment_change) - project_model.refreshed.connect(self._on_projects_refresh) - project_combobox.currentIndexChanged.connect(self._on_project_change) - asset_view.selectionModel().selectionChanged.connect( - self._on_asset_change - ) - asset_model.items_changed.connect(self._on_asset_model_change) - task_view.selectionModel().selectionChanged.connect( - self._on_task_change - ) - task_model.items_changed.connect(self._on_task_model_change) + publish_btn.clicked.connect(self._on_select_click) cancel_btn.clicked.connect(self._on_close_click) overlay_close_btn.clicked.connect(self._on_close_click) overlay_try_btn.clicked.connect(self._on_try_again_click) - controller.event_system.add_callback( - "new_asset_name.changed", self._on_controller_new_asset_change + controller.register_event_callback( + "new_folder_name.changed", + self._on_controller_new_asset_change ) - controller.event_system.add_callback( + controller.register_event_callback( "variant.changed", self._on_controller_variant_change ) - controller.event_system.add_callback( + controller.register_event_callback( "comment.changed", self._on_controller_comment_change ) - controller.event_system.add_callback( + controller.register_event_callback( "submission.enabled.changed", self._on_submission_change ) - controller.event_system.add_callback( + controller.register_event_callback( "source.changed", self._on_controller_source_change ) - controller.event_system.add_callback( + controller.register_event_callback( "submit.started", self._on_controller_submit_start ) - controller.event_system.add_callback( + controller.register_event_callback( "submit.finished", self._on_controller_submit_end ) - controller.event_system.add_callback( + controller.register_event_callback( "push.message.added", self._on_push_message ) @@ -571,20 +200,12 @@ class PushToContextSelectWindow(QtWidgets.QWidget): self._header_label = header_label self._main_splitter = main_splitter - self._project_combobox = project_combobox - self._project_model = project_model - self._project_proxy = project_proxy - self._project_delegate = project_delegate - - self._asset_view = asset_view - self._asset_model = asset_model - self._asset_proxy_model = asset_proxy - - self._task_view = task_view - self._task_proxy_model = task_proxy + self._projects_combobox = projects_combobox + self._folders_widget = folders_widget + self._tasks_widget = tasks_widget self._variant_input = variant_input - self._asset_name_input = asset_name_input + self._folder_name_input = folder_name_input self._comment_input = comment_input self._publish_btn = publish_btn @@ -600,60 +221,78 @@ class PushToContextSelectWindow(QtWidgets.QWidget): # The goal is to have controll over changes happened during user change # in UI and controller auto-changes self._variant_input_text = None - self._new_asset_name_input_text = None + self._new_folder_name_input_text = None self._comment_input_text = None - self._show_timer = show_timer - self._show_counter = 2 + self._first_show = True + self._show_timer = show_timer + self._show_counter = 0 self._main_thread_timer = main_thread_timer self._main_thread_timer_can_stop = True self._last_submit_message = None self._process_item = None + self._variant_is_valid = None + self._folder_is_valid = None + publish_btn.setEnabled(False) overlay_close_btn.setVisible(False) overlay_try_btn.setVisible(False) - if controller.user_values.new_asset_name: - asset_name_input.setText(controller.user_values.new_asset_name) - if controller.user_values.variant: - variant_input.setText(controller.user_values.variant) - self._invalidate_variant() - self._invalidate_new_asset_name() + # Support of public api function of controller + def set_source(self, project_name, version_id): + """Set source project and version. - @property - def controller(self): - return self._controller + Call the method on controller. + + Args: + project_name (Union[str, None]): Name of project. + version_id (Union[str, None]): Version id. + """ + + self._controller.set_source(project_name, version_id) def showEvent(self, event): super(PushToContextSelectWindow, self).showEvent(event) if self._first_show: self._first_show = False - self.setStyleSheet(load_stylesheet()) - self._invalidate_variant() - self._show_timer.start() + self._on_first_show() + + def refresh(self): + user_values = self._controller.get_user_values() + new_folder_name = user_values["new_folder_name"] + variant = user_values["variant"] + self._folder_name_input.setText(new_folder_name or "") + self._variant_input.setText(variant or "") + self._invalidate_variant(user_values["is_variant_valid"]) + self._invalidate_new_folder_name( + new_folder_name, user_values["is_new_folder_name_valid"] + ) + + self._projects_combobox.refresh() + + def _on_first_show(self): + width = 740 + height = 640 + inputs_width = 360 + self.setStyleSheet(load_stylesheet()) + self.resize(width, height) + self._main_splitter.setSizes([width - inputs_width, inputs_width]) + self._show_timer.start() def _on_show_timer(self): - if self._show_counter == 0: - self._show_timer.stop() + if self._show_counter < 3: + self._show_counter += 1 return + self._show_timer.stop() - self._show_counter -= 1 - if self._show_counter == 1: - width = 740 - height = 640 - inputs_width = 360 - self.resize(width, height) - self._main_splitter.setSizes([width - inputs_width, inputs_width]) + self._show_counter = 0 - if self._show_counter > 0: - return - - self._controller.model.refresh_projects() + self.refresh() def _on_new_asset_change(self, text): - self._new_asset_name_input_text = text + self._new_folder_name_input_text = text self._user_input_changed_timer.start() def _on_variant_change(self, text): @@ -665,42 +304,41 @@ class PushToContextSelectWindow(QtWidgets.QWidget): self._user_input_changed_timer.start() def _on_user_input_timer(self): - asset_name = self._new_asset_name_input_text - if asset_name is not None: - self._new_asset_name_input_text = None - self._controller.user_values.set_new_asset(asset_name) + folder_name = self._new_folder_name_input_text + if folder_name is not None: + self._new_folder_name_input_text = None + self._controller.set_user_value_folder_name(folder_name) variant = self._variant_input_text if variant is not None: self._variant_input_text = None - self._controller.user_values.set_variant(variant) + self._controller.set_user_value_variant(variant) comment = self._comment_input_text if comment is not None: self._comment_input_text = None - self._controller.user_values.set_comment(comment) + self._controller.set_user_value_comment(comment) def _on_controller_new_asset_change(self, event): - asset_name = event["changes"]["new_asset_name"]["new"] + folder_name = event["new_folder_name"] if ( - self._new_asset_name_input_text is None - and asset_name != self._asset_name_input.text() + self._new_folder_name_input_text is None + and folder_name != self._folder_name_input.text() ): - self._asset_name_input.setText(asset_name) + self._folder_name_input.setText(folder_name) - self._invalidate_new_asset_name() + self._invalidate_new_folder_name(folder_name, event["is_valid"]) def _on_controller_variant_change(self, event): - is_valid_changes = event["changes"]["is_valid"] - variant = event["changes"]["variant"]["new"] + is_valid = event["is_valid"] + variant = event["variant"] if ( self._variant_input_text is None and variant != self._variant_input.text() ): self._variant_input.setText(variant) - if is_valid_changes["old"] != is_valid_changes["new"]: - self._invalidate_variant() + self._invalidate_variant(is_valid) def _on_controller_comment_change(self, event): comment = event["comment"] @@ -711,66 +349,30 @@ class PushToContextSelectWindow(QtWidgets.QWidget): self._comment_input.setText(comment) def _on_controller_source_change(self): - self._header_label.setText(self._controller.src_label) + self._header_label.setText(self._controller.get_source_label()) - def _invalidate_new_asset_name(self): - asset_name = self._controller.user_values.new_asset_name - self._task_view.setVisible(not asset_name) - - valid = None - if asset_name: - valid = self._controller.user_values.is_new_asset_name_valid - - state = "" - if valid is True: - state = "valid" - elif valid is False: - state = "invalid" - set_style_property(self._asset_name_input, "state", state) - - def _invalidate_variant(self): - valid = self._controller.user_values.is_variant_valid - state = "invalid" - if valid is True: - state = "valid" - set_style_property(self._variant_input, "state", state) - - def _on_projects_refresh(self): - self._project_proxy.sort(0, QtCore.Qt.AscendingOrder) - - def _on_project_change(self): - idx = self._project_combobox.currentIndex() - if idx < 0: - self._project_proxy.set_filter_empty_project(False) + def _invalidate_new_folder_name(self, folder_name, is_valid): + print(folder_name) + self._tasks_widget.setVisible(not folder_name) + if self._folder_is_valid is is_valid: return + self._folder_is_valid = is_valid + state = "" + if folder_name: + if is_valid is True: + state = "valid" + elif is_valid is False: + state = "invalid" + set_style_property( + self._folder_name_input, "state", state + ) - project_name = self._project_combobox.itemData(idx, PROJECT_NAME_ROLE) - self._project_proxy.set_filter_empty_project(project_name is not None) - self._controller.selection_model.select_project(project_name) - - def _on_asset_change(self): - indexes = self._asset_view.selectedIndexes() - index = next(iter(indexes), None) - asset_id = None - if index is not None: - model = self._asset_view.model() - asset_id = model.data(index, ASSET_ID_ROLE) - self._controller.selection_model.select_asset(asset_id) - - def _on_asset_model_change(self): - self._asset_proxy_model.sort(0, QtCore.Qt.AscendingOrder) - - def _on_task_model_change(self): - self._task_proxy_model.sort(0, QtCore.Qt.AscendingOrder) - - def _on_task_change(self): - indexes = self._task_view.selectedIndexes() - index = next(iter(indexes), None) - task_name = None - if index is not None: - model = self._task_view.model() - task_name = model.data(index, TASK_NAME_ROLE) - self._controller.selection_model.select_task(task_name) + def _invalidate_variant(self, is_valid): + if self._variant_is_valid is is_valid: + return + self._variant_is_valid = is_valid + state = "valid" if is_valid else "invalid" + set_style_property(self._variant_input, "state", state) def _on_submission_change(self, event): self._publish_btn.setEnabled(event["enabled"]) From 2ac76ad4afc402963e435fe1a25eae643ae526b6 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 13 Oct 2023 18:22:56 +0200 Subject: [PATCH 27/46] moved user values model to different file --- .../tools/ayon_push_to_project/control.py | 128 ++---------------- .../ayon_push_to_project/models/__init__.py | 2 + .../models/user_values.py | 110 +++++++++++++++ 3 files changed, 123 insertions(+), 117 deletions(-) create mode 100644 openpype/tools/ayon_push_to_project/models/user_values.py diff --git a/openpype/tools/ayon_push_to_project/control.py b/openpype/tools/ayon_push_to_project/control.py index 4aef09156f..4cba437553 100644 --- a/openpype/tools/ayon_push_to_project/control.py +++ b/openpype/tools/ayon_push_to_project/control.py @@ -1,4 +1,3 @@ -import re import threading from openpype.client import ( @@ -10,10 +9,7 @@ from openpype.client import ( from openpype.settings import get_project_settings from openpype.lib import prepare_template_data from openpype.lib.events import QueuedEventSystem -from openpype.pipeline.create import ( - SUBSET_NAME_ALLOWED_SYMBOLS, - get_subset_name_template, -) +from openpype.pipeline.create import get_subset_name_template from openpype.tools.ayon_utils.models import ProjectsModel, HierarchyModel from .control_integrate import ( @@ -21,114 +17,10 @@ from .control_integrate import ( ProjectPushItemProcess, ProjectPushItemStatus, ) -from .models import PushToProjectSelectionModel - - -class UserPublishValues: - """Helper object to validate values required for push to different project. - - Args: - controller (PushToContextController): Event system to catch - and emit events. - """ - - folder_name_regex = re.compile("^[a-zA-Z0-9_.]+$") - variant_regex = re.compile("^[{}]+$".format(SUBSET_NAME_ALLOWED_SYMBOLS)) - - def __init__(self, controller): - self._controller = controller - self._new_folder_name = None - self._variant = None - self._comment = None - self._is_variant_valid = False - self._is_new_folder_name_valid = False - - self.set_new_folder_name("") - self.set_variant("") - self.set_comment("") - - @property - def new_folder_name(self): - return self._new_folder_name - - @property - def variant(self): - return self._variant - - @property - def comment(self): - return self._comment - - @property - def is_variant_valid(self): - return self._is_variant_valid - - @property - def is_new_folder_name_valid(self): - return self._is_new_folder_name_valid - - @property - def is_valid(self): - return self.is_variant_valid and self.is_new_folder_name_valid - - def get_data(self): - return { - "new_folder_name": self._new_folder_name, - "variant": self._variant, - "comment": self._comment, - "is_variant_valid": self._is_variant_valid, - "is_new_folder_name_valid": self._is_new_folder_name_valid, - "is_valid": self.is_valid - } - - def set_variant(self, variant): - if variant == self._variant: - return - - self._variant = variant - is_valid = False - if variant: - is_valid = self.variant_regex.match(variant) is not None - self._is_variant_valid = is_valid - - self._controller.emit_event( - "variant.changed", - { - "variant": variant, - "is_valid": self._is_variant_valid, - }, - "user_values" - ) - - def set_new_folder_name(self, folder_name): - if self._new_folder_name == folder_name: - return - - self._new_folder_name = folder_name - is_valid = True - if folder_name: - is_valid = ( - self.folder_name_regex.match(folder_name) is not None - ) - self._is_new_folder_name_valid = is_valid - self._controller.emit_event( - "new_folder_name.changed", - { - "new_folder_name": self._new_folder_name, - "is_valid": self._is_new_folder_name_valid, - }, - "user_values" - ) - - def set_comment(self, comment): - if comment == self._comment: - return - self._comment = comment - self._controller.emit_event( - "comment.changed", - {"comment": comment}, - "user_values" - ) +from .models import ( + PushToProjectSelectionModel, + UserPublishValuesModel, +) class PushToContextController: @@ -139,7 +31,7 @@ class PushToContextController: self._hierarchy_model = HierarchyModel(self) self._selection_model = PushToProjectSelectionModel(self) - self._user_values = UserPublishValues(self) + self._user_values = UserPublishValuesModel(self) self._src_project_name = None self._src_version_id = None @@ -229,18 +121,23 @@ class PushToContextController: def set_user_value_folder_name(self, folder_name): self._user_values.set_new_folder_name(folder_name) + self._invalidate() def set_user_value_variant(self, variant): self._user_values.set_variant(variant) + self._invalidate() def set_user_value_comment(self, comment): self._user_values.set_comment(comment) + self._invalidate() def set_selected_project(self, project_name): self._selection_model.set_selected_project(project_name) + self._invalidate() def set_selected_folder(self, folder_id): self._selection_model.set_selected_folder(folder_id) + self._invalidate() def set_selected_task(self, task_id, task_name): self._selection_model.set_selected_task(task_id, task_name) @@ -394,9 +291,6 @@ class PushToContextController: subset_name = subset_name[:len(subset_e)] return subset_name - def _on_project_change(self, event): - self._invalidate() - def _invalidate(self): submission_enabled = self._check_submit_validations() if submission_enabled == self._submission_enabled: diff --git a/openpype/tools/ayon_push_to_project/models/__init__.py b/openpype/tools/ayon_push_to_project/models/__init__.py index 0123fc9355..48eb5e9f14 100644 --- a/openpype/tools/ayon_push_to_project/models/__init__.py +++ b/openpype/tools/ayon_push_to_project/models/__init__.py @@ -1,6 +1,8 @@ from .selection import PushToProjectSelectionModel +from .user_values import UserPublishValuesModel __all__ = ( "PushToProjectSelectionModel", + "UserPublishValuesModel", ) diff --git a/openpype/tools/ayon_push_to_project/models/user_values.py b/openpype/tools/ayon_push_to_project/models/user_values.py new file mode 100644 index 0000000000..2a4faeb136 --- /dev/null +++ b/openpype/tools/ayon_push_to_project/models/user_values.py @@ -0,0 +1,110 @@ +import re + +from openpype.pipeline.create import SUBSET_NAME_ALLOWED_SYMBOLS + + +class UserPublishValuesModel: + """Helper object to validate values required for push to different project. + + Args: + controller (PushToContextController): Event system to catch + and emit events. + """ + + folder_name_regex = re.compile("^[a-zA-Z0-9_.]+$") + variant_regex = re.compile("^[{}]+$".format(SUBSET_NAME_ALLOWED_SYMBOLS)) + + def __init__(self, controller): + self._controller = controller + self._new_folder_name = None + self._variant = None + self._comment = None + self._is_variant_valid = False + self._is_new_folder_name_valid = False + + self.set_new_folder_name("") + self.set_variant("") + self.set_comment("") + + @property + def new_folder_name(self): + return self._new_folder_name + + @property + def variant(self): + return self._variant + + @property + def comment(self): + return self._comment + + @property + def is_variant_valid(self): + return self._is_variant_valid + + @property + def is_new_folder_name_valid(self): + return self._is_new_folder_name_valid + + @property + def is_valid(self): + return self.is_variant_valid and self.is_new_folder_name_valid + + def get_data(self): + return { + "new_folder_name": self._new_folder_name, + "variant": self._variant, + "comment": self._comment, + "is_variant_valid": self._is_variant_valid, + "is_new_folder_name_valid": self._is_new_folder_name_valid, + "is_valid": self.is_valid + } + + def set_variant(self, variant): + if variant == self._variant: + return + + self._variant = variant + is_valid = False + if variant: + is_valid = self.variant_regex.match(variant) is not None + self._is_variant_valid = is_valid + + self._controller.emit_event( + "variant.changed", + { + "variant": variant, + "is_valid": self._is_variant_valid, + }, + "user_values" + ) + + def set_new_folder_name(self, folder_name): + if self._new_folder_name == folder_name: + return + + self._new_folder_name = folder_name + is_valid = True + if folder_name: + is_valid = ( + self.folder_name_regex.match(folder_name) is not None + ) + self._is_new_folder_name_valid = is_valid + self._controller.emit_event( + "new_folder_name.changed", + { + "new_folder_name": self._new_folder_name, + "is_valid": self._is_new_folder_name_valid, + }, + "user_values" + ) + + def set_comment(self, comment): + if comment == self._comment: + return + self._comment = comment + self._controller.emit_event( + "comment.changed", + {"comment": comment}, + "user_values" + ) From 7951c95f095e5418d0deb2d19e934868f6686238 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 13 Oct 2023 18:23:45 +0200 Subject: [PATCH 28/46] renamed '_get_source_label' to '_prepare_source_label' --- openpype/tools/ayon_push_to_project/control.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/openpype/tools/ayon_push_to_project/control.py b/openpype/tools/ayon_push_to_project/control.py index 4cba437553..1e6cbd55d4 100644 --- a/openpype/tools/ayon_push_to_project/control.py +++ b/openpype/tools/ayon_push_to_project/control.py @@ -102,7 +102,7 @@ class PushToContextController: def get_source_label(self): if self._src_label is None: - self._src_label = self._get_source_label() + self._src_label = self._prepare_source_label() return self._src_label def get_project_items(self, sender=None): @@ -182,7 +182,7 @@ class PushToContextController: self._process_thread.join() self._process_thread = None - def _get_source_label(self): + def _prepare_source_label(self): if not self._src_project_name or not self._src_version_id: return "Source is not defined" @@ -190,14 +190,14 @@ class PushToContextController: if not asset_doc: return "Source is invalid" - asset_path_parts = list(asset_doc["data"]["parents"]) - asset_path_parts.append(asset_doc["name"]) - asset_path = "/".join(asset_path_parts) + folder_path_parts = list(asset_doc["data"]["parents"]) + folder_path_parts.append(asset_doc["name"]) + folder_path = "/".join(folder_path_parts) subset_doc = self._src_subset_doc version_doc = self._src_version_doc return "Source: {}/{}/{}/v{:0>3}".format( self._src_project_name, - asset_path, + folder_path, subset_doc["name"], version_doc["name"] ) From 37da54b438a3d109b6bfab21f49d046ef89aa38c Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 13 Oct 2023 18:24:07 +0200 Subject: [PATCH 29/46] implemented helper method to trigger controller events --- .../tools/ayon_push_to_project/control.py | 39 ++++++++++--------- 1 file changed, 21 insertions(+), 18 deletions(-) diff --git a/openpype/tools/ayon_push_to_project/control.py b/openpype/tools/ayon_push_to_project/control.py index 1e6cbd55d4..d07b915bf7 100644 --- a/openpype/tools/ayon_push_to_project/control.py +++ b/openpype/tools/ayon_push_to_project/control.py @@ -92,12 +92,12 @@ class PushToContextController: if comment: self._user_values.set_comment(comment) - self._event_system.emit( - "source.changed", { + self._emit_event( + "source.changed", + { "project_name": project_name, "version_id": version_id - }, - "controller" + } ) def get_source_label(self): @@ -165,7 +165,7 @@ class PushToContextController: status_item = ProjectPushItemStatus(event_system=self._event_system) process_item = ProjectPushItemProcess(item, status_item) self._process_item = process_item - self._event_system.emit("submit.started", {}, "controller") + self._emit_event("submit.started") if wait: self._submit_callback() self._process_item = None @@ -291,17 +291,6 @@ class PushToContextController: subset_name = subset_name[:len(subset_e)] return subset_name - def _invalidate(self): - submission_enabled = self._check_submit_validations() - if submission_enabled == self._submission_enabled: - return - self._submission_enabled = submission_enabled - self._event_system.emit( - "submission.enabled.changed", - {"enabled": submission_enabled}, - "controller" - ) - def _check_submit_validations(self): if not self._user_values.is_valid: return False @@ -314,17 +303,31 @@ class PushToContextController: and not self._selection_model.get_selected_folder_id() ): return False - return True + def _invalidate(self): + submission_enabled = self._check_submit_validations() + if submission_enabled == self._submission_enabled: + return + self._submission_enabled = submission_enabled + self._emit_event( + "submission.enabled.changed", + {"enabled": submission_enabled} + ) + def _submit_callback(self): process_item = self._process_item if process_item is None: return process_item.process() - self._event_system.emit("submit.finished", {}, "controller") + self._emit_event("submit.finished", {}) if process_item is self._process_item: self._process_item = None + def _emit_event(self, topic, data=None): + if data is None: + data = {} + self.emit_event(topic, data, "controller") + def _create_event_system(self): return QueuedEventSystem() From faadf3582c43ef19126d6d351501a4e8ccdbdc3f Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 13 Oct 2023 18:24:16 +0200 Subject: [PATCH 30/46] removed debug print --- openpype/tools/ayon_push_to_project/ui/window.py | 1 - 1 file changed, 1 deletion(-) diff --git a/openpype/tools/ayon_push_to_project/ui/window.py b/openpype/tools/ayon_push_to_project/ui/window.py index d5b2823490..57c4c2619f 100644 --- a/openpype/tools/ayon_push_to_project/ui/window.py +++ b/openpype/tools/ayon_push_to_project/ui/window.py @@ -352,7 +352,6 @@ class PushToContextSelectWindow(QtWidgets.QWidget): self._header_label.setText(self._controller.get_source_label()) def _invalidate_new_folder_name(self, folder_name, is_valid): - print(folder_name) self._tasks_widget.setVisible(not folder_name) if self._folder_is_valid is is_valid: return From e729cc1964cfd351ba47057a48df143c4379f2b5 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 13 Oct 2023 18:26:16 +0200 Subject: [PATCH 31/46] moved 'control_integrate.py' to models as 'integrate.py' --- openpype/tools/ayon_push_to_project/control.py | 10 +++++----- openpype/tools/ayon_push_to_project/models/__init__.py | 10 ++++++++++ .../{control_integrate.py => models/integrate.py} | 0 3 files changed, 15 insertions(+), 5 deletions(-) rename openpype/tools/ayon_push_to_project/{control_integrate.py => models/integrate.py} (100%) diff --git a/openpype/tools/ayon_push_to_project/control.py b/openpype/tools/ayon_push_to_project/control.py index d07b915bf7..4fc011da09 100644 --- a/openpype/tools/ayon_push_to_project/control.py +++ b/openpype/tools/ayon_push_to_project/control.py @@ -12,15 +12,15 @@ from openpype.lib.events import QueuedEventSystem from openpype.pipeline.create import get_subset_name_template from openpype.tools.ayon_utils.models import ProjectsModel, HierarchyModel -from .control_integrate import ( +from .models import ( + PushToProjectSelectionModel, + + UserPublishValuesModel, + ProjectPushItem, ProjectPushItemProcess, ProjectPushItemStatus, ) -from .models import ( - PushToProjectSelectionModel, - UserPublishValuesModel, -) class PushToContextController: diff --git a/openpype/tools/ayon_push_to_project/models/__init__.py b/openpype/tools/ayon_push_to_project/models/__init__.py index 48eb5e9f14..e8c0fae02e 100644 --- a/openpype/tools/ayon_push_to_project/models/__init__.py +++ b/openpype/tools/ayon_push_to_project/models/__init__.py @@ -1,8 +1,18 @@ from .selection import PushToProjectSelectionModel from .user_values import UserPublishValuesModel +from .integrate import ( + ProjectPushItem, + ProjectPushItemProcess, + ProjectPushItemStatus, +) __all__ = ( "PushToProjectSelectionModel", + "UserPublishValuesModel", + + "ProjectPushItem", + "ProjectPushItemProcess", + "ProjectPushItemStatus", ) diff --git a/openpype/tools/ayon_push_to_project/control_integrate.py b/openpype/tools/ayon_push_to_project/models/integrate.py similarity index 100% rename from openpype/tools/ayon_push_to_project/control_integrate.py rename to openpype/tools/ayon_push_to_project/models/integrate.py From ed4c306c43907f5ea8bbf0860aa08d5abb032dcc Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 16 Oct 2023 12:16:44 +0200 Subject: [PATCH 32/46] modified integration to avoid direct access to integrate objects --- .../tools/ayon_push_to_project/control.py | 47 +- .../ayon_push_to_project/models/__init__.py | 2 + .../ayon_push_to_project/models/integrate.py | 572 +++++++++--------- .../tools/ayon_push_to_project/ui/window.py | 18 +- 4 files changed, 328 insertions(+), 311 deletions(-) diff --git a/openpype/tools/ayon_push_to_project/control.py b/openpype/tools/ayon_push_to_project/control.py index 4fc011da09..0a19136701 100644 --- a/openpype/tools/ayon_push_to_project/control.py +++ b/openpype/tools/ayon_push_to_project/control.py @@ -14,12 +14,8 @@ from openpype.tools.ayon_utils.models import ProjectsModel, HierarchyModel from .models import ( PushToProjectSelectionModel, - UserPublishValuesModel, - - ProjectPushItem, - ProjectPushItemProcess, - ProjectPushItemStatus, + IntegrateModel, ) @@ -29,6 +25,7 @@ class PushToContextController: self._projects_model = ProjectsModel(self) self._hierarchy_model = HierarchyModel(self) + self._integrate_model = IntegrateModel(self) self._selection_model = PushToProjectSelectionModel(self) self._user_values = UserPublishValuesModel(self) @@ -42,7 +39,7 @@ class PushToContextController: self._submission_enabled = False self._process_thread = None - self._process_item = None + self._process_item_id = None self.set_source(project_name, version_id) @@ -58,6 +55,13 @@ class PushToContextController: self._event_system.add_callback(topic, callback) def set_source(self, project_name, version_id): + """Set source project and version. + + Args: + project_name (Union[str, None]): Source project name. + version_id (Union[str, None]): Source version id. + """ + if ( project_name == self._src_project_name and version_id == self._src_version_id @@ -101,6 +105,12 @@ class PushToContextController: ) def get_source_label(self): + """Get source label. + + Returns: + str: Label describing source project and version as path. + """ + if self._src_label is None: self._src_label = self._prepare_source_label() return self._src_label @@ -142,6 +152,9 @@ class PushToContextController: def set_selected_task(self, task_id, task_name): self._selection_model.set_selected_task(task_id, task_name) + def get_process_item_status(self, item_id): + return self._integrate_model.get_item_status(item_id) + # Processing methods def submit(self, wait=True): if not self._submission_enabled: @@ -150,7 +163,7 @@ class PushToContextController: if self._process_thread is not None: return - item = ProjectPushItem( + item_id = self._integrate_model.create_process_item( self._src_project_name, self._src_version_id, self._selection_model.get_selected_project_name(), @@ -162,19 +175,17 @@ class PushToContextController: dst_version=1 ) - status_item = ProjectPushItemStatus(event_system=self._event_system) - process_item = ProjectPushItemProcess(item, status_item) - self._process_item = process_item + self._process_item_id = item_id self._emit_event("submit.started") if wait: self._submit_callback() - self._process_item = None - return process_item + self._process_item_id = None + return item_id thread = threading.Thread(target=self._submit_callback) self._process_thread = thread thread.start() - return process_item + return item_id def wait_for_process_thread(self): if self._process_thread is None: @@ -316,13 +327,13 @@ class PushToContextController: ) def _submit_callback(self): - process_item = self._process_item - if process_item is None: + process_item_id = self._process_item_id + if process_item_id is None: return - process_item.process() + self._integrate_model.integrate_item(process_item_id) self._emit_event("submit.finished", {}) - if process_item is self._process_item: - self._process_item = None + if process_item_id == self._process_item_id: + self._process_item_id = None def _emit_event(self, topic, data=None): if data is None: diff --git a/openpype/tools/ayon_push_to_project/models/__init__.py b/openpype/tools/ayon_push_to_project/models/__init__.py index e8c0fae02e..5f909437a7 100644 --- a/openpype/tools/ayon_push_to_project/models/__init__.py +++ b/openpype/tools/ayon_push_to_project/models/__init__.py @@ -4,6 +4,7 @@ from .integrate import ( ProjectPushItem, ProjectPushItemProcess, ProjectPushItemStatus, + IntegrateModel, ) @@ -15,4 +16,5 @@ __all__ = ( "ProjectPushItem", "ProjectPushItemProcess", "ProjectPushItemStatus", + "IntegrateModel", ) diff --git a/openpype/tools/ayon_push_to_project/models/integrate.py b/openpype/tools/ayon_push_to_project/models/integrate.py index a822339ccf..b3de69c79a 100644 --- a/openpype/tools/ayon_push_to_project/models/integrate.py +++ b/openpype/tools/ayon_push_to_project/models/integrate.py @@ -6,6 +6,7 @@ import itertools import datetime import sys import traceback +import uuid from bson.objectid import ObjectId @@ -98,38 +99,62 @@ class ProjectPushItem: src_project_name, src_version_id, dst_project_name, - dst_asset_id, + dst_folder_id, dst_task_name, variant, - comment=None, - new_asset_name=None, - dst_version=None + comment, + new_folder_name, + dst_version, + item_id=None, ): + if not item_id: + item_id = uuid.uuid4().hex self.src_project_name = src_project_name self.src_version_id = src_version_id self.dst_project_name = dst_project_name - self.dst_asset_id = dst_asset_id + self.dst_folder_id = dst_folder_id self.dst_task_name = dst_task_name self.dst_version = dst_version self.variant = variant - self.new_asset_name = new_asset_name + self.new_folder_name = new_folder_name self.comment = comment or "" - self._id = "|".join([ - src_project_name, - src_version_id, - dst_project_name, - str(dst_asset_id), - str(new_asset_name), - str(dst_task_name), - str(dst_version) - ]) + self.item_id = item_id + self._repr_value = None @property - def id(self): - return self._id + def _repr(self): + if not self._repr_value: + self._repr_value = "|".join([ + self.src_project_name, + self.src_version_id, + self.dst_project_name, + str(self.dst_folder_id), + str(self.new_folder_name), + str(self.dst_task_name), + str(self.dst_version) + ]) + return self._repr_value def __repr__(self): - return "<{} - {}>".format(self.__class__.__name__, self.id) + return "<{} - {}>".format(self.__class__.__name__, self._repr) + + def to_data(self): + return { + "src_project_name": self.src_project_name, + "src_version_id": self.src_version_id, + "dst_project_name": self.dst_project_name, + "dst_folder_id": self.dst_folder_id, + "dst_task_name": self.dst_task_name, + "dst_version": self.dst_version, + "variant": self.variant, + "comment": self.comment, + "new_folder_name": self.new_folder_name, + "item_id": self.item_id, + } + + @classmethod + def from_data(cls, data): + return cls(**data) class StatusMessage: @@ -149,49 +174,17 @@ class StatusMessage: class ProjectPushItemStatus: def __init__( self, + started=False, failed=False, finished=False, fail_reason=None, - formatted_traceback=None, - messages=None, - event_system=None + full_traceback=None ): - if messages is None: - messages = [] - self._failed = failed - self._finished = finished - self._fail_reason = fail_reason - self._traceback = formatted_traceback - self._messages = messages - self._event_system = event_system - - def emit_event(self, topic, data=None): - if self._event_system is None: - return - - self._event_system.emit(topic, data or {}, "push.status") - - def get_finished(self): - """Processing of push to project finished. - - Returns: - bool: Finished. - """ - - return self._finished - - def set_finished(self, finished=True): - """Mark status as finished. - - Args: - finished (bool): Processing finished (failed or not). - """ - - if finished != self._finished: - self._finished = finished - self.emit_event("push.finished.changed", {"finished": finished}) - - finished = property(get_finished, set_finished) + self.started = started + self.failed = failed + self.finished = finished + self.fail_reason = fail_reason + self.full_traceback = full_traceback def set_failed(self, fail_reason, exc_info=None): """Set status as failed. @@ -201,8 +194,8 @@ class ProjectPushItemStatus: is set to 'True' and reason is not set. Args: - failed (bool): Push to project failed. fail_reason (str): Reason why failed. + exc_info(tuple): Exception info. """ failed = True @@ -215,84 +208,22 @@ class ProjectPushItemStatus: if not fail_reason: fail_reason = "Failed without specified reason" - if ( - self._failed == failed - and self._traceback == full_traceback - and self._fail_reason == fail_reason - ): - return + self.failed = failed + self.fail_reason = fail_reason or None + self.full_traceback = full_traceback - self._failed = failed - self._fail_reason = fail_reason or None - self._traceback = full_traceback + def to_data(self): + return { + "started": self.started, + "failed": self.failed, + "finished": self.finished, + "fail_reason": self.fail_reason, + "full_traceback": self.full_traceback, + } - self.emit_event( - "push.failed.changed", - { - "failed": failed, - "reason": fail_reason, - "traceback": full_traceback - } - ) - - @property - def failed(self): - """Processing failed. - - Returns: - bool: Processing failed. - """ - - return self._failed - - @property - def fail_reason(self): - """Reason why push to process failed. - - Returns: - Union[str, None]: Reason why push failed or None. - """ - - return self._fail_reason - - @property - def traceback(self): - """Traceback of failed process. - - Traceback is available only if unhandled exception happened. - - Returns: - Union[str, None]: Formatted traceback. - """ - - return self._traceback - - # Loggin helpers - # TODO better logging - def add_message(self, message, level): - message_obj = StatusMessage(message, level) - self._messages.append(message_obj) - self.emit_event( - "push.message.added", - {"message": message, "level": level} - ) - print(message_obj) - return message_obj - - def debug(self, message): - return self.add_message(message, "debug") - - def info(self, message): - return self.add_message(message, "info") - - def warning(self, message): - return self.add_message(message, "warning") - - def error(self, message): - return self.add_message(message, "error") - - def critical(self, message): - return self.add_message(message, "critical") + @classmethod + def from_data(cls, data): + return cls(**data) class ProjectPushRepreItem: @@ -508,22 +439,21 @@ class ProjectPushRepreItem: class ProjectPushItemProcess: """ Args: + model (IntegrateModel): Model which is processing item. item (ProjectPushItem): Item which is being processed. - item_status (ProjectPushItemStatus): Object to store status. """ # TODO where to get host?!!! host_name = "republisher" - def __init__(self, item, item_status=None): + def __init__(self, model, item): + self._model = model self._item = item - self._src_project_doc = None self._src_asset_doc = None self._src_subset_doc = None self._src_version_doc = None self._src_repre_items = None - self._src_anatomy = None self._project_doc = None self._anatomy = None @@ -539,85 +469,98 @@ class ProjectPushItemProcess: self._project_settings = None self._template_name = None - if item_status is None: - item_status = ProjectPushItemStatus() - self._status = item_status + self._status = ProjectPushItemStatus() self._operations = OperationsSession() self._file_transaction = FileTransaction() - @property - def status(self): - return self._status + self._messages = [] @property - def src_project_doc(self): - return self._src_project_doc + def item_id(self): + return self._item.item_id @property - def src_anatomy(self): - return self._src_anatomy + def started(self): + return self._status.started - @property - def src_asset_doc(self): - return self._src_asset_doc + def get_status_data(self): + return self._status.to_data() - @property - def src_subset_doc(self): - return self._src_subset_doc + def integrate(self): + self._status.started = True + try: + self._log_info("Process started") + self._fill_source_variables() + self._log_info("Source entities were found") + self._fill_destination_project() + self._log_info("Destination project was found") + self._fill_or_create_destination_asset() + self._log_info("Destination asset was determined") + self._determine_family() + self._determine_publish_template_name() + self._determine_subset_name() + self._make_sure_subset_exists() + self._make_sure_version_exists() + self._log_info("Prerequirements were prepared") + self._integrate_representations() + self._log_info("Integration finished") - @property - def src_version_doc(self): - return self._src_version_doc + except PushToProjectError as exc: + if not self._status.failed: + self._status.set_failed(str(exc)) - @property - def src_repre_items(self): - return self._src_repre_items + except Exception as exc: + _exc, _value, _tb = sys.exc_info() + self._status.set_failed( + "Unhandled error happened: {}".format(str(exc)), + (_exc, _value, _tb) + ) - @property - def project_doc(self): - return self._project_doc + finally: + self._status.finished = True + self._emit_event( + "push.finished.changed", + { + "finished": True, + "item_id": self.item_id, + } + ) - @property - def anatomy(self): - return self._anatomy + def _emit_event(self, topic, data): + self._model.emit_event(topic, data) - @property - def project_settings(self): - return self._project_settings + # Loggin helpers + # TODO better logging + def _add_message(self, message, level): + message_obj = StatusMessage(message, level) + self._messages.append(message_obj) + self._emit_event( + "push.message.added", + { + "message": message, + "level": level, + "item_id": self.item_id, + } + ) + print(message_obj) + return message_obj - @property - def asset_doc(self): - return self._asset_doc + def _log_debug(self, message): + return self._add_message(message, "debug") - @property - def task_info(self): - return self._task_info + def _log_info(self, message): + return self._add_message(message, "info") - @property - def subset_doc(self): - return self._subset_doc + def _log_warning(self, message): + return self._add_message(message, "warning") - @property - def version_doc(self): - return self._version_doc + def _log_error(self, message): + return self._add_message(message, "error") - @property - def variant(self): - return self._item.variant + def _log_critical(self, message): + return self._add_message(message, "critical") - @property - def family(self): - return self._family - - @property - def subset_name(self): - return self._subset_name - - @property - def template_name(self): - return self._template_name - - def fill_source_variables(self): + def _fill_source_variables(self): src_project_name = self._item.src_project_name src_version_id = self._item.src_version_id @@ -626,9 +569,14 @@ class ProjectPushItemProcess: self._status.set_failed( f"Source project \"{src_project_name}\" was not found" ) + + self._emit_event( + "push.failed.changed", + {"item_id": self.item_id} + ) raise PushToProjectError(self._status.fail_reason) - self._status.debug(f"Project '{src_project_name}' found") + self._log_debug(f"Project '{src_project_name}' found") version_doc = get_version_by_id(src_project_name, src_version_id) if not version_doc: @@ -666,7 +614,7 @@ class ProjectPushItemProcess: ProjectPushRepreItem(repre_doc, anatomy.roots) for repre_doc in repre_docs ] - self._status.debug(( + self._log_debug(( f"Found {len(repre_items)} representations on" f" version {src_version_id} in project '{src_project_name}'" )) @@ -677,14 +625,12 @@ class ProjectPushItemProcess: ) raise PushToProjectError(self._status.fail_reason) - self._src_anatomy = anatomy - self._src_project_doc = project_doc self._src_asset_doc = asset_doc self._src_subset_doc = subset_doc self._src_version_doc = version_doc self._src_repre_items = repre_items - def fill_destination_project(self): + def _fill_destination_project(self): # --- Destination entities --- dst_project_name = self._item.dst_project_name # Validate project existence @@ -695,7 +641,7 @@ class ProjectPushItemProcess: ) raise PushToProjectError(self._status.fail_reason) - self._status.debug( + self._log_debug( f"Destination project '{dst_project_name}' found" ) self._project_doc = dst_project_doc @@ -739,7 +685,7 @@ class ProjectPushItemProcess: )) raise PushToProjectError(self._status.fail_reason) - self._status.debug(( + self._log_debug(( f"Found already existing asset with name \"{other_name}\"" f" which match requested name \"{asset_name}\"" )) @@ -780,18 +726,18 @@ class ProjectPushItemProcess: asset_doc["type"], asset_doc ) - self._status.info( + self._log_info( f"Creating new asset with name \"{asset_name}\"" ) self._created_asset_doc = asset_doc return asset_doc - def fill_or_create_destination_asset(self): + def _fill_or_create_destination_asset(self): dst_project_name = self._item.dst_project_name - dst_asset_id = self._item.dst_asset_id + dst_folder_id = self._item.dst_folder_id dst_task_name = self._item.dst_task_name - new_asset_name = self._item.new_asset_name - if not dst_asset_id and not new_asset_name: + new_folder_name = self._item.new_folder_name + if not dst_folder_id and not new_folder_name: self._status.set_failed( "Push item does not have defined destination asset" ) @@ -799,25 +745,25 @@ class ProjectPushItemProcess: # Get asset document parent_asset_doc = None - if dst_asset_id: + if dst_folder_id: parent_asset_doc = get_asset_by_id( - self._item.dst_project_name, self._item.dst_asset_id + self._item.dst_project_name, self._item.dst_folder_id ) if not parent_asset_doc: self._status.set_failed( - f"Could find asset with id \"{dst_asset_id}\"" + f"Could find asset with id \"{dst_folder_id}\"" f" in project \"{dst_project_name}\"" ) raise PushToProjectError(self._status.fail_reason) - if not new_asset_name: + if not new_folder_name: asset_doc = parent_asset_doc else: asset_doc = self._create_asset( - self.src_asset_doc, - self.project_doc, + self._src_asset_doc, + self._project_doc, parent_asset_doc, - new_asset_name + new_folder_name ) self._asset_doc = asset_doc if not dst_task_name: @@ -842,12 +788,13 @@ class ProjectPushItemProcess: task_info["name"] = dst_task_name # Fill rest of task information based on task type task_type = task_info["type"] - task_type_info = self.project_doc["config"]["tasks"].get(task_type, {}) + task_type_info = self._project_doc["config"]["tasks"].get( + task_type, {}) task_info.update(task_type_info) self._task_info = task_info - def determine_family(self): - subset_doc = self.src_subset_doc + def _determine_family(self): + subset_doc = self._src_subset_doc family = subset_doc["data"].get("family") families = subset_doc["data"].get("families") if not family and families: @@ -859,48 +806,48 @@ class ProjectPushItemProcess: ) raise PushToProjectError(self._status.fail_reason) - self._status.debug( + self._log_debug( f"Publishing family is '{family}' (Based on source subset)" ) self._family = family - def determine_publish_template_name(self): + def _determine_publish_template_name(self): template_name = get_publish_template_name( self._item.dst_project_name, self.host_name, - self.family, - self.task_info.get("name"), - self.task_info.get("type"), - project_settings=self.project_settings + self._family, + self._task_info.get("name"), + self._task_info.get("type"), + project_settings=self._project_settings ) - self._status.debug( + self._log_debug( f"Using template '{template_name}' for integration" ) self._template_name = template_name - def determine_subset_name(self): - family = self.family - asset_doc = self.asset_doc - task_info = self.task_info + def _determine_subset_name(self): + family = self._family + asset_doc = self._asset_doc + task_info = self._task_info subset_name = get_subset_name( family, - self.variant, + self._item.variant, task_info.get("name"), asset_doc, project_name=self._item.dst_project_name, host_name=self.host_name, - project_settings=self.project_settings + project_settings=self._project_settings ) - self._status.info( + self._log_info( f"Push will be integrating to subset with name '{subset_name}'" ) self._subset_name = subset_name - def make_sure_subset_exists(self): + def _make_sure_subset_exists(self): project_name = self._item.dst_project_name - asset_id = self.asset_doc["_id"] - subset_name = self.subset_name - family = self.family + asset_id = self._asset_doc["_id"] + subset_name = self._subset_name + family = self._family subset_doc = get_subset_by_name(project_name, subset_name, asset_id) if subset_doc: self._subset_doc = subset_doc @@ -915,13 +862,13 @@ class ProjectPushItemProcess: self._operations.create_entity(project_name, "subset", subset_doc) self._subset_doc = subset_doc - def make_sure_version_exists(self): + def _make_sure_version_exists(self): """Make sure version document exits in database.""" project_name = self._item.dst_project_name version = self._item.dst_version - src_version_doc = self.src_version_doc - subset_doc = self.subset_doc + src_version_doc = self._src_version_doc + subset_doc = self._subset_doc subset_id = subset_doc["_id"] src_data = src_version_doc["data"] families = subset_doc["data"].get("families") @@ -947,8 +894,8 @@ class ProjectPushItemProcess: version = get_versioning_start( project_name, self.host_name, - task_name=self.task_info["name"], - task_type=self.task_info["type"], + task_name=self._task_info["name"], + task_type=self._task_info["type"], family=families[0], subset=subset_doc["name"] ) @@ -982,16 +929,16 @@ class ProjectPushItemProcess: self._version_doc = version_doc - def integrate_representations(self): + def _integrate_representations(self): try: - self._integrate_representations() + self._real_integrate_representations() except Exception: self._operations.clear() self._file_transaction.rollback() raise - def _integrate_representations(self): - version_doc = self.version_doc + def _real_integrate_representations(self): + version_doc = self._version_doc version_id = version_doc["_id"] existing_repres = get_representations( self._item.dst_project_name, @@ -1001,17 +948,17 @@ class ProjectPushItemProcess: repre_doc["name"].lower(): repre_doc for repre_doc in existing_repres } - template_name = self.template_name - anatomy = self.anatomy + template_name = self._template_name + anatomy = self._anatomy formatting_data = get_template_data( - self.project_doc, - self.asset_doc, - self.task_info.get("name"), + self._project_doc, + self._asset_doc, + self._task_info.get("name"), self.host_name ) formatting_data.update({ - "subset": self.subset_name, - "family": self.family, + "subset": self._subset_name, + "family": self._family, "version": version_doc["name"] }) @@ -1021,19 +968,19 @@ class ProjectPushItemProcess: file_template = StringTemplate( anatomy.templates[template_name]["file"] ) - self._status.info("Preparing files to transfer") + self._log_info("Preparing files to transfer") processed_repre_items = self._prepare_file_transactions( anatomy, template_name, formatting_data, file_template ) self._file_transaction.process() - self._status.info("Preparing database changes") + self._log_info("Preparing database changes") self._prepare_database_operations( version_id, processed_repre_items, path_template, existing_repres_by_low_name ) - self._status.info("Finalization") + self._log_info("Finalization") self._operations.commit() self._file_transaction.finalize() @@ -1041,7 +988,7 @@ class ProjectPushItemProcess: self, anatomy, template_name, formatting_data, file_template ): processed_repre_items = [] - for repre_item in self.src_repre_items: + for repre_item in self._src_repre_items: repre_doc = repre_item.repre_doc repre_name = repre_doc["name"] repre_format_data = copy.deepcopy(formatting_data) @@ -1050,6 +997,9 @@ class ProjectPushItemProcess: ext = os.path.splitext(src_file.path)[-1] repre_format_data["ext"] = ext[1:] break + repre_output_name = repre_doc["context"].get("output") + if repre_output_name is not None: + repre_format_data["output"] = repre_output_name template_obj = anatomy.templates_obj[template_name]["folder"] folder_path = template_obj.format_strict(formatting_data) @@ -1177,34 +1127,86 @@ class ProjectPushItemProcess: {"type": "archived_representation"} ) - def process(self): - try: - self._status.info("Process started") - self.fill_source_variables() - self._status.info("Source entities were found") - self.fill_destination_project() - self._status.info("Destination project was found") - self.fill_or_create_destination_asset() - self._status.info("Destination asset was determined") - self.determine_family() - self.determine_publish_template_name() - self.determine_subset_name() - self.make_sure_subset_exists() - self.make_sure_version_exists() - self._status.info("Prerequirements were prepared") - self.integrate_representations() - self._status.info("Integration finished") - except PushToProjectError as exc: - if not self._status.failed: - self._status.set_failed(str(exc)) +class IntegrateModel: + def __init__(self, controller): + self._controller = controller + self._process_items = {} - except Exception as exc: - _exc, _value, _tb = sys.exc_info() - self._status.set_failed( - "Unhandled error happened: {}".format(str(exc)), - (_exc, _value, _tb) - ) + def reset(self): + self._process_items = {} - finally: - self._status.set_finished() + def emit_event(self, topic, data=None, source=None): + self._controller.emit_event(topic, data, source) + + def create_process_item( + self, + src_project_name, + src_version_id, + dst_project_name, + dst_folder_id, + dst_task_name, + variant, + comment, + new_folder_name, + dst_version, + ): + """Create new item for integration. + + Args: + src_project_name (str): Source project name. + src_version_id (str): Source version id. + dst_project_name (str): Destination project name. + dst_folder_id (str): Destination folder id. + dst_task_name (str): Destination task name. + variant (str): Variant name. + comment (Union[str, None]): Comment. + new_folder_name (Union[str, None]): New folder name. + dst_version (int): Destination version number. + + Returns: + str: Item id. The id can be used to trigger integration or get + status information. + """ + + item = ProjectPushItem( + src_project_name, + src_version_id, + dst_project_name, + dst_folder_id, + dst_task_name, + variant, + comment=comment, + new_folder_name=new_folder_name, + dst_version=dst_version + ) + process_item = ProjectPushItemProcess(self, item) + self._process_items[item.item_id] = process_item + return item.item_id + + def integrate_item(self, item_id): + """Start integration of item. + + Args: + item_id (str): Item id which should be integrated. + """ + + item = self._process_items.get(item_id) + if item is None or item.started: + return + item.integrate() + + def get_item_status(self, item_id): + """Status of an item. + + Args: + item_id (str): Item id for which status should be returned. + + Returns: + dict[str, Any]: Status data. + """ + + item = self._process_items.get(item_id) + if item is not None: + return item.get_status_data() + return None diff --git a/openpype/tools/ayon_push_to_project/ui/window.py b/openpype/tools/ayon_push_to_project/ui/window.py index 57c4c2619f..535c01c643 100644 --- a/openpype/tools/ayon_push_to_project/ui/window.py +++ b/openpype/tools/ayon_push_to_project/ui/window.py @@ -231,7 +231,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget): self._main_thread_timer = main_thread_timer self._main_thread_timer_can_stop = True self._last_submit_message = None - self._process_item = None + self._process_item_id = None self._variant_is_valid = None self._folder_is_valid = None @@ -380,10 +380,10 @@ class PushToContextSelectWindow(QtWidgets.QWidget): self.close() def _on_select_click(self): - self._process_item = self._controller.submit(wait=False) + self._process_item_id = self._controller.submit(wait=False) def _on_try_again_click(self): - self._process_item = None + self._process_item_id = None self._last_submit_message = None self._overlay_close_btn.setVisible(False) @@ -395,9 +395,11 @@ class PushToContextSelectWindow(QtWidgets.QWidget): self._overlay_label.setText(self._last_submit_message) self._last_submit_message = None - process_status = self._process_item.status - push_failed = process_status.failed - fail_traceback = process_status.traceback + process_status = self._controller.get_process_item_status( + self._process_item_id + ) + push_failed = process_status["failed"] + fail_traceback = process_status["full_traceback"] if self._main_thread_timer_can_stop: self._main_thread_timer.stop() self._overlay_close_btn.setVisible(True) @@ -405,7 +407,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget): self._overlay_try_btn.setVisible(True) if push_failed: - message = "Push Failed:\n{}".format(process_status.fail_reason) + message = "Push Failed:\n{}".format(process_status["fail_reason"]) if fail_traceback: message += "\n{}".format(fail_traceback) self._overlay_label.setText(message) @@ -415,7 +417,7 @@ class PushToContextSelectWindow(QtWidgets.QWidget): # Join thread in controller self._controller.wait_for_process_thread() # Reset process item to None - self._process_item = None + self._process_item_id = None def _on_controller_submit_start(self): self._main_thread_timer_can_stop = False From 74b73648180b06966fbd44dc7fef995f073c080c Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 16 Oct 2023 12:24:18 +0200 Subject: [PATCH 33/46] fix re-use of 'output' for representation --- openpype/tools/ayon_push_to_project/models/integrate.py | 2 ++ openpype/tools/push_to_project/control_integrate.py | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/openpype/tools/ayon_push_to_project/models/integrate.py b/openpype/tools/ayon_push_to_project/models/integrate.py index b3de69c79a..976d8cb4f0 100644 --- a/openpype/tools/ayon_push_to_project/models/integrate.py +++ b/openpype/tools/ayon_push_to_project/models/integrate.py @@ -997,6 +997,8 @@ class ProjectPushItemProcess: ext = os.path.splitext(src_file.path)[-1] repre_format_data["ext"] = ext[1:] break + + # Re-use 'output' from source representation repre_output_name = repre_doc["context"].get("output") if repre_output_name is not None: repre_format_data["output"] = repre_output_name diff --git a/openpype/tools/push_to_project/control_integrate.py b/openpype/tools/push_to_project/control_integrate.py index a822339ccf..9f083d8eb7 100644 --- a/openpype/tools/push_to_project/control_integrate.py +++ b/openpype/tools/push_to_project/control_integrate.py @@ -1051,6 +1051,11 @@ class ProjectPushItemProcess: repre_format_data["ext"] = ext[1:] break + # Re-use 'output' from source representation + repre_output_name = repre_doc["context"].get("output") + if repre_output_name is not None: + repre_format_data["output"] = repre_output_name + template_obj = anatomy.templates_obj[template_name]["folder"] folder_path = template_obj.format_strict(formatting_data) repre_context = folder_path.used_values From 38883e4bddd699db3edd33b393adf0da347b5ffd Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Mon, 16 Oct 2023 14:04:16 +0200 Subject: [PATCH 34/46] removed unnecessary imports --- .../tools/ayon_push_to_project/models/__init__.py | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/openpype/tools/ayon_push_to_project/models/__init__.py b/openpype/tools/ayon_push_to_project/models/__init__.py index 5f909437a7..99355b4296 100644 --- a/openpype/tools/ayon_push_to_project/models/__init__.py +++ b/openpype/tools/ayon_push_to_project/models/__init__.py @@ -1,20 +1,10 @@ from .selection import PushToProjectSelectionModel from .user_values import UserPublishValuesModel -from .integrate import ( - ProjectPushItem, - ProjectPushItemProcess, - ProjectPushItemStatus, - IntegrateModel, -) +from .integrate import IntegrateModel __all__ = ( "PushToProjectSelectionModel", - "UserPublishValuesModel", - - "ProjectPushItem", - "ProjectPushItemProcess", - "ProjectPushItemStatus", "IntegrateModel", ) From 32501fbb2bbb44fe9751c6420a3c37f440b98620 Mon Sep 17 00:00:00 2001 From: jmichael7 <148431692+jmichael7@users.noreply.github.com> Date: Thu, 19 Oct 2023 22:46:54 +0530 Subject: [PATCH 35/46] Corrected a typo in Readme.md (Top -> To) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index ce98f845e6..ed3e058002 100644 --- a/README.md +++ b/README.md @@ -279,7 +279,7 @@ arguments and it will create zip file that OpenPype can use. Building documentation ---------------------- -Top build API documentation, run `.\tools\make_docs(.ps1|.sh)`. It will create html documentation +To build API documentation, run `.\tools\make_docs(.ps1|.sh)`. It will create html documentation from current sources in `.\docs\build`. **Note that it needs existing virtual environment.** From 8369dfddc98ec289c2daf5fa39b3f1af70532221 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 20 Oct 2023 11:43:30 +0800 Subject: [PATCH 36/46] use asset entity data for get_frame_range --- openpype/hosts/max/api/lib.py | 18 ++++++++++++------ .../plugins/publish/validate_frame_range.py | 5 +++-- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index 8b70b3ced7..fcd21111fa 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -234,22 +234,28 @@ def reset_scene_resolution(): set_scene_resolution(width, height) -def get_frame_range() -> Union[Dict[str, Any], None]: +def get_frame_range(asset_doc=None) -> Union[Dict[str, Any], None]: """Get the current assets frame range and handles. + Args: + asset_doc (dict): Asset Entity Data + Returns: dict: with frame start, frame end, handle start, handle end. """ # Set frame start/end - asset = get_current_project_asset() - frame_start = asset["data"].get("frameStart") - frame_end = asset["data"].get("frameEnd") + if asset_doc is None: + asset_doc = get_current_project_asset() + + data = asset_doc["data"] + frame_start = data.get("frameStart") + frame_end = data.get("frameEnd") if frame_start is None or frame_end is None: return - handle_start = asset["data"].get("handleStart", 0) - handle_end = asset["data"].get("handleEnd", 0) + handle_start = data.get("handleStart", 0) + handle_end = data.get("handleEnd", 0) return { "frameStart": frame_start, "frameEnd": frame_end, diff --git a/openpype/hosts/max/plugins/publish/validate_frame_range.py b/openpype/hosts/max/plugins/publish/validate_frame_range.py index b1e8aafbb7..1ca9761da6 100644 --- a/openpype/hosts/max/plugins/publish/validate_frame_range.py +++ b/openpype/hosts/max/plugins/publish/validate_frame_range.py @@ -37,10 +37,11 @@ class ValidateFrameRange(pyblish.api.InstancePlugin, def process(self, instance): if not self.is_active(instance.data): - self.log.info("Skipping validation...") + self.log.debug("Skipping Validate Frame Range...") return - frame_range = get_frame_range() + frame_range = get_frame_range( + asset_doc=instance.data["assetEntity"]) inst_frame_start = instance.data.get("frameStart") inst_frame_end = instance.data.get("frameEnd") From ea1c9bf70722ef075a02c446df2685e4dd50e00f Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Fri, 20 Oct 2023 15:12:54 +0200 Subject: [PATCH 37/46] Removed redundant copy of extension.zxp (#5802) --- .../hosts/photoshop/api/extension/extension.zxp | Bin 54056 -> 0 bytes 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 openpype/hosts/photoshop/api/extension/extension.zxp diff --git a/openpype/hosts/photoshop/api/extension/extension.zxp b/openpype/hosts/photoshop/api/extension/extension.zxp deleted file mode 100644 index 39b766cd0d354e63c5b0e5b874be47131860e3c6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 54056 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 Date: Fri, 20 Oct 2023 21:34:48 +0800 Subject: [PATCH 38/46] rename frame range data --- openpype/hosts/max/api/lib.py | 23 +++++++++--------- .../plugins/publish/collect_frame_range.py | 8 +++---- .../max/plugins/publish/collect_render.py | 4 ++-- .../max/plugins/publish/collect_review.py | 2 ++ .../max/plugins/publish/extract_camera_abc.py | 4 ++-- .../max/plugins/publish/extract_pointcache.py | 4 ++-- .../max/plugins/publish/extract_pointcloud.py | 4 ++-- .../plugins/publish/extract_redshift_proxy.py | 4 ++-- .../publish/extract_review_animation.py | 4 ++-- .../max/plugins/publish/extract_thumbnail.py | 2 +- .../plugins/publish/validate_frame_range.py | 24 +++++++++---------- 11 files changed, 42 insertions(+), 41 deletions(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index fcd21111fa..979665d892 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -248,19 +248,19 @@ def get_frame_range(asset_doc=None) -> Union[Dict[str, Any], None]: asset_doc = get_current_project_asset() data = asset_doc["data"] - frame_start = data.get("frameStart") - frame_end = data.get("frameEnd") + frame_start = data.get("frameStart", 0) + frame_end = data.get("frameEnd", 0) if frame_start is None or frame_end is None: return handle_start = data.get("handleStart", 0) handle_end = data.get("handleEnd", 0) + frame_start_handle = int(frame_start) - int(handle_start) + frame_end_handle = int(frame_end) + int(handle_end) return { - "frameStart": frame_start, - "frameEnd": frame_end, - "handleStart": handle_start, - "handleEnd": handle_end + "frame_start_handle": frame_start_handle, + "frame_end_handle": frame_end_handle, } @@ -280,12 +280,11 @@ def reset_frame_range(fps: bool = True): fps_number = float(data_fps["data"]["fps"]) rt.frameRate = fps_number frame_range = get_frame_range() - frame_start_handle = frame_range["frameStart"] - int( - frame_range["handleStart"] - ) - frame_end_handle = frame_range["frameEnd"] + int(frame_range["handleEnd"]) - set_timeline(frame_start_handle, frame_end_handle) - set_render_frame_range(frame_start_handle, frame_end_handle) + + set_timeline( + frame_range["frame_start_handle"], frame_range["frame_end_handle"]) + set_render_frame_range( + frame_range["frame_start_handle"], frame_range["frame_end_handle"]) def set_context_setting(): diff --git a/openpype/hosts/max/plugins/publish/collect_frame_range.py b/openpype/hosts/max/plugins/publish/collect_frame_range.py index 2dd39b5b50..e83733e4f6 100644 --- a/openpype/hosts/max/plugins/publish/collect_frame_range.py +++ b/openpype/hosts/max/plugins/publish/collect_frame_range.py @@ -16,8 +16,8 @@ class CollectFrameRange(pyblish.api.InstancePlugin): def process(self, instance): if instance.data["family"] == "maxrender": - instance.data["frameStart"] = int(rt.rendStart) - instance.data["frameEnd"] = int(rt.rendEnd) + instance.data["frameStartHandle"] = int(rt.rendStart) + instance.data["frameEndHandle"] = int(rt.rendEnd) else: - instance.data["frameStart"] = int(rt.animationRange.start) - instance.data["frameEnd"] = int(rt.animationRange.end) + instance.data["frameStartHandle"] = int(rt.animationRange.start) + instance.data["frameEndHandle"] = int(rt.animationRange.end) diff --git a/openpype/hosts/max/plugins/publish/collect_render.py b/openpype/hosts/max/plugins/publish/collect_render.py index a359e61921..fe580aafc8 100644 --- a/openpype/hosts/max/plugins/publish/collect_render.py +++ b/openpype/hosts/max/plugins/publish/collect_render.py @@ -97,8 +97,8 @@ class CollectRender(pyblish.api.InstancePlugin): "renderer": renderer, "source": filepath, "plugin": "3dsmax", - "frameStart": int(rt.rendStart), - "frameEnd": int(rt.rendEnd), + "frameStart": instance.data.get("frameStartHandle"), + "frameEnd": instance.data.get("frameEndHandle"), "version": version_int, "farm": True } diff --git a/openpype/hosts/max/plugins/publish/collect_review.py b/openpype/hosts/max/plugins/publish/collect_review.py index cc4caae497..fd5bfddf20 100644 --- a/openpype/hosts/max/plugins/publish/collect_review.py +++ b/openpype/hosts/max/plugins/publish/collect_review.py @@ -29,6 +29,8 @@ class CollectReview(pyblish.api.InstancePlugin, attr_values = self.get_attr_values_from_data(instance.data) data = { "review_camera": camera_name, + "frameStart": instance.data.get("frameStartHandle"), + "frameEnd": instance.data.get("frameEndHandle"), "fps": instance.context.data["fps"], "dspGeometry": attr_values.get("dspGeometry"), "dspShapes": attr_values.get("dspShapes"), diff --git a/openpype/hosts/max/plugins/publish/extract_camera_abc.py b/openpype/hosts/max/plugins/publish/extract_camera_abc.py index ea33bc67ed..a42f27be6e 100644 --- a/openpype/hosts/max/plugins/publish/extract_camera_abc.py +++ b/openpype/hosts/max/plugins/publish/extract_camera_abc.py @@ -19,8 +19,8 @@ class ExtractCameraAlembic(publish.Extractor, OptionalPyblishPluginMixin): def process(self, instance): if not self.is_active(instance.data): return - start = instance.data["frameStart"] - end = instance.data["frameEnd"] + start = instance.data["frameStartHandle"] + end = instance.data["frameEndHandle"] self.log.info("Extracting Camera ...") diff --git a/openpype/hosts/max/plugins/publish/extract_pointcache.py b/openpype/hosts/max/plugins/publish/extract_pointcache.py index a5480ff0dc..f6a8500c08 100644 --- a/openpype/hosts/max/plugins/publish/extract_pointcache.py +++ b/openpype/hosts/max/plugins/publish/extract_pointcache.py @@ -51,8 +51,8 @@ class ExtractAlembic(publish.Extractor): families = ["pointcache"] def process(self, instance): - start = instance.data["frameStart"] - end = instance.data["frameEnd"] + start = instance.data["frameStartHandle"] + end = instance.data["frameEndHandle"] self.log.debug("Extracting pointcache ...") diff --git a/openpype/hosts/max/plugins/publish/extract_pointcloud.py b/openpype/hosts/max/plugins/publish/extract_pointcloud.py index 79b4301377..d9fbe5e9dd 100644 --- a/openpype/hosts/max/plugins/publish/extract_pointcloud.py +++ b/openpype/hosts/max/plugins/publish/extract_pointcloud.py @@ -40,8 +40,8 @@ class ExtractPointCloud(publish.Extractor): def process(self, instance): self.settings = self.get_setting(instance) - start = instance.data["frameStart"] - end = instance.data["frameEnd"] + start = instance.data["frameStartHandle"] + end = instance.data["frameEndHandle"] self.log.info("Extracting PRT...") stagingdir = self.staging_dir(instance) diff --git a/openpype/hosts/max/plugins/publish/extract_redshift_proxy.py b/openpype/hosts/max/plugins/publish/extract_redshift_proxy.py index 4f64e88584..47ed85977b 100644 --- a/openpype/hosts/max/plugins/publish/extract_redshift_proxy.py +++ b/openpype/hosts/max/plugins/publish/extract_redshift_proxy.py @@ -16,8 +16,8 @@ class ExtractRedshiftProxy(publish.Extractor): families = ["redshiftproxy"] def process(self, instance): - start = instance.data["frameStart"] - end = instance.data["frameEnd"] + start = instance.data["frameStartHandle"] + end = instance.data["frameEndHandle"] self.log.debug("Extracting Redshift Proxy...") stagingdir = self.staging_dir(instance) diff --git a/openpype/hosts/max/plugins/publish/extract_review_animation.py b/openpype/hosts/max/plugins/publish/extract_review_animation.py index 8e06e52b5c..af86ed7694 100644 --- a/openpype/hosts/max/plugins/publish/extract_review_animation.py +++ b/openpype/hosts/max/plugins/publish/extract_review_animation.py @@ -48,8 +48,8 @@ class ExtractReviewAnimation(publish.Extractor): "ext": instance.data["imageFormat"], "files": filenames, "stagingDir": staging_dir, - "frameStart": instance.data["frameStart"], - "frameEnd": instance.data["frameEnd"], + "frameStart": instance.data["frameStartHandle"], + "frameEnd": instance.data["frameEndHandle"], "tags": tags, "preview": True, "camera_name": review_camera diff --git a/openpype/hosts/max/plugins/publish/extract_thumbnail.py b/openpype/hosts/max/plugins/publish/extract_thumbnail.py index 82f4fc7a8b..0e7da89fa2 100644 --- a/openpype/hosts/max/plugins/publish/extract_thumbnail.py +++ b/openpype/hosts/max/plugins/publish/extract_thumbnail.py @@ -24,7 +24,7 @@ class ExtractThumbnail(publish.Extractor): f"Create temp directory {tmp_staging} for thumbnail" ) fps = int(instance.data["fps"]) - frame = int(instance.data["frameStart"]) + frame = int(instance.data["frameStartHandle"]) instance.context.data["cleanupFullPaths"].append(tmp_staging) filename = "{name}_thumbnail..png".format(**instance.data) filepath = os.path.join(tmp_staging, filename) diff --git a/openpype/hosts/max/plugins/publish/validate_frame_range.py b/openpype/hosts/max/plugins/publish/validate_frame_range.py index 1ca9761da6..fa1ff7e380 100644 --- a/openpype/hosts/max/plugins/publish/validate_frame_range.py +++ b/openpype/hosts/max/plugins/publish/validate_frame_range.py @@ -43,14 +43,10 @@ class ValidateFrameRange(pyblish.api.InstancePlugin, frame_range = get_frame_range( asset_doc=instance.data["assetEntity"]) - inst_frame_start = instance.data.get("frameStart") - inst_frame_end = instance.data.get("frameEnd") - frame_start_handle = frame_range["frameStart"] - int( - frame_range["handleStart"] - ) - frame_end_handle = frame_range["frameEnd"] + int( - frame_range["handleEnd"] - ) + inst_frame_start = instance.data.get("frameStartHandle") + inst_frame_end = instance.data.get("frameEndHandle") + frame_start_handle = frame_range["frame_start_handle"] + frame_end_handle = frame_range["frame_end_handle"] errors = [] if frame_start_handle != inst_frame_start: errors.append( @@ -63,10 +59,14 @@ class ValidateFrameRange(pyblish.api.InstancePlugin, "from the asset data. ") if errors: - errors.append("You can use repair action to fix it.") - report = "Frame range settings are incorrect.\n\n" - for error in errors: - report += "- {}\n\n".format(error) + bullet_point_errors = "\n".join( + "- {}".format(error) for error in errors + ) + report = ( + "Frame range settings are incorrect.\n\n" + f"{bullet_point_errors}\n\n" + "You can use repair action to fix it." + ) raise PublishValidationError(report, title="Frame Range incorrect") @classmethod From 25c0c1996f7dccefcc6016c364d5d87e5cd65bd4 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 20 Oct 2023 15:41:34 +0200 Subject: [PATCH 39/46] removed 'shotgun_api3' from openpype dependencies --- server_addon/openpype/client/pyproject.toml | 1 - 1 file changed, 1 deletion(-) diff --git a/server_addon/openpype/client/pyproject.toml b/server_addon/openpype/client/pyproject.toml index 6d5ac92ca7..40da8f6716 100644 --- a/server_addon/openpype/client/pyproject.toml +++ b/server_addon/openpype/client/pyproject.toml @@ -8,7 +8,6 @@ aiohttp_json_rpc = "*" # TVPaint server aiohttp-middlewares = "^2.0.0" wsrpc_aiohttp = "^3.1.1" # websocket server clique = "1.6.*" -shotgun_api3 = {git = "https://github.com/shotgunsoftware/python-api.git", rev = "v3.3.3"} gazu = "^0.9.3" google-api-python-client = "^1.12.8" # sync server google support (should be separate?) jsonschema = "^2.6.0" From 6453de982326f086e1d8dd1fda4d17ab0fc0fc7e Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 20 Oct 2023 22:11:11 +0800 Subject: [PATCH 40/46] align the frame range data with other hosts like Maya --- openpype/hosts/max/api/lib.py | 12 +++++++---- .../plugins/publish/validate_frame_range.py | 21 +++++++++++-------- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index 979665d892..8aa38b013a 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -259,8 +259,12 @@ def get_frame_range(asset_doc=None) -> Union[Dict[str, Any], None]: frame_start_handle = int(frame_start) - int(handle_start) frame_end_handle = int(frame_end) + int(handle_end) return { - "frame_start_handle": frame_start_handle, - "frame_end_handle": frame_end_handle, + "frameStart": frame_start, + "frameEnd": frame_end, + "handleStart": handle_start, + "handleEnd": handle_end, + "frameStartHandle": frame_start_handle, + "frameEndHandle": frame_end_handle, } @@ -282,9 +286,9 @@ def reset_frame_range(fps: bool = True): frame_range = get_frame_range() set_timeline( - frame_range["frame_start_handle"], frame_range["frame_end_handle"]) + frame_range["frameStartHandle"], frame_range["frameEndHandle"]) set_render_frame_range( - frame_range["frame_start_handle"], frame_range["frame_end_handle"]) + frame_range["frameStartHandle"], frame_range["frameEndHandle"]) def set_context_setting(): diff --git a/openpype/hosts/max/plugins/publish/validate_frame_range.py b/openpype/hosts/max/plugins/publish/validate_frame_range.py index fa1ff7e380..0e8316e844 100644 --- a/openpype/hosts/max/plugins/publish/validate_frame_range.py +++ b/openpype/hosts/max/plugins/publish/validate_frame_range.py @@ -7,7 +7,8 @@ from openpype.pipeline import ( from openpype.pipeline.publish import ( RepairAction, ValidateContentsOrder, - PublishValidationError + PublishValidationError, + KnownPublishError ) from openpype.hosts.max.api.lib import get_frame_range, set_timeline @@ -45,8 +46,13 @@ class ValidateFrameRange(pyblish.api.InstancePlugin, inst_frame_start = instance.data.get("frameStartHandle") inst_frame_end = instance.data.get("frameEndHandle") - frame_start_handle = frame_range["frame_start_handle"] - frame_end_handle = frame_range["frame_end_handle"] + if inst_frame_start is None or inst_frame_end is None: + raise KnownPublishError( + "Missing frame start and frame end on " + "instance to to validate." + ) + frame_start_handle = frame_range["frameStartHandle"] + frame_end_handle = frame_range["frameEndHandle"] errors = [] if frame_start_handle != inst_frame_start: errors.append( @@ -72,12 +78,9 @@ class ValidateFrameRange(pyblish.api.InstancePlugin, @classmethod def repair(cls, instance): frame_range = get_frame_range() - frame_start_handle = frame_range["frameStart"] - int( - frame_range["handleStart"] - ) - frame_end_handle = frame_range["frameEnd"] + int( - frame_range["handleEnd"] - ) + frame_start_handle = frame_range["frameStartHandle"] + frame_end_handle = frame_range["frameEndHandle"] + if instance.data["family"] == "maxrender": rt.rendStart = frame_start_handle rt.rendEnd = frame_end_handle From c5b63b241d199c87766f5a7d8a7c59946b8c9dd3 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 20 Oct 2023 22:12:20 +0800 Subject: [PATCH 41/46] check if frame start and frame end is None, if yes it will return empty dict --- openpype/hosts/max/api/lib.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index 8aa38b013a..f62f580e83 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -248,11 +248,11 @@ def get_frame_range(asset_doc=None) -> Union[Dict[str, Any], None]: asset_doc = get_current_project_asset() data = asset_doc["data"] - frame_start = data.get("frameStart", 0) - frame_end = data.get("frameEnd", 0) + frame_start = data.get("frameStart") + frame_end = data.get("frameEnd") if frame_start is None or frame_end is None: - return + return {} handle_start = data.get("handleStart", 0) handle_end = data.get("handleEnd", 0) From 69d665fd7d7809e9cab1941433627215956dd5fb Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 20 Oct 2023 23:03:52 +0800 Subject: [PATCH 42/46] make sure the collectorcorder fro collect render is later than collect frame range --- openpype/hosts/max/plugins/publish/collect_render.py | 6 +++--- openpype/hosts/max/plugins/publish/collect_review.py | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/max/plugins/publish/collect_render.py b/openpype/hosts/max/plugins/publish/collect_render.py index fe580aafc8..7765b3b924 100644 --- a/openpype/hosts/max/plugins/publish/collect_render.py +++ b/openpype/hosts/max/plugins/publish/collect_render.py @@ -14,7 +14,7 @@ from openpype.client import get_last_version_by_subset_name class CollectRender(pyblish.api.InstancePlugin): """Collect Render for Deadline""" - order = pyblish.api.CollectorOrder + 0.01 + order = pyblish.api.CollectorOrder + 0.02 label = "Collect 3dsmax Render Layers" hosts = ['max'] families = ["maxrender"] @@ -97,8 +97,8 @@ class CollectRender(pyblish.api.InstancePlugin): "renderer": renderer, "source": filepath, "plugin": "3dsmax", - "frameStart": instance.data.get("frameStartHandle"), - "frameEnd": instance.data.get("frameEndHandle"), + "frameStart": instance.data["frameStartHandle"], + "frameEnd": instance.data["frameEndHandle"], "version": version_int, "farm": True } diff --git a/openpype/hosts/max/plugins/publish/collect_review.py b/openpype/hosts/max/plugins/publish/collect_review.py index fd5bfddf20..531521fa38 100644 --- a/openpype/hosts/max/plugins/publish/collect_review.py +++ b/openpype/hosts/max/plugins/publish/collect_review.py @@ -29,8 +29,8 @@ class CollectReview(pyblish.api.InstancePlugin, attr_values = self.get_attr_values_from_data(instance.data) data = { "review_camera": camera_name, - "frameStart": instance.data.get("frameStartHandle"), - "frameEnd": instance.data.get("frameEndHandle"), + "frameStart": instance.data["frameStartHandle"], + "frameEnd": instance.data["frameEndHandle"], "fps": instance.context.data["fps"], "dspGeometry": attr_values.get("dspGeometry"), "dspShapes": attr_values.get("dspShapes"), From 0240b2665d0a8ae446334d660cae9afaa64e7b74 Mon Sep 17 00:00:00 2001 From: Jakub Trllo Date: Fri, 20 Oct 2023 17:17:35 +0200 Subject: [PATCH 43/46] use context data instead of 'legacy_io' --- .../modules/timers_manager/plugins/publish/start_timer.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/openpype/modules/timers_manager/plugins/publish/start_timer.py b/openpype/modules/timers_manager/plugins/publish/start_timer.py index 6408327ca1..19a67292f5 100644 --- a/openpype/modules/timers_manager/plugins/publish/start_timer.py +++ b/openpype/modules/timers_manager/plugins/publish/start_timer.py @@ -6,8 +6,6 @@ Requires: import pyblish.api -from openpype.pipeline import legacy_io - class StartTimer(pyblish.api.ContextPlugin): label = "Start Timer" @@ -25,9 +23,9 @@ class StartTimer(pyblish.api.ContextPlugin): self.log.debug("Publish is not affecting running timers.") return - project_name = legacy_io.active_project() - asset_name = legacy_io.Session.get("AVALON_ASSET") - task_name = legacy_io.Session.get("AVALON_TASK") + project_name = context.data["projectName"] + asset_name = context.data.get("asset") + task_name = context.data.get("task") if not project_name or not asset_name or not task_name: self.log.info(( "Current context does not contain all" From bf38b2bbefa47614af271ae7c68ee265c84701e2 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 20 Oct 2023 23:25:37 +0800 Subject: [PATCH 44/46] clean up the codes for collect frame range and get frame range function --- openpype/hosts/max/api/lib.py | 11 +++++++---- .../hosts/max/plugins/publish/collect_frame_range.py | 1 - 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/openpype/hosts/max/api/lib.py b/openpype/hosts/max/api/lib.py index f62f580e83..edbd14bb8b 100644 --- a/openpype/hosts/max/api/lib.py +++ b/openpype/hosts/max/api/lib.py @@ -254,10 +254,13 @@ def get_frame_range(asset_doc=None) -> Union[Dict[str, Any], None]: if frame_start is None or frame_end is None: return {} - handle_start = data.get("handleStart", 0) - handle_end = data.get("handleEnd", 0) - frame_start_handle = int(frame_start) - int(handle_start) - frame_end_handle = int(frame_end) + int(handle_end) + frame_start = int(frame_start) + frame_end = int(frame_end) + handle_start = int(data.get("handleStart", 0)) + handle_end = int(data.get("handleEnd", 0)) + frame_start_handle = frame_start - handle_start + frame_end_handle = frame_end + handle_end + return { "frameStart": frame_start, "frameEnd": frame_end, diff --git a/openpype/hosts/max/plugins/publish/collect_frame_range.py b/openpype/hosts/max/plugins/publish/collect_frame_range.py index e83733e4f6..86fb6e856c 100644 --- a/openpype/hosts/max/plugins/publish/collect_frame_range.py +++ b/openpype/hosts/max/plugins/publish/collect_frame_range.py @@ -1,5 +1,4 @@ # -*- coding: utf-8 -*- -"""Collect instance members.""" import pyblish.api from pymxs import runtime as rt From caf1cc5cc258fedd2dbcc3fc58e79a7f6acf489c Mon Sep 17 00:00:00 2001 From: Ynbot Date: Sat, 21 Oct 2023 03:24:31 +0000 Subject: [PATCH 45/46] [Automated] Bump version --- openpype/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/version.py b/openpype/version.py index ec09c45abb..e2e3c663af 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.17.3" +__version__ = "3.17.4-nightly.1" From 085398d14c87f3f1882e0fbc3ca40191f6b3241f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 21 Oct 2023 03:25:11 +0000 Subject: [PATCH 46/46] chore(): update bug report / version --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index d63d05f477..3c126048da 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,7 @@ body: label: Version description: What version are you running? Look to OpenPype Tray options: + - 3.17.4-nightly.1 - 3.17.3 - 3.17.3-nightly.2 - 3.17.3-nightly.1 @@ -134,7 +135,6 @@ body: - 3.15.0 - 3.15.0-nightly.1 - 3.14.11-nightly.4 - - 3.14.11-nightly.3 validations: required: true - type: dropdown