Merge branch 'develop' into enhancement/1443-allow-host-addons-to-store-dcc-version-in-workfile-metadata

This commit is contained in:
Jakub Trllo 2025-09-23 14:33:06 +02:00 committed by GitHub
commit c4aace810f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 665 additions and 106 deletions

View file

@ -35,6 +35,7 @@ body:
label: Version
description: What version are you running? Look to AYON Tray
options:
- 1.6.1
- 1.6.0
- 1.5.3
- 1.5.2

View file

@ -38,18 +38,20 @@ class AddLastWorkfileToLaunchArgs(PreLaunchHook):
launch_types = {LaunchTypes.local}
def execute(self):
if not self.data.get("start_last_workfile"):
self.log.info("It is set to not start last workfile on start.")
return
workfile_path = self.data.get("workfile_path")
if not workfile_path:
if not self.data.get("start_last_workfile"):
self.log.info("It is set to not start last workfile on start.")
return
last_workfile = self.data.get("last_workfile_path")
if not last_workfile:
self.log.warning("Last workfile was not collected.")
return
workfile_path = self.data.get("last_workfile_path")
if not workfile_path:
self.log.warning("Last workfile was not collected.")
return
if not os.path.exists(last_workfile):
if not os.path.exists(workfile_path):
self.log.info("Current context does not have any workfile yet.")
return
# Add path to workfile to arguments
self.launch_context.launch_args.append(last_workfile)
self.launch_context.launch_args.append(workfile_path)

View file

