diff --git a/openpype/hosts/aftereffects/api/__init__.py b/openpype/hosts/aftereffects/api/__init__.py
index 9a80801652..99636e8dda 100644
--- a/openpype/hosts/aftereffects/api/__init__.py
+++ b/openpype/hosts/aftereffects/api/__init__.py
@@ -5,7 +5,7 @@ import logging
from avalon import io
from avalon import api as avalon
from avalon.vendor import Qt
-from openpype import lib
+from openpype import lib, api
import pyblish.api as pyblish
import openpype.hosts.aftereffects
@@ -81,3 +81,69 @@ def uninstall():
def on_pyblish_instance_toggled(instance, old_value, new_value):
"""Toggle layer visibility on instance toggles."""
instance[0].Visible = new_value
+
+
+def get_asset_settings():
+ """Get settings on current asset from database.
+
+ Returns:
+ dict: Scene data.
+
+ """
+ asset_data = lib.get_asset()["data"]
+ fps = asset_data.get("fps")
+ frame_start = asset_data.get("frameStart")
+ frame_end = asset_data.get("frameEnd")
+ handle_start = asset_data.get("handleStart")
+ handle_end = asset_data.get("handleEnd")
+ resolution_width = asset_data.get("resolutionWidth")
+ resolution_height = asset_data.get("resolutionHeight")
+ duration = frame_end + handle_end - max(frame_start - handle_start, 0)
+ entity_type = asset_data.get("entityType")
+
+ scene_data = {
+ "fps": fps,
+ "frameStart": frame_start,
+ "frameEnd": frame_end,
+ "handleStart": handle_start,
+ "handleEnd": handle_end,
+ "resolutionWidth": resolution_width,
+ "resolutionHeight": resolution_height,
+ "duration": duration
+ }
+
+ try:
+ # temporary, in pype3 replace with api.get_current_project_settings
+ skip_resolution_check = (
+ api.get_current_project_settings()
+ ["plugins"]
+ ["aftereffects"]
+ ["publish"]
+ ["ValidateSceneSettings"]
+ ["skip_resolution_check"]
+ )
+ skip_timelines_check = (
+ api.get_current_project_settings()
+ ["plugins"]
+ ["aftereffects"]
+ ["publish"]
+ ["ValidateSceneSettings"]
+ ["skip_timelines_check"]
+ )
+ except KeyError:
+ skip_resolution_check = ['*']
+ skip_timelines_check = ['*']
+
+ if os.getenv('AVALON_TASK') in skip_resolution_check or \
+ '*' in skip_timelines_check:
+ scene_data.pop("resolutionWidth")
+ scene_data.pop("resolutionHeight")
+
+ if entity_type in skip_timelines_check or '*' in skip_timelines_check:
+ scene_data.pop('fps', None)
+ scene_data.pop('frameStart', None)
+ scene_data.pop('frameEnd', None)
+ scene_data.pop('handleStart', None)
+ scene_data.pop('handleEnd', None)
+
+ return scene_data
diff --git a/openpype/hosts/aftereffects/plugins/publish/collect_render.py b/openpype/hosts/aftereffects/plugins/publish/collect_render.py
index ba64551283..baac64ed0c 100644
--- a/openpype/hosts/aftereffects/plugins/publish/collect_render.py
+++ b/openpype/hosts/aftereffects/plugins/publish/collect_render.py
@@ -12,6 +12,7 @@ class AERenderInstance(RenderInstance):
# extend generic, composition name is needed
comp_name = attr.ib(default=None)
comp_id = attr.ib(default=None)
+ fps = attr.ib(default=None)
class CollectAERender(abstract_collect_render.AbstractCollectRender):
@@ -45,6 +46,7 @@ class CollectAERender(abstract_collect_render.AbstractCollectRender):
raise ValueError("Couldn't find id, unable to publish. " +
"Please recreate instance.")
item_id = inst["members"][0]
+
work_area_info = self.stub.get_work_area(int(item_id))
if not work_area_info:
@@ -57,6 +59,8 @@ class CollectAERender(abstract_collect_render.AbstractCollectRender):
frameEnd = round(work_area_info.workAreaStart +
float(work_area_info.workAreaDuration) *
float(work_area_info.frameRate)) - 1
+ fps = work_area_info.frameRate
+ # TODO add resolution when supported by extension
if inst["family"] == "render" and inst["active"]:
instance = AERenderInstance(
@@ -86,7 +90,8 @@ class CollectAERender(abstract_collect_render.AbstractCollectRender):
frameStart=frameStart,
frameEnd=frameEnd,
frameStep=1,
- toBeRenderedOn='deadline'
+ toBeRenderedOn='deadline',
+ fps=fps
)
comp = compositions_by_id.get(int(item_id))
@@ -102,7 +107,6 @@ class CollectAERender(abstract_collect_render.AbstractCollectRender):
instances.append(instance)
- self.log.debug("instances::{}".format(instances))
return instances
def get_expected_files(self, render_instance):
diff --git a/openpype/hosts/aftereffects/plugins/publish/validate_scene_settings.py b/openpype/hosts/aftereffects/plugins/publish/validate_scene_settings.py
new file mode 100644
index 0000000000..cc7db3141f
--- /dev/null
+++ b/openpype/hosts/aftereffects/plugins/publish/validate_scene_settings.py
@@ -0,0 +1,110 @@
+# -*- coding: utf-8 -*-
+"""Validate scene settings."""
+import os
+
+import pyblish.api
+
+from avalon import aftereffects
+
+import openpype.hosts.aftereffects.api as api
+
+stub = aftereffects.stub()
+
+
+class ValidateSceneSettings(pyblish.api.InstancePlugin):
+ """
+ Ensures that Composition Settings (right mouse on comp) are same as
+ in FTrack on task.
+
+ By default checks only duration - how many frames should be rendered.
+ Compares:
+ Frame start - Frame end + 1 from FTrack
+ against
+ Duration in Composition Settings.
+
+ If this complains:
+ Check error message where is discrepancy.
+ Check FTrack task 'pype' section of task attributes for expected
+ values.
+ Check/modify rendered Composition Settings.
+
+ If you know what you are doing run publishing again, uncheck this
+ validation before Validation phase.
+ """
+
+ """
+ Dev docu:
+ Could be configured by 'presets/plugins/aftereffects/publish'
+
+ skip_timelines_check - fill task name for which skip validation of
+ frameStart
+ frameEnd
+ fps
+ handleStart
+ handleEnd
+ skip_resolution_check - fill entity type ('asset') to skip validation
+ resolutionWidth
+ resolutionHeight
+ TODO support in extension is missing for now
+
+ By defaults validates duration (how many frames should be published)
+ """
+
+ order = pyblish.api.ValidatorOrder
+ label = "Validate Scene Settings"
+ families = ["render.farm"]
+ hosts = ["aftereffects"]
+ optional = True
+
+ skip_timelines_check = ["*"] # * >> skip for all
+ skip_resolution_check = ["*"]
+
+ def process(self, instance):
+ """Plugin entry point."""
+ expected_settings = api.get_asset_settings()
+ self.log.info("expected_settings::{}".format(expected_settings))
+
+ # handle case where ftrack uses only two decimal places
+ # 23.976023976023978 vs. 23.98
+ fps = instance.data.get("fps")
+ if fps:
+ if isinstance(fps, float):
+ fps = float(
+ "{:.2f}".format(fps))
+ expected_settings["fps"] = fps
+
+ duration = instance.data.get("frameEndHandle") - \
+ instance.data.get("frameStartHandle") + 1
+
+ current_settings = {
+ "fps": fps,
+ "frameStartHandle": instance.data.get("frameStartHandle"),
+ "frameEndHandle": instance.data.get("frameEndHandle"),
+ "resolutionWidth": instance.data.get("resolutionWidth"),
+ "resolutionHeight": instance.data.get("resolutionHeight"),
+ "duration": duration
+ }
+ self.log.info("current_settings:: {}".format(current_settings))
+
+ invalid_settings = []
+ for key, value in expected_settings.items():
+ if value != current_settings[key]:
+ invalid_settings.append(
+ "{} expected: {} found: {}".format(key, value,
+ current_settings[key])
+ )
+
+ if ((expected_settings.get("handleStart")
+ or expected_settings.get("handleEnd"))
+ and invalid_settings):
+ msg = "Handles included in calculation. Remove handles in DB " +\
+ "or extend frame range in Composition Setting."
+ invalid_settings[-1]["reason"] = msg
+
+ msg = "Found invalid settings:\n{}".format(
+ "\n".join(invalid_settings)
+ )
+ assert not invalid_settings, msg
+ assert os.path.exists(instance.data.get("source")), (
+ "Scene file not found (saved under wrong name)"
+ )
diff --git a/openpype/hosts/blender/api/plugin.py b/openpype/hosts/blender/api/plugin.py
index eb88e7af63..de30da3319 100644
--- a/openpype/hosts/blender/api/plugin.py
+++ b/openpype/hosts/blender/api/plugin.py
@@ -9,7 +9,7 @@ from avalon import api
import avalon.blender
from openpype.api import PypeCreatorMixin
-VALID_EXTENSIONS = [".blend", ".json"]
+VALID_EXTENSIONS = [".blend", ".json", ".abc"]
def asset_name(
diff --git a/openpype/hosts/blender/plugins/create/create_pointcache.py b/openpype/hosts/blender/plugins/create/create_pointcache.py
new file mode 100644
index 0000000000..03a468f82e
--- /dev/null
+++ b/openpype/hosts/blender/plugins/create/create_pointcache.py
@@ -0,0 +1,35 @@
+"""Create a pointcache asset."""
+
+import bpy
+
+from avalon import api
+from avalon.blender import lib
+import openpype.hosts.blender.api.plugin
+
+
+class CreatePointcache(openpype.hosts.blender.api.plugin.Creator):
+ """Polygonal static geometry"""
+
+ name = "pointcacheMain"
+ label = "Point Cache"
+ family = "pointcache"
+ icon = "gears"
+
+ def process(self):
+
+ 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)
+ self.data['task'] = api.Session.get('AVALON_TASK')
+ lib.imprint(collection, self.data)
+
+ 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)
+
+ return collection
diff --git a/openpype/hosts/blender/plugins/load/load_abc.py b/openpype/hosts/blender/plugins/load/load_abc.py
new file mode 100644
index 0000000000..4248cffd69
--- /dev/null
+++ b/openpype/hosts/blender/plugins/load/load_abc.py
@@ -0,0 +1,246 @@
+"""Load an asset in Blender from an Alembic file."""
+
+from pathlib import Path
+from pprint import pformat
+from typing import Dict, List, Optional
+
+from avalon import api, blender
+import bpy
+import openpype.hosts.blender.api.plugin as plugin
+
+
+class CacheModelLoader(plugin.AssetLoader):
+ """Load cache models.
+
+ Stores the imported asset in a collection named after the asset.
+
+ Note:
+ At least for now it only supports Alembic files.
+ """
+
+ families = ["model", "pointcache"]
+ representations = ["abc"]
+
+ label = "Link Alembic"
+ icon = "code-fork"
+ color = "orange"
+
+ def _remove(self, objects, container):
+ for obj in list(objects):
+ if obj.type == 'MESH':
+ bpy.data.meshes.remove(obj.data)
+ elif obj.type == 'EMPTY':
+ bpy.data.objects.remove(obj)
+
+ bpy.data.collections.remove(container)
+
+ def _process(self, libpath, container_name, parent_collection):
+ bpy.ops.object.select_all(action='DESELECT')
+
+ view_layer = bpy.context.view_layer
+ view_layer_collection = view_layer.active_layer_collection.collection
+
+ relative = bpy.context.preferences.filepaths.use_relative_paths
+ bpy.ops.wm.alembic_import(
+ filepath=libpath,
+ relative_path=relative
+ )
+
+ parent = parent_collection
+
+ if parent is None:
+ parent = bpy.context.scene.collection
+
+ model_container = bpy.data.collections.new(container_name)
+ parent.children.link(model_container)
+ for obj in bpy.context.selected_objects:
+ model_container.objects.link(obj)
+ view_layer_collection.objects.unlink(obj)
+
+ name = obj.name
+ obj.name = f"{name}:{container_name}"
+
+ # Groups are imported as Empty objects in Blender
+ if obj.type == 'MESH':
+ data_name = obj.data.name
+ obj.data.name = f"{data_name}:{container_name}"
+
+ if not obj.get(blender.pipeline.AVALON_PROPERTY):
+ obj[blender.pipeline.AVALON_PROPERTY] = dict()
+
+ avalon_info = obj[blender.pipeline.AVALON_PROPERTY]
+ avalon_info.update({"container_name": container_name})
+
+ bpy.ops.object.select_all(action='DESELECT')
+
+ return model_container
+
+ def process_asset(
+ self, context: dict, name: str, namespace: Optional[str] = None,
+ options: Optional[Dict] = None
+ ) -> Optional[List]:
+ """
+ Arguments:
+ name: Use pre-defined name
+ namespace: Use pre-defined namespace
+ context: Full parenthood of representation to load
+ options: Additional settings dictionary
+ """
+
+ libpath = self.fname
+ asset = context["asset"]["name"]
+ subset = context["subset"]["name"]
+
+ lib_container = plugin.asset_name(
+ asset, subset
+ )
+ unique_number = plugin.get_unique_number(
+ asset, subset
+ )
+ namespace = namespace or f"{asset}_{unique_number}"
+ container_name = plugin.asset_name(
+ asset, subset, unique_number
+ )
+
+ container = bpy.data.collections.new(lib_container)
+ container.name = container_name
+ blender.pipeline.containerise_existing(
+ container,
+ name,
+ namespace,
+ context,
+ self.__class__.__name__,
+ )
+
+ container_metadata = container.get(
+ blender.pipeline.AVALON_PROPERTY)
+
+ container_metadata["libpath"] = libpath
+ container_metadata["lib_container"] = lib_container
+
+ obj_container = self._process(
+ libpath, container_name, None)
+
+ container_metadata["obj_container"] = obj_container
+
+ # Save the list of objects in the metadata container
+ container_metadata["objects"] = obj_container.all_objects
+
+ nodes = list(container.objects)
+ nodes.append(container)
+ self[:] = nodes
+ return nodes
+
+ def update(self, container: Dict, representation: Dict):
+ """Update the loaded asset.
+
+ This will remove all objects of the current collection, load the new
+ ones and add them to the collection.
+ If the objects of the collection are used in another collection they
+ will not be removed, only unlinked. Normally this should not be the
+ case though.
+
+ Warning:
+ No nested collections are supported at the moment!
+ """
+ collection = bpy.data.collections.get(
+ container["objectName"]
+ )
+ libpath = Path(api.get_representation_path(representation))
+ extension = libpath.suffix.lower()
+
+ self.log.info(
+ "Container: %s\nRepresentation: %s",
+ pformat(container, indent=2),
+ pformat(representation, indent=2),
+ )
+
+ assert collection, (
+ f"The asset is not loaded: {container['objectName']}"
+ )
+ assert not (collection.children), (
+ "Nested collections are not supported."
+ )
+ assert libpath, (
+ "No existing library file found for {container['objectName']}"
+ )
+ assert libpath.is_file(), (
+ f"The file doesn't exist: {libpath}"
+ )
+ assert extension in plugin.VALID_EXTENSIONS, (
+ f"Unsupported file: {libpath}"
+ )
+
+ collection_metadata = collection.get(
+ blender.pipeline.AVALON_PROPERTY)
+ collection_libpath = collection_metadata["libpath"]
+
+ obj_container = plugin.get_local_collection_with_name(
+ collection_metadata["obj_container"].name
+ )
+ objects = obj_container.all_objects
+
+ container_name = obj_container.name
+
+ normalized_collection_libpath = (
+ str(Path(bpy.path.abspath(collection_libpath)).resolve())
+ )
+ normalized_libpath = (
+ str(Path(bpy.path.abspath(str(libpath))).resolve())
+ )
+ self.log.debug(
+ "normalized_collection_libpath:\n %s\nnormalized_libpath:\n %s",
+ normalized_collection_libpath,
+ normalized_libpath,
+ )
+ if normalized_collection_libpath == normalized_libpath:
+ self.log.info("Library already loaded, not updating...")
+ return
+
+ parent = plugin.get_parent_collection(obj_container)
+
+ self._remove(objects, obj_container)
+
+ obj_container = self._process(
+ str(libpath), container_name, parent)
+
+ collection_metadata["obj_container"] = obj_container
+ collection_metadata["objects"] = obj_container.all_objects
+ collection_metadata["libpath"] = str(libpath)
+ collection_metadata["representation"] = str(representation["_id"])
+
+ def remove(self, container: Dict) -> bool:
+ """Remove an existing container from a Blender scene.
+
+ Arguments:
+ container (openpype:container-1.0): Container to remove,
+ from `host.ls()`.
+
+ Returns:
+ bool: Whether the container was deleted.
+
+ Warning:
+ No nested collections are supported at the moment!
+ """
+ collection = bpy.data.collections.get(
+ container["objectName"]
+ )
+ if not collection:
+ return False
+ assert not (collection.children), (
+ "Nested collections are not supported."
+ )
+
+ collection_metadata = collection.get(
+ blender.pipeline.AVALON_PROPERTY)
+
+ obj_container = plugin.get_local_collection_with_name(
+ collection_metadata["obj_container"].name
+ )
+ objects = obj_container.all_objects
+
+ self._remove(objects, obj_container)
+
+ bpy.data.collections.remove(collection)
+
+ return True
diff --git a/openpype/hosts/blender/plugins/load/load_model.py b/openpype/hosts/blender/plugins/load/load_model.py
index 7297e459a6..d645bedfcc 100644
--- a/openpype/hosts/blender/plugins/load/load_model.py
+++ b/openpype/hosts/blender/plugins/load/load_model.py
@@ -242,65 +242,3 @@ class BlendModelLoader(plugin.AssetLoader):
bpy.data.collections.remove(collection)
return True
-
-
-class CacheModelLoader(plugin.AssetLoader):
- """Load cache models.
-
- Stores the imported asset in a collection named after the asset.
-
- Note:
- At least for now it only supports Alembic files.
- """
-
- families = ["model"]
- representations = ["abc"]
-
- label = "Link Model"
- icon = "code-fork"
- color = "orange"
-
- def process_asset(
- self, context: dict, name: str, namespace: Optional[str] = None,
- options: Optional[Dict] = None
- ) -> Optional[List]:
- """
- Arguments:
- name: Use pre-defined name
- namespace: Use pre-defined namespace
- context: Full parenthood of representation to load
- options: Additional settings dictionary
- """
- raise NotImplementedError(
- "Loading of Alembic files is not yet implemented.")
- # TODO (jasper): implement Alembic import.
-
- libpath = self.fname
- asset = context["asset"]["name"]
- subset = context["subset"]["name"]
- # TODO (jasper): evaluate use of namespace which is 'alien' to Blender.
- lib_container = container_name = (
- plugin.asset_name(asset, subset, namespace)
- )
- relative = bpy.context.preferences.filepaths.use_relative_paths
-
- with bpy.data.libraries.load(
- libpath, link=True, relative=relative
- ) as (data_from, data_to):
- data_to.collections = [lib_container]
-
- scene = bpy.context.scene
- instance_empty = bpy.data.objects.new(
- container_name, None
- )
- scene.collection.objects.link(instance_empty)
- instance_empty.instance_type = 'COLLECTION'
- collection = bpy.data.collections[lib_container]
- collection.name = container_name
- instance_empty.instance_collection = collection
-
- nodes = list(collection.objects)
- nodes.append(collection)
- nodes.append(instance_empty)
- self[:] = nodes
- return nodes
diff --git a/openpype/hosts/blender/plugins/publish/extract_abc.py b/openpype/hosts/blender/plugins/publish/extract_abc.py
index 6a89c6019b..a6315908fc 100644
--- a/openpype/hosts/blender/plugins/publish/extract_abc.py
+++ b/openpype/hosts/blender/plugins/publish/extract_abc.py
@@ -11,14 +11,14 @@ class ExtractABC(openpype.api.Extractor):
label = "Extract ABC"
hosts = ["blender"]
- families = ["model"]
+ families = ["model", "pointcache"]
optional = True
def process(self, instance):
# Define extract output file path
stagingdir = self.staging_dir(instance)
- filename = f"{instance.name}.fbx"
+ filename = f"{instance.name}.abc"
filepath = os.path.join(stagingdir, filename)
context = bpy.context
@@ -52,6 +52,8 @@ class ExtractABC(openpype.api.Extractor):
old_scale = scene.unit_settings.scale_length
+ bpy.ops.object.select_all(action='DESELECT')
+
selected = list()
for obj in instance:
@@ -67,14 +69,11 @@ class ExtractABC(openpype.api.Extractor):
# We set the scale of the scene for the export
scene.unit_settings.scale_length = 0.01
- self.log.info(new_context)
-
# We export the abc
bpy.ops.wm.alembic_export(
new_context,
filepath=filepath,
- start=1,
- end=1
+ selected=True
)
view_layer.active_layer_collection = old_active_layer_collection
diff --git a/openpype/hosts/maya/plugins/publish/extract_redshift_proxy.py b/openpype/hosts/maya/plugins/publish/extract_redshift_proxy.py
index d0c6c4eb14..7c9e201986 100644
--- a/openpype/hosts/maya/plugins/publish/extract_redshift_proxy.py
+++ b/openpype/hosts/maya/plugins/publish/extract_redshift_proxy.py
@@ -74,6 +74,8 @@ class ExtractRedshiftProxy(openpype.api.Extractor):
'files': repr_files,
"stagingDir": staging_dir,
}
+ if anim_on:
+ representation["frameStart"] = instance.data["proxyFrameStart"]
instance.data["representations"].append(representation)
self.log.info("Extracted instance '%s' to: %s"
diff --git a/openpype/hosts/nuke/api/__init__.py b/openpype/hosts/nuke/api/__init__.py
index c80507e7ea..bd7a95f916 100644
--- a/openpype/hosts/nuke/api/__init__.py
+++ b/openpype/hosts/nuke/api/__init__.py
@@ -106,7 +106,7 @@ def on_pyblish_instance_toggled(instance, old_value, new_value):
log.info("instance toggle: {}, old_value: {}, new_value:{} ".format(
instance, old_value, new_value))
- from avalon.api.nuke import (
+ from avalon.nuke import (
viewer_update_and_undo_stop,
add_publish_knob
)
diff --git a/openpype/hosts/nuke/api/menu.py b/openpype/hosts/nuke/api/menu.py
index 2317066528..021ea04159 100644
--- a/openpype/hosts/nuke/api/menu.py
+++ b/openpype/hosts/nuke/api/menu.py
@@ -26,9 +26,9 @@ def install():
menu.addCommand(
name,
workfiles.show,
- index=(rm_item[0])
+ index=2
)
-
+ menu.addSeparator(index=3)
# replace reset resolution from avalon core to pype's
name = "Reset Resolution"
new_name = "Set Resolution"
@@ -63,16 +63,7 @@ def install():
# add colorspace menu item
name = "Set Colorspace"
menu.addCommand(
- name, lambda: WorkfileSettings().set_colorspace(),
- index=(rm_item[0] + 2)
- )
- log.debug("Adding menu item: {}".format(name))
-
- # add workfile builder menu item
- name = "Build Workfile"
- menu.addCommand(
- name, lambda: BuildWorkfile().process(),
- index=(rm_item[0] + 7)
+ name, lambda: WorkfileSettings().set_colorspace()
)
log.debug("Adding menu item: {}".format(name))
@@ -80,11 +71,20 @@ def install():
name = "Apply All Settings"
menu.addCommand(
name,
- lambda: WorkfileSettings().set_context_settings(),
- index=(rm_item[0] + 3)
+ lambda: WorkfileSettings().set_context_settings()
)
log.debug("Adding menu item: {}".format(name))
+ menu.addSeparator()
+
+ # add workfile builder menu item
+ name = "Build Workfile"
+ menu.addCommand(
+ name, lambda: BuildWorkfile().process()
+ )
+ log.debug("Adding menu item: {}".format(name))
+
+
# adding shortcuts
add_shortcuts_from_presets()
diff --git a/openpype/launcher_actions.py b/openpype/launcher_actions.py
deleted file mode 100644
index cf68dfb5c1..0000000000
--- a/openpype/launcher_actions.py
+++ /dev/null
@@ -1,30 +0,0 @@
-import os
-import sys
-
-from avalon import api, pipeline
-
-PACKAGE_DIR = os.path.dirname(__file__)
-PLUGINS_DIR = os.path.join(PACKAGE_DIR, "plugins", "launcher")
-ACTIONS_DIR = os.path.join(PLUGINS_DIR, "actions")
-
-
-def register_launcher_actions():
- """Register specific actions which should be accessible in the launcher"""
-
- actions = []
- ext = ".py"
- sys.path.append(ACTIONS_DIR)
-
- for f in os.listdir(ACTIONS_DIR):
- file, extention = os.path.splitext(f)
- if ext in extention:
- module = __import__(file)
- klass = getattr(module, file)
- actions.append(klass)
-
- if actions is []:
- return
-
- for action in actions:
- print("Using launcher action from config @ '{}'".format(action.name))
- pipeline.register_plugin(api.Action, action)
diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py
index f46c81bf7a..895d11601f 100644
--- a/openpype/lib/__init__.py
+++ b/openpype/lib/__init__.py
@@ -79,6 +79,16 @@ from .avalon_context import (
change_timer_to_current_context
)
+from .local_settings import (
+ IniSettingRegistry,
+ JSONSettingRegistry,
+ OpenPypeSecureRegistry,
+ OpenPypeSettingsRegistry,
+ get_local_site_id,
+ change_openpype_mongo_url,
+ get_openpype_username
+)
+
from .applications import (
ApplicationLaunchFailed,
ApplictionExecutableNotFound,
@@ -112,15 +122,6 @@ from .plugin_tools import (
should_decompress
)
-from .local_settings import (
- IniSettingRegistry,
- JSONSettingRegistry,
- OpenPypeSecureRegistry,
- OpenPypeSettingsRegistry,
- get_local_site_id,
- change_openpype_mongo_url
-)
-
from .path_tools import (
version_up,
get_version_from_path,
@@ -179,6 +180,14 @@ __all__ = [
"change_timer_to_current_context",
+ "IniSettingRegistry",
+ "JSONSettingRegistry",
+ "OpenPypeSecureRegistry",
+ "OpenPypeSettingsRegistry",
+ "get_local_site_id",
+ "change_openpype_mongo_url",
+ "get_openpype_username",
+
"ApplicationLaunchFailed",
"ApplictionExecutableNotFound",
"ApplicationNotFound",
@@ -224,13 +233,6 @@ __all__ = [
"validate_mongo_connection",
"OpenPypeMongoConnection",
- "IniSettingRegistry",
- "JSONSettingRegistry",
- "OpenPypeSecureRegistry",
- "OpenPypeSettingsRegistry",
- "get_local_site_id",
- "change_openpype_mongo_url",
-
"timeit",
"is_overlapping_otio_ranges",
diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py
index 730d4230b6..c5c192f51b 100644
--- a/openpype/lib/applications.py
+++ b/openpype/lib/applications.py
@@ -25,6 +25,7 @@ from . import (
PypeLogger,
Anatomy
)
+from .local_settings import get_openpype_username
from .avalon_context import (
get_workdir_data,
get_workdir_with_workdir_data
@@ -262,14 +263,32 @@ class Application:
class ApplicationManager:
- def __init__(self):
- self.log = PypeLogger().get_logger(self.__class__.__name__)
+ """Load applications and tools and store them by their full name.
+
+ Args:
+ system_settings (dict): Preloaded system settings. When passed manager
+ will always use these values. Gives ability to create manager
+ using different settings.
+ """
+ def __init__(self, system_settings=None):
+ self.log = PypeLogger.get_logger(self.__class__.__name__)
self.app_groups = {}
self.applications = {}
self.tool_groups = {}
self.tools = {}
+ self._system_settings = system_settings
+
+ self.refresh()
+
+ def set_system_settings(self, system_settings):
+ """Ability to change init system settings.
+
+ This will trigger refresh of manager.
+ """
+ self._system_settings = system_settings
+
self.refresh()
def refresh(self):
@@ -279,9 +298,12 @@ class ApplicationManager:
self.tool_groups.clear()
self.tools.clear()
- settings = get_system_settings(
- clear_metadata=False, exclude_locals=False
- )
+ if self._system_settings is not None:
+ settings = copy.deepcopy(self._system_settings)
+ else:
+ settings = get_system_settings(
+ clear_metadata=False, exclude_locals=False
+ )
app_defs = settings["applications"]
for group_name, variant_defs in app_defs.items():
@@ -1225,7 +1247,7 @@ def _prepare_last_workfile(data, workdir):
file_template = anatomy.templates["work"]["file"]
workdir_data.update({
"version": 1,
- "user": os.environ.get("OPENPYPE_USERNAME") or getpass.getuser(),
+ "user": get_openpype_username(),
"ext": extensions[0]
})
diff --git a/openpype/lib/local_settings.py b/openpype/lib/local_settings.py
index 56bdd047c9..67845c77cf 100644
--- a/openpype/lib/local_settings.py
+++ b/openpype/lib/local_settings.py
@@ -1,9 +1,11 @@
# -*- coding: utf-8 -*-
"""Package to deal with saving and retrieving user specific settings."""
import os
+import json
+import getpass
+import platform
from datetime import datetime
from abc import ABCMeta, abstractmethod
-import json
# TODO Use pype igniter logic instead of using duplicated code
# disable lru cache in Python 2
@@ -24,11 +26,11 @@ try:
except ImportError:
import ConfigParser as configparser
-import platform
-
import six
import appdirs
+from openpype.settings import get_local_settings
+
from .import validate_mongo_connection
_PLACEHOLDER = object()
@@ -538,3 +540,25 @@ def change_openpype_mongo_url(new_mongo_url):
if existing_value is not None:
registry.delete_item(key)
registry.set_item(key, new_mongo_url)
+
+
+def get_openpype_username():
+ """OpenPype username used for templates and publishing.
+
+ May be different than machine's username.
+
+ Always returns "OPENPYPE_USERNAME" environment if is set then tries local
+ settings and last option is to use `getpass.getuser()` which returns
+ machine username.
+ """
+ username = os.environ.get("OPENPYPE_USERNAME")
+ if not username:
+ local_settings = get_local_settings()
+ username = (
+ local_settings
+ .get("general", {})
+ .get("username")
+ )
+ if not username:
+ username = getpass.getuser()
+ return username
diff --git a/openpype/lib/log.py b/openpype/lib/log.py
index 9745279e28..39b6c67080 100644
--- a/openpype/lib/log.py
+++ b/openpype/lib/log.py
@@ -123,6 +123,8 @@ class PypeFormatter(logging.Formatter):
if record.exc_info is not None:
line_len = len(str(record.exc_info[1]))
+ if line_len > 30:
+ line_len = 30
out = "{}\n{}\n{}\n{}\n{}".format(
out,
line_len * "=",
diff --git a/openpype/modules/__init__.py b/openpype/modules/__init__.py
index d7c6d99fe6..bae48c540b 100644
--- a/openpype/modules/__init__.py
+++ b/openpype/modules/__init__.py
@@ -18,10 +18,6 @@ from .webserver import (
WebServerModule,
IWebServerRoutes
)
-from .user import (
- UserModule,
- IUserModule
-)
from .idle_manager import (
IdleManager,
IIdleManager
@@ -60,9 +56,6 @@ __all__ = (
"WebServerModule",
"IWebServerRoutes",
- "UserModule",
- "IUserModule",
-
"IdleManager",
"IIdleManager",
diff --git a/openpype/modules/deadline/plugins/publish/submit_aftereffects_deadline.py b/openpype/modules/deadline/plugins/publish/submit_aftereffects_deadline.py
index 38a6b9b246..69159fda1a 100644
--- a/openpype/modules/deadline/plugins/publish/submit_aftereffects_deadline.py
+++ b/openpype/modules/deadline/plugins/publish/submit_aftereffects_deadline.py
@@ -64,7 +64,6 @@ class AfterEffectsSubmitDeadline(abstract_submit_deadline.AbstractSubmitDeadline
"AVALON_ASSET",
"AVALON_TASK",
"AVALON_APP_NAME",
- "OPENPYPE_USERNAME",
"OPENPYPE_DEV",
"OPENPYPE_LOG_NO_COLORS"
]
diff --git a/openpype/modules/deadline/plugins/publish/submit_harmony_deadline.py b/openpype/modules/deadline/plugins/publish/submit_harmony_deadline.py
index ba1ffdcf30..37041a84b1 100644
--- a/openpype/modules/deadline/plugins/publish/submit_harmony_deadline.py
+++ b/openpype/modules/deadline/plugins/publish/submit_harmony_deadline.py
@@ -273,7 +273,6 @@ class HarmonySubmitDeadline(
"AVALON_ASSET",
"AVALON_TASK",
"AVALON_APP_NAME",
- "OPENPYPE_USERNAME",
"OPENPYPE_DEV",
"OPENPYPE_LOG_NO_COLORS"
]
diff --git a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py
index 3aea837bb1..0e92fb38bb 100644
--- a/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py
+++ b/openpype/modules/deadline/plugins/publish/submit_maya_deadline.py
@@ -441,7 +441,6 @@ class MayaSubmitDeadline(pyblish.api.InstancePlugin):
"AVALON_ASSET",
"AVALON_TASK",
"AVALON_APP_NAME",
- "OPENPYPE_USERNAME",
"OPENPYPE_DEV",
"OPENPYPE_LOG_NO_COLORS"
]
diff --git a/openpype/modules/ftrack/event_handlers_server/action_prepare_project.py b/openpype/modules/ftrack/event_handlers_server/action_prepare_project.py
index 8248bf532e..12d687bbf2 100644
--- a/openpype/modules/ftrack/event_handlers_server/action_prepare_project.py
+++ b/openpype/modules/ftrack/event_handlers_server/action_prepare_project.py
@@ -2,9 +2,9 @@ import json
from openpype.api import ProjectSettings
-from openpype.modules.ftrack.lib import ServerAction
-from openpype.modules.ftrack.lib.avalon_sync import (
- get_pype_attr,
+from openpype.modules.ftrack.lib import (
+ ServerAction,
+ get_openpype_attr,
CUST_ATTR_AUTO_SYNC
)
@@ -159,7 +159,7 @@ class PrepareProjectServer(ServerAction):
for key, entity in project_anatom_settings["attributes"].items():
attribute_values_by_key[key] = entity.value
- cust_attrs, hier_cust_attrs = get_pype_attr(self.session, True)
+ cust_attrs, hier_cust_attrs = get_openpype_attr(self.session, True)
for attr in hier_cust_attrs:
key = attr["key"]
diff --git a/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py b/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py
index 347b227dd3..3bb01798e4 100644
--- a/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py
+++ b/openpype/modules/ftrack/event_handlers_server/event_sync_to_avalon.py
@@ -18,12 +18,15 @@ from avalon import schema
from avalon.api import AvalonMongoDB
from openpype.modules.ftrack.lib import (
+ get_openpype_attr,
+ CUST_ATTR_ID_KEY,
+ CUST_ATTR_AUTO_SYNC,
+
avalon_sync,
+
BaseEvent
)
from openpype.modules.ftrack.lib.avalon_sync import (
- CUST_ATTR_ID_KEY,
- CUST_ATTR_AUTO_SYNC,
EntitySchemas
)
@@ -125,7 +128,7 @@ class SyncToAvalonEvent(BaseEvent):
@property
def avalon_cust_attrs(self):
if self._avalon_cust_attrs is None:
- self._avalon_cust_attrs = avalon_sync.get_pype_attr(
+ self._avalon_cust_attrs = get_openpype_attr(
self.process_session, query_keys=self.cust_attr_query_keys
)
return self._avalon_cust_attrs
diff --git a/openpype/modules/ftrack/event_handlers_user/action_clean_hierarchical_attributes.py b/openpype/modules/ftrack/event_handlers_user/action_clean_hierarchical_attributes.py
index c326c56a7c..45cc9adf55 100644
--- a/openpype/modules/ftrack/event_handlers_user/action_clean_hierarchical_attributes.py
+++ b/openpype/modules/ftrack/event_handlers_user/action_clean_hierarchical_attributes.py
@@ -1,7 +1,10 @@
import collections
import ftrack_api
-from openpype.modules.ftrack.lib import BaseAction, statics_icon
-from openpype.modules.ftrack.lib.avalon_sync import get_pype_attr
+from openpype.modules.ftrack.lib import (
+ BaseAction,
+ statics_icon,
+ get_openpype_attr
+)
class CleanHierarchicalAttrsAction(BaseAction):
@@ -52,7 +55,7 @@ class CleanHierarchicalAttrsAction(BaseAction):
)
entity_ids_joined = ", ".join(all_entities_ids)
- attrs, hier_attrs = get_pype_attr(session)
+ attrs, hier_attrs = get_openpype_attr(session)
for attr in hier_attrs:
configuration_key = attr["key"]
diff --git a/openpype/modules/ftrack/event_handlers_user/action_create_cust_attrs.py b/openpype/modules/ftrack/event_handlers_user/action_create_cust_attrs.py
index 63025d35b3..63605eda5e 100644
--- a/openpype/modules/ftrack/event_handlers_user/action_create_cust_attrs.py
+++ b/openpype/modules/ftrack/event_handlers_user/action_create_cust_attrs.py
@@ -2,10 +2,20 @@ import collections
import json
import arrow
import ftrack_api
-from openpype.modules.ftrack.lib import BaseAction, statics_icon
-from openpype.modules.ftrack.lib.avalon_sync import (
- CUST_ATTR_ID_KEY, CUST_ATTR_GROUP, default_custom_attributes_definition
+from openpype.modules.ftrack.lib import (
+ BaseAction,
+ statics_icon,
+
+ CUST_ATTR_ID_KEY,
+ CUST_ATTR_GROUP,
+ CUST_ATTR_TOOLS,
+ CUST_ATTR_APPLICATIONS,
+
+ default_custom_attributes_definition,
+ app_definitions_from_app_manager,
+ tool_definitions_from_app_manager
)
+
from openpype.api import get_system_settings
from openpype.lib import ApplicationManager
@@ -370,24 +380,12 @@ class CustomAttributes(BaseAction):
exc_info=True
)
- def app_defs_from_app_manager(self):
- app_definitions = []
- for app_name, app in self.app_manager.applications.items():
- if app.enabled and app.is_host:
- app_definitions.append({
- app_name: app.full_label
- })
-
- if not app_definitions:
- app_definitions.append({"empty": "< Empty >"})
- return app_definitions
-
def applications_attribute(self, event):
- apps_data = self.app_defs_from_app_manager()
+ apps_data = app_definitions_from_app_manager(self.app_manager)
applications_custom_attr_data = {
"label": "Applications",
- "key": "applications",
+ "key": CUST_ATTR_APPLICATIONS,
"type": "enumerator",
"entity_type": "show",
"group": CUST_ATTR_GROUP,
@@ -399,19 +397,11 @@ class CustomAttributes(BaseAction):
self.process_attr_data(applications_custom_attr_data, event)
def tools_attribute(self, event):
- tools_data = []
- for tool_name, tool in self.app_manager.tools.items():
- tools_data.append({
- tool_name: tool.label
- })
-
- # Make sure there is at least one item
- if not tools_data:
- tools_data.append({"empty": "< Empty >"})
+ tools_data = tool_definitions_from_app_manager(self.app_manager)
tools_custom_attr_data = {
"label": "Tools",
- "key": "tools_env",
+ "key": CUST_ATTR_TOOLS,
"type": "enumerator",
"is_hierarchical": True,
"group": CUST_ATTR_GROUP,
diff --git a/openpype/modules/ftrack/event_handlers_user/action_prepare_project.py b/openpype/modules/ftrack/event_handlers_user/action_prepare_project.py
index bd25f995fe..5298c06371 100644
--- a/openpype/modules/ftrack/event_handlers_user/action_prepare_project.py
+++ b/openpype/modules/ftrack/event_handlers_user/action_prepare_project.py
@@ -4,10 +4,8 @@ from openpype.api import ProjectSettings
from openpype.modules.ftrack.lib import (
BaseAction,
- statics_icon
-)
-from openpype.modules.ftrack.lib.avalon_sync import (
- get_pype_attr,
+ statics_icon,
+ get_openpype_attr,
CUST_ATTR_AUTO_SYNC
)
@@ -162,7 +160,7 @@ class PrepareProjectLocal(BaseAction):
for key, entity in project_anatom_settings["attributes"].items():
attribute_values_by_key[key] = entity.value
- cust_attrs, hier_cust_attrs = get_pype_attr(self.session, True)
+ cust_attrs, hier_cust_attrs = get_openpype_attr(self.session, True)
for attr in hier_cust_attrs:
key = attr["key"]
diff --git a/openpype/modules/ftrack/ftrack_module.py b/openpype/modules/ftrack/ftrack_module.py
index d242268048..af578de86b 100644
--- a/openpype/modules/ftrack/ftrack_module.py
+++ b/openpype/modules/ftrack/ftrack_module.py
@@ -1,4 +1,5 @@
import os
+import json
import collections
from abc import ABCMeta, abstractmethod
import six
@@ -8,10 +9,10 @@ from openpype.modules import (
ITrayModule,
IPluginPaths,
ITimersManager,
- IUserModule,
ILaunchHookPaths,
ISettingsChangeListener
)
+from openpype.settings import SaveWarningExc
FTRACK_MODULE_DIR = os.path.dirname(os.path.abspath(__file__))
@@ -32,7 +33,6 @@ class FtrackModule(
ITrayModule,
IPluginPaths,
ITimersManager,
- IUserModule,
ILaunchHookPaths,
ISettingsChangeListener
):
@@ -123,15 +123,86 @@ class FtrackModule(
if self.tray_module:
self.tray_module.stop_timer_manager()
- def on_pype_user_change(self, username):
- """Implementation of IUserModule interface."""
- if self.tray_module:
- self.tray_module.changed_user()
-
- def on_system_settings_save(self, *_args, **_kwargs):
+ def on_system_settings_save(
+ self, old_value, new_value, changes, new_value_metadata
+ ):
"""Implementation of ISettingsChangeListener interface."""
- # Ignore
- return
+ try:
+ session = self.create_ftrack_session()
+ except Exception:
+ self.log.warning("Couldn't create ftrack session.", exc_info=True)
+ raise SaveWarningExc((
+ "Saving of attributes to ftrack wasn't successful,"
+ " try running Create/Update Avalon Attributes in ftrack."
+ ))
+
+ from .lib import (
+ get_openpype_attr,
+ CUST_ATTR_APPLICATIONS,
+ CUST_ATTR_TOOLS,
+ app_definitions_from_app_manager,
+ tool_definitions_from_app_manager
+ )
+ from openpype.api import ApplicationManager
+ query_keys = [
+ "id",
+ "key",
+ "config"
+ ]
+ custom_attributes = get_openpype_attr(
+ session,
+ split_hierarchical=False,
+ query_keys=query_keys
+ )
+ app_attribute = None
+ tool_attribute = None
+ for custom_attribute in custom_attributes:
+ key = custom_attribute["key"]
+ if key == CUST_ATTR_APPLICATIONS:
+ app_attribute = custom_attribute
+ elif key == CUST_ATTR_TOOLS:
+ tool_attribute = custom_attribute
+
+ app_manager = ApplicationManager(new_value_metadata)
+ missing_attributes = []
+ if not app_attribute:
+ missing_attributes.append(CUST_ATTR_APPLICATIONS)
+ else:
+ config = json.loads(app_attribute["config"])
+ new_data = app_definitions_from_app_manager(app_manager)
+ prepared_data = []
+ for item in new_data:
+ for key, label in item.items():
+ prepared_data.append({
+ "menu": label,
+ "value": key
+ })
+
+ config["data"] = json.dumps(prepared_data)
+ app_attribute["config"] = json.dumps(config)
+
+ if not tool_attribute:
+ missing_attributes.append(CUST_ATTR_TOOLS)
+ else:
+ config = json.loads(tool_attribute["config"])
+ new_data = tool_definitions_from_app_manager(app_manager)
+ prepared_data = []
+ for item in new_data:
+ for key, label in item.items():
+ prepared_data.append({
+ "menu": label,
+ "value": key
+ })
+ config["data"] = json.dumps(prepared_data)
+ tool_attribute["config"] = json.dumps(config)
+
+ session.commit()
+
+ if missing_attributes:
+ raise SaveWarningExc((
+ "Couldn't find custom attribute/s ({}) to update."
+ " Try running Create/Update Avalon Attributes in ftrack."
+ ).format(", ".join(missing_attributes)))
def on_project_settings_save(self, *_args, **_kwargs):
"""Implementation of ISettingsChangeListener interface."""
@@ -139,7 +210,7 @@ class FtrackModule(
return
def on_project_anatomy_save(
- self, old_value, new_value, changes, project_name
+ self, old_value, new_value, changes, project_name, new_value_metadata
):
"""Implementation of ISettingsChangeListener interface."""
if not project_name:
@@ -150,32 +221,49 @@ class FtrackModule(
return
import ftrack_api
- from openpype.modules.ftrack.lib import avalon_sync
+ from openpype.modules.ftrack.lib import get_openpype_attr
+
+ try:
+ session = self.create_ftrack_session()
+ except Exception:
+ self.log.warning("Couldn't create ftrack session.", exc_info=True)
+ raise SaveWarningExc((
+ "Saving of attributes to ftrack wasn't successful,"
+ " try running Create/Update Avalon Attributes in ftrack."
+ ))
- session = self.create_ftrack_session()
project_entity = session.query(
"Project where full_name is \"{}\"".format(project_name)
).first()
if not project_entity:
- self.log.warning((
- "Ftrack project with names \"{}\" was not found."
- " Skipping settings attributes change callback."
- ))
- return
+ msg = (
+ "Ftrack project with name \"{}\" was not found in Ftrack."
+ " Can't push attribute changes."
+ ).format(project_name)
+ self.log.warning(msg)
+ raise SaveWarningExc(msg)
project_id = project_entity["id"]
- cust_attr, hier_attr = avalon_sync.get_pype_attr(session)
+ cust_attr, hier_attr = get_openpype_attr(session)
cust_attr_by_key = {attr["key"]: attr for attr in cust_attr}
hier_attrs_by_key = {attr["key"]: attr for attr in hier_attr}
+
+ failed = {}
+ missing = {}
for key, value in attributes_changes.items():
configuration = hier_attrs_by_key.get(key)
if not configuration:
configuration = cust_attr_by_key.get(key)
if not configuration:
+ self.log.warning(
+ "Custom attribute \"{}\" was not found.".format(key)
+ )
+ missing[key] = value
continue
+ # TODO add add permissions check
# TODO add value validations
# - value type and list items
entity_key = collections.OrderedDict()
@@ -189,10 +277,45 @@ class FtrackModule(
"value",
ftrack_api.symbol.NOT_SET,
value
-
)
)
- session.commit()
+ try:
+ session.commit()
+ self.log.debug(
+ "Changed project custom attribute \"{}\" to \"{}\"".format(
+ key, value
+ )
+ )
+ except Exception:
+ self.log.warning(
+ "Failed to set \"{}\" to \"{}\"".format(key, value),
+ exc_info=True
+ )
+ session.rollback()
+ failed[key] = value
+
+ if not failed and not missing:
+ return
+
+ error_msg = (
+ "Values were not updated on Ftrack which may cause issues."
+ " try running Create/Update Avalon Attributes in ftrack "
+ " and resave project settings."
+ )
+ if missing:
+ error_msg += "\nMissing Custom attributes on Ftrack: {}.".format(
+ ", ".join([
+ '"{}"'.format(key)
+ for key in missing.keys()
+ ])
+ )
+ if failed:
+ joined_failed = ", ".join([
+ '"{}": "{}"'.format(key, value)
+ for key, value in failed.items()
+ ])
+ error_msg += "\nFailed to set: {}".format(joined_failed)
+ raise SaveWarningExc(error_msg)
def create_ftrack_session(self, **session_kwargs):
import ftrack_api
diff --git a/openpype/modules/ftrack/lib/__init__.py b/openpype/modules/ftrack/lib/__init__.py
index 82b6875590..ce6d5284b6 100644
--- a/openpype/modules/ftrack/lib/__init__.py
+++ b/openpype/modules/ftrack/lib/__init__.py
@@ -1,7 +1,21 @@
+from .constants import (
+ CUST_ATTR_ID_KEY,
+ CUST_ATTR_AUTO_SYNC,
+ CUST_ATTR_GROUP,
+ CUST_ATTR_TOOLS,
+ CUST_ATTR_APPLICATIONS
+)
from . settings import (
get_ftrack_url_from_settings,
get_ftrack_event_mongo_info
)
+from .custom_attributes import (
+ default_custom_attributes_definition,
+ app_definitions_from_app_manager,
+ tool_definitions_from_app_manager,
+ get_openpype_attr
+)
+
from . import avalon_sync
from . import credentials
from .ftrack_base_handler import BaseHandler
@@ -10,9 +24,20 @@ from .ftrack_action_handler import BaseAction, ServerAction, statics_icon
__all__ = (
+ "CUST_ATTR_ID_KEY",
+ "CUST_ATTR_AUTO_SYNC",
+ "CUST_ATTR_GROUP",
+ "CUST_ATTR_TOOLS",
+ "CUST_ATTR_APPLICATIONS",
+
"get_ftrack_url_from_settings",
"get_ftrack_event_mongo_info",
+ "default_custom_attributes_definition",
+ "app_definitions_from_app_manager",
+ "tool_definitions_from_app_manager",
+ "get_openpype_attr",
+
"avalon_sync",
"credentials",
diff --git a/openpype/modules/ftrack/lib/avalon_sync.py b/openpype/modules/ftrack/lib/avalon_sync.py
index 79e1366a0d..f58e858a5a 100644
--- a/openpype/modules/ftrack/lib/avalon_sync.py
+++ b/openpype/modules/ftrack/lib/avalon_sync.py
@@ -14,17 +14,21 @@ else:
from avalon.api import AvalonMongoDB
import avalon
+
from openpype.api import (
Logger,
Anatomy,
get_anatomy_settings
)
+from openpype.lib import ApplicationManager
+
+from .constants import CUST_ATTR_ID_KEY
+from .custom_attributes import get_openpype_attr
from bson.objectid import ObjectId
from bson.errors import InvalidId
from pymongo import UpdateOne
import ftrack_api
-from openpype.lib import ApplicationManager
log = Logger.get_logger(__name__)
@@ -36,23 +40,6 @@ EntitySchemas = {
"config": "openpype:config-2.0"
}
-# Group name of custom attributes
-CUST_ATTR_GROUP = "openpype"
-
-# name of Custom attribute that stores mongo_id from avalon db
-CUST_ATTR_ID_KEY = "avalon_mongo_id"
-CUST_ATTR_AUTO_SYNC = "avalon_auto_sync"
-
-
-def default_custom_attributes_definition():
- json_file_path = os.path.join(
- os.path.dirname(os.path.abspath(__file__)),
- "custom_attributes.json"
- )
- with open(json_file_path, "r") as json_stream:
- data = json.load(json_stream)
- return data
-
def check_regex(name, entity_type, in_schema=None, schema_patterns=None):
schema_name = "asset-3.0"
@@ -91,39 +78,6 @@ def join_query_keys(keys):
return ",".join(["\"{}\"".format(key) for key in keys])
-def get_pype_attr(session, split_hierarchical=True, query_keys=None):
- custom_attributes = []
- hier_custom_attributes = []
- if not query_keys:
- query_keys = [
- "id",
- "entity_type",
- "object_type_id",
- "is_hierarchical",
- "default"
- ]
- # TODO remove deprecated "pype" group from query
- cust_attrs_query = (
- "select {}"
- " from CustomAttributeConfiguration"
- # Kept `pype` for Backwards Compatiblity
- " where group.name in (\"pype\", \"{}\")"
- ).format(", ".join(query_keys), CUST_ATTR_GROUP)
- all_avalon_attr = session.query(cust_attrs_query).all()
- for cust_attr in all_avalon_attr:
- if split_hierarchical and cust_attr["is_hierarchical"]:
- hier_custom_attributes.append(cust_attr)
- continue
-
- custom_attributes.append(cust_attr)
-
- if split_hierarchical:
- # return tuple
- return custom_attributes, hier_custom_attributes
-
- return custom_attributes
-
-
def get_python_type_for_custom_attribute(cust_attr, cust_attr_type_name=None):
"""Python type that should value of custom attribute have.
@@ -921,7 +875,7 @@ class SyncEntitiesFactory:
def set_cutom_attributes(self):
self.log.debug("* Preparing custom attributes")
# Get custom attributes and values
- custom_attrs, hier_attrs = get_pype_attr(
+ custom_attrs, hier_attrs = get_openpype_attr(
self.session, query_keys=self.cust_attr_query_keys
)
ent_types = self.session.query("select id, name from ObjectType").all()
@@ -2508,7 +2462,7 @@ class SyncEntitiesFactory:
if new_entity_id not in p_chilren:
self.entities_dict[parent_id]["children"].append(new_entity_id)
- cust_attr, _ = get_pype_attr(self.session)
+ cust_attr, _ = get_openpype_attr(self.session)
for _attr in cust_attr:
key = _attr["key"]
if key not in av_entity["data"]:
diff --git a/openpype/modules/ftrack/lib/constants.py b/openpype/modules/ftrack/lib/constants.py
new file mode 100644
index 0000000000..73d5112e6d
--- /dev/null
+++ b/openpype/modules/ftrack/lib/constants.py
@@ -0,0 +1,12 @@
+# Group name of custom attributes
+CUST_ATTR_GROUP = "openpype"
+
+# name of Custom attribute that stores mongo_id from avalon db
+CUST_ATTR_ID_KEY = "avalon_mongo_id"
+# Auto sync of project
+CUST_ATTR_AUTO_SYNC = "avalon_auto_sync"
+
+# Applications custom attribute name
+CUST_ATTR_APPLICATIONS = "applications"
+# Environment tools custom attribute
+CUST_ATTR_TOOLS = "tools_env"
diff --git a/openpype/modules/ftrack/lib/custom_attributes.py b/openpype/modules/ftrack/lib/custom_attributes.py
new file mode 100644
index 0000000000..33eea32baa
--- /dev/null
+++ b/openpype/modules/ftrack/lib/custom_attributes.py
@@ -0,0 +1,73 @@
+import os
+import json
+
+from .constants import CUST_ATTR_GROUP
+
+
+def default_custom_attributes_definition():
+ json_file_path = os.path.join(
+ os.path.dirname(os.path.abspath(__file__)),
+ "custom_attributes.json"
+ )
+ with open(json_file_path, "r") as json_stream:
+ data = json.load(json_stream)
+ return data
+
+
+def app_definitions_from_app_manager(app_manager):
+ app_definitions = []
+ for app_name, app in app_manager.applications.items():
+ if app.enabled and app.is_host:
+ app_definitions.append({
+ app_name: app.full_label
+ })
+
+ if not app_definitions:
+ app_definitions.append({"empty": "< Empty >"})
+ return app_definitions
+
+
+def tool_definitions_from_app_manager(app_manager):
+ tools_data = []
+ for tool_name, tool in app_manager.tools.items():
+ tools_data.append({
+ tool_name: tool.label
+ })
+
+ # Make sure there is at least one item
+ if not tools_data:
+ tools_data.append({"empty": "< Empty >"})
+ return tools_data
+
+
+def get_openpype_attr(session, split_hierarchical=True, query_keys=None):
+ custom_attributes = []
+ hier_custom_attributes = []
+ if not query_keys:
+ query_keys = [
+ "id",
+ "entity_type",
+ "object_type_id",
+ "is_hierarchical",
+ "default"
+ ]
+ # TODO remove deprecated "pype" group from query
+ cust_attrs_query = (
+ "select {}"
+ " from CustomAttributeConfiguration"
+ # Kept `pype` for Backwards Compatiblity
+ " where group.name in (\"pype\", \"{}\")"
+ ).format(", ".join(query_keys), CUST_ATTR_GROUP)
+ all_avalon_attr = session.query(cust_attrs_query).all()
+ for cust_attr in all_avalon_attr:
+ if split_hierarchical and cust_attr["is_hierarchical"]:
+ hier_custom_attributes.append(cust_attr)
+ continue
+
+ custom_attributes.append(cust_attr)
+
+ if split_hierarchical:
+ # return tuple
+ return custom_attributes, hier_custom_attributes
+
+ return custom_attributes
diff --git a/openpype/modules/launcher_action.py b/openpype/modules/launcher_action.py
index da0468d495..5ed8585b6a 100644
--- a/openpype/modules/launcher_action.py
+++ b/openpype/modules/launcher_action.py
@@ -22,7 +22,6 @@ class LauncherAction(PypeModule, ITrayAction):
# Register actions
if self.tray_initialized:
from openpype.tools.launcher import actions
- # actions.register_default_actions()
actions.register_config_actions()
actions_paths = self.manager.collect_plugin_paths()["actions"]
actions.register_actions_from_paths(actions_paths)
diff --git a/openpype/modules/settings_action.py b/openpype/modules/settings_action.py
index 371e190c12..3f7cb8c3ba 100644
--- a/openpype/modules/settings_action.py
+++ b/openpype/modules/settings_action.py
@@ -16,18 +16,20 @@ class ISettingsChangeListener:
}
"""
@abstractmethod
- def on_system_settings_save(self, old_value, new_value, changes):
+ def on_system_settings_save(
+ self, old_value, new_value, changes, new_value_metadata
+ ):
pass
@abstractmethod
def on_project_settings_save(
- self, old_value, new_value, changes, project_name
+ self, old_value, new_value, changes, project_name, new_value_metadata
):
pass
@abstractmethod
def on_project_anatomy_save(
- self, old_value, new_value, changes, project_name
+ self, old_value, new_value, changes, project_name, new_value_metadata
):
pass
diff --git a/openpype/modules/sync_server/tray/lib.py b/openpype/modules/sync_server/tray/lib.py
index 0282d79ea1..3597213b31 100644
--- a/openpype/modules/sync_server/tray/lib.py
+++ b/openpype/modules/sync_server/tray/lib.py
@@ -1,4 +1,7 @@
from Qt import QtCore
+import attr
+import abc
+import six
from openpype.lib import PypeLogger
@@ -20,8 +23,111 @@ ProviderRole = QtCore.Qt.UserRole + 2
ProgressRole = QtCore.Qt.UserRole + 4
DateRole = QtCore.Qt.UserRole + 6
FailedRole = QtCore.Qt.UserRole + 8
+HeaderNameRole = QtCore.Qt.UserRole + 10
+@six.add_metaclass(abc.ABCMeta)
+class AbstractColumnFilter:
+
+ def __init__(self, column_name, dbcon=None):
+ self.column_name = column_name
+ self.dbcon = dbcon
+ self._search_variants = []
+
+ def search_variants(self):
+ """
+ Returns all flavors of search available for this column,
+ """
+ return self._search_variants
+
+ @abc.abstractmethod
+ def values(self):
+ """
+ Returns dict of available values for filter {'label':'value'}
+ """
+ pass
+
+ @abc.abstractmethod
+ def prepare_match_part(self, values):
+ """
+ Prepares format valid for $match part from 'values
+
+ Args:
+ values (dict): {'label': 'value'}
+ Returns:
+ (dict): {'COLUMN_NAME': {'$in': ['val1', 'val2']}}
+ """
+ pass
+
+
+class PredefinedSetFilter(AbstractColumnFilter):
+
+ def __init__(self, column_name, values):
+ super().__init__(column_name)
+ self._search_variants = ['checkbox']
+ self._values = values
+ if self._values and \
+ list(self._values.keys())[0] == list(self._values.values())[0]:
+ self._search_variants.append('text')
+
+ def values(self):
+ return {k: v for k, v in self._values.items()}
+
+ def prepare_match_part(self, values):
+ return {'$in': list(values.keys())}
+
+
+class RegexTextFilter(AbstractColumnFilter):
+
+ def __init__(self, column_name):
+ super().__init__(column_name)
+ self._search_variants = ['text']
+
+ def values(self):
+ return {}
+
+ def prepare_match_part(self, values):
+ """ values = {'text1 text2': 'text1 text2'} """
+ if not values:
+ return {}
+
+ regex_strs = set()
+ text = list(values.keys())[0] # only single key always expected
+ for word in text.split():
+ regex_strs.add('.*{}.*'.format(word))
+
+ return {"$regex": "|".join(regex_strs),
+ "$options": 'i'}
+
+
+class MultiSelectFilter(AbstractColumnFilter):
+
+ def __init__(self, column_name, values=None, dbcon=None):
+ super().__init__(column_name)
+ self._values = values
+ self.dbcon = dbcon
+ self._search_variants = ['checkbox']
+
+ def values(self):
+ if self._values:
+ return {k: v for k, v in self._values.items()}
+
+ recs = self.dbcon.find({'type': self.column_name}, {"name": 1,
+ "_id": -1})
+ values = {}
+ for item in recs:
+ values[item["name"]] = item["name"]
+ return dict(sorted(values.items(), key=lambda it: it[1]))
+
+ def prepare_match_part(self, values):
+ return {'$in': list(values.keys())}
+
+
+@attr.s
+class FilterDefinition:
+ type = attr.ib()
+ values = attr.ib(factory=list)
+
def pretty_size(value, suffix='B'):
for unit in ['', 'Ki', 'Mi', 'Gi', 'Ti', 'Pi', 'Ei', 'Zi']:
if abs(value) < 1024.0:
diff --git a/openpype/modules/sync_server/tray/models.py b/openpype/modules/sync_server/tray/models.py
index 3cc53c6ec4..981299c6cf 100644
--- a/openpype/modules/sync_server/tray/models.py
+++ b/openpype/modules/sync_server/tray/models.py
@@ -56,17 +56,31 @@ class _SyncRepresentationModel(QtCore.QAbstractTableModel):
"""Returns project"""
return self._project
+ @property
+ def column_filtering(self):
+ return self._column_filtering
+
def rowCount(self, _index):
return len(self._data)
- def columnCount(self, _index):
+ def columnCount(self, _index=None):
return len(self._header)
- def headerData(self, section, orientation, role):
+ def headerData(self, section, orientation, role=Qt.DisplayRole):
+ if section >= len(self.COLUMN_LABELS):
+ return
+
if role == Qt.DisplayRole:
if orientation == Qt.Horizontal:
return self.COLUMN_LABELS[section][1]
+ if role == lib.HeaderNameRole:
+ if orientation == Qt.Horizontal:
+ return self.COLUMN_LABELS[section][0] # return name
+
+ def get_column(self, index):
+ return self.COLUMN_LABELS[index]
+
def get_header_index(self, value):
"""
Returns index of 'value' in headers
@@ -103,7 +117,7 @@ class _SyncRepresentationModel(QtCore.QAbstractTableModel):
self._rec_loaded = 0
if not representations:
- self.query = self.get_default_query(load_records)
+ self.query = self.get_query(load_records)
representations = self.dbcon.aggregate(self.query)
self.add_page_records(self.local_site, self.remote_site,
@@ -138,7 +152,7 @@ class _SyncRepresentationModel(QtCore.QAbstractTableModel):
log.debug("fetchMore")
items_to_fetch = min(self._total_records - self._rec_loaded,
self.PAGE_SIZE)
- self.query = self.get_default_query(self._rec_loaded)
+ self.query = self.get_query(self._rec_loaded)
representations = self.dbcon.aggregate(self.query)
self.beginInsertRows(index,
self._rec_loaded,
@@ -171,7 +185,7 @@ class _SyncRepresentationModel(QtCore.QAbstractTableModel):
order = -1
self.sort = {self.SORT_BY_COLUMN[index]: order, '_id': 1}
- self.query = self.get_default_query()
+ self.query = self.get_query()
# import json
# log.debug(json.dumps(self.query, indent=4).\
# replace('False', 'false').\
@@ -180,16 +194,86 @@ class _SyncRepresentationModel(QtCore.QAbstractTableModel):
representations = self.dbcon.aggregate(self.query)
self.refresh(representations)
- def set_filter(self, word_filter):
+ def set_word_filter(self, word_filter):
"""
Adds text value filtering
Args:
word_filter (str): string inputted by user
"""
- self.word_filter = word_filter
+ self._word_filter = word_filter
self.refresh()
+ def get_filters(self):
+ """
+ Returns all available filter editors per column_name keys.
+ """
+ filters = {}
+ for column_name, _ in self.COLUMN_LABELS:
+ filter_rec = self.COLUMN_FILTERS.get(column_name)
+ if filter_rec:
+ filter_rec.dbcon = self.dbcon
+ filters[column_name] = filter_rec
+
+ return filters
+
+ def get_column_filter(self, index):
+ """
+ Returns filter object for column 'index
+
+ Args:
+ index(int): index of column in header
+
+ Returns:
+ (AbstractColumnFilter)
+ """
+ column_name = self._header[index]
+
+ filter_rec = self.COLUMN_FILTERS.get(column_name)
+ if filter_rec:
+ filter_rec.dbcon = self.dbcon # up-to-date db connection
+
+ return filter_rec
+
+ def set_column_filtering(self, checked_values):
+ """
+ Sets dictionary used in '$match' part of MongoDB aggregate
+
+ Args:
+ checked_values(dict): key:values ({'status':{1:"Foo",3:"Bar"}}
+
+ Modifies:
+ self._column_filtering : {'status': {'$in': [1, 2, 3]}}
+ """
+ filtering = {}
+ for column_name, dict_value in checked_values.items():
+ column_f = self.COLUMN_FILTERS.get(column_name)
+ if not column_f:
+ continue
+ column_f.dbcon = self.dbcon
+ filtering[column_name] = column_f.prepare_match_part(dict_value)
+
+ self._column_filtering = filtering
+
+ def get_column_filter_values(self, index):
+ """
+ Returns list of available values for filtering in the column
+
+ Args:
+ index(int): index of column in header
+
+ Returns:
+ (dict) of value: label shown in filtering menu
+ 'value' is used in MongoDB query, 'label' is human readable for
+ menu
+ for some columns ('subset') might be 'value' and 'label' same
+ """
+ filter_rec = self.get_column_filter(index)
+ if not filter_rec:
+ return {}
+
+ return filter_rec.values()
+
def set_project(self, project):
"""
Changes project, called after project selection is changed
@@ -251,7 +335,7 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel):
("files_count", "Files"),
("files_size", "Size"),
("priority", "Priority"),
- ("state", "Status")
+ ("status", "Status")
]
DEFAULT_SORT = {
@@ -259,18 +343,25 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel):
"_id": 1
}
SORT_BY_COLUMN = [
- "context.asset", # asset
- "context.subset", # subset
- "context.version", # version
- "context.representation", # representation
+ "asset", # asset
+ "subset", # subset
+ "version", # version
+ "representation", # representation
"updated_dt_local", # local created_dt
"updated_dt_remote", # remote created_dt
"files_count", # count of files
"files_size", # file size of all files
"context.asset", # priority TODO
- "status" # state
+ "status" # status
]
+ COLUMN_FILTERS = {
+ 'status': lib.PredefinedSetFilter('status', lib.STATUS),
+ 'subset': lib.RegexTextFilter('subset'),
+ 'asset': lib.RegexTextFilter('asset'),
+ 'representation': lib.MultiSelectFilter('representation')
+ }
+
refresh_started = QtCore.Signal()
refresh_finished = QtCore.Signal()
@@ -297,7 +388,7 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel):
files_count = attr.ib(default=None)
files_size = attr.ib(default=None)
priority = attr.ib(default=None)
- state = attr.ib(default=None)
+ status = attr.ib(default=None)
path = attr.ib(default=None)
def __init__(self, sync_server, header, project=None):
@@ -307,7 +398,10 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel):
self._project = project
self._rec_loaded = 0
self._total_records = 0 # how many documents query actually found
- self.word_filter = None
+ self._word_filter = None
+ self._column_filtering = {}
+
+ self._word_filter = None
self._initialized = False
if not self._project or self._project == lib.DUMMY_PROJECT:
@@ -319,12 +413,10 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel):
self.local_site = self.sync_server.get_active_site(self.project)
self.remote_site = self.sync_server.get_remote_site(self.project)
- self.projection = self.get_default_projection()
-
self.sort = self.DEFAULT_SORT
- self.query = self.get_default_query()
- self.default_query = list(self.get_default_query())
+ self.query = self.get_query()
+ self.default_query = list(self.get_query())
representations = self.dbcon.aggregate(self.query)
self.refresh(representations)
@@ -359,9 +451,11 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel):
if role == lib.FailedRole:
if header_value == 'local_site':
- return item.state == lib.STATUS[2] and item.local_progress < 1
+ return item.status == lib.STATUS[2] and \
+ item.local_progress < 1
if header_value == 'remote_site':
- return item.state == lib.STATUS[2] and item.remote_progress < 1
+ return item.status == lib.STATUS[2] and \
+ item.remote_progress < 1
if role == Qt.DisplayRole:
# because of ImageDelegate
@@ -397,7 +491,6 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel):
remote_site)
for repre in result.get("paginatedResults"):
- context = repre.get("context").pop()
files = repre.get("files", [])
if isinstance(files, dict): # aggregate returns dictionary
files = [files]
@@ -420,17 +513,17 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel):
avg_progress_local = lib.convert_progress(
repre.get('avg_progress_local', '0'))
- if context.get("version"):
- version = "v{:0>3d}".format(context.get("version"))
+ if repre.get("version"):
+ version = "v{:0>3d}".format(repre.get("version"))
else:
version = "master"
item = self.SyncRepresentation(
repre.get("_id"),
- context.get("asset"),
- context.get("subset"),
+ repre.get("asset"),
+ repre.get("subset"),
version,
- context.get("representation"),
+ repre.get("representation"),
local_updated,
remote_updated,
local_site,
@@ -449,7 +542,7 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel):
self._data.append(item)
self._rec_loaded += 1
- def get_default_query(self, limit=0):
+ def get_query(self, limit=0):
"""
Returns basic aggregate query for main table.
@@ -461,7 +554,7 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel):
'sync_dt' - same for remote side
'local_site' - progress of repr on local side, 1 = finished
'remote_site' - progress on remote side, calculates from files
- 'state' -
+ 'status' -
0 - in progress
1 - failed
2 - queued
@@ -481,7 +574,7 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel):
if limit == 0:
limit = SyncRepresentationSummaryModel.PAGE_SIZE
- return [
+ aggr = [
{"$match": self.get_match_part()},
{'$unwind': '$files'},
# merge potentially unwinded records back to single per repre
@@ -584,16 +677,26 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel):
'paused_local': {'$sum': '$paused_local'},
'updated_dt_local': {'$max': "$updated_dt_local"}
}},
- {"$project": self.projection},
- {"$sort": self.sort},
- {
+ {"$project": self.projection}
+ ]
+
+ if self.column_filtering:
+ aggr.append(
+ {"$match": self.column_filtering}
+ )
+
+ aggr.extend(
+ [{"$sort": self.sort},
+ {
'$facet': {
'paginatedResults': [{'$skip': self._rec_loaded},
{'$limit': limit}],
'totalCount': [{'$count': 'count'}]
}
- }
- ]
+ }]
+ )
+
+ return aggr
def get_match_part(self):
"""
@@ -614,22 +717,23 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel):
'files.sites.name': {'$all': [self.local_site,
self.remote_site]}
}
- if not self.word_filter:
+ if not self._word_filter:
return base_match
else:
- regex_str = '.*{}.*'.format(self.word_filter)
+ regex_str = '.*{}.*'.format(self._word_filter)
base_match['$or'] = [
{'context.subset': {'$regex': regex_str, '$options': 'i'}},
{'context.asset': {'$regex': regex_str, '$options': 'i'}},
{'context.representation': {'$regex': regex_str,
'$options': 'i'}}]
- if ObjectId.is_valid(self.word_filter):
- base_match['$or'] = [{'_id': ObjectId(self.word_filter)}]
+ if ObjectId.is_valid(self._word_filter):
+ base_match['$or'] = [{'_id': ObjectId(self._word_filter)}]
return base_match
- def get_default_projection(self):
+ @property
+ def projection(self):
"""
Projection part for aggregate query.
@@ -639,10 +743,10 @@ class SyncRepresentationSummaryModel(_SyncRepresentationModel):
(dict)
"""
return {
- "context.subset": 1,
- "context.asset": 1,
- "context.version": 1,
- "context.representation": 1,
+ "subset": {"$first": "$context.subset"},
+ "asset": {"$first": "$context.asset"},
+ "version": {"$first": "$context.version"},
+ "representation": {"$first": "$context.representation"},
"data.path": 1,
"files": 1,
'files_count': 1,
@@ -721,7 +825,7 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel):
("remote_site", "Remote site"),
("files_size", "Size"),
("priority", "Priority"),
- ("state", "Status")
+ ("status", "Status")
]
PAGE_SIZE = 30
@@ -733,10 +837,15 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel):
"updated_dt_local", # local created_dt
"updated_dt_remote", # remote created_dt
"size", # remote progress
- "context.asset", # priority TODO
- "status" # state
+ "size", # priority TODO
+ "status" # status
]
+ COLUMN_FILTERS = {
+ 'status': lib.PredefinedSetFilter('status', lib.STATUS),
+ 'file': lib.RegexTextFilter('file'),
+ }
+
refresh_started = QtCore.Signal()
refresh_finished = QtCore.Signal()
@@ -759,7 +868,7 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel):
remote_progress = attr.ib(default=None)
size = attr.ib(default=None)
priority = attr.ib(default=None)
- state = attr.ib(default=None)
+ status = attr.ib(default=None)
tries = attr.ib(default=None)
error = attr.ib(default=None)
path = attr.ib(default=None)
@@ -772,9 +881,10 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel):
self._project = project
self._rec_loaded = 0
self._total_records = 0 # how many documents query actually found
- self.word_filter = None
+ self._word_filter = None
self._id = _id
self._initialized = False
+ self._column_filtering = {}
self.sync_server = sync_server
# TODO think about admin mode
@@ -784,10 +894,7 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel):
self.sort = self.DEFAULT_SORT
- # in case we would like to hide/show some columns
- self.projection = self.get_default_projection()
-
- self.query = self.get_default_query()
+ self.query = self.get_query()
representations = self.dbcon.aggregate(self.query)
self.refresh(representations)
@@ -821,9 +928,11 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel):
if role == lib.FailedRole:
if header_value == 'local_site':
- return item.state == lib.STATUS[2] and item.local_progress < 1
+ return item.status == lib.STATUS[2] and \
+ item.local_progress < 1
if header_value == 'remote_site':
- return item.state == lib.STATUS[2] and item.remote_progress < 1
+ return item.status == lib.STATUS[2] and \
+ item.remote_progress < 1
if role == Qt.DisplayRole:
# because of ImageDelegate
@@ -909,7 +1018,7 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel):
self._data.append(item)
self._rec_loaded += 1
- def get_default_query(self, limit=0):
+ def get_query(self, limit=0):
"""
Gets query that gets used when no extra sorting, filtering or
projecting is needed.
@@ -923,7 +1032,7 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel):
if limit == 0:
limit = SyncRepresentationSummaryModel.PAGE_SIZE
- return [
+ aggr = [
{"$match": self.get_match_part()},
{"$unwind": "$files"},
{'$addFields': {
@@ -1019,7 +1128,16 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel):
]}
]}}
}},
- {"$project": self.projection},
+ {"$project": self.projection}
+ ]
+
+ if self.column_filtering:
+ aggr.append(
+ {"$match": self.column_filtering}
+ )
+ print(self.column_filtering)
+
+ aggr.extend([
{"$sort": self.sort},
{
'$facet': {
@@ -1028,7 +1146,9 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel):
'totalCount': [{'$count': 'count'}]
}
}
- ]
+ ])
+
+ return aggr
def get_match_part(self):
"""
@@ -1038,20 +1158,21 @@ class SyncRepresentationDetailModel(_SyncRepresentationModel):
Returns:
(dict)
"""
- if not self.word_filter:
+ if not self._word_filter:
return {
"type": "representation",
"_id": self._id
}
else:
- regex_str = '.*{}.*'.format(self.word_filter)
+ regex_str = '.*{}.*'.format(self._word_filter)
return {
"type": "representation",
"_id": self._id,
'$or': [{'files.path': {'$regex': regex_str, '$options': 'i'}}]
}
- def get_default_projection(self):
+ @property
+ def projection(self):
"""
Projection part for aggregate query.
diff --git a/openpype/modules/sync_server/tray/widgets.py b/openpype/modules/sync_server/tray/widgets.py
index 5071ffa2b0..6d8348becb 100644
--- a/openpype/modules/sync_server/tray/widgets.py
+++ b/openpype/modules/sync_server/tray/widgets.py
@@ -1,6 +1,7 @@
import os
import subprocess
import sys
+from functools import partial
from Qt import QtWidgets, QtCore, QtGui
from Qt.QtCore import Qt
@@ -14,6 +15,7 @@ from openpype.api import get_local_site_id
from openpype.lib import PypeLogger
from avalon.tools.delegates import pretty_timestamp
+from avalon.vendor import qtawesome
from openpype.modules.sync_server.tray.models import (
SyncRepresentationSummaryModel,
@@ -40,6 +42,8 @@ class SyncProjectListWidget(ProjectListWidget):
self.local_site = None
self.icons = {}
+ self.layout().setContentsMargins(0, 0, 0, 0)
+
def validate_context_change(self):
return True
@@ -91,7 +95,6 @@ class SyncProjectListWidget(ProjectListWidget):
self.project_name = point_index.data(QtCore.Qt.DisplayRole)
menu = QtWidgets.QMenu()
- menu.setStyleSheet(style.load_stylesheet())
actions_mapping = {}
if self.sync_server.is_project_paused(self.project_name):
@@ -141,16 +144,16 @@ class SyncRepresentationWidget(QtWidgets.QWidget):
message_generated = QtCore.Signal(str)
default_widths = (
- ("asset", 220),
- ("subset", 190),
- ("version", 55),
- ("representation", 95),
- ("local_site", 170),
- ("remote_site", 170),
+ ("asset", 190),
+ ("subset", 170),
+ ("version", 60),
+ ("representation", 145),
+ ("local_site", 160),
+ ("remote_site", 160),
("files_count", 50),
("files_size", 60),
- ("priority", 50),
- ("state", 110)
+ ("priority", 70),
+ ("status", 110)
)
def __init__(self, sync_server, project=None, parent=None):
@@ -162,13 +165,16 @@ class SyncRepresentationWidget(QtWidgets.QWidget):
self.representation_id = None
self.site_name = None # to pause/unpause representation
- self.filter = QtWidgets.QLineEdit()
- self.filter.setPlaceholderText("Filter representations..")
+ self.txt_filter = QtWidgets.QLineEdit()
+ self.txt_filter.setPlaceholderText("Quick filter representations..")
+ self.txt_filter.setClearButtonEnabled(True)
+ self.txt_filter.addAction(qtawesome.icon("fa.filter", color="gray"),
+ QtWidgets.QLineEdit.LeadingPosition)
self._scrollbar_pos = None
top_bar_layout = QtWidgets.QHBoxLayout()
- top_bar_layout.addWidget(self.filter)
+ top_bar_layout.addWidget(self.txt_filter)
self.table_view = QtWidgets.QTableView()
headers = [item[0] for item in self.default_widths]
@@ -182,8 +188,6 @@ class SyncRepresentationWidget(QtWidgets.QWidget):
QtWidgets.QAbstractItemView.SelectRows)
self.table_view.horizontalHeader().setSortIndicator(
-1, Qt.AscendingOrder)
- self.table_view.setSortingEnabled(True)
- self.table_view.horizontalHeader().setSortIndicatorShown(True)
self.table_view.setAlternatingRowColors(True)
self.table_view.verticalHeader().hide()
@@ -195,32 +199,39 @@ class SyncRepresentationWidget(QtWidgets.QWidget):
delegate = ImageDelegate(self)
self.table_view.setItemDelegateForColumn(column, delegate)
- for column_name, width in self.default_widths:
- idx = model.get_header_index(column_name)
- self.table_view.setColumnWidth(idx, width)
-
layout = QtWidgets.QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addLayout(top_bar_layout)
layout.addWidget(self.table_view)
self.table_view.doubleClicked.connect(self._double_clicked)
- self.filter.textChanged.connect(lambda: model.set_filter(
- self.filter.text()))
+ self.txt_filter.textChanged.connect(lambda: model.set_word_filter(
+ self.txt_filter.text()))
self.table_view.customContextMenuRequested.connect(
self._on_context_menu)
model.refresh_started.connect(self._save_scrollbar)
model.refresh_finished.connect(self._set_scrollbar)
- self.table_view.model().modelReset.connect(self._set_selection)
+ model.modelReset.connect(self._set_selection)
+
+ self.model = model
self.selection_model = self.table_view.selectionModel()
self.selection_model.selectionChanged.connect(self._selection_changed)
+ horizontal_header = HorizontalHeader(self)
+
+ self.table_view.setHorizontalHeader(horizontal_header)
+ self.table_view.setSortingEnabled(True)
+
+ for column_name, width in self.default_widths:
+ idx = model.get_header_index(column_name)
+ self.table_view.setColumnWidth(idx, width)
+
def _selection_changed(self, _new_selection):
index = self.selection_model.currentIndex()
self._selected_id = \
- self.table_view.model().data(index, Qt.UserRole)
+ self.model.data(index, Qt.UserRole)
def _set_selection(self):
"""
@@ -229,7 +240,7 @@ class SyncRepresentationWidget(QtWidgets.QWidget):
Keep selection during model refresh.
"""
if self._selected_id:
- index = self.table_view.model().get_index(self._selected_id)
+ index = self.model.get_index(self._selected_id)
if index and index.isValid():
mode = QtCore.QItemSelectionModel.Select | \
QtCore.QItemSelectionModel.Rows
@@ -241,9 +252,9 @@ class SyncRepresentationWidget(QtWidgets.QWidget):
"""
Opens representation dialog with all files after doubleclick
"""
- _id = self.table_view.model().data(index, Qt.UserRole)
+ _id = self.model.data(index, Qt.UserRole)
detail_window = SyncServerDetailWindow(
- self.sync_server, _id, self.table_view.model().project)
+ self.sync_server, _id, self.model.project)
detail_window.exec()
def _on_context_menu(self, point):
@@ -254,13 +265,12 @@ class SyncRepresentationWidget(QtWidgets.QWidget):
if not point_index.isValid():
return
- self.item = self.table_view.model()._data[point_index.row()]
+ self.item = self.model._data[point_index.row()]
self.representation_id = self.item._id
log.debug("menu representation _id:: {}".
format(self.representation_id))
menu = QtWidgets.QMenu()
- menu.setStyleSheet(style.load_stylesheet())
actions_mapping = {}
actions_kwargs_mapping = {}
@@ -271,7 +281,7 @@ class SyncRepresentationWidget(QtWidgets.QWidget):
for site, progress in {local_site: local_progress,
remote_site: remote_progress}.items():
- project = self.table_view.model().project
+ project = self.model.project
provider = self.sync_server.get_provider_for_site(project,
site)
if provider == 'local_drive':
@@ -291,17 +301,17 @@ class SyncRepresentationWidget(QtWidgets.QWidget):
else:
self.site_name = remote_site
- if self.item.state in [lib.STATUS[0], lib.STATUS[1]]:
+ if self.item.status in [lib.STATUS[0], lib.STATUS[1]]:
action = QtWidgets.QAction("Pause")
actions_mapping[action] = self._pause
menu.addAction(action)
- if self.item.state == lib.STATUS[3]:
+ if self.item.status == lib.STATUS[3]:
action = QtWidgets.QAction("Unpause")
actions_mapping[action] = self._unpause
menu.addAction(action)
- # if self.item.state == lib.STATUS[1]:
+ # if self.item.status == lib.STATUS[1]:
# action = QtWidgets.QAction("Open error detail")
# actions_mapping[action] = self._show_detail
# menu.addAction(action)
@@ -337,10 +347,10 @@ class SyncRepresentationWidget(QtWidgets.QWidget):
if to_run:
to_run(**to_run_kwargs)
- self.table_view.model().refresh()
+ self.model.refresh()
def _pause(self):
- self.sync_server.pause_representation(self.table_view.model().project,
+ self.sync_server.pause_representation(self.model.project,
self.representation_id,
self.site_name)
self.site_name = None
@@ -348,7 +358,7 @@ class SyncRepresentationWidget(QtWidgets.QWidget):
def _unpause(self):
self.sync_server.unpause_representation(
- self.table_view.model().project,
+ self.model.project,
self.representation_id,
self.site_name)
self.site_name = None
@@ -358,7 +368,7 @@ class SyncRepresentationWidget(QtWidgets.QWidget):
# temporary here for testing, will be removed TODO
def _add_site(self):
log.info(self.representation_id)
- project_name = self.table_view.model().project
+ project_name = self.model.project
local_site_name = get_local_site_id()
try:
self.sync_server.add_site(
@@ -386,15 +396,15 @@ class SyncRepresentationWidget(QtWidgets.QWidget):
try:
local_site = get_local_site_id()
self.sync_server.remove_site(
- self.table_view.model().project,
+ self.model.project,
self.representation_id,
local_site,
True)
self.message_generated.emit("Site {} removed".format(local_site))
except ValueError as exp:
self.message_generated.emit("Error {}".format(str(exp)))
- self.table_view.model().refresh(
- load_records=self.table_view.model()._rec_loaded)
+ self.model.refresh(
+ load_records=self.model._rec_loaded)
def _reset_local_site(self):
"""
@@ -402,11 +412,11 @@ class SyncRepresentationWidget(QtWidgets.QWidget):
redo of upload/download
"""
self.sync_server.reset_provider_for_file(
- self.table_view.model().project,
+ self.model.project,
self.representation_id,
'local')
- self.table_view.model().refresh(
- load_records=self.table_view.model()._rec_loaded)
+ self.model.refresh(
+ load_records=self.model._rec_loaded)
def _reset_remote_site(self):
"""
@@ -414,18 +424,18 @@ class SyncRepresentationWidget(QtWidgets.QWidget):
redo of upload/download
"""
self.sync_server.reset_provider_for_file(
- self.table_view.model().project,
+ self.model.project,
self.representation_id,
'remote')
- self.table_view.model().refresh(
- load_records=self.table_view.model()._rec_loaded)
+ self.model.refresh(
+ load_records=self.model._rec_loaded)
def _open_in_explorer(self, site):
if not self.item:
return
fpath = self.item.path
- project = self.table_view.model().project
+ project = self.model.project
fpath = self.sync_server.get_local_file_path(project,
site,
fpath)
@@ -466,8 +476,8 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget):
("local_site", 185),
("remote_site", 185),
("size", 60),
- ("priority", 25),
- ("state", 110)
+ ("priority", 60),
+ ("status", 110)
)
def __init__(self, sync_server, _id=None, project=None, parent=None):
@@ -482,64 +492,73 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget):
self._selected_id = None
- self.filter = QtWidgets.QLineEdit()
- self.filter.setPlaceholderText("Filter representation..")
+ self.txt_filter = QtWidgets.QLineEdit()
+ self.txt_filter.setPlaceholderText("Quick filter representation..")
+ self.txt_filter.setClearButtonEnabled(True)
+ self.txt_filter.addAction(qtawesome.icon("fa.filter", color="gray"),
+ QtWidgets.QLineEdit.LeadingPosition)
self._scrollbar_pos = None
top_bar_layout = QtWidgets.QHBoxLayout()
- top_bar_layout.addWidget(self.filter)
+ top_bar_layout.addWidget(self.txt_filter)
- self.table_view = QtWidgets.QTableView()
+ table_view = QtWidgets.QTableView()
headers = [item[0] for item in self.default_widths]
model = SyncRepresentationDetailModel(sync_server, headers, _id,
project)
- self.table_view.setModel(model)
- self.table_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
- self.table_view.setSelectionMode(
+ table_view.setModel(model)
+ table_view.setContextMenuPolicy(QtCore.Qt.CustomContextMenu)
+ table_view.setSelectionMode(
QtWidgets.QAbstractItemView.SingleSelection)
- self.table_view.setSelectionBehavior(
+ table_view.setSelectionBehavior(
QtWidgets.QTableView.SelectRows)
- self.table_view.horizontalHeader().setSortIndicator(-1,
- Qt.AscendingOrder)
- self.table_view.setSortingEnabled(True)
- self.table_view.horizontalHeader().setSortIndicatorShown(True)
- self.table_view.setAlternatingRowColors(True)
- self.table_view.verticalHeader().hide()
+ table_view.horizontalHeader().setSortIndicator(-1, Qt.AscendingOrder)
+ table_view.horizontalHeader().setSortIndicatorShown(True)
+ table_view.setAlternatingRowColors(True)
+ table_view.verticalHeader().hide()
- column = self.table_view.model().get_header_index("local_site")
+ column = model.get_header_index("local_site")
delegate = ImageDelegate(self)
- self.table_view.setItemDelegateForColumn(column, delegate)
+ table_view.setItemDelegateForColumn(column, delegate)
- column = self.table_view.model().get_header_index("remote_site")
+ column = model.get_header_index("remote_site")
delegate = ImageDelegate(self)
- self.table_view.setItemDelegateForColumn(column, delegate)
-
- for column_name, width in self.default_widths:
- idx = model.get_header_index(column_name)
- self.table_view.setColumnWidth(idx, width)
+ table_view.setItemDelegateForColumn(column, delegate)
layout = QtWidgets.QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addLayout(top_bar_layout)
- layout.addWidget(self.table_view)
+ layout.addWidget(table_view)
- self.filter.textChanged.connect(lambda: model.set_filter(
- self.filter.text()))
- self.table_view.customContextMenuRequested.connect(
- self._on_context_menu)
+ self.model = model
+
+ self.selection_model = table_view.selectionModel()
+ self.selection_model.selectionChanged.connect(self._selection_changed)
+
+ horizontal_header = HorizontalHeader(self)
+
+ table_view.setHorizontalHeader(horizontal_header)
+ table_view.setSortingEnabled(True)
+
+ for column_name, width in self.default_widths:
+ idx = model.get_header_index(column_name)
+ table_view.setColumnWidth(idx, width)
+
+ self.table_view = table_view
+
+ self.txt_filter.textChanged.connect(lambda: model.set_word_filter(
+ self.txt_filter.text()))
+ table_view.customContextMenuRequested.connect(self._on_context_menu)
model.refresh_started.connect(self._save_scrollbar)
model.refresh_finished.connect(self._set_scrollbar)
- self.table_view.model().modelReset.connect(self._set_selection)
-
- self.selection_model = self.table_view.selectionModel()
- self.selection_model.selectionChanged.connect(self._selection_changed)
+ model.modelReset.connect(self._set_selection)
def _selection_changed(self):
index = self.selection_model.currentIndex()
- self._selected_id = self.table_view.model().data(index, Qt.UserRole)
+ self._selected_id = self.model.data(index, Qt.UserRole)
def _set_selection(self):
"""
@@ -548,7 +567,7 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget):
Keep selection during model refresh.
"""
if self._selected_id:
- index = self.table_view.model().get_index(self._selected_id)
+ index = self.model.get_index(self._selected_id)
if index and index.isValid():
mode = QtCore.QItemSelectionModel.Select | \
QtCore.QItemSelectionModel.Rows
@@ -576,10 +595,9 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget):
if not point_index.isValid():
return
- self.item = self.table_view.model()._data[point_index.row()]
+ self.item = self.model._data[point_index.row()]
menu = QtWidgets.QMenu()
- menu.setStyleSheet(style.load_stylesheet())
actions_mapping = {}
actions_kwargs_mapping = {}
@@ -590,7 +608,7 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget):
for site, progress in {local_site: local_progress,
remote_site: remote_progress}.items():
- project = self.table_view.model().project
+ project = self.model.project
provider = self.sync_server.get_provider_for_site(project,
site)
if provider == 'local_drive':
@@ -604,7 +622,7 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget):
actions_kwargs_mapping[action] = {'site': site}
menu.addAction(action)
- if self.item.state == lib.STATUS[2]:
+ if self.item.status == lib.STATUS[2]:
action = QtWidgets.QAction("Open error detail")
actions_mapping[action] = self._show_detail
menu.addAction(action)
@@ -637,12 +655,12 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget):
redo of upload/download
"""
self.sync_server.reset_provider_for_file(
- self.table_view.model().project,
+ self.model.project,
self.representation_id,
'local',
self.item._id)
- self.table_view.model().refresh(
- load_records=self.table_view.model()._rec_loaded)
+ self.model.refresh(
+ load_records=self.model._rec_loaded)
def _reset_remote_site(self):
"""
@@ -650,12 +668,12 @@ class SyncRepresentationDetailWidget(QtWidgets.QWidget):
redo of upload/download
"""
self.sync_server.reset_provider_for_file(
- self.table_view.model().project,
+ self.model.project,
self.representation_id,
'remote',
self.item._id)
- self.table_view.model().refresh(
- load_records=self.table_view.model()._rec_loaded)
+ self.model.refresh(
+ load_records=self.model._rec_loaded)
def _open_in_explorer(self, site):
if not self.item:
@@ -818,3 +836,274 @@ class SyncRepresentationErrorWindow(QtWidgets.QDialog):
self.setLayout(body_layout)
self.setWindowTitle("Sync Representation Error Detail")
+
+
+class TransparentWidget(QtWidgets.QWidget):
+ clicked = QtCore.Signal(str)
+
+ def __init__(self, column_name, *args, **kwargs):
+ super(TransparentWidget, self).__init__(*args, **kwargs)
+ self.column_name = column_name
+ # self.setStyleSheet("background: red;")
+
+ def mouseReleaseEvent(self, event):
+ if event.button() == QtCore.Qt.LeftButton:
+ self.clicked.emit(self.column_name)
+
+ super(TransparentWidget, self).mouseReleaseEvent(event)
+
+
+class HorizontalHeader(QtWidgets.QHeaderView):
+
+ def __init__(self, parent=None):
+ super(HorizontalHeader, self).__init__(QtCore.Qt.Horizontal, parent)
+ self._parent = parent
+ self.checked_values = {}
+
+ self.setModel(self._parent.model)
+
+ self.setSectionsClickable(True)
+
+ self.menu_items_dict = {}
+ self.menu = None
+ self.header_cells = []
+ self.filter_buttons = {}
+
+ self.filter_icon = qtawesome.icon("fa.filter", color="gray")
+ self.filter_set_icon = qtawesome.icon("fa.filter", color="white")
+
+ self.init_layout()
+
+ self._resetting = False
+
+ @property
+ def model(self):
+ """Keep model synchronized with parent widget"""
+ return self._parent.model
+
+ def init_layout(self):
+ for column_idx in range(self.model.columnCount()):
+ column_name, column_label = self.model.get_column(column_idx)
+ filter_rec = self.model.get_filters().get(column_name)
+ if not filter_rec:
+ continue
+
+ icon = self.filter_icon
+ button = QtWidgets.QPushButton(icon, "", self)
+
+ button.setFixedSize(24, 24)
+ button.setStyleSheet(
+ "QPushButton::menu-indicator{width:0px;}"
+ "QPushButton{border: none;background: transparent;}")
+ button.clicked.connect(partial(self._get_menu,
+ column_name, column_idx))
+ button.setFlat(True)
+ self.filter_buttons[column_name] = button
+
+ def showEvent(self, event):
+ super(HorizontalHeader, self).showEvent(event)
+
+ for i in range(len(self.header_cells)):
+ cell_content = self.header_cells[i]
+ cell_content.setGeometry(self.sectionViewportPosition(i), 0,
+ self.sectionSize(i) - 1, self.height())
+
+ cell_content.show()
+
+ def _set_filter_icon(self, column_name):
+ button = self.filter_buttons.get(column_name)
+ if button:
+ if self.checked_values.get(column_name):
+ button.setIcon(self.filter_set_icon)
+ else:
+ button.setIcon(self.filter_icon)
+
+ def _reset_filter(self, column_name):
+ """
+ Remove whole column from filter >> not in $match at all (faster)
+ """
+ self._resetting = True # mark changes to consume them
+ if self.checked_values.get(column_name) is not None:
+ self.checked_values.pop(column_name)
+ self._set_filter_icon(column_name)
+ self._filter_and_refresh_model_and_menu(column_name, True, True)
+ self._resetting = False
+
+ def _apply_filter(self, column_name, values, state):
+ """
+ Sets 'values' to specific 'state' (checked/unchecked),
+ sends to model.
+ """
+ if self._resetting: # event triggered by _resetting, skip it
+ return
+
+ self._update_checked_values(column_name, values, state)
+ self._set_filter_icon(column_name)
+ self._filter_and_refresh_model_and_menu(column_name, True, False)
+
+ def _apply_text_filter(self, column_name, items, line_edit):
+ """
+ Resets all checkboxes, prefers inserted text.
+ """
+ le_text = line_edit.text()
+ self._update_checked_values(column_name, items, 0) # reset other
+ if self.checked_values.get(column_name) is not None or \
+ le_text == '':
+ self.checked_values.pop(column_name) # reset during typing
+
+ if le_text:
+ self._update_checked_values(column_name, {le_text: le_text}, 2)
+ self._set_filter_icon(column_name)
+ self._filter_and_refresh_model_and_menu(column_name, True, True)
+
+ def _filter_and_refresh_model_and_menu(self, column_name,
+ model=True, menu=True):
+ """
+ Refresh model and its content and possibly menu for big changes.
+ """
+ if model:
+ self.model.set_column_filtering(self.checked_values)
+ self.model.refresh()
+ if menu:
+ self._menu_refresh(column_name)
+
+ def _get_menu(self, column_name, index):
+ """Prepares content of menu for 'column_name'"""
+ menu = QtWidgets.QMenu(self)
+ filter_rec = self.model.get_filters()[column_name]
+ self.menu_items_dict[column_name] = filter_rec.values()
+
+ # text filtering only if labels same as values, not if codes are used
+ if 'text' in filter_rec.search_variants():
+ line_edit = QtWidgets.QLineEdit(menu)
+ line_edit.setClearButtonEnabled(True)
+ line_edit.addAction(self.filter_icon,
+ QtWidgets.QLineEdit.LeadingPosition)
+
+ line_edit.setFixedHeight(line_edit.height())
+ txt = ""
+ if self.checked_values.get(column_name):
+ txt = list(self.checked_values.get(column_name).keys())[0]
+ line_edit.setText(txt)
+
+ action_le = QtWidgets.QWidgetAction(menu)
+ action_le.setDefaultWidget(line_edit)
+ line_edit.textChanged.connect(
+ partial(self._apply_text_filter, column_name,
+ filter_rec.values(), line_edit))
+ menu.addAction(action_le)
+ menu.addSeparator()
+
+ if 'checkbox' in filter_rec.search_variants():
+ action_all = QtWidgets.QAction("All", self)
+ action_all.triggered.connect(partial(self._reset_filter,
+ column_name))
+ menu.addAction(action_all)
+
+ action_none = QtWidgets.QAction("Unselect all", self)
+ state_unchecked = 0
+ action_none.triggered.connect(partial(self._apply_filter,
+ column_name,
+ filter_rec.values(),
+ state_unchecked))
+ menu.addAction(action_none)
+ menu.addSeparator()
+
+ # nothing explicitly >> ALL implicitly >> first time
+ if self.checked_values.get(column_name) is None:
+ checked_keys = self.menu_items_dict[column_name].keys()
+ else:
+ checked_keys = self.checked_values[column_name]
+
+ for value, label in self.menu_items_dict[column_name].items():
+ checkbox = QtWidgets.QCheckBox(str(label), menu)
+
+ # temp
+ checkbox.setStyleSheet("QCheckBox{spacing: 5px;"
+ "padding:5px 5px 5px 5px;}")
+ if value in checked_keys:
+ checkbox.setChecked(True)
+
+ action = QtWidgets.QWidgetAction(menu)
+ action.setDefaultWidget(checkbox)
+
+ checkbox.stateChanged.connect(partial(self._apply_filter,
+ column_name, {value: label}))
+ menu.addAction(action)
+
+ self.menu = menu
+
+ self._show_menu(index, menu)
+
+ def _show_menu(self, index, menu):
+ """Shows 'menu' under header column of 'index'"""
+ global_pos_point = self.mapToGlobal(
+ QtCore.QPoint(self.sectionViewportPosition(index), 0))
+ menu.setMinimumWidth(self.sectionSize(index))
+ menu.setMinimumHeight(self.height())
+ menu.exec_(QtCore.QPoint(global_pos_point.x(),
+ global_pos_point.y() + self.height()))
+
+ def _menu_refresh(self, column_name):
+ """
+ Reset boxes after big change - word filtering or reset
+ """
+ for action in self.menu.actions():
+ if not isinstance(action, QtWidgets.QWidgetAction):
+ continue
+
+ widget = action.defaultWidget()
+ if not isinstance(widget, QtWidgets.QCheckBox):
+ continue
+
+ if not self.checked_values.get(column_name) or \
+ widget.text() in self.checked_values[column_name].values():
+ widget.setChecked(True)
+ else:
+ widget.setChecked(False)
+
+ def _update_checked_values(self, column_name, values, state):
+ """
+ Modify dictionary of set values in columns for filtering.
+
+ Modifies 'self.checked_values'
+ """
+ copy_menu_items = dict(self.menu_items_dict[column_name])
+ checked = self.checked_values.get(column_name, copy_menu_items)
+ set_items = dict(values.items()) # prevent dict change during loop
+ for value, label in set_items.items():
+ if state == 2 and label: # checked
+ checked[value] = label
+ elif state == 0 and checked.get(value):
+ checked.pop(value)
+
+ self.checked_values[column_name] = checked
+
+ def paintEvent(self, event):
+ self._fix_size()
+ super(HorizontalHeader, self).paintEvent(event)
+
+ def _fix_size(self):
+ for column_idx in range(self.model.columnCount()):
+ vis_index = self.visualIndex(column_idx)
+ index = self.logicalIndex(vis_index)
+ section_width = self.sectionSize(index)
+
+ column_name = self.model.headerData(column_idx,
+ QtCore.Qt.Horizontal,
+ lib.HeaderNameRole)
+ button = self.filter_buttons.get(column_name)
+ if not button:
+ continue
+
+ pos_x = self.sectionViewportPosition(
+ index) + section_width - self.height()
+
+ pos_y = 0
+ if button.height() < self.height():
+ pos_y = int((self.height() - button.height()) / 2)
+ button.setGeometry(
+ pos_x,
+ pos_y,
+ self.height(),
+ self.height())
diff --git a/openpype/modules/user/__init__.py b/openpype/modules/user/__init__.py
deleted file mode 100644
index a97ac0eef6..0000000000
--- a/openpype/modules/user/__init__.py
+++ /dev/null
@@ -1,10 +0,0 @@
-from .user_module import (
- UserModule,
- IUserModule
-)
-
-
-__all__ = (
- "UserModule",
- "IUserModule"
-)
diff --git a/openpype/modules/user/rest_api.py b/openpype/modules/user/rest_api.py
deleted file mode 100644
index 566425a19b..0000000000
--- a/openpype/modules/user/rest_api.py
+++ /dev/null
@@ -1,35 +0,0 @@
-import json
-from aiohttp.web_response import Response
-
-
-class UserModuleRestApi:
- def __init__(self, user_module, server_manager):
- self.module = user_module
- self.server_manager = server_manager
-
- self.prefix = "/user"
-
- self.register()
-
- def register(self):
- self.server_manager.add_route(
- "GET",
- self.prefix + "/username",
- self.get_username
- )
- self.server_manager.add_route(
- "GET",
- self.prefix + "/show_widget",
- self.show_user_widget
- )
-
- async def get_username(self, request):
- return Response(
- status=200,
- body=json.dumps(self.module.cred, indent=4),
- content_type="application/json"
- )
-
- async def show_user_widget(self, request):
- self.module.action_show_widget.trigger()
- return Response(status=200)
diff --git a/openpype/modules/user/user_module.py b/openpype/modules/user/user_module.py
deleted file mode 100644
index 7d257f1781..0000000000
--- a/openpype/modules/user/user_module.py
+++ /dev/null
@@ -1,169 +0,0 @@
-import os
-import json
-import getpass
-
-from abc import ABCMeta, abstractmethod
-
-import six
-import appdirs
-
-from .. import (
- PypeModule,
- ITrayModule,
- IWebServerRoutes
-)
-
-
-@six.add_metaclass(ABCMeta)
-class IUserModule:
- """Interface for other modules to use user change callbacks."""
-
- @abstractmethod
- def on_pype_user_change(self, username):
- """What should happen on Pype user change."""
- pass
-
-
-class UserModule(PypeModule, ITrayModule, IWebServerRoutes):
- cred_folder_path = os.path.normpath(
- appdirs.user_data_dir('pype-app', 'pype')
- )
- cred_filename = 'user_info.json'
- env_name = "OPENPYPE_USERNAME"
-
- name = "user"
-
- def initialize(self, modules_settings):
- user_settings = modules_settings[self.name]
- self.enabled = user_settings["enabled"]
-
- self.callbacks_on_user_change = []
- self.cred = {}
- self.cred_path = os.path.normpath(os.path.join(
- self.cred_folder_path, self.cred_filename
- ))
-
- # Tray attributes
- self.widget_login = None
- self.action_show_widget = None
-
- self.rest_api_obj = None
-
- def tray_init(self):
- from .widget_user import UserWidget
- self.widget_login = UserWidget(self)
-
- self.load_credentials()
-
- def register_callback_on_user_change(self, callback):
- self.callbacks_on_user_change.append(callback)
-
- def tray_start(self):
- """Store credentials to env and preset them to widget"""
- username = ""
- if self.cred:
- username = self.cred.get("username") or ""
-
- os.environ[self.env_name] = username
- self.widget_login.set_user(username)
-
- def tray_exit(self):
- """Nothing special for User."""
- return
-
- def get_user(self):
- return self.cred.get("username") or getpass.getuser()
-
- def webserver_initialization(self, server_manager):
- """Implementation of IWebServerRoutes interface."""
- from .rest_api import UserModuleRestApi
-
- self.rest_api_obj = UserModuleRestApi(self, server_manager)
-
- def connect_with_modules(self, enabled_modules):
- for module in enabled_modules:
- if isinstance(module, IUserModule):
- self.callbacks_on_user_change.append(
- module.on_pype_user_change
- )
-
- # Definition of Tray menu
- def tray_menu(self, parent_menu):
- from Qt import QtWidgets
- """Add menu or action to Tray(or parent)'s menu"""
- action = QtWidgets.QAction("Username", parent_menu)
- action.triggered.connect(self.show_widget)
- parent_menu.addAction(action)
- parent_menu.addSeparator()
-
- self.action_show_widget = action
-
- def load_credentials(self):
- """Get credentials from JSON file """
- credentials = {}
- try:
- file = open(self.cred_path, "r")
- credentials = json.load(file)
- file.close()
-
- self.cred = credentials
- username = credentials.get("username")
- if username:
- self.log.debug("Loaded Username \"{}\"".format(username))
- else:
- self.log.debug("Pype Username is not set")
-
- return credentials
-
- except FileNotFoundError:
- return self.save_credentials(getpass.getuser())
-
- except json.decoder.JSONDecodeError:
- self.log.warning((
- "File where users credentials should be stored"
- " has invalid json format. Loading system username."
- ))
- return self.save_credentials(getpass.getuser())
-
- def change_credentials(self, username):
- self.save_credentials(username)
- for callback in self.callbacks_on_user_change:
- try:
- callback(username)
- except Exception:
- self.log.warning(
- "Failed to execute callback \"{}\".".format(
- str(callback)
- ),
- exc_info=True
- )
-
- def save_credentials(self, username):
- """Save credentials to JSON file, env and widget"""
- if username is None:
- username = ""
-
- username = str(username).strip()
-
- self.cred = {"username": username}
- os.environ[self.env_name] = username
- if self.widget_login:
- self.widget_login.set_user(username)
- try:
- file = open(self.cred_path, "w")
- file.write(json.dumps(self.cred))
- file.close()
- self.log.debug("Username \"{}\" stored".format(username))
- except Exception:
- self.log.error(
- "Could not store username to file \"{}\"".format(
- self.cred_path
- ),
- exc_info=True
- )
-
- return self.cred
-
- def show_widget(self):
- """Show dialog to enter credentials"""
- self.widget_login.show()
diff --git a/openpype/modules/user/widget_user.py b/openpype/modules/user/widget_user.py
deleted file mode 100644
index f8ecadf56b..0000000000
--- a/openpype/modules/user/widget_user.py
+++ /dev/null
@@ -1,88 +0,0 @@
-from Qt import QtCore, QtGui, QtWidgets
-from avalon import style
-from openpype import resources
-
-
-class UserWidget(QtWidgets.QWidget):
-
- MIN_WIDTH = 300
-
- def __init__(self, module):
-
- super(UserWidget, self).__init__()
-
- self.module = module
-
- # Style
- icon = QtGui.QIcon(resources.pype_icon_filepath())
- self.setWindowIcon(icon)
- self.setWindowTitle("Username Settings")
- self.setMinimumWidth(self.MIN_WIDTH)
- self.setStyleSheet(style.load_stylesheet())
-
- self.setWindowFlags(
- QtCore.Qt.WindowCloseButtonHint |
- QtCore.Qt.WindowMinimizeButtonHint
- )
-
- self.setLayout(self._main())
-
- def show(self, *args, **kwargs):
- super().show(*args, **kwargs)
- # Move widget to center of active screen on show
- screen = QtWidgets.QApplication.desktop().screen()
- screen_center = lambda self: (
- screen.rect().center() - self.rect().center()
- )
- self.move(screen_center(self))
-
- def _main(self):
- main_layout = QtWidgets.QVBoxLayout()
-
- form_layout = QtWidgets.QFormLayout()
- form_layout.setContentsMargins(10, 15, 10, 5)
-
- label_username = QtWidgets.QLabel("Username:")
- label_username.setCursor(QtGui.QCursor(QtCore.Qt.ArrowCursor))
- label_username.setTextFormat(QtCore.Qt.RichText)
-
- input_username = QtWidgets.QLineEdit()
- input_username.setPlaceholderText(
- QtCore.QCoreApplication.translate("main", "e.g. John Smith")
- )
-
- form_layout.addRow(label_username, input_username)
-
- btn_save = QtWidgets.QPushButton("Save")
- btn_save.clicked.connect(self.click_save)
-
- btn_cancel = QtWidgets.QPushButton("Cancel")
- btn_cancel.clicked.connect(self.close)
-
- btn_group = QtWidgets.QHBoxLayout()
- btn_group.addStretch(1)
- btn_group.addWidget(btn_save)
- btn_group.addWidget(btn_cancel)
-
- main_layout.addLayout(form_layout)
- main_layout.addLayout(btn_group)
-
- self.input_username = input_username
-
- return main_layout
-
- def set_user(self, username):
- self.input_username.setText(username)
-
- def click_save(self):
- # all what should happen - validations and saving into appsdir
- username = self.input_username.text()
- self.module.change_credentials(username)
- self._close_widget()
-
- def closeEvent(self, event):
- event.ignore()
- self._close_widget()
-
- def _close_widget(self):
- self.hide()
diff --git a/openpype/plugins/publish/collect_current_pype_user.py b/openpype/plugins/publish/collect_current_pype_user.py
index de4e950d56..003c779836 100644
--- a/openpype/plugins/publish/collect_current_pype_user.py
+++ b/openpype/plugins/publish/collect_current_pype_user.py
@@ -1,6 +1,7 @@
import os
import getpass
import pyblish.api
+from openpype.lib import get_openpype_username
class CollectCurrentUserPype(pyblish.api.ContextPlugin):
@@ -11,9 +12,6 @@ class CollectCurrentUserPype(pyblish.api.ContextPlugin):
label = "Collect Pype User"
def process(self, context):
- user = os.getenv("OPENPYPE_USERNAME", "").strip()
- if not user:
- user = context.data.get("user", getpass.getuser())
-
+ user = get_openpype_username()
context.data["user"] = user
self.log.debug("Colected user \"{}\"".format(user))
diff --git a/openpype/settings/__init__.py b/openpype/settings/__init__.py
index b4187829fc..b5810deef4 100644
--- a/openpype/settings/__init__.py
+++ b/openpype/settings/__init__.py
@@ -1,9 +1,13 @@
+from .exceptions import (
+ SaveWarningExc
+)
from .lib import (
get_system_settings,
get_project_settings,
get_current_project_settings,
get_anatomy_settings,
- get_environments
+ get_environments,
+ get_local_settings
)
from .entities import (
SystemSettings,
@@ -12,11 +16,14 @@ from .entities import (
__all__ = (
+ "SaveWarningExc",
+
"get_system_settings",
"get_project_settings",
"get_current_project_settings",
"get_anatomy_settings",
"get_environments",
+ "get_local_settings",
"SystemSettings",
"ProjectSettings"
diff --git a/openpype/settings/defaults/system_settings/applications.json b/openpype/settings/defaults/system_settings/applications.json
index 2355f39aa1..56d63ecf09 100644
--- a/openpype/settings/defaults/system_settings/applications.json
+++ b/openpype/settings/defaults/system_settings/applications.json
@@ -1165,6 +1165,7 @@
},
"variants": {
"4-26": {
+ "use_python_2": false,
"executables": {
"windows": [],
"darwin": [],
diff --git a/openpype/settings/defaults/system_settings/modules.json b/openpype/settings/defaults/system_settings/modules.json
index b3065058a1..6e4b493116 100644
--- a/openpype/settings/defaults/system_settings/modules.json
+++ b/openpype/settings/defaults/system_settings/modules.json
@@ -161,9 +161,6 @@
"log_viewer": {
"enabled": true
},
- "user": {
- "enabled": true
- },
"standalonepublish_tool": {
"enabled": true
}
diff --git a/openpype/settings/entities/root_entities.py b/openpype/settings/entities/root_entities.py
index eed3d47f46..b89473d9fb 100644
--- a/openpype/settings/entities/root_entities.py
+++ b/openpype/settings/entities/root_entities.py
@@ -23,6 +23,7 @@ from openpype.settings.constants import (
PROJECT_ANATOMY_KEY,
KEY_REGEX
)
+from openpype.settings.exceptions import SaveWarningExc
from openpype.settings.lib import (
DEFAULTS_DIR,
@@ -724,8 +725,19 @@ class ProjectSettings(RootEntity):
project_settings = settings_value.get(PROJECT_SETTINGS_KEY) or {}
project_anatomy = settings_value.get(PROJECT_ANATOMY_KEY) or {}
- save_project_settings(self.project_name, project_settings)
- save_project_anatomy(self.project_name, project_anatomy)
+ warnings = []
+ try:
+ save_project_settings(self.project_name, project_settings)
+ except SaveWarningExc as exc:
+ warnings.extend(exc.warnings)
+
+ try:
+ save_project_anatomy(self.project_name, project_anatomy)
+ except SaveWarningExc as exc:
+ warnings.extend(exc.warnings)
+
+ if warnings:
+ raise SaveWarningExc(warnings)
def _validate_defaults_to_save(self, value):
"""Valiations of default values before save."""
diff --git a/openpype/settings/entities/schemas/system_schema/schema_modules.json b/openpype/settings/entities/schemas/system_schema/schema_modules.json
index a30cafd0c2..878958b12d 100644
--- a/openpype/settings/entities/schemas/system_schema/schema_modules.json
+++ b/openpype/settings/entities/schemas/system_schema/schema_modules.json
@@ -154,20 +154,6 @@
}
]
},
- {
- "type": "dict",
- "key": "user",
- "label": "User setting",
- "collapsible": true,
- "checkbox_key": "enabled",
- "children": [
- {
- "type": "boolean",
- "key": "enabled",
- "label": "Enabled"
- }
- ]
- },
{
"type": "dict",
"key": "standalonepublish_tool",
diff --git a/openpype/settings/exceptions.py b/openpype/settings/exceptions.py
new file mode 100644
index 0000000000..a06138eeaf
--- /dev/null
+++ b/openpype/settings/exceptions.py
@@ -0,0 +1,11 @@
+class SaveSettingsValidation(Exception):
+ pass
+
+
+class SaveWarningExc(SaveSettingsValidation):
+ def __init__(self, warnings):
+ if isinstance(warnings, str):
+ warnings = [warnings]
+ self.warnings = warnings
+ msg = " | ".join(warnings)
+ super(SaveWarningExc, self).__init__(msg)
diff --git a/openpype/settings/lib.py b/openpype/settings/lib.py
index 3bf2141808..f61166fa69 100644
--- a/openpype/settings/lib.py
+++ b/openpype/settings/lib.py
@@ -4,6 +4,9 @@ import functools
import logging
import platform
import copy
+from .exceptions import (
+ SaveWarningExc
+)
from .constants import (
M_OVERRIDEN_KEY,
M_ENVIRONMENT_KEY,
@@ -101,8 +104,14 @@ def save_studio_settings(data):
For saving of data cares registered Settings handler.
+ Warning messages are not logged as module raising them should log it within
+ it's logger.
+
Args:
data(dict): Overrides data with metadata defying studio overrides.
+
+ Raises:
+ SaveWarningExc: If any module raises the exception.
"""
# Notify Pype modules
from openpype.modules import ModulesManager, ISettingsChangeListener
@@ -110,15 +119,25 @@ def save_studio_settings(data):
old_data = get_system_settings()
default_values = get_default_settings()[SYSTEM_SETTINGS_KEY]
new_data = apply_overrides(default_values, copy.deepcopy(data))
+ new_data_with_metadata = copy.deepcopy(new_data)
clear_metadata_from_settings(new_data)
changes = calculate_changes(old_data, new_data)
modules_manager = ModulesManager(_system_settings=new_data)
+
+ warnings = []
for module in modules_manager.get_enabled_modules():
if isinstance(module, ISettingsChangeListener):
- module.on_system_settings_save(old_data, new_data, changes)
+ try:
+ module.on_system_settings_save(
+ old_data, new_data, changes, new_data_with_metadata
+ )
+ except SaveWarningExc as exc:
+ warnings.extend(exc.warnings)
- return _SETTINGS_HANDLER.save_studio_settings(data)
+ _SETTINGS_HANDLER.save_studio_settings(data)
+ if warnings:
+ raise SaveWarningExc(warnings)
@require_handler
@@ -130,10 +149,16 @@ def save_project_settings(project_name, overrides):
For saving of data cares registered Settings handler.
+ Warning messages are not logged as module raising them should log it within
+ it's logger.
+
Args:
project_name (str): Project name for which overrides are passed.
Default project's value is None.
overrides(dict): Overrides data with metadata defying studio overrides.
+
+ Raises:
+ SaveWarningExc: If any module raises the exception.
"""
# Notify Pype modules
from openpype.modules import ModulesManager, ISettingsChangeListener
@@ -151,17 +176,29 @@ def save_project_settings(project_name, overrides):
old_data = get_default_project_settings(exclude_locals=True)
new_data = apply_overrides(default_values, copy.deepcopy(overrides))
+ new_data_with_metadata = copy.deepcopy(new_data)
clear_metadata_from_settings(new_data)
changes = calculate_changes(old_data, new_data)
modules_manager = ModulesManager()
+ warnings = []
for module in modules_manager.get_enabled_modules():
if isinstance(module, ISettingsChangeListener):
- module.on_project_settings_save(
- old_data, new_data, project_name, changes
- )
+ try:
+ module.on_project_settings_save(
+ old_data,
+ new_data,
+ project_name,
+ changes,
+ new_data_with_metadata
+ )
+ except SaveWarningExc as exc:
+ warnings.extend(exc.warnings)
- return _SETTINGS_HANDLER.save_project_settings(project_name, overrides)
+ _SETTINGS_HANDLER.save_project_settings(project_name, overrides)
+
+ if warnings:
+ raise SaveWarningExc(warnings)
@require_handler
@@ -173,10 +210,16 @@ def save_project_anatomy(project_name, anatomy_data):
For saving of data cares registered Settings handler.
+ Warning messages are not logged as module raising them should log it within
+ it's logger.
+
Args:
project_name (str): Project name for which overrides are passed.
Default project's value is None.
overrides(dict): Overrides data with metadata defying studio overrides.
+
+ Raises:
+ SaveWarningExc: If any module raises the exception.
"""
# Notify Pype modules
from openpype.modules import ModulesManager, ISettingsChangeListener
@@ -194,17 +237,29 @@ def save_project_anatomy(project_name, anatomy_data):
old_data = get_default_anatomy_settings(exclude_locals=True)
new_data = apply_overrides(default_values, copy.deepcopy(anatomy_data))
+ new_data_with_metadata = copy.deepcopy(new_data)
clear_metadata_from_settings(new_data)
changes = calculate_changes(old_data, new_data)
modules_manager = ModulesManager()
+ warnings = []
for module in modules_manager.get_enabled_modules():
if isinstance(module, ISettingsChangeListener):
- module.on_project_anatomy_save(
- old_data, new_data, changes, project_name
- )
+ try:
+ module.on_project_anatomy_save(
+ old_data,
+ new_data,
+ changes,
+ project_name,
+ new_data_with_metadata
+ )
+ except SaveWarningExc as exc:
+ warnings.extend(exc.warnings)
- return _SETTINGS_HANDLER.save_project_anatomy(project_name, anatomy_data)
+ _SETTINGS_HANDLER.save_project_anatomy(project_name, anatomy_data)
+
+ if warnings:
+ raise SaveWarningExc(warnings)
@require_handler
diff --git a/openpype/tools/launcher/actions.py b/openpype/tools/launcher/actions.py
index 6261fe91ca..72c7aece72 100644
--- a/openpype/tools/launcher/actions.py
+++ b/openpype/tools/launcher/actions.py
@@ -1,7 +1,7 @@
import os
-import importlib
-from avalon import api, lib, style
+from avalon import api, style
+from openpype import PLUGINS_DIR
from openpype.api import Logger, resources
from openpype.lib import (
ApplictionExecutableNotFound,
@@ -10,81 +10,6 @@ from openpype.lib import (
from Qt import QtWidgets, QtGui
-class ProjectManagerAction(api.Action):
- name = "projectmanager"
- label = "Project Manager"
- icon = "gear"
- order = 999 # at the end
-
- def is_compatible(self, session):
- return "AVALON_PROJECT" in session
-
- def process(self, session, **kwargs):
- return lib.launch(
- executable="python",
- args=[
- "-u", "-m", "avalon.tools.projectmanager",
- session['AVALON_PROJECT']
- ]
- )
-
-
-class LoaderAction(api.Action):
- name = "loader"
- label = "Loader"
- icon = "cloud-download"
- order = 998
-
- def is_compatible(self, session):
- return "AVALON_PROJECT" in session
-
- def process(self, session, **kwargs):
- return lib.launch(
- executable="python",
- args=[
- "-u", "-m", "avalon.tools.loader", session['AVALON_PROJECT']
- ]
- )
-
-
-class LoaderLibrary(api.Action):
- name = "loader_os"
- label = "Library Loader"
- icon = "book"
- order = 997 # at the end
-
- def is_compatible(self, session):
- return True
-
- def process(self, session, **kwargs):
- return lib.launch(
- executable="python",
- args=["-u", "-m", "avalon.tools.libraryloader"]
- )
-
-
-def register_default_actions():
- """Register default actions for Launcher"""
- api.register_plugin(api.Action, ProjectManagerAction)
- api.register_plugin(api.Action, LoaderAction)
- api.register_plugin(api.Action, LoaderLibrary)
-
-
-def register_config_actions():
- """Register actions from the configuration for Launcher"""
-
- module_name = os.environ["AVALON_CONFIG"]
- config = importlib.import_module(module_name)
- if not hasattr(config, "register_launcher_actions"):
- print(
- "Current configuration `%s` has no 'register_launcher_actions'"
- % config.__name__
- )
- return
-
- config.register_launcher_actions()
-
-
def register_actions_from_paths(paths):
if not paths:
return
@@ -106,6 +31,13 @@ def register_actions_from_paths(paths):
api.register_plugin_path(api.Action, path)
+def register_config_actions():
+ """Register actions from the configuration for Launcher"""
+
+ actions_dir = os.path.join(PLUGINS_DIR, "actions")
+ register_actions_from_paths([actions_dir])
+
+
def register_environment_actions():
"""Register actions from AVALON_ACTIONS for Launcher."""
diff --git a/openpype/tools/settings/local_settings/general_widget.py b/openpype/tools/settings/local_settings/general_widget.py
index e820d8ab8b..78bc53fdd2 100644
--- a/openpype/tools/settings/local_settings/general_widget.py
+++ b/openpype/tools/settings/local_settings/general_widget.py
@@ -1,3 +1,5 @@
+import getpass
+
from Qt import QtWidgets
@@ -5,16 +7,29 @@ class LocalGeneralWidgets(QtWidgets.QWidget):
def __init__(self, parent):
super(LocalGeneralWidgets, self).__init__(parent)
+ username_input = QtWidgets.QLineEdit(self)
+ username_input.setPlaceholderText(getpass.getuser())
+
+ layout = QtWidgets.QFormLayout(self)
+ layout.setContentsMargins(0, 0, 0, 0)
+
+ layout.addRow("OpenPype Username", username_input)
+
+ self.username_input = username_input
def update_local_settings(self, value):
- return
-
- # RETURNING EARLY TO HIDE WIDGET WITHOUT CONTENT
+ username = ""
+ if value:
+ username = value.get("username", username)
+ self.username_input.setText(username)
def settings_value(self):
# Add changed
# If these have changed then
output = {}
- # TEMPORARILY EMPTY AS THERE IS NOTHING TO PUT HERE
-
+ username = self.username_input.text()
+ if username:
+ output["username"] = username
+ # Do not return output yet since we don't have mechanism to save or
+ # load these data through api calls
return output
diff --git a/openpype/tools/settings/local_settings/window.py b/openpype/tools/settings/local_settings/window.py
index a12a2289b5..b6ca56d348 100644
--- a/openpype/tools/settings/local_settings/window.py
+++ b/openpype/tools/settings/local_settings/window.py
@@ -80,7 +80,6 @@ class LocalSettingsWidget(QtWidgets.QWidget):
general_widget = LocalGeneralWidgets(general_content)
general_layout.addWidget(general_widget)
- general_expand_widget.hide()
self.main_layout.addWidget(general_expand_widget)
@@ -127,9 +126,9 @@ class LocalSettingsWidget(QtWidgets.QWidget):
self.system_settings.reset()
self.project_settings.reset()
- # self.general_widget.update_local_settings(
- # value.get(LOCAL_GENERAL_KEY)
- # )
+ self.general_widget.update_local_settings(
+ value.get(LOCAL_GENERAL_KEY)
+ )
self.app_widget.update_local_settings(
value.get(LOCAL_APPS_KEY)
)
@@ -139,9 +138,9 @@ class LocalSettingsWidget(QtWidgets.QWidget):
def settings_value(self):
output = {}
- # general_value = self.general_widget.settings_value()
- # if general_value:
- # output[LOCAL_GENERAL_KEY] = general_value
+ general_value = self.general_widget.settings_value()
+ if general_value:
+ output[LOCAL_GENERAL_KEY] = general_value
app_value = self.app_widget.settings_value()
if app_value:
diff --git a/openpype/tools/settings/settings/widgets/categories.py b/openpype/tools/settings/settings/widgets/categories.py
index 9d286485a3..e4832c989a 100644
--- a/openpype/tools/settings/settings/widgets/categories.py
+++ b/openpype/tools/settings/settings/widgets/categories.py
@@ -27,7 +27,7 @@ from openpype.settings.entities import (
SchemaError
)
-from openpype.settings.lib import get_system_settings
+from openpype.settings import SaveWarningExc
from .widgets import ProjectListWidget
from . import lib
@@ -272,6 +272,22 @@ class SettingsCategoryWidget(QtWidgets.QWidget):
# not required.
self.reset()
+ except SaveWarningExc as exc:
+ warnings = [
+ "Settings were saved but few issues happened."
+ ]
+ for item in exc.warnings:
+ warnings.append(item.replace("\n", "
"))
+
+ msg = "
".join(warnings)
+
+ dialog = QtWidgets.QMessageBox(self)
+ dialog.setText(msg)
+ dialog.setIcon(QtWidgets.QMessageBox.Warning)
+ dialog.exec_()
+
+ self.reset()
+
except Exception as exc:
formatted_traceback = traceback.format_exception(*sys.exc_info())
dialog = QtWidgets.QMessageBox(self)