From 4013148167783590d62e1a6d6882c2d07ada2d65 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 11 Aug 2023 15:29:36 +0800 Subject: [PATCH 01/12] name of the read node should be updated correctly when setting versions and switching assets --- openpype/hosts/nuke/plugins/load/load_image.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/openpype/hosts/nuke/plugins/load/load_image.py b/openpype/hosts/nuke/plugins/load/load_image.py index d8c0a82206..225365056a 100644 --- a/openpype/hosts/nuke/plugins/load/load_image.py +++ b/openpype/hosts/nuke/plugins/load/load_image.py @@ -212,6 +212,8 @@ class LoadImage(load.LoaderPlugin): last = first = int(frame_number) # Set the global in to the start frame of the sequence + read_name = self._get_node_name(representation) + node["name"].setValue(read_name) node["file"].setValue(file) node["origfirst"].setValue(first) node["first"].setValue(first) @@ -250,3 +252,17 @@ class LoadImage(load.LoaderPlugin): with viewer_update_and_undo_stop(): nuke.delete(node) + + def _get_node_name(self, representation): + + repre_cont = representation["context"] + name_data = { + "asset": repre_cont["asset"], + "subset": repre_cont["subset"], + "representation": representation["name"], + "ext": repre_cont["representation"], + "id": representation["_id"], + "class_name": self.__class__.__name__ + } + + return self.node_name_template.format(**name_data) From 43796c2c1c14fee0f33e8d1e2480deb0e3c19256 Mon Sep 17 00:00:00 2001 From: Kayla Man Date: Fri, 11 Aug 2023 18:23:28 +0800 Subject: [PATCH 02/12] roy's comment --- openpype/hosts/nuke/plugins/load/load_image.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/openpype/hosts/nuke/plugins/load/load_image.py b/openpype/hosts/nuke/plugins/load/load_image.py index 225365056a..0dd3a940db 100644 --- a/openpype/hosts/nuke/plugins/load/load_image.py +++ b/openpype/hosts/nuke/plugins/load/load_image.py @@ -96,7 +96,8 @@ class LoadImage(load.LoaderPlugin): file = file.replace("\\", "/") - repr_cont = context["representation"]["context"] + representation = context["representation"] + repr_cont = representation["context"] frame = repr_cont.get("frame") if frame: padding = len(frame) @@ -104,16 +105,7 @@ class LoadImage(load.LoaderPlugin): frame, format(frame_number, "0{}".format(padding))) - name_data = { - "asset": repr_cont["asset"], - "subset": repr_cont["subset"], - "representation": context["representation"]["name"], - "ext": repr_cont["representation"], - "id": context["representation"]["_id"], - "class_name": self.__class__.__name__ - } - - read_name = self.node_name_template.format(**name_data) + read_name = self._get_node_name(representation) # Create the Loader with the filename path set with viewer_update_and_undo_stop(): From 04b36e961180e455605dd513f626895b8e818f31 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 15 Aug 2023 12:52:56 +0200 Subject: [PATCH 03/12] fix provider icons access (#5450) --- openpype/tools/sceneinventory/model.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/tools/sceneinventory/model.py b/openpype/tools/sceneinventory/model.py index 64c439712c..4fd82f04a4 100644 --- a/openpype/tools/sceneinventory/model.py +++ b/openpype/tools/sceneinventory/model.py @@ -85,7 +85,7 @@ class InventoryModel(TreeModel): self.remote_provider = remote_provider self._site_icons = { provider: QtGui.QIcon(icon_path) - for provider, icon_path in self.get_site_icons().items() + for provider, icon_path in sync_server.get_site_icons().items() } if "active_site" not in self.Columns: self.Columns.append("active_site") From cf565a205e9c3aaa7ae54ab729d74b4111e89a11 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 15 Aug 2023 13:36:40 +0200 Subject: [PATCH 04/12] Chore: Default variant in create plugin (#5429) * define constant 'DEFAULT_VARIANT_VALUE' * 'get_default_variant' always returns string * added 'default_variant' property for backwards compatibility * added more options to receive default variant * added backwards compatibility for 'default_variant' attribute * better autofix for backwards compatibility * use 'DEFAULT_VARIANT_VALUE' in publisher UI * fix docstring * Use 'Main' instead of 'main' for default variant --- openpype/pipeline/create/__init__.py | 2 + openpype/pipeline/create/constants.py | 2 + openpype/pipeline/create/creator_plugins.py | 79 ++++++++++++++++--- .../tools/publisher/widgets/create_widget.py | 5 +- 4 files changed, 77 insertions(+), 11 deletions(-) diff --git a/openpype/pipeline/create/__init__.py b/openpype/pipeline/create/__init__.py index 6755224c19..94d575a776 100644 --- a/openpype/pipeline/create/__init__.py +++ b/openpype/pipeline/create/__init__.py @@ -2,6 +2,7 @@ from .constants import ( SUBSET_NAME_ALLOWED_SYMBOLS, DEFAULT_SUBSET_TEMPLATE, PRE_CREATE_THUMBNAIL_KEY, + DEFAULT_VARIANT_VALUE, ) from .utils import ( @@ -50,6 +51,7 @@ __all__ = ( "SUBSET_NAME_ALLOWED_SYMBOLS", "DEFAULT_SUBSET_TEMPLATE", "PRE_CREATE_THUMBNAIL_KEY", + "DEFAULT_VARIANT_VALUE", "get_last_versions_for_instances", "get_next_versions_for_instances", diff --git a/openpype/pipeline/create/constants.py b/openpype/pipeline/create/constants.py index 375cfc4a12..7d1d0154e9 100644 --- a/openpype/pipeline/create/constants.py +++ b/openpype/pipeline/create/constants.py @@ -1,10 +1,12 @@ SUBSET_NAME_ALLOWED_SYMBOLS = "a-zA-Z0-9_." DEFAULT_SUBSET_TEMPLATE = "{family}{Variant}" PRE_CREATE_THUMBNAIL_KEY = "thumbnail_source" +DEFAULT_VARIANT_VALUE = "Main" __all__ = ( "SUBSET_NAME_ALLOWED_SYMBOLS", "DEFAULT_SUBSET_TEMPLATE", "PRE_CREATE_THUMBNAIL_KEY", + "DEFAULT_VARIANT_VALUE", ) diff --git a/openpype/pipeline/create/creator_plugins.py b/openpype/pipeline/create/creator_plugins.py index c9edbbfd71..38d6b6f465 100644 --- a/openpype/pipeline/create/creator_plugins.py +++ b/openpype/pipeline/create/creator_plugins.py @@ -1,4 +1,3 @@ -import os import copy import collections @@ -20,6 +19,7 @@ from openpype.pipeline.plugin_discover import ( deregister_plugin_path ) +from .constants import DEFAULT_VARIANT_VALUE from .subset_name import get_subset_name from .utils import get_next_versions_for_instances from .legacy_create import LegacyCreator @@ -517,7 +517,7 @@ class Creator(BaseCreator): default_variants = [] # Default variant used in 'get_default_variant' - default_variant = None + _default_variant = None # Short description of family # - may not be used if `get_description` is overriden @@ -543,6 +543,21 @@ class Creator(BaseCreator): # - similar to instance attribute definitions pre_create_attr_defs = [] + def __init__(self, *args, **kwargs): + cls = self.__class__ + + # Fix backwards compatibility for plugins which override + # 'default_variant' attribute directly + if not isinstance(cls.default_variant, property): + # Move value from 'default_variant' to '_default_variant' + self._default_variant = self.default_variant + # Create property 'default_variant' on the class + cls.default_variant = property( + cls._get_default_variant_wrap, + cls._set_default_variant_wrap + ) + super(Creator, self).__init__(*args, **kwargs) + @property def show_order(self): """Order in which is creator shown in UI. @@ -595,10 +610,10 @@ class Creator(BaseCreator): def get_default_variants(self): """Default variant values for UI tooltips. - Replacement of `defatults` attribute. Using method gives ability to - have some "logic" other than attribute values. + Replacement of `default_variants` attribute. Using method gives + ability to have some "logic" other than attribute values. - By default returns `default_variants` value. + By default, returns `default_variants` value. Returns: List[str]: Whisper variants for user input. @@ -606,17 +621,63 @@ class Creator(BaseCreator): return copy.deepcopy(self.default_variants) - def get_default_variant(self): + def get_default_variant(self, only_explicit=False): """Default variant value that will be used to prefill variant input. This is for user input and value may not be content of result from `get_default_variants`. - Can return `None`. In that case first element from - `get_default_variants` should be used. + Note: + This method does not allow to have empty string as + default variant. + + Args: + only_explicit (Optional[bool]): If True, only explicit default + variant from '_default_variant' will be returned. + + Returns: + str: Variant value. """ - return self.default_variant + if only_explicit or self._default_variant: + return self._default_variant + + for variant in self.get_default_variants(): + return variant + return DEFAULT_VARIANT_VALUE + + def _get_default_variant_wrap(self): + """Default variant value that will be used to prefill variant input. + + Wrapper for 'get_default_variant'. + + Notes: + This method is wrapper for 'get_default_variant' + for 'default_variant' property, so creator can override + the method. + + Returns: + str: Variant value. + """ + + return self.get_default_variant() + + def _set_default_variant_wrap(self, variant): + """Set default variant value. + + This method is needed for automated settings overrides which are + changing attributes based on keys in settings. + + Args: + variant (str): New default variant value. + """ + + self._default_variant = variant + + default_variant = property( + _get_default_variant_wrap, + _set_default_variant_wrap + ) def get_pre_create_attr_defs(self): """Plugin attribute definitions needed for creation. diff --git a/openpype/tools/publisher/widgets/create_widget.py b/openpype/tools/publisher/widgets/create_widget.py index 1940d16eb8..64fed1d70c 100644 --- a/openpype/tools/publisher/widgets/create_widget.py +++ b/openpype/tools/publisher/widgets/create_widget.py @@ -6,6 +6,7 @@ from openpype import AYON_SERVER_ENABLED from openpype.pipeline.create import ( SUBSET_NAME_ALLOWED_SYMBOLS, PRE_CREATE_THUMBNAIL_KEY, + DEFAULT_VARIANT_VALUE, TaskNotSetError, ) @@ -626,7 +627,7 @@ class CreateWidget(QtWidgets.QWidget): default_variants = creator_item.default_variants if not default_variants: - default_variants = ["Main"] + default_variants = [DEFAULT_VARIANT_VALUE] default_variant = creator_item.default_variant if not default_variant: @@ -642,7 +643,7 @@ class CreateWidget(QtWidgets.QWidget): elif variant: self.variant_hints_menu.addAction(variant) - variant_text = default_variant or "Main" + variant_text = default_variant or DEFAULT_VARIANT_VALUE # Make sure subset name is updated to new plugin if variant_text == self.variant_input.text(): self._on_variant_change() From 4d96eff2ed7d272179337e65ed370b71ce2fa441 Mon Sep 17 00:00:00 2001 From: Ynbot Date: Wed, 16 Aug 2023 03:24:46 +0000 Subject: [PATCH 05/12] [Automated] Bump version --- openpype/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/version.py b/openpype/version.py index afbac53385..70eb32baff 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.16.4-nightly.1" +__version__ = "3.16.4-nightly.2" From bdc42761bdfbb06f0b167d7cf0ac49b87ced1a6e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 16 Aug 2023 03:25:32 +0000 Subject: [PATCH 06/12] chore(): update bug report / version --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 96fcc38d13..d2a4067a6a 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -35,6 +35,7 @@ body: label: Version description: What version are you running? Look to OpenPype Tray options: + - 3.16.4-nightly.2 - 3.16.4-nightly.1 - 3.16.3 - 3.16.3-nightly.5 @@ -134,7 +135,6 @@ body: - 3.14.7 - 3.14.7-nightly.8 - 3.14.7-nightly.7 - - 3.14.7-nightly.6 validations: required: true - type: dropdown From 328c3d9c7fa499ca39b1351b94f2d0ae0d261a69 Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Wed, 16 Aug 2023 16:16:21 +0200 Subject: [PATCH 07/12] OP-6567 - fix setting of version to workfile instance (#5452) If there are multiple instances of renderlayer published, previous logic resulted in unpredictable rewrite of instance family to 'workfile' --- openpype/hosts/maya/plugins/publish/collect_render.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openpype/hosts/maya/plugins/publish/collect_render.py b/openpype/hosts/maya/plugins/publish/collect_render.py index c37b54ea9a..c17a8789e4 100644 --- a/openpype/hosts/maya/plugins/publish/collect_render.py +++ b/openpype/hosts/maya/plugins/publish/collect_render.py @@ -304,9 +304,9 @@ class CollectMayaRender(pyblish.api.InstancePlugin): if self.sync_workfile_version: data["version"] = context.data["version"] - for instance in context: - if instance.data['family'] == "workfile": - instance.data["version"] = context.data["version"] + for _instance in context: + if _instance.data['family'] == "workfile": + _instance.data["version"] = context.data["version"] # Define nice label label = "{0} ({1})".format(layer_name, instance.data["asset"]) From c5d882c7eae662deb1a6477bb93fe7884f033dca Mon Sep 17 00:00:00 2001 From: Petr Kalis Date: Thu, 17 Aug 2023 10:33:52 +0200 Subject: [PATCH 08/12] Maya: Fix wrong subset name of render family in deadline (#5442) * Use existing subset_name as group_name by default New publisher already carries real subset name (`renderModelingMain`), it should build group name only if subset_name is weird. * Let legacy conversion of render instance recreate subset_name Without it would create subset names like `renderingMain` which are not matching to newly created `renderMain` instances. This would cause issue in version restarts. * Let Render Creator for Maya create proper subset_name It was using hardcoded logic not matching other DCCs. * Hound * Fix method calls * Fix typos * Do not import unnecessary * Capitalize is wrong function for here * Overwrite get_subset_name for standardized results It makes sense to override this method for other parts of code getting same results. * Force change It seems that GH doesn't recognize changes with adding() * Update openpype/hosts/maya/plugins/create/convert_legacy.py Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> * Hound --------- Co-authored-by: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> --- openpype/hosts/maya/api/plugin.py | 37 +++++++++++++++---- .../maya/plugins/create/convert_legacy.py | 14 +++++++ openpype/pipeline/farm/pyblish_functions.py | 12 ++++-- 3 files changed, 53 insertions(+), 10 deletions(-) diff --git a/openpype/hosts/maya/api/plugin.py b/openpype/hosts/maya/api/plugin.py index f705133e4f..00d6602ef9 100644 --- a/openpype/hosts/maya/api/plugin.py +++ b/openpype/hosts/maya/api/plugin.py @@ -22,10 +22,10 @@ from openpype.pipeline import ( LegacyCreator, LoaderPlugin, get_representation_path, - - legacy_io, ) from openpype.pipeline.load import LoadError +from openpype.client import get_asset_by_name +from openpype.pipeline.create import get_subset_name from . import lib from .lib import imprint, read @@ -405,14 +405,21 @@ class RenderlayerCreator(NewCreator, MayaCreatorBase): # No existing scene instance node for this layer. Note that # this instance will not have the `instance_node` data yet # until it's been saved/persisted at least once. - # TODO: Correctly define the subset name using templates - prefix = self.layer_instance_prefix or self.family - subset_name = "{}{}".format(prefix, layer.name()) + project_name = self.create_context.get_current_project_name() + instance_data = { - "asset": legacy_io.Session["AVALON_ASSET"], - "task": legacy_io.Session["AVALON_TASK"], + "asset": self.create_context.get_current_asset_name(), + "task": self.create_context.get_current_task_name(), "variant": layer.name(), } + asset_doc = get_asset_by_name(project_name, + instance_data["asset"]) + subset_name = self.get_subset_name( + layer.name(), + instance_data["task"], + asset_doc, + project_name) + instance = CreatedInstance( family=self.family, subset_name=subset_name, @@ -519,6 +526,22 @@ class RenderlayerCreator(NewCreator, MayaCreatorBase): if node and cmds.objExists(node): cmds.delete(node) + def get_subset_name( + self, + variant, + task_name, + asset_doc, + project_name, + host_name=None, + instance=None + ): + # creator.family != 'render' as expected + return get_subset_name(self.layer_instance_prefix, + variant, + task_name, + asset_doc, + project_name) + class Loader(LoaderPlugin): hosts = ["maya"] diff --git a/openpype/hosts/maya/plugins/create/convert_legacy.py b/openpype/hosts/maya/plugins/create/convert_legacy.py index 33a1e020dd..cd8faf291b 100644 --- a/openpype/hosts/maya/plugins/create/convert_legacy.py +++ b/openpype/hosts/maya/plugins/create/convert_legacy.py @@ -2,6 +2,8 @@ from openpype.pipeline.create.creator_plugins import SubsetConvertorPlugin from openpype.hosts.maya.api import plugin from openpype.hosts.maya.api.lib import read +from openpype.client import get_asset_by_name + from maya import cmds from maya.app.renderSetup.model import renderSetup @@ -135,6 +137,18 @@ class MayaLegacyConvertor(SubsetConvertorPlugin, # "rendering" family being converted to "renderlayer" family) original_data["family"] = creator.family + # recreate subset name as without it would be + # `renderingMain` vs correct `renderMain` + project_name = self.create_context.get_current_project_name() + asset_doc = get_asset_by_name(project_name, + original_data["asset"]) + subset_name = creator.get_subset_name( + original_data["variant"], + data["task"], + asset_doc, + project_name) + original_data["subset"] = subset_name + # Convert to creator attributes when relevant creator_attributes = {} for key in list(original_data.keys()): diff --git a/openpype/pipeline/farm/pyblish_functions.py b/openpype/pipeline/farm/pyblish_functions.py index 8b9058359e..288602b77c 100644 --- a/openpype/pipeline/farm/pyblish_functions.py +++ b/openpype/pipeline/farm/pyblish_functions.py @@ -568,9 +568,15 @@ def _create_instances_for_aov(instance, skeleton, aov_filter, additional_data, col = list(cols[0]) # create subset name `familyTaskSubset_AOV` - group_name = 'render{}{}{}{}'.format( - task[0].upper(), task[1:], - subset[0].upper(), subset[1:]) + # TODO refactor/remove me + family = skeleton["family"] + if not subset.startswith(family): + group_name = '{}{}{}{}{}'.format( + family, + task[0].upper(), task[1:], + subset[0].upper(), subset[1:]) + else: + group_name = subset # if there are multiple cameras, we need to add camera name if isinstance(col, (list, tuple)): From 447921b22e51f0fbc412dcbae72ab543c60a93a9 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 17 Aug 2023 10:38:21 +0200 Subject: [PATCH 09/12] Publisher: Thumbnail widget enhancements (#5439) * screenshot widget from @BigRoy * small tweaks of screen capture logic * added take screenshot button to thumbnail widget * added tooltips * Use constants from class * adde PySide 6 support * minimize window when on take screenshot * Keep origin state of window. Co-authored-by: Roy Nieterau * Fix support for Qt version below 5.10 * draw pixel with alpha when disabled * clear image cache on resize * added more buttons and options button with animation * removed unnecessary options widget * fix escape button * keep icons visible all the time --------- Co-authored-by: Roy Nieterau --- .../tools/publisher/widgets/images/browse.png | Bin 0 -> 12225 bytes .../publisher/widgets/images/options.png | Bin 0 -> 3216 bytes .../tools/publisher/widgets/images/paste.png | Bin 0 -> 6513 bytes .../widgets/images/take_screenshot.png | Bin 0 -> 11003 bytes .../publisher/widgets/screenshot_widget.py | 314 ++++++++++++++++++ .../publisher/widgets/thumbnail_widget.py | 126 ++++++- openpype/tools/utils/widgets.py | 18 + 7 files changed, 449 insertions(+), 9 deletions(-) create mode 100644 openpype/tools/publisher/widgets/images/browse.png create mode 100644 openpype/tools/publisher/widgets/images/options.png create mode 100644 openpype/tools/publisher/widgets/images/paste.png create mode 100644 openpype/tools/publisher/widgets/images/take_screenshot.png create mode 100644 openpype/tools/publisher/widgets/screenshot_widget.py diff --git a/openpype/tools/publisher/widgets/images/browse.png b/openpype/tools/publisher/widgets/images/browse.png new file mode 100644 index 0000000000000000000000000000000000000000..b115bb67662ead09af54700e8b7f91b580672916 GIT binary patch literal 12225 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 H*x3IA=zj~i literal 0 HcmV?d00001 diff --git a/openpype/tools/publisher/widgets/images/options.png b/openpype/tools/publisher/widgets/images/options.png new file mode 100644 index 0000000000000000000000000000000000000000..b394dbd4ce517004dfeb6126e99ba2fee2b0a34a GIT binary patch literal 3216 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 literal 0 HcmV?d00001 diff --git a/openpype/tools/publisher/widgets/screenshot_widget.py b/openpype/tools/publisher/widgets/screenshot_widget.py new file mode 100644 index 0000000000..4ccf920571 --- /dev/null +++ b/openpype/tools/publisher/widgets/screenshot_widget.py @@ -0,0 +1,314 @@ +import os +import tempfile + +from qtpy import QtCore, QtGui, QtWidgets + + +class ScreenMarquee(QtWidgets.QDialog): + """Dialog to interactively define screen area. + + This allows to select a screen area through a marquee selection. + + You can use any of its classmethods for easily saving an image, + capturing to QClipboard or returning a QPixmap, respectively + `capture_to_file`, `capture_to_clipboard` and `capture_to_pixmap`. + """ + + def __init__(self, parent=None): + super(ScreenMarquee, self).__init__(parent=parent) + + self.setWindowFlags( + QtCore.Qt.FramelessWindowHint + | QtCore.Qt.WindowStaysOnTopHint + | QtCore.Qt.CustomizeWindowHint + | QtCore.Qt.Tool) + self.setAttribute(QtCore.Qt.WA_TranslucentBackground) + self.setCursor(QtCore.Qt.CrossCursor) + self.setMouseTracking(True) + + fade_anim = QtCore.QVariantAnimation() + fade_anim.setStartValue(0) + fade_anim.setEndValue(50) + fade_anim.setDuration(200) + fade_anim.setEasingCurve(QtCore.QEasingCurve.OutCubic) + fade_anim.start(QtCore.QAbstractAnimation.DeleteWhenStopped) + + fade_anim.valueChanged.connect(self._on_fade_anim) + + app = QtWidgets.QApplication.instance() + if hasattr(app, "screenAdded"): + app.screenAdded.connect(self._on_screen_added) + app.screenRemoved.connect(self._fit_screen_geometry) + elif hasattr(app, "desktop"): + desktop = app.desktop() + desktop.screenCountChanged.connect(self._fit_screen_geometry) + + for screen in QtWidgets.QApplication.screens(): + screen.geometryChanged.connect(self._fit_screen_geometry) + + self._opacity = fade_anim.currentValue() + self._click_pos = None + self._capture_rect = None + + self._fade_anim = fade_anim + + def get_captured_pixmap(self): + if self._capture_rect is None: + return QtGui.QPixmap() + + return self.get_desktop_pixmap(self._capture_rect) + + def paintEvent(self, event): + """Paint event""" + + # Convert click and current mouse positions to local space. + mouse_pos = self.mapFromGlobal(QtGui.QCursor.pos()) + click_pos = None + if self._click_pos is not None: + click_pos = self.mapFromGlobal(self._click_pos) + + painter = QtGui.QPainter(self) + + # Draw background. Aside from aesthetics, this makes the full + # tool region accept mouse events. + painter.setBrush(QtGui.QColor(0, 0, 0, self._opacity)) + painter.setPen(QtCore.Qt.NoPen) + painter.drawRect(event.rect()) + + # Clear the capture area + if click_pos is not None: + capture_rect = QtCore.QRect(click_pos, mouse_pos) + painter.setCompositionMode( + QtGui.QPainter.CompositionMode_Clear) + painter.drawRect(capture_rect) + painter.setCompositionMode( + QtGui.QPainter.CompositionMode_SourceOver) + + pen_color = QtGui.QColor(255, 255, 255, 64) + pen = QtGui.QPen(pen_color, 1, QtCore.Qt.DotLine) + painter.setPen(pen) + + # Draw cropping markers at click position + rect = event.rect() + if click_pos is not None: + painter.drawLine( + rect.left(), click_pos.y(), + rect.right(), click_pos.y() + ) + painter.drawLine( + click_pos.x(), rect.top(), + click_pos.x(), rect.bottom() + ) + + # Draw cropping markers at current mouse position + painter.drawLine( + rect.left(), mouse_pos.y(), + rect.right(), mouse_pos.y() + ) + painter.drawLine( + mouse_pos.x(), rect.top(), + mouse_pos.x(), rect.bottom() + ) + + def mousePressEvent(self, event): + """Mouse click event""" + + if event.button() == QtCore.Qt.LeftButton: + # Begin click drag operation + self._click_pos = event.globalPos() + + def mouseReleaseEvent(self, event): + """Mouse release event""" + if ( + self._click_pos is not None + and event.button() == QtCore.Qt.LeftButton + ): + # End click drag operation and commit the current capture rect + self._capture_rect = QtCore.QRect( + self._click_pos, event.globalPos() + ).normalized() + self._click_pos = None + self.close() + + def mouseMoveEvent(self, event): + """Mouse move event""" + self.repaint() + + def keyPressEvent(self, event): + """Mouse press event""" + if event.key() == QtCore.Qt.Key_Escape: + self._click_pos = None + self._capture_rect = None + self.close() + return + return super(ScreenMarquee, self).mousePressEvent(event) + + def showEvent(self, event): + self._fit_screen_geometry() + self._fade_anim.start() + + def _fit_screen_geometry(self): + # Compute the union of all screen geometries, and resize to fit. + workspace_rect = QtCore.QRect() + for screen in QtWidgets.QApplication.screens(): + workspace_rect = workspace_rect.united(screen.geometry()) + self.setGeometry(workspace_rect) + + def _on_fade_anim(self): + """Animation callback for opacity.""" + + self._opacity = self._fade_anim.currentValue() + self.repaint() + + def _on_screen_added(self): + for screen in QtGui.QGuiApplication.screens(): + screen.geometryChanged.connect(self._fit_screen_geometry) + + @classmethod + def get_desktop_pixmap(cls, rect): + """Performs a screen capture on the specified rectangle. + + Args: + rect (QtCore.QRect): The rectangle to capture. + + Returns: + QtGui.QPixmap: Captured pixmap image + """ + + if rect.width() < 1 or rect.height() < 1: + return QtGui.QPixmap() + + screen_pixes = [] + for screen in QtWidgets.QApplication.screens(): + screen_geo = screen.geometry() + if not screen_geo.intersects(rect): + continue + + screen_pix_rect = screen_geo.intersected(rect) + screen_pix = screen.grabWindow( + 0, + screen_pix_rect.x() - screen_geo.x(), + screen_pix_rect.y() - screen_geo.y(), + screen_pix_rect.width(), screen_pix_rect.height() + ) + paste_point = QtCore.QPoint( + screen_pix_rect.x() - rect.x(), + screen_pix_rect.y() - rect.y() + ) + screen_pixes.append((screen_pix, paste_point)) + + output_pix = QtGui.QPixmap(rect.width(), rect.height()) + output_pix.fill(QtCore.Qt.transparent) + pix_painter = QtGui.QPainter() + pix_painter.begin(output_pix) + for item in screen_pixes: + (screen_pix, offset) = item + pix_painter.drawPixmap(offset, screen_pix) + + pix_painter.end() + + return output_pix + + @classmethod + def capture_to_pixmap(cls): + """Take screenshot with marquee into pixmap. + + Note: + The pixmap can be invalid (use 'isNull' to check). + + Returns: + QtGui.QPixmap: Captured pixmap image. + """ + + tool = cls() + tool.exec_() + return tool.get_captured_pixmap() + + @classmethod + def capture_to_file(cls, filepath=None): + """Take screenshot with marquee into file. + + Args: + filepath (Optional[str]): Path where screenshot will be saved. + + Returns: + Union[str, None]: Path to the saved screenshot, or None if user + cancelled the operation. + """ + + pixmap = cls.capture_to_pixmap() + if pixmap.isNull(): + return None + + if filepath is None: + with tempfile.NamedTemporaryFile( + prefix="screenshot_", suffix=".png", delete=False + ) as tmpfile: + filepath = tmpfile.name + + else: + output_dir = os.path.dirname(filepath) + if not os.path.exists(output_dir): + os.makedirs(output_dir) + + pixmap.save(filepath) + return filepath + + @classmethod + def capture_to_clipboard(cls): + """Take screenshot with marquee into clipboard. + + Notes: + Screenshot is not in clipboard if user cancelled the operation. + + Returns: + bool: Screenshot was added to clipboard. + """ + + clipboard = QtWidgets.QApplication.clipboard() + pixmap = cls.capture_to_pixmap() + if pixmap.isNull(): + return False + image = pixmap.toImage() + clipboard.setImage(image, QtGui.QClipboard.Clipboard) + return True + + +def capture_to_pixmap(): + """Take screenshot with marquee into pixmap. + + Note: + The pixmap can be invalid (use 'isNull' to check). + + Returns: + QtGui.QPixmap: Captured pixmap image. + """ + + return ScreenMarquee.capture_to_pixmap() + + +def capture_to_file(filepath=None): + """Take screenshot with marquee into file. + + Args: + filepath (Optional[str]): Path where screenshot will be saved. + + Returns: + Union[str, None]: Path to the saved screenshot, or None if user + cancelled the operation. + """ + + return ScreenMarquee.capture_to_file(filepath) + + +def capture_to_clipboard(): + """Take screenshot with marquee into clipboard. + + Notes: + Screenshot is not in clipboard if user cancelled the operation. + + Returns: + bool: Screenshot was added to clipboard. + """ + + return ScreenMarquee.capture_to_clipboard() diff --git a/openpype/tools/publisher/widgets/thumbnail_widget.py b/openpype/tools/publisher/widgets/thumbnail_widget.py index 80d156185b..60970710d8 100644 --- a/openpype/tools/publisher/widgets/thumbnail_widget.py +++ b/openpype/tools/publisher/widgets/thumbnail_widget.py @@ -22,6 +22,7 @@ from openpype.tools.utils import ( from openpype.tools.publisher.control import CardMessageTypes from .icons import get_image +from .screenshot_widget import capture_to_file class ThumbnailPainterWidget(QtWidgets.QWidget): @@ -306,20 +307,43 @@ class ThumbnailWidget(QtWidgets.QWidget): thumbnail_painter = ThumbnailPainterWidget(self) + icon_color = get_objected_colors("bg-view-selection").get_qcolor() + icon_color.setAlpha(255) + buttons_widget = QtWidgets.QWidget(self) buttons_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) - icon_color = get_objected_colors("bg-view-selection").get_qcolor() - icon_color.setAlpha(255) clear_image = get_image("clear_thumbnail") clear_pix = paint_image_with_color(clear_image, icon_color) - clear_button = PixmapButton(clear_pix, buttons_widget) clear_button.setObjectName("ThumbnailPixmapHoverButton") + clear_button.setToolTip("Clear thumbnail") + + take_screenshot_image = get_image("take_screenshot") + take_screenshot_pix = paint_image_with_color( + take_screenshot_image, icon_color) + take_screenshot_btn = PixmapButton( + take_screenshot_pix, buttons_widget) + take_screenshot_btn.setObjectName("ThumbnailPixmapHoverButton") + take_screenshot_btn.setToolTip("Take screenshot") + + paste_image = get_image("paste") + paste_pix = paint_image_with_color(paste_image, icon_color) + paste_btn = PixmapButton(paste_pix, buttons_widget) + paste_btn.setObjectName("ThumbnailPixmapHoverButton") + paste_btn.setToolTip("Paste from clipboard") + + browse_image = get_image("browse") + browse_pix = paint_image_with_color(browse_image, icon_color) + browse_btn = PixmapButton(browse_pix, buttons_widget) + browse_btn.setObjectName("ThumbnailPixmapHoverButton") + browse_btn.setToolTip("Browse...") buttons_layout = QtWidgets.QHBoxLayout(buttons_widget) - buttons_layout.setContentsMargins(3, 3, 3, 3) - buttons_layout.addStretch(1) + buttons_layout.setContentsMargins(0, 0, 0, 0) + buttons_layout.addWidget(take_screenshot_btn, 0) + buttons_layout.addWidget(paste_btn, 0) + buttons_layout.addWidget(browse_btn, 0) buttons_layout.addWidget(clear_button, 0) layout = QtWidgets.QHBoxLayout(self) @@ -327,6 +351,9 @@ class ThumbnailWidget(QtWidgets.QWidget): layout.addWidget(thumbnail_painter) clear_button.clicked.connect(self._on_clear_clicked) + take_screenshot_btn.clicked.connect(self._on_take_screenshot) + paste_btn.clicked.connect(self._on_paste_from_clipboard) + browse_btn.clicked.connect(self._on_browse_clicked) self._controller = controller self._output_dir = controller.get_thumbnail_temp_dir_path() @@ -338,9 +365,16 @@ class ThumbnailWidget(QtWidgets.QWidget): self._adapted_to_size = True self._last_width = None self._last_height = None + self._hide_on_finish = False self._buttons_widget = buttons_widget self._thumbnail_painter = thumbnail_painter + self._clear_button = clear_button + self._take_screenshot_btn = take_screenshot_btn + self._paste_btn = paste_btn + self._browse_btn = browse_btn + + clear_button.setEnabled(False) @property def width_ratio(self): @@ -430,13 +464,75 @@ class ThumbnailWidget(QtWidgets.QWidget): self._thumbnail_painter.clear_cache() + def _set_current_thumbails(self, thumbnail_paths): + self._thumbnail_painter.set_current_thumbnails(thumbnail_paths) + self._update_buttons_position() + def set_current_thumbnails(self, thumbnail_paths=None): self._thumbnail_painter.set_current_thumbnails(thumbnail_paths) self._update_buttons_position() + self._clear_button.setEnabled(self._thumbnail_painter.has_pixes) def _on_clear_clicked(self): self.set_current_thumbnails() self.thumbnail_cleared.emit() + self._clear_button.setEnabled(False) + + def _on_take_screenshot(self): + window = self.window() + state = window.windowState() + window.setWindowState(QtCore.Qt.WindowMinimized) + output_path = os.path.join( + self._output_dir, uuid.uuid4().hex + ".png") + if capture_to_file(output_path): + self.thumbnail_created.emit(output_path) + # restore original window state + window.setWindowState(state) + + def _on_paste_from_clipboard(self): + """Set thumbnail from a pixmap image in the system clipboard""" + + clipboard = QtWidgets.QApplication.clipboard() + pixmap = clipboard.pixmap() + if pixmap.isNull(): + return + + # Save as temporary file + output_path = os.path.join( + self._output_dir, uuid.uuid4().hex + ".png") + + output_dir = os.path.dirname(output_path) + if not os.path.exists(output_dir): + os.makedirs(output_dir) + + if pixmap.save(output_path): + self.thumbnail_created.emit(output_path) + + def _on_browse_clicked(self): + ext_filter = "Source (*{0})".format( + " *".join(self._review_extensions) + ) + filepath, _ = QtWidgets.QFileDialog.getOpenFileName( + self, "Choose thumbnail", os.path.expanduser("~"), ext_filter + ) + if not filepath: + return + valid_path = False + ext = os.path.splitext(filepath)[-1].lower() + if ext in self._review_extensions: + valid_path = True + + output = None + if valid_path: + output = export_thumbnail(filepath, self._output_dir) + + if output: + self.thumbnail_created.emit(output) + else: + self._controller.emit_card_message( + "Couldn't convert the source for thumbnail", + CardMessageTypes.error + ) def _adapt_to_size(self): if not self._adapted_to_size: @@ -452,13 +548,25 @@ class ThumbnailWidget(QtWidgets.QWidget): self._thumbnail_painter.clear_cache() def _update_buttons_position(self): - self._buttons_widget.setVisible(self._thumbnail_painter.has_pixes) size = self.size() + my_width = size.width() my_height = size.height() - height = self._buttons_widget.sizeHint().height() + buttons_sh = self._buttons_widget.sizeHint() + buttons_height = buttons_sh.height() + buttons_width = buttons_sh.width() + pos_x = my_width - (buttons_width + 3) + pos_y = my_height - (buttons_height + 3) + if pos_x < 0: + pos_x = 0 + buttons_width = my_width + if pos_y < 0: + pos_y = 0 + buttons_height = my_height self._buttons_widget.setGeometry( - 0, my_height - height, - size.width(), height + pos_x, + pos_y, + buttons_width, + buttons_height ) def resizeEvent(self, event): diff --git a/openpype/tools/utils/widgets.py b/openpype/tools/utils/widgets.py index 5a8104611b..a70437cc65 100644 --- a/openpype/tools/utils/widgets.py +++ b/openpype/tools/utils/widgets.py @@ -410,6 +410,18 @@ class PixmapButtonPainter(QtWidgets.QWidget): self._pixmap = pixmap self._cached_pixmap = None + self._disabled = False + + def resizeEvent(self, event): + super(PixmapButtonPainter, self).resizeEvent(event) + self._cached_pixmap = None + self.repaint() + + def set_enabled(self, enabled): + if self._disabled != enabled: + return + self._disabled = not enabled + self.repaint() def set_pixmap(self, pixmap): self._pixmap = pixmap @@ -444,6 +456,8 @@ class PixmapButtonPainter(QtWidgets.QWidget): if self._cached_pixmap is None: self._cache_pixmap() + if self._disabled: + painter.setOpacity(0.5) painter.drawPixmap(0, 0, self._cached_pixmap) painter.end() @@ -464,6 +478,10 @@ class PixmapButton(ClickableFrame): layout.setContentsMargins(*args) self._update_painter_geo() + def setEnabled(self, enabled): + self._button_painter.set_enabled(enabled) + super(PixmapButton, self).setEnabled(enabled) + def set_pixmap(self, pixmap): self._button_painter.set_pixmap(pixmap) From bd9a79427421c664021bc296baa852b698800269 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabi=C3=A0=20Serra=20Arrizabalaga?= Date: Thu, 17 Aug 2023 10:41:34 +0200 Subject: [PATCH 10/12] Fix typo on deadline OP plugin name (#5453) --- openpype/modules/deadline/plugins/publish/submit_publish_job.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/openpype/modules/deadline/plugins/publish/submit_publish_job.py b/openpype/modules/deadline/plugins/publish/submit_publish_job.py index 5e8c005d07..da96b429ce 100644 --- a/openpype/modules/deadline/plugins/publish/submit_publish_job.py +++ b/openpype/modules/deadline/plugins/publish/submit_publish_job.py @@ -211,7 +211,7 @@ class ProcessSubmittedJobOnFarm(pyblish.api.InstancePlugin, environment["OPENPYPE_PUBLISH_JOB"] = "1" environment["OPENPYPE_RENDER_JOB"] = "0" environment["OPENPYPE_REMOTE_PUBLISH"] = "0" - deadline_plugin = "Openpype" + deadline_plugin = "OpenPype" # Add OpenPype version if we are running from build. if is_running_from_build(): self.environ_keys.append("OPENPYPE_VERSION") From 6ae58875b5a9e0dde4a045e248e821a61997349b Mon Sep 17 00:00:00 2001 From: Kayla Man <64118225+moonyuet@users.noreply.github.com> Date: Thu, 17 Aug 2023 16:57:00 +0800 Subject: [PATCH 11/12] 3dsMax: Settings for Ayon (#5388) * 3dsmax settings for ayon * lower version to '0.1.0' * remove arguments from max application settings * RenderSettings instead of render_settings for max --------- Co-authored-by: Jakub Trllo --- .../applications/server/applications.json | 4 +- server_addon/max/server/__init__.py | 17 ++++++ server_addon/max/server/settings/__init__.py | 10 ++++ server_addon/max/server/settings/imageio.py | 48 +++++++++++++++ server_addon/max/server/settings/main.py | 60 +++++++++++++++++++ .../max/server/settings/publishers.py | 26 ++++++++ .../max/server/settings/render_settings.py | 49 +++++++++++++++ server_addon/max/server/version.py | 1 + 8 files changed, 212 insertions(+), 3 deletions(-) create mode 100644 server_addon/max/server/__init__.py create mode 100644 server_addon/max/server/settings/__init__.py create mode 100644 server_addon/max/server/settings/imageio.py create mode 100644 server_addon/max/server/settings/main.py create mode 100644 server_addon/max/server/settings/publishers.py create mode 100644 server_addon/max/server/settings/render_settings.py create mode 100644 server_addon/max/server/version.py diff --git a/server_addon/applications/server/applications.json b/server_addon/applications/server/applications.json index b19308ee7c..8e5b28623e 100644 --- a/server_addon/applications/server/applications.json +++ b/server_addon/applications/server/applications.json @@ -127,9 +127,7 @@ "linux": [] }, "arguments": { - "windows": [ - "-U MAXScript {OPENPYPE_ROOT}\\openpype\\hosts\\max\\startup\\startup.ms" - ], + "windows": [], "darwin": [], "linux": [] }, diff --git a/server_addon/max/server/__init__.py b/server_addon/max/server/__init__.py new file mode 100644 index 0000000000..31c694a084 --- /dev/null +++ b/server_addon/max/server/__init__.py @@ -0,0 +1,17 @@ +from typing import Type + +from ayon_server.addons import BaseServerAddon + +from .version import __version__ +from .settings import MaxSettings, DEFAULT_VALUES + + +class MaxAddon(BaseServerAddon): + name = "max" + title = "Max" + version = __version__ + settings_model: Type[MaxSettings] = MaxSettings + + async def get_default_settings(self): + settings_model_cls = self.get_settings_model() + return settings_model_cls(**DEFAULT_VALUES) diff --git a/server_addon/max/server/settings/__init__.py b/server_addon/max/server/settings/__init__.py new file mode 100644 index 0000000000..986b1903a5 --- /dev/null +++ b/server_addon/max/server/settings/__init__.py @@ -0,0 +1,10 @@ +from .main import ( + MaxSettings, + DEFAULT_VALUES, +) + + +__all__ = ( + "MaxSettings", + "DEFAULT_VALUES", +) diff --git a/server_addon/max/server/settings/imageio.py b/server_addon/max/server/settings/imageio.py new file mode 100644 index 0000000000..5e46104fa7 --- /dev/null +++ b/server_addon/max/server/settings/imageio.py @@ -0,0 +1,48 @@ +from pydantic import Field, validator +from ayon_server.settings import BaseSettingsModel +from ayon_server.settings.validators import ensure_unique_names + + +class ImageIOConfigModel(BaseSettingsModel): + override_global_config: bool = Field( + False, + title="Override global OCIO config" + ) + filepath: list[str] = Field( + default_factory=list, + title="Config path" + ) + + +class ImageIOFileRuleModel(BaseSettingsModel): + name: str = Field("", title="Rule name") + pattern: str = Field("", title="Regex pattern") + colorspace: str = Field("", title="Colorspace name") + ext: str = Field("", title="File extension") + + +class ImageIOFileRulesModel(BaseSettingsModel): + activate_host_rules: bool = Field(False) + rules: list[ImageIOFileRuleModel] = Field( + default_factory=list, + title="Rules" + ) + + @validator("rules") + def validate_unique_outputs(cls, value): + ensure_unique_names(value) + return value + + +class ImageIOSettings(BaseSettingsModel): + activate_host_color_management: bool = Field( + True, title="Enable Color Management" + ) + ocio_config: ImageIOConfigModel = Field( + default_factory=ImageIOConfigModel, + title="OCIO config" + ) + file_rules: ImageIOFileRulesModel = Field( + default_factory=ImageIOFileRulesModel, + title="File Rules" + ) diff --git a/server_addon/max/server/settings/main.py b/server_addon/max/server/settings/main.py new file mode 100644 index 0000000000..7f4561cbb1 --- /dev/null +++ b/server_addon/max/server/settings/main.py @@ -0,0 +1,60 @@ +from pydantic import Field +from ayon_server.settings import BaseSettingsModel +from .imageio import ImageIOSettings +from .render_settings import ( + RenderSettingsModel, DEFAULT_RENDER_SETTINGS +) +from .publishers import ( + PublishersModel, DEFAULT_PUBLISH_SETTINGS +) + + +class PRTAttributesModel(BaseSettingsModel): + _layout = "compact" + name: str = Field(title="Name") + value: str = Field(title="Attribute") + + +class PointCloudSettings(BaseSettingsModel): + attribute: list[PRTAttributesModel] = Field( + default_factory=list, title="Channel Attribute") + + +class MaxSettings(BaseSettingsModel): + imageio: ImageIOSettings = Field( + default_factory=ImageIOSettings, + title="Color Management (ImageIO)" + ) + RenderSettings: RenderSettingsModel = Field( + default_factory=RenderSettingsModel, + title="Render Settings" + ) + PointCloud: PointCloudSettings = Field( + default_factory=PointCloudSettings, + title="Point Cloud" + ) + publish: PublishersModel = Field( + default_factory=PublishersModel, + title="Publish Plugins") + + +DEFAULT_VALUES = { + "RenderSettings": DEFAULT_RENDER_SETTINGS, + "PointCloud": { + "attribute": [ + {"name": "Age", "value": "age"}, + {"name": "Radius", "value": "radius"}, + {"name": "Position", "value": "position"}, + {"name": "Rotation", "value": "rotation"}, + {"name": "Scale", "value": "scale"}, + {"name": "Velocity", "value": "velocity"}, + {"name": "Color", "value": "color"}, + {"name": "TextureCoordinate", "value": "texcoord"}, + {"name": "MaterialID", "value": "matid"}, + {"name": "custFloats", "value": "custFloats"}, + {"name": "custVecs", "value": "custVecs"}, + ] + }, + "publish": DEFAULT_PUBLISH_SETTINGS + +} diff --git a/server_addon/max/server/settings/publishers.py b/server_addon/max/server/settings/publishers.py new file mode 100644 index 0000000000..a695b85e89 --- /dev/null +++ b/server_addon/max/server/settings/publishers.py @@ -0,0 +1,26 @@ +from pydantic import Field + +from ayon_server.settings import BaseSettingsModel + + +class BasicValidateModel(BaseSettingsModel): + enabled: bool = Field(title="Enabled") + optional: bool = Field(title="Optional") + active: bool = Field(title="Active") + + +class PublishersModel(BaseSettingsModel): + ValidateFrameRange: BasicValidateModel = Field( + default_factory=BasicValidateModel, + title="Validate Frame Range", + section="Validators" + ) + + +DEFAULT_PUBLISH_SETTINGS = { + "ValidateFrameRange": { + "enabled": True, + "optional": True, + "active": True + } +} diff --git a/server_addon/max/server/settings/render_settings.py b/server_addon/max/server/settings/render_settings.py new file mode 100644 index 0000000000..6c236d9f12 --- /dev/null +++ b/server_addon/max/server/settings/render_settings.py @@ -0,0 +1,49 @@ +from pydantic import Field + +from ayon_server.settings import BaseSettingsModel + + +def aov_separators_enum(): + return [ + {"value": "dash", "label": "- (dash)"}, + {"value": "underscore", "label": "_ (underscore)"}, + {"value": "dot", "label": ". (dot)"} + ] + + +def image_format_enum(): + """Return enumerator for image output formats.""" + return [ + {"label": "bmp", "value": "bmp"}, + {"label": "exr", "value": "exr"}, + {"label": "tif", "value": "tif"}, + {"label": "tiff", "value": "tiff"}, + {"label": "jpg", "value": "jpg"}, + {"label": "png", "value": "png"}, + {"label": "tga", "value": "tga"}, + {"label": "dds", "value": "dds"} + ] + + +class RenderSettingsModel(BaseSettingsModel): + default_render_image_folder: str = Field( + title="Default render image folder" + ) + aov_separator: str = Field( + "underscore", + title="AOV Separator character", + enum_resolver=aov_separators_enum + ) + image_format: str = Field( + enum_resolver=image_format_enum, + title="Output Image Format" + ) + multipass: bool = Field(title="multipass") + + +DEFAULT_RENDER_SETTINGS = { + "default_render_image_folder": "renders/3dsmax", + "aov_separator": "underscore", + "image_format": "png", + "multipass": True +} diff --git a/server_addon/max/server/version.py b/server_addon/max/server/version.py new file mode 100644 index 0000000000..3dc1f76bc6 --- /dev/null +++ b/server_addon/max/server/version.py @@ -0,0 +1 @@ +__version__ = "0.1.0" From ecf16356378ee6daf4f2abcd771144df7b4990d1 Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Thu, 17 Aug 2023 12:46:57 +0200 Subject: [PATCH 12/12] updated ayon api to '0.3.5' (#5460) --- .../vendor/python/common/ayon_api/__init__.py | 22 + .../vendor/python/common/ayon_api/_api.py | 62 ++ .../python/common/ayon_api/constants.py | 19 + .../python/common/ayon_api/entity_hub.py | 748 +++++++++++++++++- .../python/common/ayon_api/graphql_queries.py | 25 + .../python/common/ayon_api/operations.py | 117 ++- .../python/common/ayon_api/server_api.py | 416 +++++++--- .../python/common/ayon_api/thumbnails.py | 219 ----- .../vendor/python/common/ayon_api/utils.py | 39 + .../vendor/python/common/ayon_api/version.py | 2 +- 10 files changed, 1320 insertions(+), 349 deletions(-) delete mode 100644 openpype/vendor/python/common/ayon_api/thumbnails.py diff --git a/openpype/vendor/python/common/ayon_api/__init__.py b/openpype/vendor/python/common/ayon_api/__init__.py index 0540d7692d..027e7a3da2 100644 --- a/openpype/vendor/python/common/ayon_api/__init__.py +++ b/openpype/vendor/python/common/ayon_api/__init__.py @@ -30,6 +30,8 @@ from ._api import ( set_client_version, get_default_settings_variant, set_default_settings_variant, + get_sender, + set_sender, get_base_url, get_rest_url, @@ -92,6 +94,7 @@ from ._api import ( get_users, get_attributes_for_type, + get_attributes_fields_for_type, get_default_fields_for_type, get_project_anatomy_preset, @@ -110,6 +113,11 @@ from ._api import ( get_addons_project_settings, get_addons_settings, + get_secrets, + get_secret, + save_secret, + delete_secret, + get_project_names, get_projects, get_project, @@ -124,6 +132,8 @@ from ._api import ( get_folders_hierarchy, get_tasks, + get_task_by_id, + get_task_by_name, get_folder_ids_with_products, get_product_by_id, @@ -154,6 +164,7 @@ from ._api import ( get_workfile_info, get_workfile_info_by_id, + get_thumbnail_by_id, get_thumbnail, get_folder_thumbnail, get_version_thumbnail, @@ -216,6 +227,8 @@ __all__ = ( "set_client_version", "get_default_settings_variant", "set_default_settings_variant", + "get_sender", + "set_sender", "get_base_url", "get_rest_url", @@ -278,6 +291,7 @@ __all__ = ( "get_users", "get_attributes_for_type", + "get_attributes_fields_for_type", "get_default_fields_for_type", "get_project_anatomy_preset", @@ -295,6 +309,11 @@ __all__ = ( "get_addons_project_settings", "get_addons_settings", + "get_secrets", + "get_secret", + "save_secret", + "delete_secret", + "get_project_names", "get_projects", "get_project", @@ -308,6 +327,8 @@ __all__ = ( "get_folders", "get_tasks", + "get_task_by_id", + "get_task_by_name", "get_folder_ids_with_products", "get_product_by_id", @@ -338,6 +359,7 @@ __all__ = ( "get_workfile_info", "get_workfile_info_by_id", + "get_thumbnail_by_id", "get_thumbnail", "get_folder_thumbnail", "get_version_thumbnail", diff --git a/openpype/vendor/python/common/ayon_api/_api.py b/openpype/vendor/python/common/ayon_api/_api.py index 26a4b1530a..1d7b1837f1 100644 --- a/openpype/vendor/python/common/ayon_api/_api.py +++ b/openpype/vendor/python/common/ayon_api/_api.py @@ -392,6 +392,28 @@ def set_default_settings_variant(variant): return con.set_default_settings_variant(variant) +def get_sender(): + """Sender used to send requests. + + Returns: + Union[str, None]: Sender name or None. + """ + + con = get_server_api_connection() + return con.get_sender() + + +def set_sender(sender): + """Change sender used for requests. + + Args: + sender (Union[str, None]): Sender name or None. + """ + + con = get_server_api_connection() + return con.set_sender(sender) + + def get_base_url(): con = get_server_api_connection() return con.get_base_url() @@ -704,6 +726,26 @@ def get_addons_settings(*args, **kwargs): return con.get_addons_settings(*args, **kwargs) +def get_secrets(*args, **kwargs): + con = get_server_api_connection() + return con.get_secrets(*args, **kwargs) + + +def get_secret(*args, **kwargs): + con = get_server_api_connection() + return con.delete_secret(*args, **kwargs) + + +def save_secret(*args, **kwargs): + con = get_server_api_connection() + return con.delete_secret(*args, **kwargs) + + +def delete_secret(*args, **kwargs): + con = get_server_api_connection() + return con.delete_secret(*args, **kwargs) + + def get_project_names(*args, **kwargs): con = get_server_api_connection() return con.get_project_names(*args, **kwargs) @@ -734,6 +776,16 @@ def get_tasks(*args, **kwargs): return con.get_tasks(*args, **kwargs) +def get_task_by_id(*args, **kwargs): + con = get_server_api_connection() + return con.get_task_by_id(*args, **kwargs) + + +def get_task_by_name(*args, **kwargs): + con = get_server_api_connection() + return con.get_task_by_name(*args, **kwargs) + + def get_folder_by_id(*args, **kwargs): con = get_server_api_connection() return con.get_folder_by_id(*args, **kwargs) @@ -904,6 +956,11 @@ def delete_project(project_name): return con.delete_project(project_name) +def get_thumbnail_by_id(project_name, thumbnail_id): + con = get_server_api_connection() + con.get_thumbnail_by_id(project_name, thumbnail_id) + + def get_thumbnail(project_name, entity_type, entity_id, thumbnail_id=None): con = get_server_api_connection() con.get_thumbnail(project_name, entity_type, entity_id, thumbnail_id) @@ -934,6 +991,11 @@ def update_thumbnail(project_name, thumbnail_id, src_filepath): return con.update_thumbnail(project_name, thumbnail_id, src_filepath) +def get_attributes_fields_for_type(entity_type): + con = get_server_api_connection() + return con.get_attributes_fields_for_type(entity_type) + + def get_default_fields_for_type(entity_type): con = get_server_api_connection() return con.get_default_fields_for_type(entity_type) diff --git a/openpype/vendor/python/common/ayon_api/constants.py b/openpype/vendor/python/common/ayon_api/constants.py index e2b05a5cae..eb1ace0590 100644 --- a/openpype/vendor/python/common/ayon_api/constants.py +++ b/openpype/vendor/python/common/ayon_api/constants.py @@ -4,6 +4,25 @@ SERVER_API_ENV_KEY = "AYON_API_KEY" # Backwards compatibility SERVER_TOKEN_ENV_KEY = SERVER_API_ENV_KEY +# --- User --- +DEFAULT_USER_FIELDS = { + "roles", + "name", + "isService", + "isManager", + "isGuest", + "isAdmin", + "defaultRoles", + "createdAt", + "active", + "hasPassword", + "updatedAt", + "apiKeyPreview", + "attrib.avatarUrl", + "attrib.email", + "attrib.fullName", +} + # --- Product types --- DEFAULT_PRODUCT_TYPE_FIELDS = { "name", diff --git a/openpype/vendor/python/common/ayon_api/entity_hub.py b/openpype/vendor/python/common/ayon_api/entity_hub.py index ab1e2584d7..b9b017bac5 100644 --- a/openpype/vendor/python/common/ayon_api/entity_hub.py +++ b/openpype/vendor/python/common/ayon_api/entity_hub.py @@ -1,10 +1,11 @@ +import re import copy import collections from abc import ABCMeta, abstractmethod import six from ._api import get_server_api_connection -from .utils import create_entity_id, convert_entity_id +from .utils import create_entity_id, convert_entity_id, slugify_string UNKNOWN_VALUE = object() PROJECT_PARENT_ID = object() @@ -545,6 +546,7 @@ class EntityHub(object): library=project["library"], folder_types=project["folderTypes"], task_types=project["taskTypes"], + statuses=project["statuses"], name=project["name"], attribs=project["ownAttrib"], data=project["data"], @@ -775,8 +777,7 @@ class EntityHub(object): "projects/{}".format(self.project_name), **project_changes ) - if response.status_code != 204: - raise ValueError("Failed to update project") + response.raise_for_status() self.project_entity.lock() @@ -1485,6 +1486,722 @@ class BaseEntity(object): self._children_ids = set(children_ids) +class ProjectStatus: + """Project status class. + + Args: + name (str): Name of the status. e.g. 'In progress' + short_name (Optional[str]): Short name of the status. e.g. 'IP' + state (Optional[Literal[not_started, in_progress, done, blocked]]): A + state of the status. + icon (Optional[str]): Icon of the status. e.g. 'play_arrow'. + color (Optional[str]): Color of the status. e.g. '#eeeeee'. + index (Optional[int]): Index of the status. + project_statuses (Optional[_ProjectStatuses]): Project statuses + wrapper. + """ + + valid_states = ("not_started", "in_progress", "done", "blocked") + color_regex = re.compile(r"#([a-f0-9]{6})$") + default_state = "in_progress" + default_color = "#eeeeee" + + def __init__( + self, + name, + short_name=None, + state=None, + icon=None, + color=None, + index=None, + project_statuses=None, + is_new=None, + ): + short_name = short_name or "" + icon = icon or "" + state = state or self.default_state + color = color or self.default_color + self._name = name + self._short_name = short_name + self._icon = icon + self._slugified_name = None + self._state = None + self._color = None + self.set_state(state) + self.set_color(color) + + self._original_name = name + self._original_short_name = short_name + self._original_icon = icon + self._original_state = state + self._original_color = color + self._original_index = index + + self._index = index + self._project_statuses = project_statuses + if is_new is None: + is_new = index is None or project_statuses is None + self._is_new = is_new + + def __str__(self): + short_name = "" + if self.short_name: + short_name = "({})".format(self.short_name) + return "<{} {}{}>".format( + self.__class__.__name__, self.name, short_name + ) + + def __repr__(self): + return str(self) + + def __getitem__(self, key): + if key in { + "name", "short_name", "icon", "state", "color", "slugified_name" + }: + return getattr(self, key) + raise KeyError(key) + + def __setitem__(self, key, value): + if key in {"name", "short_name", "icon", "state", "color"}: + return setattr(self, key, value) + raise KeyError(key) + + def lock(self): + """Lock status. + + Changes were commited and current values are now the original values. + """ + + self._is_new = False + self._original_name = self.name + self._original_short_name = self.short_name + self._original_icon = self.icon + self._original_state = self.state + self._original_color = self.color + self._original_index = self.index + + @staticmethod + def slugify_name(name): + """Slugify status name for name comparison. + + Args: + name (str): Name of the status. + + Returns: + str: Slugified name. + """ + + return slugify_string(name.lower()) + + def get_project_statuses(self): + """Internal logic method. + + Returns: + _ProjectStatuses: Project statuses object. + """ + + return self._project_statuses + + def set_project_statuses(self, project_statuses): + """Internal logic method to change parent object. + + Args: + project_statuses (_ProjectStatuses): Project statuses object. + """ + + self._project_statuses = project_statuses + + def unset_project_statuses(self, project_statuses): + """Internal logic method to unset parent object. + + Args: + project_statuses (_ProjectStatuses): Project statuses object. + """ + + if self._project_statuses is project_statuses: + self._project_statuses = None + self._index = None + + @property + def changed(self): + """Status has changed. + + Returns: + bool: Status has changed. + """ + + return ( + self._is_new + or self._original_name != self._name + or self._original_short_name != self._short_name + or self._original_index != self._index + or self._original_state != self._state + or self._original_icon != self._icon + or self._original_color != self._color + ) + + def delete(self): + """Remove status from project statuses object.""" + + if self._project_statuses is not None: + self._project_statuses.remove(self) + + def get_index(self): + """Get index of status. + + Returns: + Union[int, None]: Index of status or None if status is not under + project. + """ + + return self._index + + def set_index(self, index, **kwargs): + """Change status index. + + Returns: + Union[int, None]: Index of status or None if status is not under + project. + """ + + if kwargs.get("from_parent"): + self._index = index + else: + self._project_statuses.set_status_index(self, index) + + def get_name(self): + """Status name. + + Returns: + str: Status name. + """ + + return self._name + + def set_name(self, name): + """Change status name. + + Args: + name (str): New status name. + """ + + if not isinstance(name, six.string_types): + raise TypeError("Name must be a string.") + if name == self._name: + return + self._name = name + self._slugified_name = None + + def get_short_name(self): + """Status short name 3 letters tops. + + Returns: + str: Status short name. + """ + + return self._short_name + + def set_short_name(self, short_name): + """Change status short name. + + Args: + short_name (str): New status short name. 3 letters tops. + """ + + if not isinstance(short_name, six.string_types): + raise TypeError("Short name must be a string.") + self._short_name = short_name + + def get_icon(self): + """Name of icon to use for status. + + Returns: + str: Name of the icon. + """ + + return self._icon + + def set_icon(self, icon): + """Change status icon name. + + Args: + icon (str): Name of the icon. + """ + + if icon is None: + icon = "" + if not isinstance(icon, six.string_types): + raise TypeError("Icon name must be a string.") + self._icon = icon + + @property + def slugified_name(self): + """Slugified and lowere status name. + + Can be used for comparison of existing statuses. e.g. 'In Progress' + vs. 'in-progress'. + + Returns: + str: Slugified and lower status name. + """ + + if self._slugified_name is None: + self._slugified_name = self.slugify_name(self.name) + return self._slugified_name + + def get_state(self): + """Get state of project status. + + Return: + Literal[not_started, in_progress, done, blocked]: General + state of status. + """ + + return self._state + + def set_state(self, state): + """Set color of project status. + + Args: + state (Literal[not_started, in_progress, done, blocked]): General + state of status. + """ + + if state not in self.valid_states: + raise ValueError("Invalid state '{}'".format(str(state))) + self._state = state + + def get_color(self): + """Get color of project status. + + Returns: + str: Status color. + """ + + return self._color + + def set_color(self, color): + """Set color of project status. + + Args: + color (str): Color in hex format. Example: '#ff0000'. + """ + + if not isinstance(color, six.string_types): + raise TypeError( + "Color must be string got '{}'".format(type(color))) + color = color.lower() + if self.color_regex.fullmatch(color) is None: + raise ValueError("Invalid color value '{}'".format(color)) + self._color = color + + name = property(get_name, set_name) + short_name = property(get_short_name, set_short_name) + project_statuses = property(get_project_statuses, set_project_statuses) + index = property(get_index, set_index) + state = property(get_state, set_state) + color = property(get_color, set_color) + icon = property(get_icon, set_icon) + + def _validate_other_p_statuses(self, other): + """Validate if other status can be used for move. + + To be able to work with other status, and position them in relation, + they must belong to same existing object of '_ProjectStatuses'. + + Args: + other (ProjectStatus): Other status to validate. + """ + + o_project_statuses = other.project_statuses + m_project_statuses = self.project_statuses + if o_project_statuses is None and m_project_statuses is None: + raise ValueError("Both statuses are not assigned to a project.") + + missing_status = None + if o_project_statuses is None: + missing_status = other + elif m_project_statuses is None: + missing_status = self + if missing_status is not None: + raise ValueError( + "Status '{}' is not assigned to a project.".format( + missing_status.name)) + if m_project_statuses is not o_project_statuses: + raise ValueError( + "Statuse are assigned to different projects." + " Cannot execute move." + ) + + def move_before(self, other): + """Move status before other status. + + Args: + other (ProjectStatus): Status to move before. + """ + + self._validate_other_p_statuses(other) + self._project_statuses.set_status_index(self, other.index) + + def move_after(self, other): + """Move status after other status. + + Args: + other (ProjectStatus): Status to move after. + """ + + self._validate_other_p_statuses(other) + self._project_statuses.set_status_index(self, other.index + 1) + + def to_data(self): + """Convert status to data. + + Returns: + dict[str, str]: Status data. + """ + + output = { + "name": self.name, + "shortName": self.short_name, + "state": self.state, + "icon": self.icon, + "color": self.color, + } + if ( + not self._is_new + and self._original_name + and self.name != self._original_name + ): + output["original_name"] = self._original_name + return output + + @classmethod + def from_data(cls, data, index=None, project_statuses=None): + """Create project status from data. + + Args: + data (dict[str, str]): Status data. + index (Optional[int]): Status index. + project_statuses (Optional[ProjectStatuses]): Project statuses + object which wraps the status for a project. + """ + + return cls( + data["name"], + data.get("shortName", data.get("short_name")), + data.get("state"), + data.get("icon"), + data.get("color"), + index=index, + project_statuses=project_statuses + ) + + +class _ProjectStatuses: + """Wrapper for project statuses. + + Supports basic methods to add, change or remove statuses from a project. + + To add new statuses use 'create' or 'add_status' methods. To change + statuses receive them by one of the getter methods and change their + values. + + Todos: + Validate if statuses are duplicated. + """ + + def __init__(self, statuses): + self._statuses = [ + ProjectStatus.from_data(status, idx, self) + for idx, status in enumerate(statuses) + ] + self._orig_status_length = len(self._statuses) + self._set_called = False + + def __len__(self): + return len(self._statuses) + + def __iter__(self): + """Iterate over statuses. + + Yields: + ProjectStatus: Project status. + """ + + for status in self._statuses: + yield status + + def create( + self, + name, + short_name=None, + state=None, + icon=None, + color=None, + ): + """Create project status. + + Args: + name (str): Name of the status. e.g. 'In progress' + short_name (Optional[str]): Short name of the status. e.g. 'IP' + state (Optional[Literal[not_started, in_progress, done, blocked]]): A + state of the status. + icon (Optional[str]): Icon of the status. e.g. 'play_arrow'. + color (Optional[str]): Color of the status. e.g. '#eeeeee'. + + Returns: + ProjectStatus: Created project status. + """ + + status = ProjectStatus( + name, short_name, state, icon, color, is_new=True + ) + self.append(status) + return status + + def lock(self): + """Lock statuses. + + Changes were commited and current values are now the original values. + """ + + self._orig_status_length = len(self._statuses) + self._set_called = False + for status in self._statuses: + status.lock() + + def to_data(self): + """Convert to project statuses data.""" + + return [ + status.to_data() + for status in self._statuses + ] + + def set(self, statuses): + """Explicitly override statuses. + + This method does not handle if statuses changed or not. + + Args: + statuses (list[dict[str, str]]): List of statuses data. + """ + + self._set_called = True + self._statuses = [ + ProjectStatus.from_data(status, idx, self) + for idx, status in enumerate(statuses) + ] + + @property + def changed(self): + """Statuses have changed. + + Returns: + bool: True if statuses changed, False otherwise. + """ + + if self._set_called: + return True + + # Check if status length changed + # - when all statuses are removed it is a changed + if self._orig_status_length != len(self._statuses): + return True + # Go through all statuses and check if any of them changed + for status in self._statuses: + if status.changed: + return True + return False + + def get(self, name, default=None): + """Get status by name. + + Args: + name (str): Status name. + default (Any): Default value of status is not found. + + Returns: + Union[ProjectStatus, Any]: Status or default value. + """ + + return next( + ( + status + for status in self._statuses + if status.name == name + ), + default + ) + + get_status_by_name = get + + def index(self, status, **kwargs): + """Get status index. + + Args: + status (ProjectStatus): Status to get index of. + default (Optional[Any]): Default value if status is not found. + + Returns: + Union[int, Any]: Status index. + + Raises: + ValueError: If status is not found and default value is not + defined. + """ + + output = next( + ( + idx + for idx, st in enumerate(self._statuses) + if st is status + ), + None + ) + if output is not None: + return output + + if "default" in kwargs: + return kwargs["default"] + raise ValueError("Status '{}' not found".format(status.name)) + + def get_status_by_slugified_name(self, name): + """Get status by slugified name. + + Args: + name (str): Status name. Is slugified before search. + + Returns: + Union[ProjectStatus, None]: Status or None if not found. + """ + + slugified_name = ProjectStatus.slugify_name(name) + return next( + ( + status + for status in self._statuses + if status.slugified_name == slugified_name + ), + None + ) + + def remove_by_name(self, name, ignore_missing=False): + """Remove status by name. + + Args: + name (str): Status name. + ignore_missing (Optional[bool]): If True, no error is raised if + status is not found. + + Returns: + ProjectStatus: Removed status. + """ + + matching_status = self.get(name) + if matching_status is None: + if ignore_missing: + return + raise ValueError( + "Status '{}' not found in project".format(name)) + return self.remove(matching_status) + + def remove(self, status, ignore_missing=False): + """Remove status. + + Args: + status (ProjectStatus): Status to remove. + ignore_missing (Optional[bool]): If True, no error is raised if + status is not found. + + Returns: + Union[ProjectStatus, None]: Removed status. + """ + + index = self.index(status, default=None) + if index is None: + if ignore_missing: + return None + raise ValueError("Status '{}' not in project".format(status)) + + return self.pop(index) + + def pop(self, index): + """Remove status by index. + + Args: + index (int): Status index. + + Returns: + ProjectStatus: Removed status. + """ + + status = self._statuses.pop(index) + status.unset_project_statuses(self) + for st in self._statuses[index:]: + st.set_index(st.index - 1, from_parent=True) + return status + + def insert(self, index, status): + """Insert status at index. + + Args: + index (int): Status index. + status (Union[ProjectStatus, dict[str, str]]): Status to insert. + Can be either status object or status data. + + Returns: + ProjectStatus: Inserted status. + """ + + if not isinstance(status, ProjectStatus): + status = ProjectStatus.from_data(status) + + start_index = index + end_index = len(self._statuses) + 1 + matching_index = self.index(status, default=None) + if matching_index is not None: + if matching_index == index: + status.set_index(index, from_parent=True) + return + + self._statuses.pop(matching_index) + if matching_index < index: + start_index = matching_index + end_index = index + 1 + else: + end_index -= 1 + + status.set_project_statuses(self) + self._statuses.insert(index, status) + for idx, st in enumerate(self._statuses[start_index:end_index]): + st.set_index(start_index + idx, from_parent=True) + return status + + def append(self, status): + """Add new status to the end of the list. + + Args: + status (Union[ProjectStatus, dict[str, str]]): Status to insert. + Can be either status object or status data. + + Returns: + ProjectStatus: Inserted status. + """ + + return self.insert(len(self._statuses), status) + + def set_status_index(self, status, index): + """Set status index. + + Args: + status (ProjectStatus): Status to set index. + index (int): New status index. + """ + + return self.insert(index, status) + + class ProjectEntity(BaseEntity): """Entity representing project on AYON server. @@ -1514,7 +2231,14 @@ class ProjectEntity(BaseEntity): default_task_type_icon = "task_alt" def __init__( - self, project_code, library, folder_types, task_types, *args, **kwargs + self, + project_code, + library, + folder_types, + task_types, + statuses, + *args, + **kwargs ): super(ProjectEntity, self).__init__(*args, **kwargs) @@ -1522,11 +2246,13 @@ class ProjectEntity(BaseEntity): self._library_project = library self._folder_types = folder_types self._task_types = task_types + self._statuses_obj = _ProjectStatuses(statuses) self._orig_project_code = project_code self._orig_library_project = library self._orig_folder_types = copy.deepcopy(folder_types) self._orig_task_types = copy.deepcopy(task_types) + self._orig_statuses = copy.deepcopy(statuses) def _prepare_entity_id(self, entity_id): if entity_id != self.project_name: @@ -1573,13 +2299,24 @@ class ProjectEntity(BaseEntity): new_task_types.append(task_type) self._task_types = new_task_types + def get_orig_statuses(self): + return copy.deepcopy(self._orig_statuses) + + def get_statuses(self): + return self._statuses_obj + + def set_statuses(self, statuses): + self._statuses_obj.set(statuses) + folder_types = property(get_folder_types, set_folder_types) task_types = property(get_task_types, set_task_types) + statuses = property(get_statuses, set_statuses) def lock(self): super(ProjectEntity, self).lock() self._orig_folder_types = copy.deepcopy(self._folder_types) self._orig_task_types = copy.deepcopy(self._task_types) + self._statuses_obj.lock() @property def changes(self): @@ -1590,6 +2327,9 @@ class ProjectEntity(BaseEntity): if self._orig_task_types != self._task_types: changes["taskTypes"] = self.get_task_types() + if self._statuses_obj.changed: + changes["statuses"] = self._statuses_obj.to_data() + return changes @classmethod diff --git a/openpype/vendor/python/common/ayon_api/graphql_queries.py b/openpype/vendor/python/common/ayon_api/graphql_queries.py index 4af8c53e4e..f31134a04d 100644 --- a/openpype/vendor/python/common/ayon_api/graphql_queries.py +++ b/openpype/vendor/python/common/ayon_api/graphql_queries.py @@ -462,3 +462,28 @@ def events_graphql_query(fields): for k, v in value.items(): query_queue.append((k, v, field)) return query + + +def users_graphql_query(fields): + query = GraphQlQuery("Users") + names_var = query.add_variable("userNames", "[String!]") + + users_field = query.add_field_with_edges("users") + users_field.set_filter("names", names_var) + + nested_fields = fields_to_dict(set(fields)) + + query_queue = collections.deque() + for key, value in nested_fields.items(): + query_queue.append((key, value, users_field)) + + while query_queue: + item = query_queue.popleft() + key, value, parent = item + field = parent.add_field(key) + if value is FIELD_VALUE: + continue + + for k, v in value.items(): + query_queue.append((k, v, field)) + return query diff --git a/openpype/vendor/python/common/ayon_api/operations.py b/openpype/vendor/python/common/ayon_api/operations.py index 7cf610a566..eb2ca8afe3 100644 --- a/openpype/vendor/python/common/ayon_api/operations.py +++ b/openpype/vendor/python/common/ayon_api/operations.py @@ -1,3 +1,4 @@ +import os import copy import collections import uuid @@ -22,6 +23,8 @@ def new_folder_entity( name, folder_type, parent_id=None, + status=None, + tags=None, attribs=None, data=None, thumbnail_id=None, @@ -32,12 +35,14 @@ def new_folder_entity( Args: name (str): Is considered as unique identifier of folder in project. folder_type (str): Type of folder. - parent_id (Optional[str]]): Id of parent folder. + parent_id (Optional[str]): Parent folder id. + status (Optional[str]): Product status. + tags (Optional[List[str]]): List of tags. attribs (Optional[Dict[str, Any]]): Explicitly set attributes of folder. data (Optional[Dict[str, Any]]): Custom folder data. Empty dictionary is used if not passed. - thumbnail_id (Optional[str]): Id of thumbnail related to folder. + thumbnail_id (Optional[str]): Thumbnail id related to folder. entity_id (Optional[str]): Predefined id of entity. New id is created if not passed. @@ -54,7 +59,7 @@ def new_folder_entity( if parent_id is not None: parent_id = _create_or_convert_to_id(parent_id) - return { + output = { "id": _create_or_convert_to_id(entity_id), "name": name, # This will be ignored @@ -64,6 +69,11 @@ def new_folder_entity( "attrib": attribs, "thumbnailId": thumbnail_id } + if status: + output["status"] = status + if tags: + output["tags"] = tags + return output def new_product_entity( @@ -71,6 +81,7 @@ def new_product_entity( product_type, folder_id, status=None, + tags=None, attribs=None, data=None, entity_id=None @@ -81,8 +92,9 @@ def new_product_entity( name (str): Is considered as unique identifier of product under folder. product_type (str): Product type. - folder_id (str): Id of parent folder. + folder_id (str): Parent folder id. status (Optional[str]): Product status. + tags (Optional[List[str]]): List of tags. attribs (Optional[Dict[str, Any]]): Explicitly set attributes of product. data (Optional[Dict[str, Any]]): product entity data. Empty dictionary @@ -110,6 +122,8 @@ def new_product_entity( } if status: output["status"] = status + if tags: + output["tags"] = tags return output @@ -119,6 +133,8 @@ def new_version_entity( task_id=None, thumbnail_id=None, author=None, + status=None, + tags=None, attribs=None, data=None, entity_id=None @@ -128,10 +144,12 @@ def new_version_entity( Args: version (int): Is considered as unique identifier of version under product. - product_id (str): Id of parent product. - task_id (Optional[str]]): Id of task under which product was created. - thumbnail_id (Optional[str]]): Thumbnail related to version. - author (Optional[str]]): Name of version author. + product_id (str): Parent product id. + task_id (Optional[str]): Task id under which product was created. + thumbnail_id (Optional[str]): Thumbnail related to version. + author (Optional[str]): Name of version author. + status (Optional[str]): Version status. + tags (Optional[List[str]]): List of tags. attribs (Optional[Dict[str, Any]]): Explicitly set attributes of version. data (Optional[Dict[str, Any]]): Version entity custom data. @@ -164,6 +182,10 @@ def new_version_entity( output["thumbnailId"] = thumbnail_id if author: output["author"] = author + if tags: + output["tags"] = tags + if status: + output["status"] = status return output @@ -173,6 +195,8 @@ def new_hero_version_entity( task_id=None, thumbnail_id=None, author=None, + status=None, + tags=None, attribs=None, data=None, entity_id=None @@ -182,10 +206,12 @@ def new_hero_version_entity( Args: version (int): Is considered as unique identifier of version under product. Should be same as standard version if there is any. - product_id (str): Id of parent product. - task_id (Optional[str]): Id of task under which product was created. + product_id (str): Parent product id. + task_id (Optional[str]): Task id under which product was created. thumbnail_id (Optional[str]): Thumbnail related to version. author (Optional[str]): Name of version author. + status (Optional[str]): Version status. + tags (Optional[List[str]]): List of tags. attribs (Optional[Dict[str, Any]]): Explicitly set attributes of version. data (Optional[Dict[str, Any]]): Version entity data. @@ -215,18 +241,32 @@ def new_hero_version_entity( output["thumbnailId"] = thumbnail_id if author: output["author"] = author + if tags: + output["tags"] = tags + if status: + output["status"] = status return output def new_representation_entity( - name, version_id, attribs=None, data=None, entity_id=None + name, + version_id, + files, + status=None, + tags=None, + attribs=None, + data=None, + entity_id=None ): """Create skeleton data of representation entity. Args: name (str): Representation name considered as unique identifier of representation under version. - version_id (str): Id of parent version. + version_id (str): Parent version id. + files (list[dict[str, str]]): List of files in representation. + status (Optional[str]): Representation status. + tags (Optional[List[str]]): List of tags. attribs (Optional[Dict[str, Any]]): Explicitly set attributes of representation. data (Optional[Dict[str, Any]]): Representation entity data. @@ -243,27 +283,42 @@ def new_representation_entity( if data is None: data = {} - return { + output = { "id": _create_or_convert_to_id(entity_id), "versionId": _create_or_convert_to_id(version_id), + "files": files, "name": name, "data": data, "attrib": attribs } + if tags: + output["tags"] = tags + if status: + output["status"] = status + return output -def new_workfile_info_doc( - filename, folder_id, task_name, files, data=None, entity_id=None +def new_workfile_info( + filepath, + task_id, + status=None, + tags=None, + attribs=None, + description=None, + data=None, + entity_id=None ): """Create skeleton data of workfile info entity. Workfile entity is at this moment used primarily for artist notes. Args: - filename (str): Filename of workfile. - folder_id (str): Id of folder under which workfile live. - task_name (str): Task under which was workfile created. - files (List[str]): List of rootless filepaths related to workfile. + filepath (str): Rootless workfile filepath. + task_id (str): Task under which was workfile created. + status (Optional[str]): Workfile status. + tags (Optional[List[str]]): Workfile tags. + attribs (Options[dic[str, Any]]): Explicitly set attributes. + description (Optional[str]): Workfile description. data (Optional[Dict[str, Any]]): Additional metadata. entity_id (Optional[str]): Predefined id of entity. New id is created if not passed. @@ -272,17 +327,31 @@ def new_workfile_info_doc( Dict[str, Any]: Skeleton of workfile info entity. """ + if attribs is None: + attribs = {} + + if "extension" not in attribs: + attribs["extension"] = os.path.splitext(filepath)[-1] + + if description: + attribs["description"] = description + if not data: data = {} - return { + output = { "id": _create_or_convert_to_id(entity_id), - "parent": _create_or_convert_to_id(folder_id), - "task_name": task_name, - "filename": filename, + "taskId": task_id, + "path": filepath, "data": data, - "files": files + "attrib": attribs } + if status: + output["status"] = status + + if tags: + output["tags"] = tags + return output @six.add_metaclass(ABCMeta) diff --git a/openpype/vendor/python/common/ayon_api/server_api.py b/openpype/vendor/python/common/ayon_api/server_api.py index c578124cfc..f2689e88dc 100644 --- a/openpype/vendor/python/common/ayon_api/server_api.py +++ b/openpype/vendor/python/common/ayon_api/server_api.py @@ -14,7 +14,16 @@ except ImportError: HTTPStatus = None import requests -from requests.exceptions import JSONDecodeError as RequestsJSONDecodeError +try: + # This should be used if 'requests' have it available + from requests.exceptions import JSONDecodeError as RequestsJSONDecodeError +except ImportError: + # Older versions of 'requests' don't have custom exception for json + # decode error + try: + from simplejson import JSONDecodeError as RequestsJSONDecodeError + except ImportError: + from json import JSONDecodeError as RequestsJSONDecodeError from .constants import ( DEFAULT_PRODUCT_TYPE_FIELDS, @@ -27,8 +36,8 @@ from .constants import ( REPRESENTATION_FILES_FIELDS, DEFAULT_WORKFILE_INFO_FIELDS, DEFAULT_EVENT_FIELDS, + DEFAULT_USER_FIELDS, ) -from .thumbnails import ThumbnailCache from .graphql import GraphQlQuery, INTROSPECTION_QUERY from .graphql_queries import ( project_graphql_query, @@ -43,6 +52,7 @@ from .graphql_queries import ( representations_parents_qraphql_query, workfiles_info_graphql_query, events_graphql_query, + users_graphql_query, ) from .exceptions import ( FailedOperations, @@ -61,6 +71,7 @@ from .utils import ( failed_json_default, TransferProgress, create_dependency_package_basename, + ThumbnailContent, ) PatternType = type(re.compile("")) @@ -319,6 +330,8 @@ class ServerAPI(object): default_settings_variant (Optional[Literal["production", "staging"]]): Settings variant used by default if a method for settings won't get any (by default is 'production'). + sender (Optional[str]): Sender of requests. Used in server logs and + propagated into events. ssl_verify (Union[bool, str, None]): Verify SSL certificate Looks for env variable value 'AYON_CA_FILE' by default. If not available then 'True' is used. @@ -335,6 +348,7 @@ class ServerAPI(object): site_id=None, client_version=None, default_settings_variant=None, + sender=None, ssl_verify=None, cert=None, create_session=True, @@ -354,6 +368,7 @@ class ServerAPI(object): default_settings_variant or "production" ) + self._sender = sender if ssl_verify is None: # Custom AYON env variable for CA file or 'True' @@ -390,7 +405,6 @@ class ServerAPI(object): self._entity_type_attributes_cache = {} self._as_user_stack = _AsUserStack() - self._thumbnail_cache = ThumbnailCache(True) # Create session if self._access_token and create_session: @@ -559,6 +573,29 @@ class ServerAPI(object): set_default_settings_variant ) + def get_sender(self): + """Sender used to send requests. + + Returns: + Union[str, None]: Sender name or None. + """ + + return self._sender + + def set_sender(self, sender): + """Change sender used for requests. + + Args: + sender (Union[str, None]): Sender name or None. + """ + + if sender == self._sender: + return + self._sender = sender + self._update_session_headers() + + sender = property(get_sender, set_sender) + def get_default_service_username(self): """Default username used for callbacks when used with service API key. @@ -742,6 +779,7 @@ class ServerAPI(object): ("X-as-user", self._as_user_stack.username), ("x-ayon-version", self._client_version), ("x-ayon-site-id", self._site_id), + ("x-sender", self._sender), ): if value is not None: self._session.headers[key] = value @@ -826,10 +864,36 @@ class ServerAPI(object): self._access_token_is_service = None return None - def get_users(self): - # TODO how to find out if user have permission? - users = self.get("users") - return users.data + def get_users(self, usernames=None, fields=None): + """Get Users. + + Args: + usernames (Optional[Iterable[str]]): Filter by usernames. + fields (Optional[Iterable[str]]): fields to be queried + for users. + + Returns: + Generator[dict[str, Any]]: Queried users. + """ + + filters = {} + if usernames is not None: + usernames = set(usernames) + if not usernames: + return + filters["userNames"] = list(usernames) + + if not fields: + fields = self.get_default_fields_for_type("user") + + query = users_graphql_query(set(fields)) + for attr, filter_value in filters.items(): + query.set_variable_value(attr, filter_value) + + for parsed_data in query.continuous_query(self): + for user in parsed_data["users"]: + user["roles"] = json.loads(user["roles"]) + yield user def get_user(self, username=None): output = None @@ -859,6 +923,9 @@ class ServerAPI(object): if self._client_version is not None: headers["x-ayon-version"] = self._client_version + if self._sender is not None: + headers["x-sender"] = self._sender + if self._access_token: if self._access_token_is_service: headers["X-Api-Key"] = self._access_token @@ -900,18 +967,24 @@ class ServerAPI(object): self.validate_server_availability() - response = self.post( - "auth/login", - name=username, - password=password - ) - if response.status_code != 200: - _detail = response.data.get("detail") - details = "" - if _detail: - details = " {}".format(_detail) + self._token_validation_started = True - raise AuthenticationError("Login failed {}".format(details)) + try: + response = self.post( + "auth/login", + name=username, + password=password + ) + if response.status_code != 200: + _detail = response.data.get("detail") + details = "" + if _detail: + details = " {}".format(_detail) + + raise AuthenticationError("Login failed {}".format(details)) + + finally: + self._token_validation_started = False self._access_token = response["token"] @@ -1127,7 +1200,7 @@ class ServerAPI(object): filters["includeLogsFilter"] = include_logs if not fields: - fields = DEFAULT_EVENT_FIELDS + fields = self.get_default_fields_for_type("event") query = events_graphql_query(set(fields)) for attr, filter_value in filters.items(): @@ -1228,7 +1301,8 @@ class ServerAPI(object): target_topic, sender, description=None, - sequential=None + sequential=None, + events_filter=None, ): """Enroll job based on events. @@ -1270,6 +1344,8 @@ class ServerAPI(object): in target event. sequential (Optional[bool]): The source topic must be processed in sequence. + events_filter (Optional[ayon_server.sqlfilter.Filter]): A dict-like + with conditions to filter the source event. Returns: Union[None, dict[str, Any]]: None if there is no event matching @@ -1285,6 +1361,8 @@ class ServerAPI(object): kwargs["sequential"] = sequential if description is not None: kwargs["description"] = description + if events_filter is not None: + kwargs["filter"] = events_filter response = self.post("enroll", **kwargs) if response.status_code == 204: return None @@ -1612,6 +1690,19 @@ class ServerAPI(object): return copy.deepcopy(attributes) + def get_attributes_fields_for_type(self, entity_type): + """Prepare attribute fields for entity type. + + Returns: + set[str]: Attributes fields for entity type. + """ + + attributes = self.get_attributes_for_type(entity_type) + return { + "attrib.{}".format(attr) + for attr in attributes + } + def get_default_fields_for_type(self, entity_type): """Default fields for entity type. @@ -1624,51 +1715,46 @@ class ServerAPI(object): set[str]: Fields that should be queried from server. """ - attributes = self.get_attributes_for_type(entity_type) + # Event does not have attributes + if entity_type == "event": + return set(DEFAULT_EVENT_FIELDS) + if entity_type == "project": - return DEFAULT_PROJECT_FIELDS | { - "attrib.{}".format(attr) - for attr in attributes - } + entity_type_defaults = DEFAULT_PROJECT_FIELDS - if entity_type == "folder": - return DEFAULT_FOLDER_FIELDS | { - "attrib.{}".format(attr) - for attr in attributes - } + elif entity_type == "folder": + entity_type_defaults = DEFAULT_FOLDER_FIELDS - if entity_type == "task": - return DEFAULT_TASK_FIELDS | { - "attrib.{}".format(attr) - for attr in attributes - } + elif entity_type == "task": + entity_type_defaults = DEFAULT_TASK_FIELDS - if entity_type == "product": - return DEFAULT_PRODUCT_FIELDS | { - "attrib.{}".format(attr) - for attr in attributes - } + elif entity_type == "product": + entity_type_defaults = DEFAULT_PRODUCT_FIELDS - if entity_type == "version": - return DEFAULT_VERSION_FIELDS | { - "attrib.{}".format(attr) - for attr in attributes - } + elif entity_type == "version": + entity_type_defaults = DEFAULT_VERSION_FIELDS - if entity_type == "representation": - return ( + elif entity_type == "representation": + entity_type_defaults = ( DEFAULT_REPRESENTATION_FIELDS | REPRESENTATION_FILES_FIELDS - | { - "attrib.{}".format(attr) - for attr in attributes - } ) - if entity_type == "productType": - return DEFAULT_PRODUCT_TYPE_FIELDS + elif entity_type == "productType": + entity_type_defaults = DEFAULT_PRODUCT_TYPE_FIELDS - raise ValueError("Unknown entity type \"{}\"".format(entity_type)) + elif entity_type == "workfile": + entity_type_defaults = DEFAULT_WORKFILE_INFO_FIELDS + + elif entity_type == "user": + entity_type_defaults = DEFAULT_USER_FIELDS + + else: + raise ValueError("Unknown entity type \"{}\"".format(entity_type)) + return ( + entity_type_defaults + | self.get_attributes_fields_for_type(entity_type) + ) def get_addons_info(self, details=True): """Get information about addons available on server. @@ -2926,6 +3012,79 @@ class ServerAPI(object): only_values=only_values ) + def get_secrets(self): + """Get all secrets. + + Example output: + [ + { + "name": "secret_1", + "value": "secret_value_1", + }, + { + "name": "secret_2", + "value": "secret_value_2", + } + ] + + Returns: + list[dict[str, str]]: List of secret entities. + """ + + response = self.get("secrets") + response.raise_for_status() + return response.data + + def get_secret(self, secret_name): + """Get secret by name. + + Example output: + { + "name": "secret_name", + "value": "secret_value", + } + + Args: + secret_name (str): Name of secret. + + Returns: + dict[str, str]: Secret entity data. + """ + + response = self.get("secrets/{}".format(secret_name)) + response.raise_for_status() + return response.data + + def save_secret(self, secret_name, secret_value): + """Save secret. + + This endpoint can create and update secret. + + Args: + secret_name (str): Name of secret. + secret_value (str): Value of secret. + """ + + response = self.put( + "secrets/{}".format(secret_name), + name=secret_name, + value=secret_value, + ) + response.raise_for_status() + return response.data + + + def delete_secret(self, secret_name): + """Delete secret by name. + + Args: + secret_name (str): Name of secret to delete. + """ + + response = self.delete("secrets/{}".format(secret_name)) + response.raise_for_status() + return response.data + # Entity getters def get_rest_project(self, project_name): """Query project by name. @@ -3070,8 +3229,6 @@ class ServerAPI(object): else: use_rest = False fields = set(fields) - if own_attributes: - fields.add("ownAttrib") for field in fields: if field.startswith("config"): use_rest = True @@ -3084,6 +3241,13 @@ class ServerAPI(object): yield project else: + if "attrib" in fields: + fields.remove("attrib") + fields |= self.get_attributes_fields_for_type("project") + + if own_attributes: + fields.add("ownAttrib") + query = projects_graphql_query(fields) for parsed_data in query.continuous_query(self): for project in parsed_data["projects"]: @@ -3124,8 +3288,12 @@ class ServerAPI(object): fill_own_attribs(project) return project + if "attrib" in fields: + fields.remove("attrib") + fields |= self.get_attributes_fields_for_type("project") + if own_attributes: - field.add("ownAttrib") + fields.add("ownAttrib") query = project_graphql_query(fields) query.set_variable_value("projectName", project_name) @@ -3282,10 +3450,13 @@ class ServerAPI(object): filters["parentFolderIds"] = list(parent_ids) - if fields: - fields = set(fields) - else: + if not fields: fields = self.get_default_fields_for_type("folder") + else: + fields = set(fields) + if "attrib" in fields: + fields.remove("attrib") + fields |= self.get_attributes_fields_for_type("folder") use_rest = False if "data" in fields: @@ -3519,8 +3690,11 @@ class ServerAPI(object): if not fields: fields = self.get_default_fields_for_type("task") - - fields = set(fields) + else: + fields = set(fields) + if "attrib" in fields: + fields.remove("attrib") + fields |= self.get_attributes_fields_for_type("task") use_rest = False if "data" in fields: @@ -3705,6 +3879,9 @@ class ServerAPI(object): # Convert fields and add minimum required fields if fields: fields = set(fields) | {"id"} + if "attrib" in fields: + fields.remove("attrib") + fields |= self.get_attributes_fields_for_type("folder") else: fields = self.get_default_fields_for_type("product") @@ -3961,7 +4138,11 @@ class ServerAPI(object): if not fields: fields = self.get_default_fields_for_type("version") - fields = set(fields) + else: + fields = set(fields) + if "attrib" in fields: + fields.remove("attrib") + fields |= self.get_attributes_fields_for_type("version") if active is not None: fields.add("active") @@ -4419,7 +4600,11 @@ class ServerAPI(object): if not fields: fields = self.get_default_fields_for_type("representation") - fields = set(fields) + else: + fields = set(fields) + if "attrib" in fields: + fields.remove("attrib") + fields |= self.get_attributes_fields_for_type("representation") use_rest = False if "data" in fields: @@ -4765,8 +4950,15 @@ class ServerAPI(object): filters["workfileIds"] = list(workfile_ids) if not fields: - fields = DEFAULT_WORKFILE_INFO_FIELDS + fields = self.get_default_fields_for_type("workfile") + fields = set(fields) + if "attrib" in fields: + fields.remove("attrib") + fields |= { + "attrib.{}".format(attr) + for attr in self.get_attributes_for_type("workfile") + } if own_attributes: fields.add("ownAttrib") @@ -4843,18 +5035,61 @@ class ServerAPI(object): return workfile_info return None + def _prepare_thumbnail_content(self, project_name, response): + content = None + content_type = response.content_type + + # It is expected the response contains thumbnail id otherwise the + # content cannot be cached and filepath returned + thumbnail_id = response.headers.get("X-Thumbnail-Id") + if thumbnail_id is not None: + content = response.content + + return ThumbnailContent( + project_name, thumbnail_id, content, content_type + ) + + def get_thumbnail_by_id(self, project_name, thumbnail_id): + """Get thumbnail from server by id. + + Permissions of thumbnails are related to entities so thumbnails must + be queried per entity. So an entity type and entity type is required + to be passed. + + Notes: + It is recommended to use one of prepared entity type specific + methods 'get_folder_thumbnail', 'get_version_thumbnail' or + 'get_workfile_thumbnail'. + We do recommend pass thumbnail id if you have access to it. Each + entity that allows thumbnails has 'thumbnailId' field, so it + can be queried. + + Args: + project_name (str): Project under which the entity is located. + thumbnail_id (Optional[str]): DEPRECATED Use + 'get_thumbnail_by_id'. + + Returns: + ThumbnailContent: Thumbnail content wrapper. Does not have to be + valid. + """ + + response = self.raw_get( + "projects/{}/thumbnails/{}".format( + project_name, + thumbnail_id + ) + ) + return self._prepare_thumbnail_content(project_name, response) + def get_thumbnail( self, project_name, entity_type, entity_id, thumbnail_id=None ): """Get thumbnail from server. - Permissions of thumbnails are related to entities so thumbnails must be - queried per entity. So an entity type and entity type is required to - be passed. - - If thumbnail id is passed logic can look into locally cached thumbnails - before calling server which can enhance loading time. If thumbnail id - is not passed the thumbnail is always downloaded even if is available. + Permissions of thumbnails are related to entities so thumbnails must + be queried per entity. So an entity type and entity type is required + to be passed. Notes: It is recommended to use one of prepared entity type specific @@ -4868,20 +5103,16 @@ class ServerAPI(object): project_name (str): Project under which the entity is located. entity_type (str): Entity type which passed entity id represents. entity_id (str): Entity id for which thumbnail should be returned. - thumbnail_id (Optional[str]): Prepared thumbnail id from entity. - Used only to check if thumbnail was already cached. + thumbnail_id (Optional[str]): DEPRECATED Use + 'get_thumbnail_by_id'. Returns: - Union[str, None]: Path to downloaded thumbnail or none if entity - does not have any (or if user does not have permissions). + ThumbnailContent: Thumbnail content wrapper. Does not have to be + valid. """ - # Look for thumbnail into cache and return the path if was found - filepath = self._thumbnail_cache.get_thumbnail_filepath( - project_name, thumbnail_id - ) - if filepath: - return filepath + if thumbnail_id: + return self.get_thumbnail_by_id(project_name, thumbnail_id) if entity_type in ( "folder", @@ -4890,29 +5121,12 @@ class ServerAPI(object): ): entity_type += "s" - # Receive thumbnail content from server - result = self.raw_get("projects/{}/{}/{}/thumbnail".format( + response = self.raw_get("projects/{}/{}/{}/thumbnail".format( project_name, entity_type, entity_id )) - - if result.content_type is None: - return None - - # It is expected the response contains thumbnail id otherwise the - # content cannot be cached and filepath returned - thumbnail_id = result.headers.get("X-Thumbnail-Id") - if thumbnail_id is None: - return None - - # Cache thumbnail and return path - return self._thumbnail_cache.store_thumbnail( - project_name, - thumbnail_id, - result.content, - result.content_type - ) + return self._prepare_thumbnail_content(project_name, response) def get_folder_thumbnail( self, project_name, folder_id, thumbnail_id=None diff --git a/openpype/vendor/python/common/ayon_api/thumbnails.py b/openpype/vendor/python/common/ayon_api/thumbnails.py deleted file mode 100644 index 50acd94dcb..0000000000 --- a/openpype/vendor/python/common/ayon_api/thumbnails.py +++ /dev/null @@ -1,219 +0,0 @@ -import os -import time -import collections - -import appdirs - -FileInfo = collections.namedtuple( - "FileInfo", - ("path", "size", "modification_time") -) - - -class ThumbnailCache: - """Cache of thumbnails on local storage. - - Thumbnails are cached to appdirs to predefined directory. Each project has - own subfolder with thumbnails -> that's because each project has own - thumbnail id validation and file names are thumbnail ids with matching - extension. Extensions are predefined (.png and .jpeg). - - Cache has cleanup mechanism which is triggered on initialized by default. - - The cleanup has 2 levels: - 1. soft cleanup which remove all files that are older then 'days_alive' - 2. max size cleanup which remove all files until the thumbnails folder - contains less then 'max_filesize' - - this is time consuming so it's not triggered automatically - - Args: - cleanup (bool): Trigger soft cleanup (Cleanup expired thumbnails). - """ - - # Lifetime of thumbnails (in seconds) - # - default 3 days - days_alive = 3 * 24 * 60 * 60 - # Max size of thumbnail directory (in bytes) - # - default 2 Gb - max_filesize = 2 * 1024 * 1024 * 1024 - - def __init__(self, cleanup=True): - self._thumbnails_dir = None - if cleanup: - self.cleanup() - - def get_thumbnails_dir(self): - """Root directory where thumbnails are stored. - - Returns: - str: Path to thumbnails root. - """ - - if self._thumbnails_dir is None: - directory = appdirs.user_data_dir("ayon", "ynput") - self._thumbnails_dir = os.path.join(directory, "thumbnails") - return self._thumbnails_dir - - thumbnails_dir = property(get_thumbnails_dir) - - def get_thumbnails_dir_file_info(self): - """Get information about all files in thumbnails directory. - - Returns: - List[FileInfo]: List of file information about all files. - """ - - thumbnails_dir = self.thumbnails_dir - files_info = [] - if not os.path.exists(thumbnails_dir): - return files_info - - for root, _, filenames in os.walk(thumbnails_dir): - for filename in filenames: - path = os.path.join(root, filename) - files_info.append(FileInfo( - path, os.path.getsize(path), os.path.getmtime(path) - )) - return files_info - - def get_thumbnails_dir_size(self, files_info=None): - """Got full size of thumbnail directory. - - Args: - files_info (List[FileInfo]): Prepared file information about - files in thumbnail directory. - - Returns: - int: File size of all files in thumbnail directory. - """ - - if files_info is None: - files_info = self.get_thumbnails_dir_file_info() - - if not files_info: - return 0 - - return sum( - file_info.size - for file_info in files_info - ) - - def cleanup(self, check_max_size=False): - """Cleanup thumbnails directory. - - Args: - check_max_size (bool): Also cleanup files to match max size of - thumbnails directory. - """ - - thumbnails_dir = self.get_thumbnails_dir() - # Skip if thumbnails dir does not exists yet - if not os.path.exists(thumbnails_dir): - return - - self._soft_cleanup(thumbnails_dir) - if check_max_size: - self._max_size_cleanup(thumbnails_dir) - - def _soft_cleanup(self, thumbnails_dir): - current_time = time.time() - for root, _, filenames in os.walk(thumbnails_dir): - for filename in filenames: - path = os.path.join(root, filename) - modification_time = os.path.getmtime(path) - if current_time - modification_time > self.days_alive: - os.remove(path) - - def _max_size_cleanup(self, thumbnails_dir): - files_info = self.get_thumbnails_dir_file_info() - size = self.get_thumbnails_dir_size(files_info) - if size < self.max_filesize: - return - - sorted_file_info = collections.deque( - sorted(files_info, key=lambda item: item.modification_time) - ) - diff = size - self.max_filesize - while diff > 0: - if not sorted_file_info: - break - - file_info = sorted_file_info.popleft() - diff -= file_info.size - os.remove(file_info.path) - - def get_thumbnail_filepath(self, project_name, thumbnail_id): - """Get thumbnail by thumbnail id. - - Args: - project_name (str): Name of project. - thumbnail_id (str): Thumbnail id. - - Returns: - Union[str, None]: Path to thumbnail image or None if thumbnail - is not cached yet. - """ - - if not thumbnail_id: - return None - - for ext in ( - ".png", - ".jpeg", - ): - filepath = os.path.join( - self.thumbnails_dir, project_name, thumbnail_id + ext - ) - if os.path.exists(filepath): - return filepath - return None - - def get_project_dir(self, project_name): - """Path to root directory for specific project. - - Args: - project_name (str): Name of project for which root directory path - should be returned. - - Returns: - str: Path to root of project's thumbnails. - """ - - return os.path.join(self.thumbnails_dir, project_name) - - def make_sure_project_dir_exists(self, project_name): - project_dir = self.get_project_dir(project_name) - if not os.path.exists(project_dir): - os.makedirs(project_dir) - return project_dir - - def store_thumbnail(self, project_name, thumbnail_id, content, mime_type): - """Store thumbnail to cache folder. - - Args: - project_name (str): Project where the thumbnail belong to. - thumbnail_id (str): Id of thumbnail. - content (bytes): Byte content of thumbnail file. - mime_data (str): Type of content. - - Returns: - str: Path to cached thumbnail image file. - """ - - if mime_type == "image/png": - ext = ".png" - elif mime_type == "image/jpeg": - ext = ".jpeg" - else: - raise ValueError( - "Unknown mime type for thumbnail \"{}\"".format(mime_type)) - - project_dir = self.make_sure_project_dir_exists(project_name) - thumbnail_path = os.path.join(project_dir, thumbnail_id + ext) - with open(thumbnail_path, "wb") as stream: - stream.write(content) - - current_time = time.time() - os.utime(thumbnail_path, (current_time, current_time)) - - return thumbnail_path diff --git a/openpype/vendor/python/common/ayon_api/utils.py b/openpype/vendor/python/common/ayon_api/utils.py index 93822a58ac..314d13faec 100644 --- a/openpype/vendor/python/common/ayon_api/utils.py +++ b/openpype/vendor/python/common/ayon_api/utils.py @@ -27,6 +27,45 @@ RepresentationParents = collections.namedtuple( ) +class ThumbnailContent: + """Wrapper for thumbnail content. + + Args: + project_name (str): Project name. + thumbnail_id (Union[str, None]): Thumbnail id. + content_type (Union[str, None]): Content type e.g. 'image/png'. + content (Union[bytes, None]): Thumbnail content. + """ + + def __init__(self, project_name, thumbnail_id, content, content_type): + self.project_name = project_name + self.thumbnail_id = thumbnail_id + self.content_type = content_type + self.content = content or b"" + + @property + def id(self): + """Wrapper for thumbnail id. + + Returns: + + """ + + return self.thumbnail_id + + @property + def is_valid(self): + """Content of thumbnail is valid. + + Returns: + bool: Content is valid and can be used. + """ + return ( + self.thumbnail_id is not None + and self.content_type is not None + ) + + def prepare_query_string(key_values): """Prepare data to query string. diff --git a/openpype/vendor/python/common/ayon_api/version.py b/openpype/vendor/python/common/ayon_api/version.py index 93024ea5f2..df841e0829 100644 --- a/openpype/vendor/python/common/ayon_api/version.py +++ b/openpype/vendor/python/common/ayon_api/version.py @@ -1,2 +1,2 @@ """Package declaring Python API for Ayon server.""" -__version__ = "0.3.3" +__version__ = "0.3.5"