diff --git a/client/ayon_core/host/__init__.py b/client/ayon_core/host/__init__.py index 950c14564e..7d5918b0ac 100644 --- a/client/ayon_core/host/__init__.py +++ b/client/ayon_core/host/__init__.py @@ -1,5 +1,5 @@ from .constants import ContextChangeReason -from .abstract import AbstractHost +from .abstract import AbstractHost, ApplicationInformation from .host import ( HostBase, ContextChangeData, @@ -21,6 +21,7 @@ __all__ = ( "ContextChangeReason", "AbstractHost", + "ApplicationInformation", "HostBase", "ContextChangeData", diff --git a/client/ayon_core/host/abstract.py b/client/ayon_core/host/abstract.py index 26771aaffa..7b4bb5b791 100644 --- a/client/ayon_core/host/abstract.py +++ b/client/ayon_core/host/abstract.py @@ -2,6 +2,7 @@ from __future__ import annotations import logging from abc import ABC, abstractmethod +from dataclasses import dataclass import typing from typing import Optional, Any @@ -13,6 +14,19 @@ if typing.TYPE_CHECKING: from .typing import HostContextData +@dataclass +class ApplicationInformation: + """Application information. + + Attributes: + app_name (Optional[str]): Application name. e.g. Maya, NukeX, Nuke + app_version (Optional[str]): Application version. e.g. 15.2.1 + + """ + app_name: Optional[str] = None + app_version: Optional[str] = None + + class AbstractHost(ABC): """Abstract definition of host implementation.""" @property @@ -26,6 +40,16 @@ class AbstractHost(ABC): """Host name.""" pass + @abstractmethod + def get_app_information(self) -> ApplicationInformation: + """Information about the application where host is running. + + Returns: + ApplicationInformation: Application information. + + """ + pass + @abstractmethod def get_current_context(self) -> HostContextData: """Get the current context of the host. diff --git a/client/ayon_core/host/host.py b/client/ayon_core/host/host.py index 28cb6b0a09..7d6d3ddbe4 100644 --- a/client/ayon_core/host/host.py +++ b/client/ayon_core/host/host.py @@ -12,7 +12,7 @@ import ayon_api from ayon_core.lib import emit_event from .constants import ContextChangeReason -from .abstract import AbstractHost +from .abstract import AbstractHost, ApplicationInformation if typing.TYPE_CHECKING: from ayon_core.pipeline import Anatomy @@ -96,6 +96,18 @@ class HostBase(AbstractHost): pass + def get_app_information(self) -> ApplicationInformation: + """Running application information. + + Host integration should override this method and return correct + information. + + Returns: + ApplicationInformation: Application information. + + """ + return ApplicationInformation() + def install(self): """Install host specific functionality. diff --git a/client/ayon_core/host/interfaces/workfiles.py b/client/ayon_core/host/interfaces/workfiles.py index 93aad4c117..5dbf29bd7b 100644 --- a/client/ayon_core/host/interfaces/workfiles.py +++ b/client/ayon_core/host/interfaces/workfiles.py @@ -55,7 +55,7 @@ class _WorkfileOptionalData: ): if kwargs: cls_name = self.__class__.__name__ - keys = ", ".join(['"{}"'.format(k) for k in kwargs.keys()]) + keys = ", ".join([f'"{k}"' for k in kwargs.keys()]) warnings.warn( f"Unknown keywords passed to {cls_name}: {keys}", ) @@ -1554,6 +1554,27 @@ class IWorkfileHost(AbstractHost): if platform.system().lower() == "windows": rootless_path = rootless_path.replace("\\", "/") + # Get application information + app_info = self.get_app_information() + data = {} + if app_info.app_name: + data["app_name"] = app_info.app_name + if app_info.app_version: + data["app_version"] = app_info.app_version + + # Use app group and app variant from applications addon (if available) + app_addon_name = os.environ.get("AYON_APP_NAME") + if not app_addon_name: + app_addon_name = None + + app_addon_tools_s = os.environ.get("AYON_APP_TOOLS") + app_addon_tools = [] + if app_addon_tools_s: + app_addon_tools = app_addon_tools_s.split(";") + + data["ayon_app_name"] = app_addon_name + data["ayon_app_tools"] = app_addon_tools + workfile_info = save_workfile_info( project_name, save_workfile_context.task_entity["id"], @@ -1562,6 +1583,7 @@ class IWorkfileHost(AbstractHost): version, comment, description, + data=data, workfile_entities=save_workfile_context.workfile_entities, ) return workfile_info diff --git a/client/ayon_core/pipeline/load/plugins.py b/client/ayon_core/pipeline/load/plugins.py index 48e860e834..ed963110c6 100644 --- a/client/ayon_core/pipeline/load/plugins.py +++ b/client/ayon_core/pipeline/load/plugins.py @@ -2,10 +2,10 @@ from __future__ import annotations from abc import abstractmethod -import logging import os from typing import Any, Optional, Type +from ayon_core.lib import Logger from ayon_core.pipeline.plugin_discover import ( deregister_plugin, deregister_plugin_path, @@ -31,8 +31,7 @@ class LoaderPlugin(list): options = [] - log = logging.getLogger("ProductLoader") - log.propagate = True + log = Logger.get_logger("ProductLoader") @classmethod def apply_settings(cls, project_settings): diff --git a/client/ayon_core/pipeline/load/utils.py b/client/ayon_core/pipeline/load/utils.py index 836fc5e096..d1731d4cf9 100644 --- a/client/ayon_core/pipeline/load/utils.py +++ b/client/ayon_core/pipeline/load/utils.py @@ -9,7 +9,7 @@ from typing import Optional, Union, Any import ayon_api -from ayon_core.host import ILoadHost +from ayon_core.host import ILoadHost, AbstractHost from ayon_core.lib import ( StringTemplate, TemplateUnsolved, @@ -942,15 +942,21 @@ def any_outdated_containers(host=None, project_name=None): return False -def get_outdated_containers(host=None, project_name=None): +def get_outdated_containers( + host: Optional[AbstractHost] = None, + project_name: Optional[str] = None, + ignore_locked_versions: bool = False, +): """Collect outdated containers from host scene. Currently registered host and project in global session are used if arguments are not passed. Args: - host (ModuleType): Host implementation with 'ls' function available. - project_name (str): Name of project in which context we are. + host (Optional[AbstractHost]): Host implementation. + project_name (Optional[str]): Name of project in which context we are. + ignore_locked_versions (bool): Locked versions are ignored. + """ from ayon_core.pipeline import registered_host, get_current_project_name @@ -964,7 +970,16 @@ def get_outdated_containers(host=None, project_name=None): containers = host.get_containers() else: containers = host.ls() - return filter_containers(containers, project_name).outdated + + outdated_containers = [] + for container in filter_containers(containers, project_name).outdated: + if ( + not ignore_locked_versions + and container.get("version_locked") is True + ): + continue + outdated_containers.append(container) + return outdated_containers def _is_valid_representation_id(repre_id: Any) -> bool: @@ -985,6 +1000,9 @@ def filter_containers(containers, project_name): 'invalid' are invalid containers (invalid content) and 'not_found' has some missing entity in database. + Todos: + Respect 'project_name' on containers if is available. + Args: containers (Iterable[dict]): List of containers referenced into scene. project_name (str): Name of project in which context shoud look for @@ -993,8 +1011,8 @@ def filter_containers(containers, project_name): Returns: ContainersFilterResult: Named tuple with 'latest', 'outdated', 'invalid' and 'not_found' containers. - """ + """ # Make sure containers is list that won't change containers = list(containers) @@ -1042,13 +1060,13 @@ def filter_containers(containers, project_name): hero=True, fields={"id", "productId", "version"} ) - verisons_by_id = {} + versions_by_id = {} versions_by_product_id = collections.defaultdict(list) hero_version_ids = set() for version_entity in version_entities: version_id = version_entity["id"] # Store versions by their ids - verisons_by_id[version_id] = version_entity + versions_by_id[version_id] = version_entity # There's no need to query products for hero versions # - they are considered as latest? if version_entity["version"] < 0: @@ -1083,24 +1101,23 @@ def filter_containers(containers, project_name): repre_entity = repre_entities_by_id.get(repre_id) if not repre_entity: - log.debug(( - "Container '{}' has an invalid representation." + log.debug( + f"Container '{container_name}' has an invalid representation." " It is missing in the database." - ).format(container_name)) + ) not_found_containers.append(container) continue version_id = repre_entity["versionId"] - if version_id in outdated_version_ids: - outdated_containers.append(container) - - elif version_id not in verisons_by_id: - log.debug(( - "Representation on container '{}' has an invalid version." - " It is missing in the database." - ).format(container_name)) + if version_id not in versions_by_id: + log.debug( + f"Representation on container '{container_name}' has an" + " invalid version. It is missing in the database." + ) not_found_containers.append(container) + elif version_id in outdated_version_ids: + outdated_containers.append(container) else: uptodate_containers.append(container) diff --git a/client/ayon_core/pipeline/workfile/utils.py b/client/ayon_core/pipeline/workfile/utils.py index 6666853998..c2b6fad660 100644 --- a/client/ayon_core/pipeline/workfile/utils.py +++ b/client/ayon_core/pipeline/workfile/utils.py @@ -207,6 +207,7 @@ def save_workfile_info( comment: Optional[str] = None, description: Optional[str] = None, username: Optional[str] = None, + data: Optional[dict[str, Any]] = None, workfile_entities: Optional[list[dict[str, Any]]] = None, ) -> dict[str, Any]: """Save workfile info entity for a workfile path. @@ -221,6 +222,7 @@ def save_workfile_info( description (Optional[str]): Workfile description. username (Optional[str]): Username of user who saves the workfile. If not provided, current user is used. + data (Optional[dict[str, Any]]): Additional workfile entity data. workfile_entities (Optional[list[dict[str, Any]]]): Pre-fetched workfile entities related to task. @@ -246,6 +248,18 @@ def save_workfile_info( if username is None: username = get_ayon_username() + attrib = {} + extension = os.path.splitext(rootless_path)[1] + for key, value in ( + ("extension", extension), + ("description", description), + ): + if value is not None: + attrib[key] = value + + if data is None: + data = {} + if not workfile_entity: return _create_workfile_info_entity( project_name, @@ -255,34 +269,38 @@ def save_workfile_info( username, version, comment, - description, + attrib, + data, ) - data = { - key: value - for key, value in ( - ("host_name", host_name), - ("version", version), - ("comment", comment), - ) - if value is not None - } - - old_data = workfile_entity["data"] + for key, value in ( + ("host_name", host_name), + ("version", version), + ("comment", comment), + ): + if value is not None: + data[key] = value changed_data = {} + old_data = workfile_entity["data"] for key, value in data.items(): if key not in old_data or old_data[key] != value: changed_data[key] = value + workfile_entity["data"][key] = value + + changed_attrib = {} + old_attrib = workfile_entity["attrib"] + for key, value in attrib.items(): + if key not in old_attrib or old_attrib[key] != value: + changed_attrib[key] = value + workfile_entity["attrib"][key] = value update_data = {} if changed_data: update_data["data"] = changed_data - old_description = workfile_entity["attrib"].get("description") - if description is not None and old_description != description: - update_data["attrib"] = {"description": description} - workfile_entity["attrib"]["description"] = description + if changed_attrib: + update_data["attrib"] = changed_attrib # Automatically fix 'createdBy' and 'updatedBy' fields # NOTE both fields were not automatically filled by server @@ -749,7 +767,8 @@ def _create_workfile_info_entity( username: str, version: Optional[int], comment: Optional[str], - description: Optional[str], + attrib: dict[str, Any], + data: dict[str, Any], ) -> dict[str, Any]: """Create workfile entity data. @@ -761,27 +780,18 @@ def _create_workfile_info_entity( username (str): Username. version (Optional[int]): Workfile version. comment (Optional[str]): Workfile comment. - description (Optional[str]): Workfile description. + attrib (dict[str, Any]): Workfile entity attributes. + data (dict[str, Any]): Workfile entity data. Returns: dict[str, Any]: Created workfile entity data. """ - extension = os.path.splitext(rootless_path)[1] - - attrib = {} - for key, value in ( - ("extension", extension), - ("description", description), - ): - if value is not None: - attrib[key] = value - - data = { + data.update({ "host_name": host_name, "version": version, "comment": comment, - } + }) workfile_info = { "id": uuid.uuid4().hex, diff --git a/client/ayon_core/tools/sceneinventory/delegates.py b/client/ayon_core/tools/sceneinventory/delegates.py index 6f91587613..9bc4294fda 100644 --- a/client/ayon_core/tools/sceneinventory/delegates.py +++ b/client/ayon_core/tools/sceneinventory/delegates.py @@ -1,10 +1,14 @@ from qtpy import QtWidgets, QtCore, QtGui -from .model import VERSION_LABEL_ROLE +from ayon_core.tools.utils import get_qt_icon + +from .model import VERSION_LABEL_ROLE, CONTAINER_VERSION_LOCKED_ROLE class VersionDelegate(QtWidgets.QStyledItemDelegate): """A delegate that display version integer formatted as version string.""" + _locked_icon = None + def paint(self, painter, option, index): fg_color = index.data(QtCore.Qt.ForegroundRole) if fg_color: @@ -45,10 +49,35 @@ class VersionDelegate(QtWidgets.QStyledItemDelegate): QtWidgets.QStyle.PM_FocusFrameHMargin, option, option.widget ) + 1 + text_rect_f = text_rect.adjusted( + text_margin, 0, - text_margin, 0 + ) + painter.drawText( - text_rect.adjusted(text_margin, 0, - text_margin, 0), + text_rect_f, option.displayAlignment, text ) + if index.data(CONTAINER_VERSION_LOCKED_ROLE) is True: + icon = self._get_locked_icon() + size = max(text_rect_f.height() // 2, 16) + margin = (text_rect_f.height() - size) // 2 + + icon_rect = QtCore.QRect( + text_rect_f.right() - size, + text_rect_f.top() + margin, + size, + size + ) + icon.paint(painter, icon_rect) painter.restore() + + def _get_locked_icon(cls): + if cls._locked_icon is None: + cls._locked_icon = get_qt_icon({ + "type": "material-symbols", + "name": "lock", + "color": "white", + }) + return cls._locked_icon diff --git a/client/ayon_core/tools/sceneinventory/model.py b/client/ayon_core/tools/sceneinventory/model.py index 9977acea21..27211165bf 100644 --- a/client/ayon_core/tools/sceneinventory/model.py +++ b/client/ayon_core/tools/sceneinventory/model.py @@ -37,6 +37,7 @@ REMOTE_SITE_ICON_ROLE = QtCore.Qt.UserRole + 23 # containers inbetween refresh. ITEM_UNIQUE_NAME_ROLE = QtCore.Qt.UserRole + 24 PROJECT_NAME_ROLE = QtCore.Qt.UserRole + 25 +CONTAINER_VERSION_LOCKED_ROLE = QtCore.Qt.UserRole + 26 class InventoryModel(QtGui.QStandardItemModel): @@ -291,6 +292,10 @@ class InventoryModel(QtGui.QStandardItemModel): item.setData(container_item.object_name, OBJECT_NAME_ROLE) item.setData(True, IS_CONTAINER_ITEM_ROLE) item.setData(unique_name, ITEM_UNIQUE_NAME_ROLE) + item.setData( + container_item.version_locked, + CONTAINER_VERSION_LOCKED_ROLE + ) container_model_items.append(item) progress = progress_by_id[repre_id] diff --git a/client/ayon_core/tools/sceneinventory/models/containers.py b/client/ayon_core/tools/sceneinventory/models/containers.py index 47f74476de..0e19f381cd 100644 --- a/client/ayon_core/tools/sceneinventory/models/containers.py +++ b/client/ayon_core/tools/sceneinventory/models/containers.py @@ -95,7 +95,8 @@ class ContainerItem: namespace, object_name, item_id, - project_name + project_name, + version_locked, ): self.representation_id = representation_id self.loader_name = loader_name @@ -103,6 +104,7 @@ class ContainerItem: self.namespace = namespace self.item_id = item_id self.project_name = project_name + self.version_locked = version_locked @classmethod def from_container_data(cls, current_project_name, container): @@ -114,7 +116,8 @@ class ContainerItem: item_id=uuid.uuid4().hex, project_name=container.get( "project_name", current_project_name - ) + ), + version_locked=container.get("version_locked", False), ) diff --git a/client/ayon_core/tools/sceneinventory/view.py b/client/ayon_core/tools/sceneinventory/view.py index fdd1bdbe75..22bc170230 100644 --- a/client/ayon_core/tools/sceneinventory/view.py +++ b/client/ayon_core/tools/sceneinventory/view.py @@ -17,6 +17,7 @@ from ayon_core.tools.utils.lib import ( format_version, preserve_expanded_rows, preserve_selection, + get_qt_icon, ) from ayon_core.tools.utils.delegates import StatusDelegate @@ -46,7 +47,7 @@ class SceneInventoryView(QtWidgets.QTreeView): hierarchy_view_changed = QtCore.Signal(bool) def __init__(self, controller, parent): - super(SceneInventoryView, self).__init__(parent=parent) + super().__init__(parent=parent) # view settings self.setIndentation(12) @@ -524,7 +525,14 @@ class SceneInventoryView(QtWidgets.QTreeView): submenu = QtWidgets.QMenu("Actions", self) for action in custom_actions: color = action.color or DEFAULT_COLOR - icon = qtawesome.icon("fa.%s" % action.icon, color=color) + icon_def = action.icon + if not isinstance(action.icon, dict): + icon_def = { + "type": "awesome-font", + "name": icon_def, + "color": color, + } + icon = get_qt_icon(icon_def) action_item = QtWidgets.QAction(icon, action.label, submenu) action_item.triggered.connect( partial( @@ -622,7 +630,7 @@ class SceneInventoryView(QtWidgets.QTreeView): if isinstance(result, (list, set)): self._select_items_by_action(result) - if isinstance(result, dict): + elif isinstance(result, dict): self._select_items_by_action( result["objectNames"], result["options"] )