diff --git a/client/ayon_core/tools/common_models/users.py b/client/ayon_core/tools/common_models/users.py index f8beb31aa1..f7939e5cd3 100644 --- a/client/ayon_core/tools/common_models/users.py +++ b/client/ayon_core/tools/common_models/users.py @@ -1,6 +1,80 @@ -import ayon_api +import json +import collections -from ayon_core.lib import CacheItem +import ayon_api +from ayon_api.graphql import FIELD_VALUE, GraphQlQuery, fields_to_dict + +from ayon_core.lib import NestedCacheItem + + +# --- Implementation that should be in ayon-python-api --- +# The implementation is not available in all versions of ayon-python-api. +def users_graphql_query(fields): + query = GraphQlQuery("Users") + names_var = query.add_variable("userNames", "[String!]") + project_name_var = query.add_variable("projectName", "String!") + + users_field = query.add_field_with_edges("users") + users_field.set_filter("names", names_var) + users_field.set_filter("projectName", project_name_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 + + +def get_users(project_name=None, usernames=None, fields=None): + """Get Users. + + Only administrators and managers can fetch all users. For other users + it is required to pass in 'project_name' filter. + + Args: + project_name (Optional[str]): Project name. + 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 project_name is not None: + filters["projectName"] = project_name + + con = ayon_api.get_server_api_connection() + if not fields: + fields = con.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(con): + for user in parsed_data["users"]: + user["accessGroups"] = json.loads(user["accessGroups"]) + yield user +# --- END of ayon-python-api implementation --- class UserItem: @@ -32,19 +106,19 @@ class UserItem: class UsersModel: def __init__(self, controller): self._controller = controller - self._users_cache = CacheItem(default_factory=list) + self._users_cache = NestedCacheItem(default_factory=list) - def get_user_items(self): + def get_user_items(self, project_name): """Get user items. Returns: List[UserItem]: List of user items. """ - self._invalidate_cache() - return self._users_cache.get_data() + self._invalidate_cache(project_name) + return self._users_cache[project_name].get_data() - def get_user_items_by_name(self): + def get_user_items_by_name(self, project_name): """Get user items by name. Implemented as most of cases using this model will need to find @@ -56,10 +130,10 @@ class UsersModel: """ return { user_item.username: user_item - for user_item in self.get_user_items() + for user_item in self.get_user_items(project_name) } - def get_user_item_by_username(self, username): + def get_user_item_by_username(self, project_name, username): """Get user item by username. Args: @@ -69,16 +143,22 @@ class UsersModel: Union[UserItem, None]: User item or None if not found. """ - self._invalidate_cache() - for user_item in self.get_user_items(): + self._invalidate_cache(project_name) + for user_item in self.get_user_items(project_name): if user_item.username == username: return user_item return None - def _invalidate_cache(self): - if self._users_cache.is_valid: + def _invalidate_cache(self, project_name): + cache = self._users_cache[project_name] + if cache.is_valid: return - self._users_cache.update_data([ + + if project_name is None: + cache.update_data([]) + return + + self._users_cache[project_name].update_data([ UserItem.from_entity_data(user) - for user in ayon_api.get_users() + for user in get_users(project_name) ]) diff --git a/client/ayon_core/tools/context_dialog/__init__.py b/client/ayon_core/tools/context_dialog/__init__.py index 4fb912fb62..8a77a46109 100644 --- a/client/ayon_core/tools/context_dialog/__init__.py +++ b/client/ayon_core/tools/context_dialog/__init__.py @@ -1,7 +1,8 @@ -from .window import ContextDialog, main +from .window import ContextDialog, main, ask_for_context __all__ = ( "ContextDialog", "main", + "ask_for_context" ) diff --git a/client/ayon_core/tools/context_dialog/window.py b/client/ayon_core/tools/context_dialog/window.py index 828d771142..ea5fdfbaec 100644 --- a/client/ayon_core/tools/context_dialog/window.py +++ b/client/ayon_core/tools/context_dialog/window.py @@ -791,3 +791,12 @@ def main( window.show() app.exec_() controller.store_output() + + +def ask_for_context(strict=True): + controller = ContextDialogController() + controller.set_strict(strict) + window = ContextDialog(controller=controller) + window.exec_() + + return controller.get_selected_context() diff --git a/client/ayon_core/tools/workfiles/abstract.py b/client/ayon_core/tools/workfiles/abstract.py index f345e20dca..330b413300 100644 --- a/client/ayon_core/tools/workfiles/abstract.py +++ b/client/ayon_core/tools/workfiles/abstract.py @@ -834,12 +834,13 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): pass @abstractmethod - def get_workarea_file_items(self, folder_id, task_id): + def get_workarea_file_items(self, folder_id, task_name, sender=None): """Get workarea file items. Args: folder_id (str): Folder id. - task_id (str): Task id. + task_name (str): Task name. + sender (Optional[str]): Who requested workarea file items. Returns: list[FileItem]: List of workarea file items. @@ -905,12 +906,12 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): pass @abstractmethod - def get_workfile_info(self, folder_id, task_id, filepath): + def get_workfile_info(self, folder_id, task_name, filepath): """Workfile info from database. Args: folder_id (str): Folder id. - task_id (str): Task id. + task_name (str): Task id. filepath (str): Workfile path. Returns: @@ -921,7 +922,7 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): pass @abstractmethod - def save_workfile_info(self, folder_id, task_id, filepath, note): + def save_workfile_info(self, folder_id, task_name, filepath, note): """Save workfile info to database. At this moment the only information which can be saved about @@ -932,7 +933,7 @@ class AbstractWorkfilesFrontend(AbstractWorkfilesCommon): Args: folder_id (str): Folder id. - task_id (str): Task id. + task_name (str): Task id. filepath (str): Workfile path. note (Union[str, None]): Note. """ diff --git a/client/ayon_core/tools/workfiles/control.py b/client/ayon_core/tools/workfiles/control.py index 8fa9135bc0..31bdb2bab6 100644 --- a/client/ayon_core/tools/workfiles/control.py +++ b/client/ayon_core/tools/workfiles/control.py @@ -278,7 +278,8 @@ class BaseWorkfileController( ) def get_user_items_by_name(self): - return self._users_model.get_user_items_by_name() + project_name = self.get_current_project_name() + return self._users_model.get_user_items_by_name(project_name) # Host information def get_workfile_extensions(self): @@ -410,9 +411,11 @@ class BaseWorkfileController( return self._workfiles_model.get_workarea_dir_by_context( folder_id, task_id) - def get_workarea_file_items(self, folder_id, task_id): + def get_workarea_file_items(self, folder_id, task_name, sender=None): + task_id = self._get_task_id(folder_id, task_name) return self._workfiles_model.get_workarea_file_items( - folder_id, task_id) + folder_id, task_id, task_name + ) def get_workarea_save_as_data(self, folder_id, task_id): return self._workfiles_model.get_workarea_save_as_data( @@ -447,12 +450,14 @@ class BaseWorkfileController( return self._workfiles_model.get_published_file_items( folder_id, task_name) - def get_workfile_info(self, folder_id, task_id, filepath): + def get_workfile_info(self, folder_id, task_name, filepath): + task_id = self._get_task_id(folder_id, task_name) return self._workfiles_model.get_workfile_info( folder_id, task_id, filepath ) - def save_workfile_info(self, folder_id, task_id, filepath, note): + def save_workfile_info(self, folder_id, task_name, filepath, note): + task_id = self._get_task_id(folder_id, task_name) self._workfiles_model.save_workfile_info( folder_id, task_id, filepath, note ) @@ -627,6 +632,17 @@ class BaseWorkfileController( def _emit_event(self, topic, data=None): self.emit_event(topic, data, "controller") + def _get_task_id(self, folder_id, task_name, sender=None): + task_item = self._hierarchy_model.get_task_item_by_name( + self.get_current_project_name(), + folder_id, + task_name, + sender + ) + if not task_item: + return None + return task_item.id + # Expected selection # - expected selection is used to restore selection after refresh # or when current context should be used @@ -722,7 +738,7 @@ class BaseWorkfileController( self._host_save_workfile(dst_filepath) # Make sure workfile info exists - self.save_workfile_info(folder_id, task_id, dst_filepath, None) + self.save_workfile_info(folder_id, task_name, dst_filepath, None) # Create extra folders create_workdir_extra_folders( diff --git a/client/ayon_core/tools/workfiles/models/workfiles.py b/client/ayon_core/tools/workfiles/models/workfiles.py index c93bbb6637..a268a9bd0e 100644 --- a/client/ayon_core/tools/workfiles/models/workfiles.py +++ b/client/ayon_core/tools/workfiles/models/workfiles.py @@ -1,6 +1,7 @@ import os import re import copy +import uuid import arrow import ayon_api @@ -173,7 +174,7 @@ class WorkareaModel: folder_mapping[task_id] = workdir return workdir - def get_file_items(self, folder_id, task_id): + def get_file_items(self, folder_id, task_id, task_name): items = [] if not folder_id or not task_id: return items @@ -192,7 +193,7 @@ class WorkareaModel: continue workfile_info = self._controller.get_workfile_info( - folder_id, task_id, filepath + folder_id, task_name, filepath ) modified = os.path.getmtime(filepath) items.append(FileItem( @@ -587,6 +588,7 @@ class WorkfileEntitiesModel: username = self._get_current_username() workfile_info = { + "id": uuid.uuid4().hex, "path": rootless_path, "taskId": task_id, "attrib": { @@ -770,19 +772,21 @@ class WorkfilesModel: return self._workarea_model.get_workarea_dir_by_context( folder_id, task_id) - def get_workarea_file_items(self, folder_id, task_id): + def get_workarea_file_items(self, folder_id, task_id, task_name): """Workfile items for passed context from workarea. Args: folder_id (Union[str, None]): Folder id. task_id (Union[str, None]): Task id. + task_name (Union[str, None]): Task name. Returns: list[FileItem]: List of file items matching workarea of passed context. """ - - return self._workarea_model.get_file_items(folder_id, task_id) + return self._workarea_model.get_file_items( + folder_id, task_id, task_name + ) def get_workarea_save_as_data(self, folder_id, task_id): return self._workarea_model.get_workarea_save_as_data( diff --git a/client/ayon_core/tools/workfiles/widgets/files_widget_workarea.py b/client/ayon_core/tools/workfiles/widgets/files_widget_workarea.py index 5c102dcdd4..7f76b6a8ab 100644 --- a/client/ayon_core/tools/workfiles/widgets/files_widget_workarea.py +++ b/client/ayon_core/tools/workfiles/widgets/files_widget_workarea.py @@ -66,7 +66,7 @@ class WorkAreaFilesModel(QtGui.QStandardItemModel): self._empty_item_used = False self._published_mode = False self._selected_folder_id = None - self._selected_task_id = None + self._selected_task_name = None self._add_missing_context_item() @@ -153,7 +153,7 @@ class WorkAreaFilesModel(QtGui.QStandardItemModel): def _on_task_changed(self, event): self._selected_folder_id = event["folder_id"] - self._selected_task_id = event["task_id"] + self._selected_task_name = event["task_name"] if not self._published_mode: self._fill_items() @@ -179,13 +179,13 @@ class WorkAreaFilesModel(QtGui.QStandardItemModel): def _fill_items_impl(self): folder_id = self._selected_folder_id - task_id = self._selected_task_id - if not folder_id or not task_id: + task_name = self._selected_task_name + if not folder_id or not task_name: self._add_missing_context_item() return file_items = self._controller.get_workarea_file_items( - folder_id, task_id + folder_id, task_name ) root_item = self.invisibleRootItem() if not file_items: diff --git a/client/ayon_core/tools/workfiles/widgets/side_panel.py b/client/ayon_core/tools/workfiles/widgets/side_panel.py index 53fdf0e0ac..7ba60b5544 100644 --- a/client/ayon_core/tools/workfiles/widgets/side_panel.py +++ b/client/ayon_core/tools/workfiles/widgets/side_panel.py @@ -75,7 +75,7 @@ class SidePanelWidget(QtWidgets.QWidget): self._btn_note_save = btn_note_save self._folder_id = None - self._task_id = None + self._task_name = None self._filepath = None self._orig_note = "" self._controller = controller @@ -93,10 +93,10 @@ class SidePanelWidget(QtWidgets.QWidget): def _on_selection_change(self, event): folder_id = event["folder_id"] - task_id = event["task_id"] + task_name = event["task_name"] filepath = event["path"] - self._set_context(folder_id, task_id, filepath) + self._set_context(folder_id, task_name, filepath) def _on_note_change(self): text = self._note_input.toPlainText() @@ -106,19 +106,19 @@ class SidePanelWidget(QtWidgets.QWidget): note = self._note_input.toPlainText() self._controller.save_workfile_info( self._folder_id, - self._task_id, + self._task_name, self._filepath, note ) self._orig_note = note self._btn_note_save.setEnabled(False) - def _set_context(self, folder_id, task_id, filepath): + def _set_context(self, folder_id, task_name, filepath): workfile_info = None # Check if folder, task and file are selected - if bool(folder_id) and bool(task_id) and bool(filepath): + if bool(folder_id) and bool(task_name) and bool(filepath): workfile_info = self._controller.get_workfile_info( - folder_id, task_id, filepath + folder_id, task_name, filepath ) enabled = workfile_info is not None @@ -127,7 +127,7 @@ class SidePanelWidget(QtWidgets.QWidget): self._btn_note_save.setEnabled(enabled) self._folder_id = folder_id - self._task_id = task_id + self._task_name = task_name self._filepath = filepath # Disable inputs and remove texts if any required arguments are diff --git a/server_addon/houdini/client/ayon_houdini/plugins/create/create_hda.py b/server_addon/houdini/client/ayon_houdini/plugins/create/create_hda.py index 6a1adce8cc..694bc4f3c3 100644 --- a/server_addon/houdini/client/ayon_houdini/plugins/create/create_hda.py +++ b/server_addon/houdini/client/ayon_houdini/plugins/create/create_hda.py @@ -2,7 +2,10 @@ """Creator plugin for creating publishable Houdini Digital Assets.""" import ayon_api -from ayon_core.pipeline import CreatorError +from ayon_core.pipeline import ( + CreatorError, + get_current_project_name +) from ayon_houdini.api import plugin import hou @@ -56,8 +59,18 @@ class CreateHDA(plugin.HoudiniCreator): raise CreatorError( "cannot create hda from node {}".format(to_hda)) + # Pick a unique type name for HDA product per folder path per project. + type_name = ( + "{project_name}{folder_path}_{node_name}".format( + project_name=get_current_project_name(), + folder_path=folder_path.replace("/","_"), + node_name=node_name + ) + ) + hda_node = to_hda.createDigitalAsset( - name=node_name, + name=type_name, + description=node_name, hda_file_name="$HIP/{}.hda".format(node_name) ) hda_node.layoutChildren() diff --git a/server_addon/houdini/client/ayon_houdini/plugins/load/load_hda.py b/server_addon/houdini/client/ayon_houdini/plugins/load/load_hda.py index b04e211aa4..5738ba7fab 100644 --- a/server_addon/houdini/client/ayon_houdini/plugins/load/load_hda.py +++ b/server_addon/houdini/client/ayon_houdini/plugins/load/load_hda.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- import os from ayon_core.pipeline import get_representation_path +from ayon_core.pipeline.load import LoadError from ayon_houdini.api import ( pipeline, plugin @@ -28,14 +29,18 @@ class HdaLoader(plugin.HoudiniLoader): # Get the root node obj = hou.node("/obj") - # Create a unique name - counter = 1 namespace = namespace or context["folder"]["name"] - formatted = "{}_{}".format(namespace, name) if namespace else name - node_name = "{0}_{1:03d}".format(formatted, counter) + node_name = "{}_{}".format(namespace, name) if namespace else name hou.hda.installFile(file_path) - hda_node = obj.createNode(name, node_name) + + # Get the type name from the HDA definition. + hda_defs = hou.hda.definitionsInFile(file_path) + if not hda_defs: + raise LoadError(f"No HDA definitions found in file: {file_path}") + + type_name = hda_defs[0].nodeTypeName() + hda_node = obj.createNode(type_name, node_name) self[:] = [hda_node] diff --git a/server_addon/nuke/client/ayon_nuke/api/pipeline.py b/server_addon/nuke/client/ayon_nuke/api/pipeline.py index ad8e17b1f6..2ba430c272 100644 --- a/server_addon/nuke/client/ayon_nuke/api/pipeline.py +++ b/server_addon/nuke/client/ayon_nuke/api/pipeline.py @@ -37,8 +37,6 @@ from .lib import ( INSTANCE_DATA_KNOB, get_main_window, WorkfileSettings, - # TODO: remove this once workfile builder will be removed - process_workfile_builder, start_workfile_template_builder, launch_workfiles_app, check_inventory_versions, @@ -67,6 +65,7 @@ from .workio import ( current_file ) from .constants import ASSIST +from . import push_to_project log = Logger.get_logger(__name__) @@ -159,9 +158,6 @@ def add_nuke_callbacks(): # template builder callbacks nuke.addOnCreate(start_workfile_template_builder, nodeClass="Root") - # TODO: remove this callback once workfile builder will be removed - nuke.addOnCreate(process_workfile_builder, nodeClass="Root") - # fix ffmpeg settings on script nuke.addOnScriptLoad(on_script_load) @@ -332,6 +328,11 @@ def _install_menu(): lambda: update_placeholder() ) + menu.addCommand( + "Push to Project", + lambda: push_to_project.main() + ) + menu.addSeparator() menu.addCommand( "Experimental tools...", diff --git a/server_addon/nuke/client/ayon_nuke/api/push_to_project.py b/server_addon/nuke/client/ayon_nuke/api/push_to_project.py new file mode 100644 index 0000000000..852e5d0e31 --- /dev/null +++ b/server_addon/nuke/client/ayon_nuke/api/push_to_project.py @@ -0,0 +1,118 @@ +from collections import defaultdict +import shutil +import os + +from ayon_api import get_project, get_folder_by_id, get_task_by_id +from ayon_core.settings import get_project_settings +from ayon_core.pipeline import Anatomy, registered_host +from ayon_core.pipeline.template_data import get_template_data +from ayon_core.pipeline.workfile import get_workdir_with_workdir_data +from ayon_core.tools import context_dialog + +from .utils import bake_gizmos_recursively +from .lib import MENU_LABEL + +import nuke + + +def bake_container(container): + """Bake containers to read nodes.""" + + node = container["node"] + + # Fetch knobs to remove in order. + knobs_to_remove = [] + remove = False + for count in range(0, node.numKnobs()): + knob = node.knob(count) + + # All knobs from "AYON" tab knob onwards. + if knob.name() == MENU_LABEL: + remove = True + + if remove: + knobs_to_remove.append(knob) + + # Dont remove knobs from "containerId" onwards. + if knob.name() == "containerId": + remove = False + + # Knobs needs to be remove in reverse order, because child knobs needs to + # be remove first. + for knob in reversed(knobs_to_remove): + node.removeKnob(knob) + + node["tile_color"].setValue(0) + + +def main(): + context = context_dialog.ask_for_context() + + if context is None: + return + + # Get workfile path to save to. + project_name = context["project_name"] + project = get_project(project_name) + folder = get_folder_by_id(project_name, context["folder_id"]) + task = get_task_by_id(project_name, context["task_id"]) + host = registered_host() + project_settings = get_project_settings(project_name) + anatomy = Anatomy(project_name) + + workdir_data = get_template_data( + project, folder, task, host.name, project_settings + ) + + workdir = get_workdir_with_workdir_data( + workdir_data, + project_name, + anatomy, + project_settings=project_settings + ) + # Save current workfile. + current_file = host.current_file() + host.save_file(current_file) + + for container in host.ls(): + bake_container(container) + + # Bake gizmos. + bake_gizmos_recursively() + + # Copy all read node files to "resources" folder next to workfile and + # change file path. + first_frame = int(nuke.root()["first_frame"].value()) + last_frame = int(nuke.root()["last_frame"].value()) + files_by_node_name = defaultdict(set) + nodes_by_name = {} + for count in range(first_frame, last_frame + 1): + nuke.frame(count) + for node in nuke.allNodes(filter="Read"): + files_by_node_name[node.name()].add( + nuke.filename(node, nuke.REPLACE) + ) + nodes_by_name[node.name()] = node + + resources_dir = os.path.join(workdir, "resources") + for name, files in files_by_node_name.items(): + dir = os.path.join(resources_dir, name) + if not os.path.exists(dir): + os.makedirs(dir) + + for f in files: + shutil.copy(f, os.path.join(dir, os.path.basename(f))) + + node = nodes_by_name[name] + path = node["file"].value().replace(os.path.dirname(f), dir) + node["file"].setValue(path.replace("\\", "/")) + + # Save current workfile to new context. + pushed_workfile = os.path.join( + workdir, os.path.basename(current_file)) + host.save_file(pushed_workfile) + + # Open current context workfile. + host.open_file(current_file) + + nuke.message(f"Pushed to project: \n{pushed_workfile}")