@ -37,16 +37,19 @@ class LauncherActionSelection:
project_name,
folder_id,
task_id,
workfile_id,
folder_path=None,
task_name=None,
project_entity=None,
folder_entity=None,
task_entity=None,
workfile_entity=None,
project_settings=None,
):
self._project_name = project_name
self._folder_id = folder_id
self._task_id = task_id
self._workfile_id = workfile_id
self._folder_path = folder_path
self._task_name = task_name
@ -54,6 +57,7 @@ class LauncherActionSelection:
self._project_entity = project_entity
self._folder_entity = folder_entity
self._task_entity = task_entity
self._workfile_entity = workfile_entity
self._project_settings = project_settings
@ -213,6 +217,15 @@ class LauncherActionSelection:
self._task_name = self.task_entity["name"]
return self._task_name
def get_workfile_id(self):
"""Selected workfile id.
Returns:
Union[str, None]: Selected workfile id.
"""
return self._workfile_id
def get_project_entity(self):
"""Project entity for the selection.
@ -259,6 +272,24 @@ class LauncherActionSelection:
)
return self._task_entity
def get_workfile_entity(self):
"""Workfile entity for the selection.
Returns:
Union[dict[str, Any], None]: Workfile entity.
"""
if (
self._project_name is None
or self._workfile_id is None
):
return None
if self._workfile_entity is None:
self._workfile_entity = ayon_api.get_workfile_info_by_id(
self._project_name, self._workfile_id
)
return self._workfile_entity
def get_project_settings(self):
"""Project settings for the selection.
@ -305,15 +336,27 @@ class LauncherActionSelection:
"""
return self._task_id is not None
@property
def is_workfile_selected(self):
"""Return whether a task is selected.
Returns:
bool: Whether a task is selected.
"""
return self._workfile_id is not None
project_name = property(get_project_name)
folder_id = property(get_folder_id)
task_id = property(get_task_id)
workfile_id = property(get_workfile_id)
folder_path = property(get_folder_path)
task_name = property(get_task_name)
project_entity = property(get_project_entity)
folder_entity = property(get_folder_entity)
task_entity = property(get_task_entity)
workfile_entity = property(get_workfile_entity)
class LauncherAction(object):

View file

@ -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):

View file

@ -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)

View file

@ -4,6 +4,7 @@ from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import Optional, Any
from ayon_core.addon import AddonsManager
from ayon_core.tools.common_models import (
ProjectItem,
FolderItem,
@ -20,6 +21,7 @@ class WebactionContext:
project_name: str
folder_id: str
task_id: str
workfile_id: str
addon_name: str
addon_version: str
@ -33,7 +35,7 @@ class ActionItem:
identifier (str): Unique identifier of action item.
order (int): Action ordering.
label (str): Action label.
variant_label (Union[str, None]): Variant label, full label is
variant_label (Optional[str]): Variant label, full label is
concatenated with space. Actions are grouped under single
action if it has same 'label' and have set 'variant_label'.
full_label (str): Full label, if not set it is generated
@ -56,6 +58,15 @@ class ActionItem:
addon_version: Optional[str] = None
@dataclass
class WorkfileItem:
workfile_id: str
filename: str
exists: bool
icon: Optional[str]
version: Optional[int]
class AbstractLauncherCommon(ABC):
@abstractmethod
def register_event_callback(self, topic, callback):
@ -85,12 +96,16 @@ class AbstractLauncherBackend(AbstractLauncherCommon):
pass
@abstractmethod
def get_addons_manager(self) -> AddonsManager:
pass
@abstractmethod
def get_project_settings(self, project_name):
"""Project settings for current project.
Args:
project_name (Union[str, None]): Project name.
project_name (Optional[str]): Project name.
Returns:
dict[str, Any]: Project settings.
@ -254,7 +269,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
"""Selected project name.
Returns:
Union[str, None]: Selected project name.
Optional[str]: Selected project name.
"""
pass
@ -264,7 +279,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
"""Selected folder id.
Returns:
Union[str, None]: Selected folder id.
Optional[str]: Selected folder id.
"""
pass
@ -274,7 +289,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
"""Selected task id.
Returns:
Union[str, None]: Selected task id.
Optional[str]: Selected task id.
"""
pass
@ -284,7 +299,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
"""Selected task name.
Returns:
Union[str, None]: Selected task name.
Optional[str]: Selected task name.
"""
pass
@ -302,7 +317,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
}
Returns:
dict[str, Union[str, None]]: Selected context.
dict[str, Optional[str]]: Selected context.
"""
pass
@ -312,7 +327,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
"""Change selected folder.
Args:
project_name (Union[str, None]): Project nameor None if no project
project_name (Optional[str]): Project nameor None if no project
is selected.
"""
@ -323,7 +338,7 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
"""Change selected folder.
Args:
folder_id (Union[str, None]): Folder id or None if no folder
folder_id (Optional[str]): Folder id or None if no folder
is selected.
"""
@ -336,14 +351,24 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
"""Change selected task.
Args:
task_id (Union[str, None]): Task id or None if no task
task_id (Optional[str]): Task id or None if no task
is selected.
task_name (Union[str, None]): Task name or None if no task
task_name (Optional[str]): Task name or None if no task
is selected.
"""
pass
@abstractmethod
def set_selected_workfile(self, workfile_id: Optional[str]):
"""Change selected workfile.
Args:
workfile_id (Optional[str]): Workfile id or None.
"""
pass
# Actions
@abstractmethod
def get_action_items(
@ -351,13 +376,15 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
project_name: Optional[str],
folder_id: Optional[str],
task_id: Optional[str],
workfile_id: Optional[str],
) -> list[ActionItem]:
"""Get action items for given context.
Args:
project_name (Union[str, None]): Project name.
folder_id (Union[str, None]): Folder id.
task_id (Union[str, None]): Task id.
project_name (Optional[str]): Project name.
folder_id (Optional[str]): Folder id.
task_id (Optional[str]): Task id.
workfile_id (Optional[str]): Workfile id.
Returns:
list[ActionItem]: List of action items that should be shown
@ -373,14 +400,16 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
project_name: Optional[str],
folder_id: Optional[str],
task_id: Optional[str],
workfile_id: Optional[str],
):
"""Trigger action on given context.
Args:
action_id (str): Action identifier.
project_name (Union[str, None]): Project name.
folder_id (Union[str, None]): Folder id.
task_id (Union[str, None]): Task id.
project_name (Optional[str]): Project name.
folder_id (Optional[str]): Folder id.
task_id (Optional[str]): Task id.
workfile_id (Optional[str]): Task id.
"""
pass
@ -465,3 +494,21 @@ class AbstractLauncherFrontEnd(AbstractLauncherCommon):
"""
pass
@abstractmethod
def get_workfile_items(
self,
project_name: Optional[str],
task_id: Optional[str],
) -> list[WorkfileItem]:
"""Get workfile items for a given context.
Args:
project_name (Optional[str]): Project name.
task_id (Optional[str]): Task id.
Returns:
list[WorkfileItem]: List of workfile items.
"""
pass

View file

@ -1,10 +1,21 @@
from typing import Optional
from ayon_core.lib import Logger, get_ayon_username
from ayon_core.lib.events import QueuedEventSystem
from ayon_core.addon import AddonsManager
from ayon_core.settings import get_project_settings, get_studio_settings
from ayon_core.tools.common_models import ProjectsModel, HierarchyModel
from .abstract import AbstractLauncherFrontEnd, AbstractLauncherBackend
from .models import LauncherSelectionModel, ActionsModel
from .abstract import (
AbstractLauncherFrontEnd,
AbstractLauncherBackend,
WorkfileItem,
)
from .models import (
LauncherSelectionModel,
ActionsModel,
WorkfilesModel,
)
NOT_SET = object()
@ -17,12 +28,15 @@ class BaseLauncherController(
self._event_system = None
self._log = None
self._addons_manager = None
self._username = NOT_SET
self._selection_model = LauncherSelectionModel(self)
self._projects_model = ProjectsModel(self)
self._hierarchy_model = HierarchyModel(self)
self._actions_model = ActionsModel(self)
self._workfiles_model = WorkfilesModel(self)
@property
def log(self):
@ -59,6 +73,11 @@ class BaseLauncherController(
def register_event_callback(self, topic, callback):
self.event_system.add_callback(topic, callback)
def get_addons_manager(self) -> AddonsManager:
if self._addons_manager is None:
self._addons_manager = AddonsManager()
return self._addons_manager
# Entity items for UI
def get_project_items(self, sender=None):
return self._projects_model.get_project_items(sender)
@ -125,6 +144,9 @@ class BaseLauncherController(
def set_selected_task(self, task_id, task_name):
self._selection_model.set_selected_task(task_id, task_name)
def set_selected_workfile(self, workfile_id):
self._selection_model.set_selected_workfile(workfile_id)
def get_selected_context(self):
return {
"project_name": self.get_selected_project_name(),
@ -133,10 +155,24 @@ class BaseLauncherController(
"task_name": self.get_selected_task_name(),
}
# Workfiles
def get_workfile_items(
self,
project_name: Optional[str],
task_id: Optional[str],
) -> list[WorkfileItem]:
return self._workfiles_model.get_workfile_items(
project_name,
task_id,
)
# Actions
def get_action_items(self, project_name, folder_id, task_id):
def get_action_items(
self, project_name, folder_id, task_id, workfile_id
):
return self._actions_model.get_action_items(
project_name, folder_id, task_id)
project_name, folder_id, task_id, workfile_id
)
def trigger_action(
self,
@ -144,12 +180,14 @@ class BaseLauncherController(
project_name,
folder_id,
task_id,
workfile_id,
):
self._actions_model.trigger_action(
identifier,
project_name,
folder_id,
task_id,
workfile_id,
)
def trigger_webaction(self, context, action_label, form_data=None):
@ -186,6 +224,8 @@ class BaseLauncherController(
self._projects_model.reset()
# Refresh actions
self._actions_model.refresh()
# Reset workfiles model
self._workfiles_model.reset()
self._emit_event("controller.refresh.actions.finished")

View file

@ -1,8 +1,10 @@
from .actions import ActionsModel
from .selection import LauncherSelectionModel
from .workfiles import WorkfilesModel
__all__ = (
"ActionsModel",
"LauncherSelectionModel",
"WorkfilesModel",
)

View file

@ -15,7 +15,6 @@ from ayon_core.lib import (
get_settings_variant,
run_detached_ayon_launcher_process,
)
from ayon_core.addon import AddonsManager
from ayon_core.pipeline.actions import (
discover_launcher_actions,
LauncherActionSelection,
@ -104,8 +103,6 @@ class ActionsModel:
levels=2, default_factory=list, lifetime=20,
)
self._addons_manager = None
self._variant = get_settings_variant()
@staticmethod
@ -131,19 +128,28 @@ class ActionsModel:
self._get_action_objects()
self._controller.emit_event("actions.refresh.finished")
def get_action_items(self, project_name, folder_id, task_id):
def get_action_items(
self,
project_name: Optional[str],
folder_id: Optional[str],
task_id: Optional[str],
workfile_id: Optional[str],
) -> list[ActionItem]:
"""Get actions for project.
Args:
project_name (Union[str, None]): Project name.
folder_id (Union[str, None]): Folder id.
task_id (Union[str, None]): Task id.
project_name (Optional[str]): Project name.
folder_id (Optional[str]): Folder id.
task_id (Optional[str]): Task id.
workfile_id (Optional[str]): Workfile id.
Returns:
list[ActionItem]: List of actions.
"""
selection = self._prepare_selection(project_name, folder_id, task_id)
selection = self._prepare_selection(
project_name, folder_id, task_id, workfile_id
)
output = []
action_items = self._get_action_items(project_name)
for identifier, action in self._get_action_objects().items():
@ -159,8 +165,11 @@ class ActionsModel:
project_name,
folder_id,
task_id,
workfile_id,
):
selection = self._prepare_selection(project_name, folder_id, task_id)
selection = self._prepare_selection(
project_name, folder_id, task_id, workfile_id
)
failed = False
error_message = None
action_label = identifier
@ -202,11 +211,15 @@ class ActionsModel:
identifier = context.identifier
folder_id = context.folder_id
task_id = context.task_id
workfile_id = context.workfile_id
project_name = context.project_name
addon_name = context.addon_name
addon_version = context.addon_version
if task_id:
if workfile_id:
entity_type = "workfile"
entity_ids.append(workfile_id)
elif task_id:
entity_type = "task"
entity_ids.append(task_id)
elif folder_id:
@ -272,6 +285,7 @@ class ActionsModel:
"project_name": project_name,
"folder_id": folder_id,
"task_id": task_id,
"workfile_id": workfile_id,
"addon_name": addon_name,
"addon_version": addon_version,
})
@ -282,7 +296,10 @@ class ActionsModel:
def get_action_config_values(self, context: WebactionContext):
selection = self._prepare_selection(
context.project_name, context.folder_id, context.task_id
context.project_name,
context.folder_id,
context.task_id,
context.workfile_id,
)
if not selection.is_project_selected:
return {}
@ -309,7 +326,10 @@ class ActionsModel:
def set_action_config_values(self, context, values):
selection = self._prepare_selection(
context.project_name, context.folder_id, context.task_id
context.project_name,
context.folder_id,
context.task_id,
context.workfile_id,
)
if not selection.is_project_selected:
return {}
@ -333,12 +353,9 @@ class ActionsModel:
exc_info=True
)
def _get_addons_manager(self):
if self._addons_manager is None:
self._addons_manager = AddonsManager()
return self._addons_manager
def _prepare_selection(self, project_name, folder_id, task_id):
def _prepare_selection(
self, project_name, folder_id, task_id, workfile_id
):
project_entity = None
if project_name:
project_entity = self._controller.get_project_entity(project_name)
@ -347,6 +364,7 @@ class ActionsModel:
project_name,
folder_id,
task_id,
workfile_id,
project_entity=project_entity,
project_settings=project_settings,
)
@ -355,7 +373,12 @@ class ActionsModel:
entity_type = None
entity_id = None
entity_subtypes = []
if selection.is_task_selected:
if selection.is_workfile_selected:
entity_type = "workfile"
entity_id = selection.workfile_id
entity_subtypes = []
elif selection.is_task_selected:
entity_type = "task"
entity_id = selection.task_entity["id"]
entity_subtypes = [selection.task_entity["taskType"]]
@ -400,7 +423,7 @@ class ActionsModel:
try:
# 'variant' query is supported since AYON backend 1.10.4
query = urlencode({"variant": self._variant})
query = urlencode({"variant": self._variant, "mode": "all"})
response = ayon_api.post(
f"actions/list?{query}", **request_data
)
@ -542,7 +565,7 @@ class ActionsModel:
# NOTE We don't need to register the paths, but that would
# require to change discovery logic and deprecate all functions
# related to registering and discovering launcher actions.
addons_manager = self._get_addons_manager()
addons_manager = self._controller.get_addons_manager()
actions_paths = addons_manager.collect_launcher_action_paths()
for path in actions_paths:
if path and os.path.exists(path):

View file

@ -1,26 +1,37 @@
class LauncherSelectionModel(object):
from __future__ import annotations
import typing
from typing import Optional
if typing.TYPE_CHECKING:
from ayon_core.tools.launcher.abstract import AbstractLauncherBackend
class LauncherSelectionModel:
"""Model handling selection changes.
Triggering events:
- "selection.project.changed"
- "selection.folder.changed"
- "selection.task.changed"
- "selection.workfile.changed"
"""
event_source = "launcher.selection.model"
def __init__(self, controller):
def __init__(self, controller: AbstractLauncherBackend) -> None:
self._controller = controller
self._project_name = None
self._folder_id = None
self._task_name = None
self._task_id = None
self._workfile_id = None
def get_selected_project_name(self):
def get_selected_project_name(self) -> Optional[str]:
return self._project_name
def set_selected_project(self, project_name):
def set_selected_project(self, project_name: Optional[str]) -> None:
if project_name == self._project_name:
return
@ -31,10 +42,10 @@ class LauncherSelectionModel(object):
self.event_source
)
def get_selected_folder_id(self):
def get_selected_folder_id(self) -> Optional[str]:
return self._folder_id
def set_selected_folder(self, folder_id):
def set_selected_folder(self, folder_id: Optional[str]) -> None:
if folder_id == self._folder_id:
return
@ -48,13 +59,15 @@ class LauncherSelectionModel(object):
self.event_source
)
def get_selected_task_name(self):
def get_selected_task_name(self) -> Optional[str]:
return self._task_name
def get_selected_task_id(self):
def get_selected_task_id(self) -> Optional[str]:
return self._task_id
def set_selected_task(self, task_id, task_name):
def set_selected_task(
self, task_id: Optional[str], task_name: Optional[str]
) -> None:
if task_id == self._task_id:
return
@ -70,3 +83,23 @@ class LauncherSelectionModel(object):
},
self.event_source
)
def get_selected_workfile(self) -> Optional[str]:
return self._workfile_id
def set_selected_workfile(self, workfile_id: Optional[str]) -> None:
if workfile_id == self._workfile_id:
return
self._workfile_id = workfile_id
self._controller.emit_event(
"selection.workfile.changed",
{
"project_name": self._project_name,
"folder_id": self._folder_id,
"task_name": self._task_name,
"task_id": self._task_id,
"workfile_id": workfile_id,
},
self.event_source
)

View file

@ -0,0 +1,102 @@
import os
from typing import Optional, Any
import ayon_api
from ayon_core.lib import (
Logger,
NestedCacheItem,
)
from ayon_core.pipeline import Anatomy
from ayon_core.tools.launcher.abstract import (
WorkfileItem,
AbstractLauncherBackend,
)
class WorkfilesModel:
def __init__(self, controller: AbstractLauncherBackend):
self._controller = controller
self._log = Logger.get_logger(self.__class__.__name__)
self._host_icons = None
self._workfile_items = NestedCacheItem(
levels=2, default_factory=list, lifetime=60,
)
def reset(self) -> None:
self._workfile_items.reset()
def get_workfile_items(
self,
project_name: Optional[str],
task_id: Optional[str],
) -> list[WorkfileItem]:
if not project_name or not task_id:
return []
cache = self._workfile_items[project_name][task_id]
if cache.is_valid:
return cache.get_data()
project_entity = self._controller.get_project_entity(project_name)
anatomy = Anatomy(project_name, project_entity=project_entity)
items = []
for workfile_entity in ayon_api.get_workfiles_info(
project_name, task_ids={task_id}, fields={"id", "path", "data"}
):
rootless_path = workfile_entity["path"]
exists = False
try:
path = anatomy.fill_root(rootless_path)
exists = os.path.exists(path)
except Exception:
self._log.warning(
"Failed to fill root for workfile path",
exc_info=True,
)
workfile_data = workfile_entity["data"]
host_name = workfile_data.get("host_name")
version = workfile_data.get("version")
items.append(WorkfileItem(
workfile_id=workfile_entity["id"],
filename=os.path.basename(rootless_path),
exists=exists,
icon=self._get_host_icon(host_name),
version=version,
))
cache.update_data(items)
return items
def _get_host_icon(
self, host_name: Optional[str]
) -> Optional[dict[str, Any]]:
if self._host_icons is None:
host_icons = {}
try:
host_icons = self._get_host_icons()
except Exception:
self._log.warning(
"Failed to get host icons",
exc_info=True,
)
self._host_icons = host_icons
return self._host_icons.get(host_name)
def _get_host_icons(self) -> dict[str, Any]:
addons_manager = self._controller.get_addons_manager()
applications_addon = addons_manager["applications"]
apps_manager = applications_addon.get_applications_manager()
output = {}
for app_group in apps_manager.app_groups.values():
host_name = app_group.host_name
icon_filename = app_group.icon
if not host_name or not icon_filename:
continue
icon_url = applications_addon.get_app_icon_url(
icon_filename, server=True
)
output[host_name] = icon_url
return output

View file

@ -136,6 +136,10 @@ class ActionsQtModel(QtGui.QStandardItemModel):
"selection.task.changed",
self._on_selection_task_changed,
)
controller.register_event_callback(
"selection.workfile.changed",
self._on_selection_workfile_changed,
)
self._controller = controller
@ -146,6 +150,7 @@ class ActionsQtModel(QtGui.QStandardItemModel):
self._selected_project_name = None
self._selected_folder_id = None
self._selected_task_id = None
self._selected_workfile_id = None
def get_selected_project_name(self):
return self._selected_project_name
@ -156,6 +161,9 @@ class ActionsQtModel(QtGui.QStandardItemModel):
def get_selected_task_id(self):
return self._selected_task_id
def get_selected_workfile_id(self):
return self._selected_workfile_id
def get_group_items(self, action_id):
return self._groups_by_id[action_id]
@ -194,6 +202,7 @@ class ActionsQtModel(QtGui.QStandardItemModel):
self._selected_project_name,
self._selected_folder_id,
self._selected_task_id,
self._selected_workfile_id,
)
if not items:
self._clear_items()
@ -286,18 +295,28 @@ class ActionsQtModel(QtGui.QStandardItemModel):
self._selected_project_name = event["project_name"]
self._selected_folder_id = None
self._selected_task_id = None
self._selected_workfile_id = None
self.refresh()
def _on_selection_folder_changed(self, event):
self._selected_project_name = event["project_name"]
self._selected_folder_id = event["folder_id"]
self._selected_task_id = None
self._selected_workfile_id = None
self.refresh()
def _on_selection_task_changed(self, event):
self._selected_project_name = event["project_name"]
self._selected_folder_id = event["folder_id"]
self._selected_task_id = event["task_id"]
self._selected_workfile_id = None
self.refresh()
def _on_selection_workfile_changed(self, event):
self._selected_project_name = event["project_name"]
self._selected_folder_id = event["folder_id"]
self._selected_task_id = event["task_id"]
self._selected_workfile_id = event["workfile_id"]
self.refresh()
@ -578,9 +597,6 @@ class ActionMenuPopup(QtWidgets.QWidget):
if not index or not index.isValid():
return
if not index.data(ACTION_HAS_CONFIGS_ROLE):
return
action_id = index.data(ACTION_ID_ROLE)
self.action_triggered.emit(action_id)
@ -970,6 +986,7 @@ class ActionsWidget(QtWidgets.QWidget):
event["project_name"],
event["folder_id"],
event["task_id"],
event["workfile_id"],
event["addon_name"],
event["addon_version"],
),
@ -1050,24 +1067,26 @@ class ActionsWidget(QtWidgets.QWidget):
project_name = self._model.get_selected_project_name()
folder_id = self._model.get_selected_folder_id()
task_id = self._model.get_selected_task_id()
workfile_id = self._model.get_selected_workfile_id()
action_item = self._model.get_action_item_by_id(action_id)
if action_item.action_type == "webaction":
action_item = self._model.get_action_item_by_id(action_id)
context = WebactionContext(
action_id,
project_name,
folder_id,
task_id,
action_item.addon_name,
action_item.addon_version
identifier=action_id,
project_name=project_name,
folder_id=folder_id,
task_id=task_id,
workfile_id=workfile_id,
addon_name=action_item.addon_name,
addon_version=action_item.addon_version,
)
self._controller.trigger_webaction(
context, action_item.full_label
)
else:
self._controller.trigger_action(
action_id, project_name, folder_id, task_id
action_id, project_name, folder_id, task_id, workfile_id
)
if index is None:
@ -1087,11 +1106,13 @@ class ActionsWidget(QtWidgets.QWidget):
project_name = self._model.get_selected_project_name()
folder_id = self._model.get_selected_folder_id()
task_id = self._model.get_selected_task_id()
workfile_id = self._model.get_selected_workfile_id()
context = WebactionContext(
action_id,
identifier=action_id,
project_name=project_name,
folder_id=folder_id,
task_id=task_id,
workfile_id=workfile_id,
addon_name=action_item.addon_name,
addon_version=action_item.addon_version,
)

View file

@ -12,6 +12,8 @@ from ayon_core.tools.utils import (
)
from ayon_core.tools.utils.lib import checkstate_int_to_enum
from .workfiles_page import WorkfilesPage
class HierarchyPage(QtWidgets.QWidget):
def __init__(self, controller, parent):
@ -73,10 +75,15 @@ class HierarchyPage(QtWidgets.QWidget):
# - Tasks widget
tasks_widget = TasksWidget(controller, content_body)
# - Third page - Workfiles
workfiles_page = WorkfilesPage(controller, content_body)
content_body.addWidget(folders_widget)
content_body.addWidget(tasks_widget)
content_body.setStretchFactor(0, 100)
content_body.setStretchFactor(1, 65)
content_body.addWidget(workfiles_page)
content_body.setStretchFactor(0, 120)
content_body.setStretchFactor(1, 85)
content_body.setStretchFactor(2, 220)
main_layout = QtWidgets.QVBoxLayout(self)
main_layout.setContentsMargins(0, 0, 0, 0)
@ -99,6 +106,7 @@ class HierarchyPage(QtWidgets.QWidget):
self._my_tasks_checkbox = my_tasks_checkbox
self._folders_widget = folders_widget
self._tasks_widget = tasks_widget
self._workfiles_page = workfiles_page
self._project_name = None
@ -117,6 +125,7 @@ class HierarchyPage(QtWidgets.QWidget):
def refresh(self):
self._folders_widget.refresh()
self._tasks_widget.refresh()
self._workfiles_page.refresh()
self._on_my_tasks_checkbox_state_changed(
self._my_tasks_checkbox.checkState()
)

View file

@ -177,7 +177,7 @@ class LauncherWindow(QtWidgets.QWidget):
self._page_slide_anim = page_slide_anim
hierarchy_page.setVisible(not self._is_on_projects_page)
self.resize(520, 740)
self.resize(920, 740)
def showEvent(self, event):
super().showEvent(event)

View file

@ -0,0 +1,175 @@
from typing import Optional
import ayon_api
from qtpy import QtCore, QtWidgets, QtGui
from ayon_core.tools.utils import get_qt_icon
from ayon_core.tools.launcher.abstract import AbstractLauncherFrontEnd
VERSION_ROLE = QtCore.Qt.UserRole + 1
WORKFILE_ID_ROLE = QtCore.Qt.UserRole + 2
class WorkfilesModel(QtGui.QStandardItemModel):
refreshed = QtCore.Signal()
def __init__(self, controller: AbstractLauncherFrontEnd) -> None:
super().__init__()
self.setColumnCount(1)
self.setHeaderData(0, QtCore.Qt.Horizontal, "Workfiles")
controller.register_event_callback(
"selection.project.changed",
self._on_selection_project_changed,
)
controller.register_event_callback(
"selection.folder.changed",
self._on_selection_folder_changed,
)
controller.register_event_callback(
"selection.task.changed",
self._on_selection_task_changed,
)
self._controller = controller
self._selected_project_name = None
self._selected_folder_id = None
self._selected_task_id = None
self._transparent_icon = None
self._cached_icons = {}
def refresh(self) -> None:
root_item = self.invisibleRootItem()
root_item.removeRows(0, root_item.rowCount())
workfile_items = self._controller.get_workfile_items(
self._selected_project_name, self._selected_task_id
)
new_items = []
for workfile_item in workfile_items:
icon = self._get_icon(workfile_item.icon)
item = QtGui.QStandardItem(workfile_item.filename)
item.setData(icon, QtCore.Qt.DecorationRole)
item.setData(workfile_item.version, VERSION_ROLE)
item.setData(workfile_item.workfile_id, WORKFILE_ID_ROLE)
flags = QtCore.Qt.NoItemFlags
if workfile_item.exists:
flags = QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable
item.setFlags(flags)
new_items.append(item)
if not new_items:
title = "< No workfiles >"
if not self._selected_project_name:
title = "< Select a project >"
elif not self._selected_folder_id:
title = "< Select a folder >"
elif not self._selected_task_id:
title = "< Select a task >"
item = QtGui.QStandardItem(title)
item.setFlags(QtCore.Qt.NoItemFlags)
new_items.append(item)
root_item.appendRows(new_items)
self.refreshed.emit()
def _on_selection_project_changed(self, event) -> None:
self._selected_project_name = event["project_name"]
self._selected_folder_id = None
self._selected_task_id = None
self.refresh()
def _on_selection_folder_changed(self, event) -> None:
self._selected_project_name = event["project_name"]
self._selected_folder_id = event["folder_id"]
self._selected_task_id = None
self.refresh()
def _on_selection_task_changed(self, event) -> None:
self._selected_project_name = event["project_name"]
self._selected_folder_id = event["folder_id"]
self._selected_task_id = event["task_id"]
self.refresh()
def _get_transparent_icon(self) -> QtGui.QIcon:
if self._transparent_icon is None:
self._transparent_icon = get_qt_icon({
"type": "transparent", "size": 256
})
return self._transparent_icon
def _get_icon(self, icon_url: Optional[str]) -> QtGui.QIcon:
if icon_url is None:
return self._get_transparent_icon()
icon = self._cached_icons.get(icon_url)
if icon is not None:
return icon
base_url = ayon_api.get_base_url()
if icon_url.startswith(base_url):
icon_def = {
"type": "ayon_url",
"url": icon_url[len(base_url) + 1:],
}
else:
icon_def = {
"type": "url",
"url": icon_url,
}
icon = get_qt_icon(icon_def)
if icon is None:
icon = self._get_transparent_icon()
self._cached_icons[icon_url] = icon
return icon
class WorkfilesView(QtWidgets.QTreeView):
def drawBranches(self, painter, rect, index):
return
class WorkfilesPage(QtWidgets.QWidget):
def __init__(
self,
controller: AbstractLauncherFrontEnd,
parent: QtWidgets.QWidget,
) -> None:
super().__init__(parent)
workfiles_view = WorkfilesView(self)
workfiles_view.setIndentation(0)
workfiles_model = WorkfilesModel(controller)
workfiles_proxy = QtCore.QSortFilterProxyModel()
workfiles_proxy.setSourceModel(workfiles_model)
workfiles_view.setModel(workfiles_proxy)
layout = QtWidgets.QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
layout.addWidget(workfiles_view, 1)
workfiles_view.selectionModel().selectionChanged.connect(
self._on_selection_changed
)
workfiles_model.refreshed.connect(self._on_refresh)
self._controller = controller
self._workfiles_view = workfiles_view
self._workfiles_model = workfiles_model
self._workfiles_proxy = workfiles_proxy
def refresh(self) -> None:
self._workfiles_model.refresh()
def _on_refresh(self) -> None:
self._workfiles_proxy.sort(0, QtCore.Qt.DescendingOrder)
def _on_selection_changed(self, selected, _deselected) -> None:
workfile_id = None
for index in selected.indexes():
workfile_id = index.data(WORKFILE_ID_ROLE)
self._controller.set_selected_workfile(workfile_id)

View file

@ -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

View file

@ -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]

View file

@ -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),
)

View file

@ -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"]
)

View file

@ -1,3 +1,3 @@
# -*- coding: utf-8 -*-
"""Package declaring AYON addon 'core' version."""
__version__ = "1.6.0+dev"
__version__ = "1.6.1+dev"

View file

@ -1,6 +1,6 @@
name = "core"
title = "Core"
version = "1.6.0+dev"
version = "1.6.1+dev"
client_dir = "ayon_core"

View file

@ -5,7 +5,7 @@
[tool.poetry]
name = "ayon-core"
version = "1.6.0+dev"
version = "1.6.1+dev"
description = ""
authors = ["Ynput Team <team@ynput.io>"]
readme = "README.md"