diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 2339ec878f..2fd2780e55 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.15.11-nightly.3 - 3.15.11-nightly.2 - 3.15.11-nightly.1 - 3.15.10 @@ -134,7 +135,6 @@ body: - 3.14.3-nightly.7 - 3.14.3-nightly.6 - 3.14.3-nightly.5 - - 3.14.3-nightly.4 validations: required: true - type: dropdown diff --git a/openpype/client/operations.py b/openpype/client/operations.py index ef48f2a1c4..e8c9d28636 100644 --- a/openpype/client/operations.py +++ b/openpype/client/operations.py @@ -220,7 +220,6 @@ def new_representation_doc( "parent": version_id, "name": name, "data": data, - # Imprint shortcut to context for performance reasons. "context": context } @@ -708,7 +707,11 @@ class OperationsSession(object): return operation -def create_project(project_name, project_code, library_project=False): +def create_project( + project_name, + project_code, + library_project=False, +): """Create project using OpenPype settings. This project creation function is not validating project document on @@ -752,7 +755,7 @@ def create_project(project_name, project_code, library_project=False): "name": project_name, "data": { "code": project_code, - "library_project": library_project + "library_project": library_project, }, "schema": CURRENT_PROJECT_SCHEMA } diff --git a/openpype/hosts/maya/plugins/load/load_arnold_standin.py b/openpype/hosts/maya/plugins/load/load_arnold_standin.py index 7c3a732389..38a7adfd7d 100644 --- a/openpype/hosts/maya/plugins/load/load_arnold_standin.py +++ b/openpype/hosts/maya/plugins/load/load_arnold_standin.py @@ -35,9 +35,15 @@ class ArnoldStandinLoader(load.LoaderPlugin): color = "orange" def load(self, context, name, namespace, options): + if not cmds.pluginInfo("mtoa", query=True, loaded=True): + cmds.loadPlugin("mtoa") + # Create defaultArnoldRenderOptions before creating aiStandin + # which tries to connect it. Since we load the plugin and directly + # create aiStandin without the defaultArnoldRenderOptions, + # we need to create the render options for aiStandin creation. + from mtoa.core import createOptions + createOptions() - # Make sure to load arnold before importing `mtoa.ui.arnoldmenu` - cmds.loadPlugin("mtoa", quiet=True) import mtoa.ui.arnoldmenu version = context['version'] diff --git a/openpype/hosts/nuke/api/lib.py b/openpype/hosts/nuke/api/lib.py index 777f4454dc..c05182ce97 100644 --- a/openpype/hosts/nuke/api/lib.py +++ b/openpype/hosts/nuke/api/lib.py @@ -2020,11 +2020,11 @@ class WorkfileSettings(object): # TODO: backward compatibility for old projects - remove later # perhaps old project overrides is having it set to older version # with use of `customOCIOConfigPath` + resolved_path = None if workfile_settings.get("customOCIOConfigPath"): unresolved_path = workfile_settings["customOCIOConfigPath"] ocio_paths = unresolved_path[platform.system().lower()] - resolved_path = None for ocio_p in ocio_paths: resolved_path = str(ocio_p).format(**os.environ) if not os.path.exists(resolved_path): @@ -2054,9 +2054,9 @@ class WorkfileSettings(object): self._root_node["colorManagement"].setValue("OCIO") # we dont need the key anymore - workfile_settings.pop("customOCIOConfigPath") - workfile_settings.pop("colorManagement") - workfile_settings.pop("OCIO_config") + workfile_settings.pop("customOCIOConfigPath", None) + workfile_settings.pop("colorManagement", None) + workfile_settings.pop("OCIO_config", None) # then set the rest for knob, value_ in workfile_settings.items(): diff --git a/openpype/hosts/standalonepublisher/plugins/publish/collect_context.py b/openpype/hosts/standalonepublisher/plugins/publish/collect_context.py index 96aaae23dc..8fa53f5f48 100644 --- a/openpype/hosts/standalonepublisher/plugins/publish/collect_context.py +++ b/openpype/hosts/standalonepublisher/plugins/publish/collect_context.py @@ -222,7 +222,6 @@ class CollectContextDataSAPublish(pyblish.api.ContextPlugin): "label": subset, "name": subset, "family": in_data["family"], - # "version": in_data.get("version", 1), "frameStart": in_data.get("representations", [None])[0].get( "frameStart", None ), @@ -232,6 +231,14 @@ class CollectContextDataSAPublish(pyblish.api.ContextPlugin): "families": instance_families } ) + # Fill version only if 'use_next_available_version' is disabled + # and version is filled in instance data + version = in_data.get("version") + use_next_available_version = in_data.get( + "use_next_available_version", True) + if not use_next_available_version and version is not None: + instance.data["version"] = version + self.log.info("collected instance: {}".format(pformat(instance.data))) self.log.info("parsing data: {}".format(pformat(in_data))) diff --git a/openpype/hosts/traypublisher/api/plugin.py b/openpype/hosts/traypublisher/api/plugin.py index 75930f0f31..36e041a32c 100644 --- a/openpype/hosts/traypublisher/api/plugin.py +++ b/openpype/hosts/traypublisher/api/plugin.py @@ -1,4 +1,14 @@ -from openpype.lib.attribute_definitions import FileDef +from openpype.client import ( + get_assets, + get_subsets, + get_last_versions, +) +from openpype.lib.attribute_definitions import ( + FileDef, + BoolDef, + NumberDef, + UISeparatorDef, +) from openpype.lib.transcoding import IMAGE_EXTENSIONS, VIDEO_EXTENSIONS from openpype.pipeline.create import ( Creator, @@ -94,6 +104,7 @@ class TrayPublishCreator(Creator): class SettingsCreator(TrayPublishCreator): create_allow_context_change = True create_allow_thumbnail = True + allow_version_control = False extensions = [] @@ -101,8 +112,18 @@ class SettingsCreator(TrayPublishCreator): # Pass precreate data to creator attributes thumbnail_path = pre_create_data.pop(PRE_CREATE_THUMBNAIL_KEY, None) + # Fill 'version_to_use' if version control is enabled + if self.allow_version_control: + asset_name = data["asset"] + subset_docs_by_asset_id = self._prepare_next_versions( + [asset_name], [subset_name]) + version = subset_docs_by_asset_id[asset_name].get(subset_name) + pre_create_data["version_to_use"] = version + data["_previous_last_version"] = version + data["creator_attributes"] = pre_create_data data["settings_creator"] = True + # Create new instance new_instance = CreatedInstance(self.family, subset_name, data, self) @@ -111,7 +132,158 @@ class SettingsCreator(TrayPublishCreator): if thumbnail_path: self.set_instance_thumbnail_path(new_instance.id, thumbnail_path) + def _prepare_next_versions(self, asset_names, subset_names): + """Prepare next versions for given asset and subset names. + + Todos: + Expect combination of subset names by asset name to avoid + unnecessary server calls for unused subsets. + + Args: + asset_names (Iterable[str]): Asset names. + subset_names (Iterable[str]): Subset names. + + Returns: + dict[str, dict[str, int]]: Last versions by asset + and subset names. + """ + + # Prepare all versions for all combinations to '1' + subset_docs_by_asset_id = { + asset_name: { + subset_name: 1 + for subset_name in subset_names + } + for asset_name in asset_names + } + if not asset_names or not subset_names: + return subset_docs_by_asset_id + + asset_docs = get_assets( + self.project_name, + asset_names=asset_names, + fields=["_id", "name"] + ) + asset_names_by_id = { + asset_doc["_id"]: asset_doc["name"] + for asset_doc in asset_docs + } + subset_docs = list(get_subsets( + self.project_name, + asset_ids=asset_names_by_id.keys(), + subset_names=subset_names, + fields=["_id", "name", "parent"] + )) + + subset_ids = {subset_doc["_id"] for subset_doc in subset_docs} + last_versions = get_last_versions( + self.project_name, + subset_ids, + fields=["name", "parent"]) + + for subset_doc in subset_docs: + asset_id = subset_doc["parent"] + asset_name = asset_names_by_id[asset_id] + subset_name = subset_doc["name"] + subset_id = subset_doc["_id"] + last_version = last_versions.get(subset_id) + version = 0 + if last_version is not None: + version = last_version["name"] + subset_docs_by_asset_id[asset_name][subset_name] += version + return subset_docs_by_asset_id + + def _fill_next_versions(self, instances_data): + """Fill next version for instances. + + Instances have also stored previous next version to be able to + recognize if user did enter different version. If version was + not changed by user, or user set it to '0' the next version will be + updated by current database state. + """ + + filtered_instance_data = [] + for instance in instances_data: + previous_last_version = instance.get("_previous_last_version") + creator_attributes = instance["creator_attributes"] + use_next_version = creator_attributes.get( + "use_next_version", True) + version = creator_attributes.get("version_to_use", 0) + if ( + use_next_version + or version == 0 + or version == previous_last_version + ): + filtered_instance_data.append(instance) + + asset_names = { + instance["asset"] + for instance in filtered_instance_data} + subset_names = { + instance["subset"] + for instance in filtered_instance_data} + subset_docs_by_asset_id = self._prepare_next_versions( + asset_names, subset_names + ) + for instance in filtered_instance_data: + asset_name = instance["asset"] + subset_name = instance["subset"] + version = subset_docs_by_asset_id[asset_name][subset_name] + instance["creator_attributes"]["version_to_use"] = version + instance["_previous_last_version"] = version + + def collect_instances(self): + """Collect instances from host. + + Overriden to be able to manage version control attributes. If version + control is disabled, the attributes will be removed from instances, + and next versions are filled if is version control enabled. + """ + + instances_by_identifier = cache_and_get_instances( + self, SHARED_DATA_KEY, list_instances + ) + instances = instances_by_identifier[self.identifier] + if not instances: + return + + if self.allow_version_control: + self._fill_next_versions(instances) + + for instance_data in instances: + # Make sure that there are not data related to version control + # if plugin does not support it + if not self.allow_version_control: + instance_data.pop("_previous_last_version", None) + creator_attributes = instance_data["creator_attributes"] + creator_attributes.pop("version_to_use", None) + creator_attributes.pop("use_next_version", None) + + instance = CreatedInstance.from_existing(instance_data, self) + self._add_instance_to_context(instance) + def get_instance_attr_defs(self): + defs = self.get_pre_create_attr_defs() + if self.allow_version_control: + defs += [ + UISeparatorDef(), + BoolDef( + "use_next_version", + default=True, + label="Use next version", + ), + NumberDef( + "version_to_use", + default=1, + minimum=0, + maximum=999, + label="Version to use", + ) + ] + return defs + + def get_pre_create_attr_defs(self): + # Use same attributes as for instance attributes return [ FileDef( "representation_files", @@ -132,10 +304,6 @@ class SettingsCreator(TrayPublishCreator): ) ] - def get_pre_create_attr_defs(self): - # Use same attributes as for instance attrobites - return self.get_instance_attr_defs() - @classmethod def from_settings(cls, item_data): identifier = item_data["identifier"] @@ -155,6 +323,8 @@ class SettingsCreator(TrayPublishCreator): "extensions": item_data["extensions"], "allow_sequences": item_data["allow_sequences"], "allow_multiple_items": item_data["allow_multiple_items"], - "default_variants": item_data["default_variants"] + "allow_version_control": item_data.get( + "allow_version_control", False), + "default_variants": item_data["default_variants"], } ) diff --git a/openpype/hosts/traypublisher/plugins/publish/collect_simple_instances.py b/openpype/hosts/traypublisher/plugins/publish/collect_simple_instances.py index c081216481..3fa3c3b8c8 100644 --- a/openpype/hosts/traypublisher/plugins/publish/collect_simple_instances.py +++ b/openpype/hosts/traypublisher/plugins/publish/collect_simple_instances.py @@ -47,6 +47,8 @@ class CollectSettingsSimpleInstances(pyblish.api.InstancePlugin): "Created temp staging directory for instance {}. {}" ).format(instance_label, tmp_folder)) + self._fill_version(instance, instance_label) + # Store filepaths for validation of their existence source_filepaths = [] # Make sure there are no representations with same name @@ -93,6 +95,28 @@ class CollectSettingsSimpleInstances(pyblish.api.InstancePlugin): ) ) + def _fill_version(self, instance, instance_label): + """Fill instance version under which will be instance integrated. + + Instance must have set 'use_next_version' to 'False' + and 'version_to_use' to version to use. + + Args: + instance (pyblish.api.Instance): Instance to fill version for. + instance_label (str): Label of instance to fill version for. + """ + + creator_attributes = instance.data["creator_attributes"] + use_next_version = creator_attributes.get("use_next_version", True) + # If 'version_to_use' is '0' it means that next version should be used + version_to_use = creator_attributes.get("version_to_use", 0) + if use_next_version or not version_to_use: + return + instance.data["version"] = version_to_use + self.log.debug( + "Version for instance \"{}\" was set to \"{}\"".format( + instance_label, version_to_use)) + def _create_main_representations( self, instance, diff --git a/openpype/hosts/traypublisher/plugins/publish/help/validate_existing_version.xml b/openpype/hosts/traypublisher/plugins/publish/help/validate_existing_version.xml new file mode 100644 index 0000000000..8a3b8f4d7d --- /dev/null +++ b/openpype/hosts/traypublisher/plugins/publish/help/validate_existing_version.xml @@ -0,0 +1,16 @@ + + + +Version already exists + +## Version already exists + +Version {version} you have set on instance '{subset_name}' under '{asset_name}' already exists. This validation is enabled by default to prevent accidental override of existing versions. + +### How to repair? +- Click on 'Repair' action -> this will change version to next available. +- Disable validation on the instance if you are sure you want to override the version. +- Reset publishing and manually change the version number. + + + diff --git a/openpype/hosts/traypublisher/plugins/publish/validate_existing_version.py b/openpype/hosts/traypublisher/plugins/publish/validate_existing_version.py new file mode 100644 index 0000000000..1fb27acdeb --- /dev/null +++ b/openpype/hosts/traypublisher/plugins/publish/validate_existing_version.py @@ -0,0 +1,57 @@ +import pyblish.api + +from openpype.pipeline.publish import ( + ValidateContentsOrder, + PublishXmlValidationError, + OptionalPyblishPluginMixin, + RepairAction, +) + + +class ValidateExistingVersion( + OptionalPyblishPluginMixin, + pyblish.api.InstancePlugin +): + label = "Validate Existing Version" + order = ValidateContentsOrder + + hosts = ["traypublisher"] + + actions = [RepairAction] + + settings_category = "traypublisher" + optional = True + + def process(self, instance): + if not self.is_active(instance.data): + return + + version = instance.data.get("version") + if version is None: + return + + last_version = instance.data.get("latestVersion") + if last_version is None or last_version < version: + return + + subset_name = instance.data["subset"] + msg = "Version {} already exists for subset {}.".format( + version, subset_name) + + formatting_data = { + "subset_name": subset_name, + "asset_name": instance.data["asset"], + "version": version + } + raise PublishXmlValidationError( + self, msg, formatting_data=formatting_data) + + @classmethod + def repair(cls, instance): + create_context = instance.context.data["create_context"] + created_instance = create_context.get_instance_by_id( + instance.data["instance_id"]) + creator_attributes = created_instance["creator_attributes"] + # Disable version override + creator_attributes["use_next_version"] = True + create_context.save_changes() diff --git a/openpype/lib/project_backpack.py b/openpype/lib/project_backpack.py index 674eaa3b91..91a5b76e35 100644 --- a/openpype/lib/project_backpack.py +++ b/openpype/lib/project_backpack.py @@ -113,12 +113,19 @@ def pack_project( project_name )) + if only_documents and not destination_dir: + raise ValueError(( + "Destination directory must be defined" + " when only documents should be packed." + )) + root_path = None source_root = {} project_source_path = None if not only_documents: roots = project_doc["config"]["roots"] # Determine root directory of project + source_root = None source_root_name = None for root_name, root_value in roots.items(): if source_root is not None: @@ -141,6 +148,11 @@ def pack_project( if not destination_dir: destination_dir = root_path + if not destination_dir: + raise ValueError( + "Project {} does not have any roots.".format(project_name) + ) + destination_dir = os.path.normpath(destination_dir) if not os.path.exists(destination_dir): os.makedirs(destination_dir) diff --git a/openpype/modules/README.md b/openpype/modules/README.md index 86afdb9d91..ce3f99b338 100644 --- a/openpype/modules/README.md +++ b/openpype/modules/README.md @@ -138,7 +138,8 @@ class ClockifyModule( "publish": [], "create": [], "load": [], - "actions": [] + "actions": [], + "inventory": [] } ``` diff --git a/openpype/modules/base.py b/openpype/modules/base.py index 732525b6eb..fb9b4e1096 100644 --- a/openpype/modules/base.py +++ b/openpype/modules/base.py @@ -740,15 +740,16 @@ class ModulesManager: Unknown keys are logged out. Returns: - dict: Output is dictionary with keys "publish", "create", "load" - and "actions" each containing list of paths. + dict: Output is dictionary with keys "publish", "create", "load", + "actions" and "inventory" each containing list of paths. """ # Output structure output = { "publish": [], "create": [], "load": [], - "actions": [] + "actions": [], + "inventory": [] } unknown_keys_by_module = {} for module in self.get_enabled_modules(): @@ -853,6 +854,21 @@ class ModulesManager: host_name ) + def collect_inventory_action_paths(self, host_name): + """Helper to collect load plugin paths from modules. + + Args: + host_name (str): For which host are load plugins meant. + + Returns: + list: List of pyblish plugin paths. + """ + + return self._collect_plugin_paths( + "get_inventory_action_paths", + host_name + ) + def get_host_module(self, host_name): """Find host module by host name. diff --git a/openpype/modules/deadline/plugins/publish/submit_celaction_deadline.py b/openpype/modules/deadline/plugins/publish/submit_celaction_deadline.py index bcf0850768..ee28612b44 100644 --- a/openpype/modules/deadline/plugins/publish/submit_celaction_deadline.py +++ b/openpype/modules/deadline/plugins/publish/submit_celaction_deadline.py @@ -59,7 +59,6 @@ class CelactionSubmitDeadline(pyblish.api.InstancePlugin): render_path).replace("\\", "/") instance.data["publishJobState"] = "Suspended" - instance.context.data['ftrackStatus'] = "Render" # adding 2d render specific family for version identification in Loader instance.data["families"] = ["render2d"] diff --git a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_api.py b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_api.py index cec48ef54f..deb8b414f0 100644 --- a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_api.py +++ b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_api.py @@ -109,8 +109,6 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin): for status in asset_version_statuses } - self._set_task_status(instance, project_entity, task_entity, session) - # Prepare AssetTypes asset_types_by_short = self._ensure_asset_types_exists( session, component_list @@ -180,45 +178,6 @@ class IntegrateFtrackApi(pyblish.api.InstancePlugin): if asset_version not in instance.data[asset_versions_key]: instance.data[asset_versions_key].append(asset_version) - def _set_task_status(self, instance, project_entity, task_entity, session): - if not project_entity: - self.log.info("Task status won't be set, project is not known.") - return - - if not task_entity: - self.log.info("Task status won't be set, task is not known.") - return - - status_name = instance.context.data.get("ftrackStatus") - if not status_name: - self.log.info("Ftrack status name is not set.") - return - - self.log.debug( - "Ftrack status name will be (maybe) set to \"{}\"".format( - status_name - ) - ) - - project_schema = project_entity["project_schema"] - task_statuses = project_schema.get_statuses( - "Task", task_entity["type_id"] - ) - task_statuses_by_low_name = { - status["name"].lower(): status for status in task_statuses - } - status = task_statuses_by_low_name.get(status_name.lower()) - if not status: - self.log.warning(( - "Task status \"{}\" won't be set," - " status is now allowed on task type \"{}\"." - ).format(status_name, task_entity["type"]["name"])) - return - - self.log.info("Setting task status to \"{}\"".format(status_name)) - task_entity["status"] = status - session.commit() - def _fill_component_locations(self, session, component_list): components_by_location_name = collections.defaultdict(list) components_by_location_id = collections.defaultdict(list) diff --git a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_farm_status.py b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_farm_status.py deleted file mode 100644 index ab5738c33f..0000000000 --- a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_farm_status.py +++ /dev/null @@ -1,150 +0,0 @@ -import pyblish.api -from openpype.lib import filter_profiles - - -class IntegrateFtrackFarmStatus(pyblish.api.ContextPlugin): - """Change task status when should be published on farm. - - Instance which has set "farm" key in data to 'True' is considered as will - be rendered on farm thus it's status should be changed. - """ - - order = pyblish.api.IntegratorOrder + 0.48 - label = "Integrate Ftrack Farm Status" - - farm_status_profiles = [] - - def process(self, context): - # Quick end - if not self.farm_status_profiles: - project_name = context.data["projectName"] - self.log.info(( - "Status profiles are not filled for project \"{}\". Skipping" - ).format(project_name)) - return - - filtered_instances = self.filter_instances(context) - instances_with_status_names = self.get_instances_with_statuse_names( - context, filtered_instances - ) - if instances_with_status_names: - self.fill_statuses(context, instances_with_status_names) - - def filter_instances(self, context): - filtered_instances = [] - for instance in context: - # Skip disabled instances - if instance.data.get("publish") is False: - continue - subset_name = instance.data["subset"] - msg_start = "Skipping instance {}.".format(subset_name) - if not instance.data.get("farm"): - self.log.debug( - "{} Won't be rendered on farm.".format(msg_start) - ) - continue - - task_entity = instance.data.get("ftrackTask") - if not task_entity: - self.log.debug( - "{} Does not have filled task".format(msg_start) - ) - continue - - filtered_instances.append(instance) - return filtered_instances - - def get_instances_with_statuse_names(self, context, instances): - instances_with_status_names = [] - for instance in instances: - family = instance.data["family"] - subset_name = instance.data["subset"] - task_entity = instance.data["ftrackTask"] - host_name = context.data["hostName"] - task_name = task_entity["name"] - task_type = task_entity["type"]["name"] - status_profile = filter_profiles( - self.farm_status_profiles, - { - "hosts": host_name, - "task_types": task_type, - "task_names": task_name, - "families": family, - "subsets": subset_name, - }, - logger=self.log - ) - if not status_profile: - # There already is log in 'filter_profiles' - continue - - status_name = status_profile["status_name"] - if status_name: - instances_with_status_names.append((instance, status_name)) - return instances_with_status_names - - def fill_statuses(self, context, instances_with_status_names): - # Prepare available task statuses on the project - project_name = context.data["projectName"] - session = context.data["ftrackSession"] - project_entity = session.query(( - "select project_schema from Project where full_name is \"{}\"" - ).format(project_name)).one() - project_schema = project_entity["project_schema"] - - task_type_ids = set() - for item in instances_with_status_names: - instance, _ = item - task_entity = instance.data["ftrackTask"] - task_type_ids.add(task_entity["type"]["id"]) - - task_statuses_by_type_id = { - task_type_id: project_schema.get_statuses("Task", task_type_id) - for task_type_id in task_type_ids - } - - # Keep track if anything has changed - skipped_status_names = set() - status_changed = False - for item in instances_with_status_names: - instance, status_name = item - task_entity = instance.data["ftrackTask"] - task_statuses = task_statuses_by_type_id[task_entity["type"]["id"]] - status_name_low = status_name.lower() - - status_id = None - status_name = None - # Skip if status name was already tried to be found - for status in task_statuses: - if status["name"].lower() == status_name_low: - status_id = status["id"] - status_name = status["name"] - break - - if status_id is None: - if status_name_low not in skipped_status_names: - skipped_status_names.add(status_name_low) - joined_status_names = ", ".join({ - '"{}"'.format(status["name"]) - for status in task_statuses - }) - self.log.warning(( - "Status \"{}\" is not available on project \"{}\"." - " Available statuses are {}" - ).format(status_name, project_name, joined_status_names)) - continue - - # Change task status id - if status_id != task_entity["status_id"]: - task_entity["status_id"] = status_id - status_changed = True - path = "/".join([ - item["name"] - for item in task_entity["link"] - ]) - self.log.debug("Set status \"{}\" to \"{}\"".format( - status_name, path - )) - - if status_changed: - session.commit() diff --git a/openpype/modules/ftrack/plugins/publish/integrate_ftrack_status.py b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_status.py new file mode 100644 index 0000000000..e862dba7fc --- /dev/null +++ b/openpype/modules/ftrack/plugins/publish/integrate_ftrack_status.py @@ -0,0 +1,433 @@ +import copy + +import pyblish.api +from openpype.lib import filter_profiles + + +def create_chunks(iterable, chunk_size=None): + """Separate iterable into multiple chunks by size. + + Args: + iterable(list|tuple|set): Object that will be separated into chunks. + chunk_size(int): Size of one chunk. Default value is 200. + + Returns: + list: Chunked items. + """ + chunks = [] + + tupled_iterable = tuple(iterable) + if not tupled_iterable: + return chunks + iterable_size = len(tupled_iterable) + if chunk_size is None: + chunk_size = 200 + + if chunk_size < 1: + chunk_size = 1 + + for idx in range(0, iterable_size, chunk_size): + chunks.append(tupled_iterable[idx:idx + chunk_size]) + return chunks + + +class CollectFtrackTaskStatuses(pyblish.api.ContextPlugin): + """Collect available task statuses on the project. + + This is preparation for integration of task statuses. + + Requirements: + ftrackSession (ftrack_api.Session): Prepared ftrack session. + + Provides: + ftrackTaskStatuses (dict[str, list[Any]]): Dictionary of available + task statuses on project by task type id. + ftrackStatusByTaskId (dict[str, str]): Empty dictionary of task + statuses by task id. Status on task can be set only once. + Value should be a name of status. + """ + + # After 'CollectFtrackApi' + order = pyblish.api.CollectorOrder + 0.4992 + label = "Collect Ftrack Task Statuses" + settings_category = "ftrack" + + def process(self, context): + ftrack_session = context.data("ftrackSession") + if ftrack_session is None: + self.log.info("Ftrack session is not created.") + return + + # Prepare available task statuses on the project + project_name = context.data["projectName"] + project_entity = ftrack_session.query(( + "select project_schema from Project where full_name is \"{}\"" + ).format(project_name)).one() + project_schema = project_entity["project_schema"] + + task_type_ids = { + task_type["id"] + for task_type in ftrack_session.query("select id from Type").all() + } + task_statuses_by_type_id = { + task_type_id: project_schema.get_statuses("Task", task_type_id) + for task_type_id in task_type_ids + } + context.data["ftrackTaskStatuses"] = task_statuses_by_type_id + context.data["ftrackStatusByTaskId"] = {} + self.log.info("Collected ftrack task statuses.") + + +class IntegrateFtrackStatusBase(pyblish.api.InstancePlugin): + """Base plugin for status collection. + + Requirements: + projectName (str): Name of the project. + hostName (str): Name of the host. + ftrackSession (ftrack_api.Session): Prepared ftrack session. + ftrackTaskStatuses (dict[str, list[Any]]): Dictionary of available + task statuses on project by task type id. + ftrackStatusByTaskId (dict[str, str]): Empty dictionary of task + statuses by task id. Status on task can be set only once. + Value should be a name of status. + """ + + active = False + settings_key = None + status_profiles = [] + + @classmethod + def apply_settings(cls, project_settings): + settings_key = cls.settings_key + if settings_key is None: + settings_key = cls.__name__ + + try: + settings = project_settings["ftrack"]["publish"][settings_key] + except KeyError: + return + + for key, value in settings.items(): + setattr(cls, key, value) + + def process(self, instance): + context = instance.context + # No profiles -> skip + profiles = self.get_status_profiles() + if not profiles: + project_name = context.data["projectName"] + self.log.info(( + "Status profiles are not filled for project \"{}\". Skipping" + ).format(project_name)) + return + + # Task statuses were not collected -> skip + task_statuses_by_type_id = context.data.get("ftrackTaskStatuses") + if not task_statuses_by_type_id: + self.log.info( + "Ftrack task statuses are not collected. Skipping.") + return + + self.prepare_status_names(context, instance, profiles) + + def get_status_profiles(self): + """List of profiles to determine status name. + + Example profile item: + { + "host_names": ["nuke"], + "task_types": ["Compositing"], + "task_names": ["Comp"], + "families": ["render"], + "subset_names": ["renderComp"], + "status_name": "Rendering", + } + + Returns: + list[dict[str, Any]]: List of profiles. + """ + + return self.status_profiles + + def prepare_status_names(self, context, instance, profiles): + if not self.is_valid_instance(context, instance): + return + + filter_data = self.get_profile_filter_data(context, instance) + status_profile = filter_profiles( + profiles, + filter_data, + logger=self.log + ) + if not status_profile: + return + + status_name = status_profile["status_name"] + if status_name: + self.fill_status(context, instance, status_name) + + def get_profile_filter_data(self, context, instance): + task_entity = instance.data["ftrackTask"] + return { + "host_names": context.data["hostName"], + "task_types": task_entity["type"]["name"], + "task_names": task_entity["name"], + "families": instance.data["family"], + "subset_names": instance.data["subset"], + } + + def is_valid_instance(self, context, instance): + """Filter instances that should be processed. + + Ignore instances that are not enabled for publishing or don't have + filled task. Also skip instances with tasks that already have defined + status. + + Plugin should do more filtering which is custom for plugin logic. + + Args: + context (pyblish.api.Context): Pyblish context. + instance (pyblish.api.Instance): Instance to process. + + Returns: + list[pyblish.api.Instance]: List of instances that should be + processed. + """ + + ftrack_status_by_task_id = context.data["ftrackStatusByTaskId"] + # Skip disabled instances + if instance.data.get("publish") is False: + return False + + task_entity = instance.data.get("ftrackTask") + if not task_entity: + self.log.debug( + "Skipping instance Does not have filled task".format( + instance.data["subset"])) + return False + + task_id = task_entity["id"] + if task_id in ftrack_status_by_task_id: + self.log.debug("Status for task {} was already defined".format( + task_entity["name"] + )) + return False + + return True + + def fill_status(self, context, instance, status_name): + """Fill status for instance task. + + If task already had set status, it will be skipped. + + Args: + context (pyblish.api.Context): Pyblish context. + instance (pyblish.api.Instance): Pyblish instance. + status_name (str): Name of status to set. + """ + + task_entity = instance.data["ftrackTask"] + task_id = task_entity["id"] + ftrack_status_by_task_id = context.data["ftrackStatusByTaskId"] + if task_id in ftrack_status_by_task_id: + self.log.debug("Status for task {} was already defined".format( + task_entity["name"] + )) + return + + ftrack_status_by_task_id[task_id] = status_name + self.log.info(( + "Task {} will be set to \"{}\" status." + ).format(task_entity["name"], status_name)) + + +class IntegrateFtrackFarmStatus(IntegrateFtrackStatusBase): + """Collect task status names for instances that are sent to farm. + + Instance which has set "farm" key in data to 'True' is considered as will + be rendered on farm thus it's status should be changed. + + Requirements: + projectName (str): Name of the project. + hostName (str): Name of the host. + ftrackSession (ftrack_api.Session): Prepared ftrack session. + ftrackTaskStatuses (dict[str, list[Any]]): Dictionary of available + task statuses on project by task type id. + ftrackStatusByTaskId (dict[str, str]): Empty dictionary of task + statuses by task id. Status on task can be set only once. + Value should be a name of status. + """ + + order = pyblish.api.IntegratorOrder + 0.48 + label = "Ftrack Task Status To Farm Status" + active = True + + farm_status_profiles = [] + status_profiles = None + + def is_valid_instance(self, context, instance): + if not instance.data.get("farm"): + self.log.debug("{} Won't be rendered on farm.".format( + instance.data["subset"] + )) + return False + return super(IntegrateFtrackFarmStatus, self).is_valid_instance( + context, instance) + + def get_status_profiles(self): + if self.status_profiles is None: + profiles = copy.deepcopy(self.farm_status_profiles) + for profile in profiles: + profile["host_names"] = profile.pop("hosts") + profile["subset_names"] = profile.pop("subsets") + self.status_profiles = profiles + return self.status_profiles + + +class IntegrateFtrackLocalStatus(IntegrateFtrackStatusBase): + """Collect task status names for instances that are published locally. + + Instance which has set "farm" key in data to 'True' is considered as will + be rendered on farm thus it's status should be changed. + + Requirements: + projectName (str): Name of the project. + hostName (str): Name of the host. + ftrackSession (ftrack_api.Session): Prepared ftrack session. + ftrackTaskStatuses (dict[str, list[Any]]): Dictionary of available + task statuses on project by task type id. + ftrackStatusByTaskId (dict[str, str]): Empty dictionary of task + statuses by task id. Status on task can be set only once. + Value should be a name of status. + """ + + order = IntegrateFtrackFarmStatus.order + 0.001 + label = "Ftrack Task Status Local Publish" + active = True + targets = ["local"] + settings_key = "ftrack_task_status_local_publish" + + def is_valid_instance(self, context, instance): + if instance.data.get("farm"): + self.log.debug("{} Will be rendered on farm.".format( + instance.data["subset"] + )) + return False + return super(IntegrateFtrackLocalStatus, self).is_valid_instance( + context, instance) + + +class IntegrateFtrackOnFarmStatus(IntegrateFtrackStatusBase): + """Collect task status names for instances that are published on farm. + + Requirements: + projectName (str): Name of the project. + hostName (str): Name of the host. + ftrackSession (ftrack_api.Session): Prepared ftrack session. + ftrackTaskStatuses (dict[str, list[Any]]): Dictionary of available + task statuses on project by task type id. + ftrackStatusByTaskId (dict[str, str]): Empty dictionary of task + statuses by task id. Status on task can be set only once. + Value should be a name of status. + """ + + order = IntegrateFtrackLocalStatus.order + 0.001 + label = "Ftrack Task Status On Farm Status" + active = True + targets = ["farm"] + settings_key = "ftrack_task_status_on_farm_publish" + + +class IntegrateFtrackTaskStatus(pyblish.api.ContextPlugin): + # Use order of Integrate Ftrack Api plugin and offset it before or after + base_order = pyblish.api.IntegratorOrder + 0.499 + # By default is after Integrate Ftrack Api + order = base_order + 0.0001 + label = "Integrate Ftrack Task Status" + + @classmethod + def apply_settings(cls, project_settings): + """Apply project settings to plugin. + + Args: + project_settings (dict[str, Any]): Project settings. + """ + + settings = ( + project_settings["ftrack"]["publish"]["IntegrateFtrackTaskStatus"] + ) + diff = 0.001 + if not settings["after_version_statuses"]: + diff = -diff + cls.order = cls.base_order + diff + + def process(self, context): + task_statuses_by_type_id = context.data.get("ftrackTaskStatuses") + if not task_statuses_by_type_id: + self.log.info("Ftrack task statuses are not collected. Skipping.") + return + + status_by_task_id = self._get_status_by_task_id(context) + if not status_by_task_id: + self.log.info("No statuses to set. Skipping.") + return + + ftrack_session = context.data["ftrackSession"] + + task_entities = self._get_task_entities( + ftrack_session, status_by_task_id) + + for task_entity in task_entities: + task_path = "/".join([ + item["name"] for item in task_entity["link"] + ]) + task_id = task_entity["id"] + type_id = task_entity["type_id"] + new_status = None + status_name = status_by_task_id[task_id] + self.log.debug( + "Status to set {} on task {}.".format(status_name, task_path)) + status_name_low = status_name.lower() + available_statuses = task_statuses_by_type_id[type_id] + for status in available_statuses: + if status["name"].lower() == status_name_low: + new_status = status + break + + if new_status is None: + joined_statuses = ", ".join([ + "'{}'".format(status["name"]) + for status in available_statuses + ]) + self.log.debug(( + "Status '{}' was not found in available statuses: {}." + ).format(status_name, joined_statuses)) + continue + + if task_entity["status_id"] != new_status["id"]: + task_entity["status_id"] = new_status["id"] + + self.log.debug("Changing status of task '{}' to '{}'".format( + task_path, status_name + )) + ftrack_session.commit() + + def _get_status_by_task_id(self, context): + status_by_task_id = context.data["ftrackStatusByTaskId"] + return { + task_id: status_name + for task_id, status_name in status_by_task_id.items() + if status_name + } + + def _get_task_entities(self, ftrack_session, status_by_task_id): + task_entities = [] + for chunk_ids in create_chunks(status_by_task_id.keys()): + joined_ids = ",".join( + ['"{}"'.format(task_id) for task_id in chunk_ids] + ) + task_entities.extend(ftrack_session.query(( + "select id, type_id, status_id, link from Task" + " where id in ({})" + ).format(joined_ids)).all()) + return task_entities diff --git a/openpype/modules/ftrack/plugins/publish/integrate_hierarchy_ftrack.py b/openpype/modules/ftrack/plugins/publish/integrate_hierarchy_ftrack.py index 6daaea5f18..a1aa7c0daa 100644 --- a/openpype/modules/ftrack/plugins/publish/integrate_hierarchy_ftrack.py +++ b/openpype/modules/ftrack/plugins/publish/integrate_hierarchy_ftrack.py @@ -63,7 +63,7 @@ class IntegrateHierarchyToFtrack(pyblish.api.ContextPlugin): """ order = pyblish.api.IntegratorOrder - 0.04 - label = 'Integrate Hierarchy To Ftrack' + label = "Integrate Hierarchy To Ftrack" families = ["shot"] hosts = [ "hiero", @@ -94,14 +94,13 @@ class IntegrateHierarchyToFtrack(pyblish.api.ContextPlugin): "Project \"{}\" was not found on ftrack.".format(project_name) ) - self.context = context self.session = session self.ft_project = project self.task_types = self.get_all_task_types(project) self.task_statuses = self.get_task_statuses(project) # import ftrack hierarchy - self.import_to_ftrack(project_name, hierarchy_context) + self.import_to_ftrack(context, project_name, hierarchy_context) def query_ftrack_entitites(self, session, ft_project): project_id = ft_project["id"] @@ -227,7 +226,7 @@ class IntegrateHierarchyToFtrack(pyblish.api.ContextPlugin): return output - def import_to_ftrack(self, project_name, hierarchy_context): + def import_to_ftrack(self, context, project_name, hierarchy_context): # Prequery hiearchical custom attributes hier_attrs = get_pype_attr(self.session)[1] hier_attr_by_key = { @@ -258,7 +257,7 @@ class IntegrateHierarchyToFtrack(pyblish.api.ContextPlugin): self.session, matching_entities, hier_attrs) # Get ftrack api module (as they are different per python version) - ftrack_api = self.context.data["ftrackPythonModule"] + ftrack_api = context.data["ftrackPythonModule"] # Use queue of hierarchy items to process import_queue = collections.deque() @@ -292,7 +291,7 @@ class IntegrateHierarchyToFtrack(pyblish.api.ContextPlugin): # CUSTOM ATTRIBUTES custom_attributes = entity_data.get('custom_attributes', {}) instances = [] - for instance in self.context: + for instance in context: instance_asset_name = instance.data.get("asset") if ( instance_asset_name @@ -369,6 +368,7 @@ class IntegrateHierarchyToFtrack(pyblish.api.ContextPlugin): if task_name: instances_by_task_name[task_name.lower()].append(instance) + ftrack_status_by_task_id = context.data["ftrackStatusByTaskId"] tasks = entity_data.get('tasks', []) existing_tasks = [] tasks_to_create = [] @@ -389,11 +389,11 @@ class IntegrateHierarchyToFtrack(pyblish.api.ContextPlugin): for task_name, task_type in tasks_to_create: task_entity = self.create_task( - name=task_name, - task_type=task_type, - parent=entity + task_name, + task_type, + entity, + ftrack_status_by_task_id ) - for instance in instances_by_task_name[task_name.lower()]: instance.data["ftrackTask"] = task_entity @@ -481,7 +481,7 @@ class IntegrateHierarchyToFtrack(pyblish.api.ContextPlugin): for status in task_workflow_statuses } - def create_task(self, name, task_type, parent): + def create_task(self, name, task_type, parent, ftrack_status_by_task_id): filter_data = { "task_names": name, "task_types": task_type @@ -491,12 +491,14 @@ class IntegrateHierarchyToFtrack(pyblish.api.ContextPlugin): filter_data ) status_id = None + status_name = None if profile: status_name = profile["status_name"] status_name_low = status_name.lower() for _status_id, status in self.task_statuses.items(): if status["name"].lower() == status_name_low: status_id = _status_id + status_name = status["name"] break if status_id is None: @@ -523,6 +525,8 @@ class IntegrateHierarchyToFtrack(pyblish.api.ContextPlugin): self.session._configure_locations() six.reraise(tp, value, tb) + if status_id is not None: + ftrack_status_by_task_id[task["id"]] = None return task def _get_active_assets(self, context): diff --git a/openpype/modules/interfaces.py b/openpype/modules/interfaces.py index 8c9a6ee1dd..0d73bc35a3 100644 --- a/openpype/modules/interfaces.py +++ b/openpype/modules/interfaces.py @@ -33,8 +33,8 @@ class OpenPypeInterface: class IPluginPaths(OpenPypeInterface): """Module has plugin paths to return. - Expected result is dictionary with keys "publish", "create", "load" or - "actions" and values as list or string. + Expected result is dictionary with keys "publish", "create", "load", + "actions" or "inventory" and values as list or string. { "publish": ["path/to/publish_plugins"] } @@ -109,6 +109,21 @@ class IPluginPaths(OpenPypeInterface): return self._get_plugin_paths_by_type("publish") + def get_inventory_action_paths(self, host_name): + """Receive inventory action paths. + + Give addons ability to add inventory action plugin paths. + + Notes: + Default implementation uses 'get_plugin_paths' and always return + all publish plugin paths. + + Args: + host_name (str): For which host are the plugins meant. + """ + + return self._get_plugin_paths_by_type("inventory") + class ILaunchHookPaths(OpenPypeInterface): """Module has launch hook paths to return. @@ -395,13 +410,11 @@ class ITrayService(ITrayModule): class ISettingsChangeListener(OpenPypeInterface): - """Module has plugin paths to return. + """Module tries to listen to settings changes. + + Only settings changes in the current process are propagated. + Changes made in other processes or machines won't trigger the callbacks. - Expected result is dictionary with keys "publish", "create", "load" or - "actions" and values as list or string. - { - "publish": ["path/to/publish_plugins"] - } """ @abstractmethod diff --git a/openpype/pipeline/colorspace.py b/openpype/pipeline/colorspace.py index 899b14148b..1999ad3bed 100644 --- a/openpype/pipeline/colorspace.py +++ b/openpype/pipeline/colorspace.py @@ -312,7 +312,8 @@ def get_views_data_subprocess(config_path): def get_imageio_config( - project_name, host_name, + project_name, + host_name, project_settings=None, anatomy_data=None, anatomy=None @@ -325,12 +326,9 @@ def get_imageio_config( Args: project_name (str): project name host_name (str): host name - project_settings (dict, optional): project settings. - Defaults to None. - anatomy_data (dict, optional): anatomy formatting data. - Defaults to None. - anatomy (lib.Anatomy, optional): Anatomy object. - Defaults to None. + project_settings (Optional[dict]): Project settings. + anatomy_data (Optional[dict]): anatomy formatting data. + anatomy (Optional[Anatomy]): Anatomy object. Returns: dict: config path data or empty dict @@ -345,37 +343,36 @@ def get_imageio_config( formatting_data = deepcopy(anatomy_data) - # add project roots to anatomy data + # Add project roots to anatomy data formatting_data["root"] = anatomy.roots formatting_data["platform"] = platform.system().lower() - # get colorspace settings - # check if global settings group is having activate_global_color_management - # key at all. If it does't then default it to False - # this is for backward compatibility only - # TODO: in future rewrite this to be more explicit + # Get colorspace settings imageio_global, imageio_host = _get_imageio_settings( project_settings, host_name) - activate_color_management = ( - imageio_global.get("activate_global_color_management", False) - # for already saved overrides from previous version - # TODO: remove this in future - backward compatibility - or imageio_host.get("ocio_config").get("enabled") - ) + # Host 'ocio_config' is optional + host_ocio_config = imageio_host.get("ocio_config") or {} + + # Global color management must be enabled to be able to use host settings + activate_color_management = imageio_global.get( + "activate_global_color_management") + # TODO: remove this in future - backward compatibility + # For already saved overrides from previous version look for 'enabled' + # on host settings. + if activate_color_management is None: + activate_color_management = host_ocio_config.get("enabled", False) if not activate_color_management: # if global settings are disabled return empty dict because # it is expected that no colorspace management is needed - log.info( - "Colorspace management is disabled globally." - ) + log.info("Colorspace management is disabled globally.") return {} - # check if host settings group is having activate_host_color_management - # if it does not have activation key then default it to True so it uses - # global settings - # this is for backward compatibility + # Check if host settings group is having 'activate_host_color_management' + # - if it does not have activation key then default it to True so it uses + # global settings + # This is for backward compatibility. # TODO: in future rewrite this to be more explicit activate_host_color_management = imageio_host.get( "activate_host_color_management", True) @@ -389,21 +386,18 @@ def get_imageio_config( ) return {} - config_host = imageio_host.get("ocio_config", {}) - - # get config path from either global or host_name + # get config path from either global or host settings # depending on override flag # TODO: in future rewrite this to be more explicit - config_data = None - override_global_config = ( - config_host.get("override_global_config") + override_global_config = host_ocio_config.get("override_global_config") + if override_global_config is None: # for already saved overrides from previous version # TODO: remove this in future - backward compatibility - or config_host.get("enabled") - ) + override_global_config = host_ocio_config.get("enabled") + if override_global_config: config_data = _get_config_data( - config_host["filepath"], formatting_data + host_ocio_config["filepath"], formatting_data ) else: # get config path from global @@ -507,34 +501,35 @@ def get_imageio_file_rules(project_name, host_name, project_settings=None): frules_host = imageio_host.get("file_rules", {}) # compile file rules dictionary - activate_host_rules = ( - frules_host.get("activate_host_rules") + activate_host_rules = frules_host.get("activate_host_rules") + if activate_host_rules is None: # TODO: remove this in future - backward compatibility - or frules_host.get("enabled") - ) + activate_host_rules = frules_host.get("enabled", False) # return host rules if activated or global rules return frules_host["rules"] if activate_host_rules else global_rules def get_remapped_colorspace_to_native( - ocio_colorspace_name, host_name, imageio_host_settings): + ocio_colorspace_name, host_name, imageio_host_settings +): """Return native colorspace name. Args: ocio_colorspace_name (str | None): ocio colorspace name + host_name (str): Host name. + imageio_host_settings (dict[str, Any]): ImageIO host settings. Returns: - str: native colorspace name defined in remapping or None + Union[str, None]: native colorspace name defined in remapping or None """ - if not CashedData.remapping.get(host_name, {}).get("to_native"): + CashedData.remapping.setdefault(host_name, {}) + if CashedData.remapping[host_name].get("to_native") is None: remapping_rules = imageio_host_settings["remapping"]["rules"] - CashedData.remapping[host_name] = { - "to_native": { - rule["ocio_name"]: input["host_native_name"] - for rule in remapping_rules - } + CashedData.remapping[host_name]["to_native"] = { + rule["ocio_name"]: rule["host_native_name"] + for rule in remapping_rules } return CashedData.remapping[host_name]["to_native"].get( @@ -542,23 +537,25 @@ def get_remapped_colorspace_to_native( def get_remapped_colorspace_from_native( - host_native_colorspace_name, host_name, imageio_host_settings): + host_native_colorspace_name, host_name, imageio_host_settings +): """Return ocio colorspace name remapped from host native used name. Args: host_native_colorspace_name (str): host native colorspace name + host_name (str): Host name. + imageio_host_settings (dict[str, Any]): ImageIO host settings. Returns: - str: ocio colorspace name defined in remapping or None + Union[str, None]: Ocio colorspace name defined in remapping or None. """ - if not CashedData.remapping.get(host_name, {}).get("from_native"): + CashedData.remapping.setdefault(host_name, {}) + if CashedData.remapping[host_name].get("from_native") is None: remapping_rules = imageio_host_settings["remapping"]["rules"] - CashedData.remapping[host_name] = { - "from_native": { - input["host_native_name"]: rule["ocio_name"] - for rule in remapping_rules - } + CashedData.remapping[host_name]["from_native"] = { + rule["host_native_name"]: rule["ocio_name"] + for rule in remapping_rules } return CashedData.remapping[host_name]["from_native"].get( diff --git a/openpype/pipeline/context_tools.py b/openpype/pipeline/context_tools.py index ada78b989d..97a5c1ba69 100644 --- a/openpype/pipeline/context_tools.py +++ b/openpype/pipeline/context_tools.py @@ -181,6 +181,11 @@ def install_openpype_plugins(project_name=None, host_name=None): for path in load_plugin_paths: register_loader_plugin_path(path) + inventory_action_paths = modules_manager.collect_inventory_action_paths( + host_name) + for path in inventory_action_paths: + register_inventory_action_path(path) + if project_name is None: project_name = os.environ.get("AVALON_PROJECT") diff --git a/openpype/pipeline/create/context.py b/openpype/pipeline/create/context.py index 2fc0669732..332e271b0d 100644 --- a/openpype/pipeline/create/context.py +++ b/openpype/pipeline/create/context.py @@ -1441,6 +1441,19 @@ class CreateContext: """Access to global publish attributes.""" return self._publish_attributes + def get_instance_by_id(self, instance_id): + """Receive instance by id. + + Args: + instance_id (str): Instance id. + + Returns: + Union[CreatedInstance, None]: Instance or None if instance with + given id is not available. + """ + + return self._instances_by_id.get(instance_id) + def get_sorted_creators(self, identifiers=None): """Sorted creators by 'order' attribute. diff --git a/openpype/plugins/publish/collect_from_create_context.py b/openpype/plugins/publish/collect_from_create_context.py index 4888476fff..8806a13ca0 100644 --- a/openpype/plugins/publish/collect_from_create_context.py +++ b/openpype/plugins/publish/collect_from_create_context.py @@ -16,7 +16,7 @@ class CollectFromCreateContext(pyblish.api.ContextPlugin): order = pyblish.api.CollectorOrder - 0.5 def process(self, context): - create_context = context.data.pop("create_context", None) + create_context = context.data.get("create_context") if not create_context: host = registered_host() if isinstance(host, IPublishHost): diff --git a/openpype/pype_commands.py b/openpype/pype_commands.py index 6a24cb0ebc..56a0fe60cd 100644 --- a/openpype/pype_commands.py +++ b/openpype/pype_commands.py @@ -356,6 +356,13 @@ class PypeCommands: def pack_project(self, project_name, dirpath, database_only): from openpype.lib.project_backpack import pack_project + if database_only and not dirpath: + raise ValueError(( + "Destination dir must be defined when using --dbonly." + " Use '--dirpath {output dir path}' flag" + " to specify directory." + )) + pack_project(project_name, dirpath, database_only) def unpack_project(self, zip_filepath, new_root, database_only): diff --git a/openpype/settings/defaults/project_settings/ftrack.json b/openpype/settings/defaults/project_settings/ftrack.json index 4ca4a35d1f..b87c45666d 100644 --- a/openpype/settings/defaults/project_settings/ftrack.json +++ b/openpype/settings/defaults/project_settings/ftrack.json @@ -493,7 +493,29 @@ "upload_reviewable_with_origin_name": false }, "IntegrateFtrackFarmStatus": { - "farm_status_profiles": [] + "farm_status_profiles": [ + { + "hosts": [ + "celaction" + ], + "task_types": [], + "task_names": [], + "families": [ + "render" + ], + "subsets": [], + "status_name": "Render" + } + ] + }, + "ftrack_task_status_local_publish": { + "status_profiles": [] + }, + "ftrack_task_status_on_farm_publish": { + "status_profiles": [] + }, + "IntegrateFtrackTaskStatus": { + "after_version_statuses": true } } } diff --git a/openpype/settings/defaults/project_settings/traypublisher.json b/openpype/settings/defaults/project_settings/traypublisher.json index 3a42c93515..4c2c2f1391 100644 --- a/openpype/settings/defaults/project_settings/traypublisher.json +++ b/openpype/settings/defaults/project_settings/traypublisher.json @@ -23,6 +23,7 @@ "detailed_description": "Workfiles are full scenes from any application that are directly edited by artists. They represent a state of work on a task at a given point and are usually not directly referenced into other scenes.", "allow_sequences": false, "allow_multiple_items": false, + "allow_version_control": false, "extensions": [ ".ma", ".mb", @@ -57,6 +58,7 @@ "detailed_description": "Models should only contain geometry data, without any extras like cameras, locators or bones.\n\nKeep in mind that models published from tray publisher are not validated for correctness. ", "allow_sequences": false, "allow_multiple_items": true, + "allow_version_control": false, "extensions": [ ".ma", ".mb", @@ -82,6 +84,7 @@ "detailed_description": "Alembic or bgeo cache of animated data", "allow_sequences": true, "allow_multiple_items": true, + "allow_version_control": false, "extensions": [ ".abc", ".bgeo", @@ -105,6 +108,7 @@ "detailed_description": "Any type of image seqeuence coming from outside of the studio. Usually camera footage, but could also be animatics used for reference.", "allow_sequences": true, "allow_multiple_items": true, + "allow_version_control": false, "extensions": [ ".exr", ".png", @@ -127,6 +131,7 @@ "detailed_description": "Sequence or single file renders", "allow_sequences": true, "allow_multiple_items": true, + "allow_version_control": false, "extensions": [ ".exr", ".png", @@ -150,6 +155,7 @@ "detailed_description": "Ideally this should be only camera itself with baked animation, however, it can technically also include helper geometry.", "allow_sequences": false, "allow_multiple_items": true, + "allow_version_control": false, "extensions": [ ".abc", ".ma", @@ -174,6 +180,7 @@ "detailed_description": "Any image data can be published as image family. References, textures, concept art, matte paints. This is a fallback 2d family for everything that doesn't fit more specific family.", "allow_sequences": false, "allow_multiple_items": true, + "allow_version_control": false, "extensions": [ ".exr", ".jpg", @@ -197,6 +204,7 @@ "detailed_description": "Hierarchical data structure for the efficient storage and manipulation of sparse volumetric data discretized on three-dimensional grids", "allow_sequences": true, "allow_multiple_items": true, + "allow_version_control": false, "extensions": [ ".vdb" ] @@ -215,6 +223,7 @@ "detailed_description": "Script exported from matchmoving application to be later processed into a tracked camera with additional data", "allow_sequences": false, "allow_multiple_items": true, + "allow_version_control": false, "extensions": [] }, { @@ -227,6 +236,7 @@ "detailed_description": "CG rigged character or prop. Rig should be clean of any extra data and directly loadable into it's respective application\t", "allow_sequences": false, "allow_multiple_items": false, + "allow_version_control": false, "extensions": [ ".ma", ".blend", @@ -244,6 +254,7 @@ "detailed_description": "Texture files with Unreal Engine naming conventions", "allow_sequences": false, "allow_multiple_items": true, + "allow_version_control": false, "extensions": [] } ], @@ -322,6 +333,11 @@ "enabled": true, "optional": true, "active": true + }, + "ValidateExistingVersion": { + "enabled": true, + "optional": true, + "active": true } } } diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_ftrack.json b/openpype/settings/entities/schemas/projects_schema/schema_project_ftrack.json index 7050721742..157a8d297e 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_ftrack.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_ftrack.json @@ -1058,7 +1058,7 @@ { "type": "dict", "key": "IntegrateFtrackFarmStatus", - "label": "Integrate Ftrack Farm Status", + "label": "Ftrack Status To Farm", "children": [ { "type": "label", @@ -1068,7 +1068,7 @@ "type": "list", "collapsible": true, "key": "farm_status_profiles", - "label": "Farm status profiles", + "label": "Profiles", "use_label_wrap": true, "object_type": { "type": "dict", @@ -1114,6 +1114,142 @@ } } ] + }, + { + "type": "dict", + "key": "ftrack_task_status_local_publish", + "label": "Ftrack Status Local Integration", + "children": [ + { + "type": "label", + "label": "Change status of task when is integrated locally" + }, + { + "type": "list", + "collapsible": true, + "key": "status_profiles", + "label": "Profiles", + "use_label_wrap": true, + "object_type": { + "type": "dict", + "children": [ + { + "key": "host_names", + "label": "Host names", + "type": "hosts-enum", + "multiselection": true + }, + { + "key": "task_types", + "label": "Task types", + "type": "task-types-enum" + }, + { + "key": "task_names", + "label": "Task names", + "type": "list", + "object_type": "text" + }, + { + "key": "families", + "label": "Families", + "type": "list", + "object_type": "text" + }, + { + "key": "subset_names", + "label": "Subset names", + "type": "list", + "object_type": "text" + }, + { + "type": "separator" + }, + { + "key": "status_name", + "label": "Status name", + "type": "text" + } + ] + } + } + ] + }, + { + "type": "dict", + "key": "ftrack_task_status_on_farm_publish", + "label": "Ftrack Status On Farm", + "children": [ + { + "type": "label", + "label": "Change status of task when it's subset is integrated on farm" + }, + { + "type": "list", + "collapsible": true, + "key": "status_profiles", + "label": "Profiles", + "use_label_wrap": true, + "object_type": { + "type": "dict", + "children": [ + { + "key": "host_names", + "label": "Host names", + "type": "hosts-enum", + "multiselection": true + }, + { + "key": "task_types", + "label": "Task types", + "type": "task-types-enum" + }, + { + "key": "task_names", + "label": "Task names", + "type": "list", + "object_type": "text" + }, + { + "key": "families", + "label": "Families", + "type": "list", + "object_type": "text" + }, + { + "key": "subset_names", + "label": "Subset names", + "type": "list", + "object_type": "text" + }, + { + "type": "separator" + }, + { + "key": "status_name", + "label": "Status name", + "type": "text" + } + ] + } + } + ] + }, + { + "type": "dict", + "key": "IntegrateFtrackTaskStatus", + "label": "Integrate Ftrack Task Status", + "children": [ + { + "type": "label", + "label": "Apply collected task statuses. This plugin can run before or after version integration. Some status automations may conflict with status changes on versions because of wrong order." + }, + { + "type": "boolean", + "key": "after_version_statuses", + "label": "After version integration" + } + ] } ] } diff --git a/openpype/settings/entities/schemas/projects_schema/schema_project_traypublisher.json b/openpype/settings/entities/schemas/projects_schema/schema_project_traypublisher.json index 3703d82856..e75e2887db 100644 --- a/openpype/settings/entities/schemas/projects_schema/schema_project_traypublisher.json +++ b/openpype/settings/entities/schemas/projects_schema/schema_project_traypublisher.json @@ -85,6 +85,12 @@ "label": "Allow multiple items", "type": "boolean" }, + { + "type": "boolean", + "key": "allow_version_control", + "label": "Allow version control", + "default": false + }, { "type": "list", "key": "extensions", @@ -346,6 +352,10 @@ { "key": "ValidateFrameRange", "label": "Validate frame range" + }, + { + "key": "ValidateExistingVersion", + "label": "Validate Existing Version" } ] } diff --git a/openpype/tools/creator/model.py b/openpype/tools/creator/model.py index 7bb2757a11..6e905d0b56 100644 --- a/openpype/tools/creator/model.py +++ b/openpype/tools/creator/model.py @@ -53,6 +53,9 @@ class CreatorsModel(QtGui.QStandardItemModel): index = self.index(row, 0) item_id = index.data(ITEM_ID_ROLE) creator_plugin = self._creators_by_id.get(item_id) - if creator_plugin and creator_plugin.family == family: + if creator_plugin and ( + creator_plugin.label.lower() == family.lower() + or creator_plugin.family.lower() == family.lower() + ): indexes.append(index) return indexes diff --git a/openpype/tools/project_manager/project_manager/widgets.py b/openpype/tools/project_manager/project_manager/widgets.py index 06ae06e4d2..3154f777df 100644 --- a/openpype/tools/project_manager/project_manager/widgets.py +++ b/openpype/tools/project_manager/project_manager/widgets.py @@ -1,4 +1,5 @@ import re +import platform from openpype.client import get_projects, create_project from .constants import ( @@ -8,13 +9,16 @@ from .constants import ( from openpype.client.operations import ( PROJECT_NAME_ALLOWED_SYMBOLS, PROJECT_NAME_REGEX, + OperationsSession, ) from openpype.style import load_stylesheet from openpype.pipeline import AvalonMongoDB from openpype.tools.utils import ( PlaceholderLineEdit, - get_warning_pixmap + get_warning_pixmap, + PixmapLabel, ) +from openpype.settings.lib import get_default_anatomy_settings from qtpy import QtWidgets, QtCore, QtGui @@ -35,7 +39,7 @@ class NameTextEdit(QtWidgets.QLineEdit): sub_regex = "[^{}]+".format(NAME_ALLOWED_SYMBOLS) new_before_text = re.sub(sub_regex, "", before_text) new_after_text = re.sub(sub_regex, "", after_text) - idx -= (len(before_text) - len(new_before_text)) + idx -= len(before_text) - len(new_before_text) self.setText(new_before_text + new_after_text) self.setCursorPosition(idx) @@ -141,13 +145,40 @@ class CreateProjectDialog(QtWidgets.QDialog): inputs_widget = QtWidgets.QWidget(self) project_name_input = QtWidgets.QLineEdit(inputs_widget) project_code_input = QtWidgets.QLineEdit(inputs_widget) + project_width_input = NumScrollWidget(0, 9999999) + project_height_input = NumScrollWidget(0, 9999999) + project_fps_input = FloatScrollWidget(1, 9999999, decimals=3, step=1) + project_aspect_input = FloatScrollWidget( + 0, 9999999, decimals=2, step=0.1 + ) + project_frame_start_input = NumScrollWidget(-9999999, 9999999) + project_frame_end_input = NumScrollWidget(-9999999, 9999999) + + default_project_data = self.get_default_attributes() + project_width_input.setValue(default_project_data["resolutionWidth"]) + project_height_input.setValue(default_project_data["resolutionHeight"]) + project_fps_input.setValue(default_project_data["fps"]) + project_aspect_input.setValue(default_project_data["pixelAspect"]) + project_frame_start_input.setValue(default_project_data["frameStart"]) + project_frame_end_input.setValue(default_project_data["frameEnd"]) + library_project_input = QtWidgets.QCheckBox(inputs_widget) inputs_layout = QtWidgets.QFormLayout(inputs_widget) + if platform.system() == "Darwin": + inputs_layout.setFieldGrowthPolicy( + QtWidgets.QFormLayout.AllNonFixedFieldsGrow + ) inputs_layout.setContentsMargins(0, 0, 0, 0) inputs_layout.addRow("Project name:", project_name_input) inputs_layout.addRow("Project code:", project_code_input) inputs_layout.addRow("Library project:", library_project_input) + inputs_layout.addRow("Width:", project_width_input) + inputs_layout.addRow("Height:", project_height_input) + inputs_layout.addRow("FPS:", project_fps_input) + inputs_layout.addRow("Aspect:", project_aspect_input) + inputs_layout.addRow("Frame Start:", project_frame_start_input) + inputs_layout.addRow("Frame End:", project_frame_end_input) project_name_label = QtWidgets.QLabel(self) project_code_label = QtWidgets.QLabel(self) @@ -183,6 +214,12 @@ class CreateProjectDialog(QtWidgets.QDialog): self.project_name_input = project_name_input self.project_code_input = project_code_input self.library_project_input = library_project_input + self.project_width_input = project_width_input + self.project_height_input = project_height_input + self.project_fps_input = project_fps_input + self.project_aspect_input = project_aspect_input + self.project_frame_start_input = project_frame_start_input + self.project_frame_end_input = project_frame_end_input self.ok_btn = ok_btn @@ -190,6 +227,10 @@ class CreateProjectDialog(QtWidgets.QDialog): def project_name(self): return self.project_name_input.text() + def get_default_attributes(self): + settings = get_default_anatomy_settings() + return settings["attributes"] + def _on_project_name_change(self, value): if self._project_code_value is None: self._ignore_code_change = True @@ -215,12 +256,12 @@ class CreateProjectDialog(QtWidgets.QDialog): is_valid = False elif value in self.invalid_project_names: - message = "Project name \"{}\" already exist".format(value) + message = 'Project name "{}" already exist'.format(value) is_valid = False elif not PROJECT_NAME_REGEX.match(value): message = ( - "Project name \"{}\" contain not supported symbols" + 'Project name "{}" contain not supported symbols' ).format(value) is_valid = False @@ -237,12 +278,12 @@ class CreateProjectDialog(QtWidgets.QDialog): is_valid = False elif value in self.invalid_project_names: - message = "Project code \"{}\" already exist".format(value) + message = 'Project code "{}" already exist'.format(value) is_valid = False elif not PROJECT_NAME_REGEX.match(value): message = ( - "Project code \"{}\" contain not supported symbols" + 'Project code "{}" contain not supported symbols' ).format(value) is_valid = False @@ -264,9 +305,35 @@ class CreateProjectDialog(QtWidgets.QDialog): project_name = self.project_name_input.text() project_code = self.project_code_input.text() - library_project = self.library_project_input.isChecked() - create_project(project_name, project_code, library_project) + project_width = self.project_width_input.value() + project_height = self.project_height_input.value() + project_fps = self.project_fps_input.value() + project_aspect = self.project_aspect_input.value() + project_frame_start = self.project_frame_start_input.value() + project_frame_end = self.project_frame_end_input.value() + library_project = self.library_project_input.isChecked() + project_doc = create_project( + project_name, + project_code, + library_project, + ) + update_data = { + "data.resolutionWidth": project_width, + "data.resolutionHeight": project_height, + "data.fps": project_fps, + "data.pixelAspect": project_aspect, + "data.frameStart": project_frame_start, + "data.frameEnd": project_frame_end, + } + session = OperationsSession() + session.update_entity( + project_name, + project_doc["type"], + project_doc["_id"], + update_data, + ) + session.commit() self.done(1) def _get_existing_projects(self): @@ -288,45 +355,15 @@ class CreateProjectDialog(QtWidgets.QDialog): return project_names, project_codes -# TODO PixmapLabel should be moved to 'utils' in other future PR so should be -# imported from there -class PixmapLabel(QtWidgets.QLabel): - """Label resizing image to height of font.""" - def __init__(self, pixmap, parent): - super(PixmapLabel, self).__init__(parent) - self._empty_pixmap = QtGui.QPixmap(0, 0) - self._source_pixmap = pixmap - - def set_source_pixmap(self, pixmap): - """Change source image.""" - self._source_pixmap = pixmap - self._set_resized_pix() - +class ProjectManagerPixmapLabel(PixmapLabel): def _get_pix_size(self): size = self.fontMetrics().height() * 4 return size, size - def _set_resized_pix(self): - if self._source_pixmap is None: - self.setPixmap(self._empty_pixmap) - return - width, height = self._get_pix_size() - self.setPixmap( - self._source_pixmap.scaled( - width, - height, - QtCore.Qt.KeepAspectRatio, - QtCore.Qt.SmoothTransformation - ) - ) - - def resizeEvent(self, event): - self._set_resized_pix() - super(PixmapLabel, self).resizeEvent(event) - class ConfirmProjectDeletion(QtWidgets.QDialog): """Dialog which confirms deletion of a project.""" + def __init__(self, project_name, parent): super(ConfirmProjectDeletion, self).__init__(parent) @@ -335,23 +372,26 @@ class ConfirmProjectDeletion(QtWidgets.QDialog): top_widget = QtWidgets.QWidget(self) warning_pixmap = get_warning_pixmap() - warning_icon_label = PixmapLabel(warning_pixmap, top_widget) + warning_icon_label = ProjectManagerPixmapLabel( + warning_pixmap, top_widget + ) message_label = QtWidgets.QLabel(top_widget) message_label.setWordWrap(True) message_label.setTextInteractionFlags(QtCore.Qt.TextBrowserInteraction) - message_label.setText(( - "WARNING: This cannot be undone.

" - "Project \"{}\" with all related data will be" - " permanently removed from the database. (This action won't remove" - " any files on disk.)" - ).format(project_name)) + message_label.setText( + ( + "WARNING: This cannot be undone.

" + 'Project "{}" with all related data will be' + " permanently removed from the database." + " (This action won't remove any files on disk.)" + ).format(project_name) + ) top_layout = QtWidgets.QHBoxLayout(top_widget) top_layout.setContentsMargins(0, 0, 0, 0) top_layout.addWidget( - warning_icon_label, 0, - QtCore.Qt.AlignTop | QtCore.Qt.AlignHCenter + warning_icon_label, 0, QtCore.Qt.AlignTop | QtCore.Qt.AlignHCenter ) top_layout.addWidget(message_label, 1) @@ -359,7 +399,7 @@ class ConfirmProjectDeletion(QtWidgets.QDialog): confirm_input = PlaceholderLineEdit(self) confirm_input.setPlaceholderText( - "Type \"{}\" to confirm...".format(project_name) + 'Type "{}" to confirm...'.format(project_name) ) cancel_btn = QtWidgets.QPushButton("Cancel", self) @@ -429,6 +469,7 @@ class ConfirmProjectDeletion(QtWidgets.QDialog): class SpinBoxScrollFixed(QtWidgets.QSpinBox): """QSpinBox which only allow edits change with scroll wheel when active""" + def __init__(self, *args, **kwargs): super(SpinBoxScrollFixed, self).__init__(*args, **kwargs) self.setFocusPolicy(QtCore.Qt.StrongFocus) @@ -442,6 +483,7 @@ class SpinBoxScrollFixed(QtWidgets.QSpinBox): class DoubleSpinBoxScrollFixed(QtWidgets.QDoubleSpinBox): """QDoubleSpinBox which only allow edits with scroll wheel when active""" + def __init__(self, *args, **kwargs): super(DoubleSpinBoxScrollFixed, self).__init__(*args, **kwargs) self.setFocusPolicy(QtCore.Qt.StrongFocus) @@ -451,3 +493,22 @@ class DoubleSpinBoxScrollFixed(QtWidgets.QDoubleSpinBox): event.ignore() else: super(DoubleSpinBoxScrollFixed, self).wheelEvent(event) + + +class NumScrollWidget(SpinBoxScrollFixed): + def __init__(self, minimum, maximum): + super(NumScrollWidget, self).__init__() + self.setMaximum(maximum) + self.setMinimum(minimum) + self.setButtonSymbols(QtWidgets.QSpinBox.NoButtons) + + +class FloatScrollWidget(DoubleSpinBoxScrollFixed): + def __init__(self, minimum, maximum, decimals, step=None): + super(FloatScrollWidget, self).__init__() + self.setMaximum(maximum) + self.setMinimum(minimum) + self.setDecimals(decimals) + if step is not None: + self.setSingleStep(step) + self.setButtonSymbols(QtWidgets.QSpinBox.NoButtons) diff --git a/openpype/tools/publisher/window.py b/openpype/tools/publisher/window.py index 006098cb37..2bda0c1cfe 100644 --- a/openpype/tools/publisher/window.py +++ b/openpype/tools/publisher/window.py @@ -453,7 +453,11 @@ class PublisherWindow(QtWidgets.QDialog): return save_match = event.matches(QtGui.QKeySequence.Save) - if save_match == QtGui.QKeySequence.ExactMatch: + # PySide2 and PySide6 support + if not isinstance(save_match, bool): + save_match = save_match == QtGui.QKeySequence.ExactMatch + + if save_match: if not self._controller.publish_has_started: self._save_changes(True) event.accept() diff --git a/openpype/tools/standalonepublish/widgets/widget_family.py b/openpype/tools/standalonepublish/widgets/widget_family.py index 11c5ec33b7..8c18a93a00 100644 --- a/openpype/tools/standalonepublish/widgets/widget_family.py +++ b/openpype/tools/standalonepublish/widgets/widget_family.py @@ -128,7 +128,8 @@ class FamilyWidget(QtWidgets.QWidget): 'family_preset_key': key, 'family': family, 'subset': self.input_result.text(), - 'version': self.version_spinbox.value() + 'version': self.version_spinbox.value(), + 'use_next_available_version': self.version_checkbox.isChecked(), } return data diff --git a/openpype/version.py b/openpype/version.py index c44b1d29fb..9c5a60964b 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.15.11-nightly.2" +__version__ = "3.15.11-nightly.3"