diff --git a/openpype/resources/__init__.py b/openpype/resources/__init__.py index 34a833d080f..49eee21002f 100644 --- a/openpype/resources/__init__.py +++ b/openpype/resources/__init__.py @@ -14,6 +14,15 @@ def get_resource(*args): return os.path.normpath(os.path.join(RESOURCES_DIR, *args)) +def get_image_path(*args): + """Helper function to get images. + + Args: + *: Filepath part items. + """ + return get_resource("images", *args) + + def get_liberation_font_path(bold=False, italic=False): font_name = "LiberationSans" suffix = "" diff --git a/openpype/resources/images/warning.png b/openpype/resources/images/warning.png new file mode 100644 index 00000000000..3b4ae861f9f Binary files /dev/null and b/openpype/resources/images/warning.png differ diff --git a/openpype/settings/__init__.py b/openpype/settings/__init__.py index 9d7598a948d..14e46780509 100644 --- a/openpype/settings/__init__.py +++ b/openpype/settings/__init__.py @@ -5,6 +5,8 @@ PROJECT_ANATOMY_KEY, LOCAL_SETTING_KEY, + LEGACY_SETTINGS_VERSION, + SCHEMA_KEY_SYSTEM_SETTINGS, SCHEMA_KEY_PROJECT_SETTINGS, @@ -37,6 +39,8 @@ "PROJECT_ANATOMY_KEY", "LOCAL_SETTING_KEY", + "LEGACY_SETTINGS_VERSION", + "SCHEMA_KEY_SYSTEM_SETTINGS", "SCHEMA_KEY_PROJECT_SETTINGS", diff --git a/openpype/settings/constants.py b/openpype/settings/constants.py index 8cc991c999d..8b8acf57142 100644 --- a/openpype/settings/constants.py +++ b/openpype/settings/constants.py @@ -21,6 +21,8 @@ PROJECT_ANATOMY_KEY = "project_anatomy" LOCAL_SETTING_KEY = "local_settings" +LEGACY_SETTINGS_VERSION = "legacy" + # Schema hub names SCHEMA_KEY_SYSTEM_SETTINGS = "system_schema" SCHEMA_KEY_PROJECT_SETTINGS = "projects_schema" diff --git a/openpype/settings/entities/root_entities.py b/openpype/settings/entities/root_entities.py index 687784a3590..edb44076795 100644 --- a/openpype/settings/entities/root_entities.py +++ b/openpype/settings/entities/root_entities.py @@ -34,15 +34,24 @@ reset_default_settings, get_studio_system_settings_overrides, + get_studio_system_settings_overrides_for_version, save_studio_settings, + get_available_studio_system_settings_overrides_versions, get_studio_project_settings_overrides, + get_studio_project_settings_overrides_for_version, get_studio_project_anatomy_overrides, + get_studio_project_anatomy_overrides_for_version, get_project_settings_overrides, + get_project_settings_overrides_for_version, get_project_anatomy_overrides, save_project_settings, save_project_anatomy, + get_available_project_settings_overrides_versions, + get_available_studio_project_settings_overrides_versions, + get_available_studio_project_anatomy_overrides_versions, + find_environments, apply_overrides ) @@ -495,17 +504,27 @@ class SystemSettings(RootEntity): root_key = SYSTEM_SETTINGS_KEY def __init__( - self, set_studio_state=True, reset=True, schema_hub=None + self, + set_studio_state=True, + reset=True, + schema_hub=None, + source_version=None ): if schema_hub is None: # Load system schemas schema_hub = SchemasHub(SCHEMA_KEY_SYSTEM_SETTINGS) + self._source_version = source_version + super(SystemSettings, self).__init__(schema_hub, reset) if set_studio_state: self.set_studio_state() + @property + def source_version(self): + return self._source_version + def get_entity_from_path(self, path): """Return system settings entity.""" path_parts = path.split("/") @@ -524,12 +543,24 @@ def _reset_values(self): value = default_value.get(key, NOT_SET) child_obj.update_default_value(value) - studio_overrides = get_studio_system_settings_overrides() + if self._source_version is None: + studio_overrides, version = get_studio_system_settings_overrides( + return_version=True + ) + self._source_version = version + + else: + studio_overrides = ( + get_studio_system_settings_overrides_for_version( + self._source_version + ) + ) + for key, child_obj in self.non_gui_children.items(): value = studio_overrides.get(key, NOT_SET) child_obj.update_studio_value(value) - def reset(self, new_state=None): + def reset(self, new_state=None, source_version=None): """Discard changes and reset entit's values. Reload default values and studio override values and update entities. @@ -547,9 +578,22 @@ def reset(self, new_state=None): if new_state is OverrideState.PROJECT: raise ValueError("System settings can't store poject overrides.") + if source_version is not None: + self._source_version = source_version + self._reset_values() self.set_override_state(new_state) + def get_available_source_versions(self, sorted=None): + if self.is_in_studio_state(): + return self.get_available_studio_versions(sorted=sorted) + return [] + + def get_available_studio_versions(self, sorted=None): + return get_available_studio_system_settings_overrides_versions( + sorted=sorted + ) + def defaults_dir(self): """Path to defaults directory. @@ -566,6 +610,8 @@ def _save_studio_values(self): json.dumps(settings_value, indent=4) )) save_studio_settings(settings_value) + # Reset source version after restart + self._source_version = None def _validate_defaults_to_save(self, value): """Valiations of default values before save.""" @@ -622,11 +668,15 @@ def __init__( project_name=None, change_state=True, reset=True, - schema_hub=None + schema_hub=None, + source_version=None, + anatomy_source_version=None ): self._project_name = project_name self._system_settings_entity = None + self._source_version = source_version + self._anatomy_source_version = anatomy_source_version if schema_hub is None: # Load system schemas @@ -640,6 +690,14 @@ def __init__( else: self.set_project_state() + @property + def source_version(self): + return self._source_version + + @property + def anatomy_source_version(self): + return self._anatomy_source_version + @property def project_name(self): return self._project_name @@ -682,23 +740,20 @@ def get_entity_from_path(self, path): output = output[path_part] return output - def change_project(self, project_name): + def change_project(self, project_name, source_version=None): if project_name == self._project_name: - return - - self._project_name = project_name - if project_name is None: - self.set_studio_state() - return - - project_override_value = { - PROJECT_SETTINGS_KEY: get_project_settings_overrides(project_name), - PROJECT_ANATOMY_KEY: get_project_anatomy_overrides(project_name) - } - for key, child_obj in self.non_gui_children.items(): - value = project_override_value.get(key, NOT_SET) - child_obj.update_project_value(value) - + if ( + source_version is None + or source_version == self._source_version + ): + if not self.is_in_project_state(): + self.set_project_state() + return + + self._source_version = source_version + self._anatomy_source_version = None + + self._set_values_for_project(project_name) self.set_project_state() def _reset_values(self): @@ -710,27 +765,97 @@ def _reset_values(self): value = default_values.get(key, NOT_SET) child_obj.update_default_value(value) + self._set_values_for_project(self.project_name) + + def _set_values_for_project(self, project_name): + self._project_name = project_name + if project_name: + project_settings_overrides = ( + get_studio_project_settings_overrides() + ) + project_anatomy_overrides = ( + get_studio_project_anatomy_overrides() + ) + else: + if self._source_version is None: + project_settings_overrides, version = ( + get_studio_project_settings_overrides(return_version=True) + ) + self._source_version = version + else: + project_settings_overrides = ( + get_studio_project_settings_overrides_for_version( + self._source_version + ) + ) + + if self._anatomy_source_version is None: + project_anatomy_overrides, anatomy_version = ( + get_studio_project_anatomy_overrides(return_version=True) + ) + self._anatomy_source_version = anatomy_version + else: + project_anatomy_overrides = ( + get_studio_project_anatomy_overrides_for_version( + self._anatomy_source_version + ) + ) + studio_overrides = { - PROJECT_SETTINGS_KEY: get_studio_project_settings_overrides(), - PROJECT_ANATOMY_KEY: get_studio_project_anatomy_overrides() + PROJECT_SETTINGS_KEY: project_settings_overrides, + PROJECT_ANATOMY_KEY: project_anatomy_overrides } for key, child_obj in self.non_gui_children.items(): value = studio_overrides.get(key, NOT_SET) child_obj.update_studio_value(value) - if not self.project_name: + if not project_name: return - project_name = self.project_name + if self._source_version is None: + project_settings_overrides, version = ( + get_project_settings_overrides( + project_name, return_version=True + ) + ) + self._source_version = version + else: + project_settings_overrides = ( + get_project_settings_overrides_for_version( + project_name, self._source_version + ) + ) + project_override_value = { - PROJECT_SETTINGS_KEY: get_project_settings_overrides(project_name), + PROJECT_SETTINGS_KEY: project_settings_overrides, PROJECT_ANATOMY_KEY: get_project_anatomy_overrides(project_name) } for key, child_obj in self.non_gui_children.items(): value = project_override_value.get(key, NOT_SET) child_obj.update_project_value(value) + def get_available_source_versions(self, sorted=None): + if self.is_in_studio_state(): + return self.get_available_studio_versions(sorted=sorted) + elif self.is_in_project_state(): + return get_available_project_settings_overrides_versions( + self.project_name, sorted=sorted + ) + return [] + + def get_available_studio_versions(self, sorted=None): + return get_available_studio_project_settings_overrides_versions( + sorted=sorted + ) + + def get_available_anatomy_source_versions(self, sorted=None): + if self.is_in_studio_state(): + return get_available_studio_project_anatomy_overrides_versions( + sorted=sorted + ) + return [] + def reset(self, new_state=None): """Discard changes and reset entit's values. @@ -763,6 +888,9 @@ def _save_studio_values(self): self._validate_values_to_save(settings_value) + self._source_version = None + self._anatomy_source_version = None + self.log.debug("Saving project settings: {}".format( json.dumps(settings_value, indent=4) )) diff --git a/openpype/settings/handlers.py b/openpype/settings/handlers.py index af05bbf413a..4ad175168ce 100644 --- a/openpype/settings/handlers.py +++ b/openpype/settings/handlers.py @@ -1,23 +1,23 @@ import os import json import copy -import logging import collections import datetime from abc import ABCMeta, abstractmethod import six -import openpype + +import openpype.version + from .constants import ( GLOBAL_SETTINGS_KEY, SYSTEM_SETTINGS_KEY, PROJECT_SETTINGS_KEY, PROJECT_ANATOMY_KEY, LOCAL_SETTING_KEY, - M_OVERRIDDEN_KEY -) -from .lib import load_json_file + M_OVERRIDDEN_KEY, -JSON_EXC = getattr(json.decoder, "JSONDecodeError", ValueError) + LEGACY_SETTINGS_VERSION +) @six.add_metaclass(ABCMeta) @@ -67,26 +67,27 @@ def save_project_anatomy(self, project_name, anatomy_data): pass @abstractmethod - def get_studio_system_settings_overrides(self): + def get_studio_system_settings_overrides(self, return_version): """Studio overrides of system settings.""" pass @abstractmethod - def get_studio_project_settings_overrides(self): + def get_studio_project_settings_overrides(self, return_version): """Studio overrides of default project settings.""" pass @abstractmethod - def get_studio_project_anatomy_overrides(self): + def get_studio_project_anatomy_overrides(self, return_version): """Studio overrides of default project anatomy data.""" pass @abstractmethod - def get_project_settings_overrides(self, project_name): + def get_project_settings_overrides(self, project_name, return_version): """Studio overrides of project settings for specific project. Args: project_name(str): Name of project for which data should be loaded. + return_version(bool): Version string will be added to output. Returns: dict: Only overrides for entered project, may be empty dictionary. @@ -94,17 +95,164 @@ def get_project_settings_overrides(self, project_name): pass @abstractmethod - def get_project_anatomy_overrides(self, project_name): + def get_project_anatomy_overrides(self, project_name, return_version): """Studio overrides of project anatomy for specific project. Args: project_name(str): Name of project for which data should be loaded. + return_version(bool): Version string will be added to output. Returns: dict: Only overrides for entered project, may be empty dictionary. """ pass + # Getters for specific version overrides + @abstractmethod + def get_studio_system_settings_overrides_for_version(self, version): + """Studio system settings overrides for specific version. + + Args: + version(str): OpenPype version for which settings should be + returned. + + Returns: + None: If the version does not have system settings overrides. + dict: Document with overrides data. + """ + pass + + @abstractmethod + def get_studio_project_anatomy_overrides_for_version(self, version): + """Studio project anatomy overrides for specific version. + + Args: + version(str): OpenPype version for which settings should be + returned. + + Returns: + None: If the version does not have system settings overrides. + dict: Document with overrides data. + """ + pass + + @abstractmethod + def get_studio_project_settings_overrides_for_version(self, version): + """Studio project settings overrides for specific version. + + Args: + version(str): OpenPype version for which settings should be + returned. + + Returns: + None: If the version does not have system settings overrides. + dict: Document with overrides data. + """ + pass + + @abstractmethod + def get_project_settings_overrides_for_version( + self, project_name, version + ): + """Studio project settings overrides for specific project and version. + + Args: + project_name(str): Name of project for which the overrides should + be loaded. + version(str): OpenPype version for which settings should be + returned. + + Returns: + None: If the version does not have system settings overrides. + dict: Document with overrides data. + """ + pass + + # Clear methods - per version + # - clearing may be helpfull when a version settings were created for + # testing purposes + @abstractmethod + def clear_studio_system_settings_overrides_for_version(self, version): + """Remove studio system settings overrides for specific version. + + If version is not available then skip processing. + """ + pass + + @abstractmethod + def clear_studio_project_settings_overrides_for_version(self, version): + """Remove studio project settings overrides for specific version. + + If version is not available then skip processing. + """ + pass + + @abstractmethod + def clear_studio_project_anatomy_overrides_for_version(self, version): + """Remove studio project anatomy overrides for specific version. + + If version is not available then skip processing. + """ + pass + + @abstractmethod + def clear_project_settings_overrides_for_version( + self, version, project_name + ): + """Remove studio project settings overrides for project and version. + + If version is not available then skip processing. + """ + pass + + # Get versions that are available for each type of settings + @abstractmethod + def get_available_studio_system_settings_overrides_versions( + self, sorted=None + ): + """OpenPype versions that have any studio system settings overrides. + + Returns: + list: OpenPype versions strings. + """ + pass + + @abstractmethod + def get_available_studio_project_anatomy_overrides_versions( + self, sorted=None + ): + """OpenPype versions that have any studio project anatomy overrides. + + Returns: + list: OpenPype versions strings. + """ + pass + + @abstractmethod + def get_available_studio_project_settings_overrides_versions( + self, sorted=None + ): + """OpenPype versions that have any studio project settings overrides. + + Returns: + list: OpenPype versions strings. + """ + pass + + @abstractmethod + def get_available_project_settings_overrides_versions( + self, project_name, sorted=None + ): + """OpenPype versions that have any project settings overrides. + + Args: + project_name(str): Name of project. + + Returns: + list: OpenPype versions strings. + """ + pass + @six.add_metaclass(ABCMeta) class LocalSettingsHandler: @@ -134,17 +282,20 @@ class CacheValues: def __init__(self): self.data = None self.creation_time = None + self.version = None def data_copy(self): if not self.data: return {} return copy.deepcopy(self.data) - def update_data(self, data): + def update_data(self, data, version=None): self.data = data self.creation_time = datetime.datetime.now() + if version is not None: + self.version = version - def update_from_document(self, document): + def update_from_document(self, document, version=None): data = {} if document: if "data" in document: @@ -154,6 +305,8 @@ def update_from_document(self, document): if value: data = json.loads(value) self.data = data + if version is not None: + self.version = version def to_json_string(self): return json.dumps(self.data or {}) @@ -175,6 +328,11 @@ class MongoSettingsHandler(SettingsHandler): "production_version", "staging_version" ) + key_suffix = "_versioned" + _version_order_key = "versions_order" + _all_versions_keys = "all_versions" + _production_versions_key = "production_versions" + _staging_versions_key = "staging_versions" def __init__(self): # Get mongo connection @@ -185,8 +343,13 @@ def __init__(self): self._anatomy_keys = None self._attribute_keys = None - # TODO prepare version of pype - # - pype version should define how are settings saved and loaded + + self._version_order_checked = False + + self._system_settings_key = SYSTEM_SETTINGS_KEY + self.key_suffix + self._project_settings_key = PROJECT_SETTINGS_KEY + self.key_suffix + self._project_anatomy_key = PROJECT_ANATOMY_KEY + self.key_suffix + self._current_version = openpype.version.__version__ database_name = os.environ["OPENPYPE_DATABASE_NAME"] # TODO modify to not use hardcoded keys @@ -345,7 +508,7 @@ def save_studio_settings(self, data): data(dict): Data of studio overrides with override metadata. """ # Update cache - self.system_settings_cache.update_data(data) + self.system_settings_cache.update_data(data, self._current_version) # Get copy of just updated cache system_settings_data = self.system_settings_cache.data_copy() @@ -358,11 +521,13 @@ def save_studio_settings(self, data): # Store system settings self.collection.replace_one( { - "type": SYSTEM_SETTINGS_KEY + "type": self._system_settings_key, + "version": self._current_version }, { - "type": SYSTEM_SETTINGS_KEY, - "data": system_settings_data + "type": self._system_settings_key, + "data": system_settings_data, + "version": self._current_version }, upsert=True ) @@ -396,10 +561,10 @@ def save_project_settings(self, project_name, overrides): data(dict): Data of project overrides with override metadata. """ data_cache = self.project_settings_cache[project_name] - data_cache.update_data(overrides) + data_cache.update_data(overrides, self._current_version) self._save_project_data( - project_name, PROJECT_SETTINGS_KEY, data_cache + project_name, self._project_settings_key, data_cache ) def save_project_anatomy(self, project_name, anatomy_data): @@ -411,14 +576,14 @@ def save_project_anatomy(self, project_name, anatomy_data): data(dict): Data of project overrides with override metadata. """ data_cache = self.project_anatomy_cache[project_name] - data_cache.update_data(anatomy_data) + data_cache.update_data(anatomy_data, self._current_version) if project_name is not None: self._save_project_anatomy_data(project_name, data_cache) else: self._save_project_data( - project_name, PROJECT_ANATOMY_KEY, data_cache + project_name, self._project_anatomy_key, data_cache ) @classmethod @@ -505,12 +670,14 @@ def _save_project_data(self, project_name, doc_type, data_cache): is_default = bool(project_name is None) replace_filter = { "type": doc_type, - "is_default": is_default + "is_default": is_default, + "version": self._current_version } replace_data = { "type": doc_type, "data": data_cache.data, - "is_default": is_default + "is_default": is_default, + "version": self._current_version } if not is_default: replace_filter["project_name"] = project_name @@ -522,52 +689,388 @@ def _save_project_data(self, project_name, doc_type, data_cache): upsert=True ) - def get_studio_system_settings_overrides(self): + def _get_versions_order_doc(self, projection=None): + # TODO cache + return self.collection.find_one( + {"type": self._version_order_key}, + projection + ) + + def _check_version_order(self): + """This method will work only in OpenPype process. + + Will create/update mongo document where OpenPype versions are stored + in semantic version order. + + This document can be then used to find closes version of settings in + processes where 'OpenPypeVersion' is not available. + """ + # Do this step only once + if self._version_order_checked: + return + self._version_order_checked = True + + from openpype.lib.openpype_version import ( + get_OpenPypeVersion, + is_running_staging + ) + + OpenPypeVersion = get_OpenPypeVersion() + # Skip if 'OpenPypeVersion' is not available + if OpenPypeVersion is None: + return + + # Query document holding sorted list of version strings + doc = self._get_versions_order_doc() + if not doc: + doc = {"type": self._version_order_key} + + if self._production_versions_key not in doc: + doc[self._production_versions_key] = [] + + if self._staging_versions_key not in doc: + doc[self._staging_versions_key] = [] + + if self._all_versions_keys not in doc: + doc[self._all_versions_keys] = [] + + if is_running_staging(): + versions_key = self._staging_versions_key + else: + versions_key = self._production_versions_key + + # Skip if current version is already available + if ( + self._current_version in doc[self._all_versions_keys] + and self._current_version in doc[versions_key] + ): + return + + if self._current_version not in doc[self._all_versions_keys]: + # Add all versions into list + all_objected_versions = [ + OpenPypeVersion(version=self._current_version) + ] + for version_str in doc[self._all_versions_keys]: + all_objected_versions.append( + OpenPypeVersion(version=version_str) + ) + + doc[self._all_versions_keys] = [ + str(version) for version in sorted(all_objected_versions) + ] + + if self._current_version not in doc[versions_key]: + objected_versions = [ + OpenPypeVersion(version=self._current_version) + ] + for version_str in doc[versions_key]: + objected_versions.append(OpenPypeVersion(version=version_str)) + + # Update versions list and push changes to Mongo + doc[versions_key] = [ + str(version) for version in sorted(objected_versions) + ] + + self.collection.replace_one( + {"type": self._version_order_key}, + doc, + upsert=True + ) + + def find_closest_version_for_projects(self, project_names): + output = { + project_name: None + for project_name in project_names + } + from openpype.lib.openpype_version import get_OpenPypeVersion + OpenPypeVersion = get_OpenPypeVersion() + if OpenPypeVersion is None: + return output + + versioned_doc = self._get_versions_order_doc() + + settings_ids = [] + for project_name in project_names: + if project_name is None: + doc_filter = {"is_default": True} + else: + doc_filter = {"project_name": project_name} + settings_id = self._find_closest_settings_id( + self._project_settings_key, + PROJECT_SETTINGS_KEY, + doc_filter, + versioned_doc + ) + if settings_id: + settings_ids.append(settings_id) + + if settings_ids: + docs = self.collection.find( + {"_id": {"$in": settings_ids}}, + {"version": True, "project_name": True} + ) + for doc in docs: + project_name = doc.get("project_name") + version = doc.get("version", LEGACY_SETTINGS_VERSION) + output[project_name] = version + return output + + def _find_closest_settings_id( + self, key, legacy_key, additional_filters=None, versioned_doc=None + ): + """Try to find closes available versioned settings for settings key. + + This method should be used only if settings for current OpenPype + version are not available. + + Args: + key(str): Settings key under which are settings stored ("type"). + legacy_key(str): Settings key under which were stored not versioned + settings. + additional_filters(dict): Additional filters of document. Used + for project specific settings. + """ + # Trigger check of versions + self._check_version_order() + + doc_filters = { + "type": {"$in": [key, legacy_key]} + } + if additional_filters: + doc_filters.update(additional_filters) + + # Query base data of each settings doc + other_versions = self.collection.find( + doc_filters, + { + "_id": True, + "version": True, + "type": True + } + ) + # Query doc with list of sorted versions + if versioned_doc is None: + versioned_doc = self._get_versions_order_doc() + + # Separate queried docs + legacy_settings_doc = None + versioned_settings_by_version = {} + for doc in other_versions: + if doc["type"] == legacy_key: + legacy_settings_doc = doc + elif doc["type"] == key: + if doc["version"] == self._current_version: + return doc["_id"] + versioned_settings_by_version[doc["version"]] = doc + + versions_in_doc = versioned_doc.get(self._all_versions_keys) or [] + # Cases when only legacy settings can be used + if ( + # There are not versioned documents yet + not versioned_settings_by_version + # Versioned document is not available at all + # - this can happen only if old build of OpenPype was used + or not versioned_doc + # Current OpenPype version is not available + # - something went really wrong when this happens + or self._current_version not in versions_in_doc + ): + if not legacy_settings_doc: + return None + return legacy_settings_doc["_id"] + + # Separate versions to lower and higher and keep their order + lower_versions = [] + higher_versions = [] + before = True + for version_str in versions_in_doc: + if version_str == self._current_version: + before = False + elif before: + lower_versions.append(version_str) + else: + higher_versions.append(version_str) + + # Use legacy settings doc as source document + src_doc_id = None + if legacy_settings_doc: + src_doc_id = legacy_settings_doc["_id"] + + # Find highest version which has available settings + if lower_versions: + for version_str in reversed(lower_versions): + doc = versioned_settings_by_version.get(version_str) + if doc: + src_doc_id = doc["_id"] + break + + # Use versions with higher version only if there are not legacy + # settings and there are not any versions before + if src_doc_id is None and higher_versions: + for version_str in higher_versions: + doc = versioned_settings_by_version.get(version_str) + if doc: + src_doc_id = doc["_id"] + break + + return src_doc_id + + def _find_closest_settings( + self, key, legacy_key, additional_filters=None, versioned_doc=None + ): + doc_id = self._find_closest_settings_id( + key, legacy_key, additional_filters, versioned_doc + ) + if doc_id is None: + return None + return self.collection.find_one({"_id": doc_id}) + + def _find_closest_system_settings(self): + return self._find_closest_settings( + self._system_settings_key, + SYSTEM_SETTINGS_KEY + ) + + def _find_closest_project_settings(self, project_name): + if project_name is None: + additional_filters = {"is_default": True} + else: + additional_filters = {"project_name": project_name} + + return self._find_closest_settings( + self._project_settings_key, + PROJECT_SETTINGS_KEY, + additional_filters + ) + + def _find_closest_project_anatomy(self): + additional_filters = {"is_default": True} + return self._find_closest_settings( + self._project_anatomy_key, + PROJECT_ANATOMY_KEY, + additional_filters + ) + + def _get_studio_system_settings_overrides_for_version(self, version=None): + # QUESTION cache? + if version == LEGACY_SETTINGS_VERSION: + return self.collection.find_one({ + "type": SYSTEM_SETTINGS_KEY + }) + + if version is None: + version = self._current_version + + return self.collection.find_one({ + "type": self._system_settings_key, + "version": version + }) + + def _get_project_settings_overrides_for_version( + self, project_name, version=None + ): + # QUESTION cache? + if version == LEGACY_SETTINGS_VERSION: + document_filter = { + "type": PROJECT_SETTINGS_KEY + } + + else: + if version is None: + version = self._current_version + + document_filter = { + "type": self._project_settings_key, + "version": version + } + + if project_name is None: + document_filter["is_default"] = True + else: + document_filter["project_name"] = project_name + return self.collection.find_one(document_filter) + + def _get_project_anatomy_overrides_for_version(self, version=None): + # QUESTION cache? + if version == LEGACY_SETTINGS_VERSION: + return self.collection.find_one({ + "type": PROJECT_SETTINGS_KEY, + "is_default": True + }) + + if version is None: + version = self._current_version + + return self.collection.find_one({ + "type": self._project_settings_key, + "is_default": True, + "version": version + }) + + def get_studio_system_settings_overrides(self, return_version): """Studio overrides of system settings.""" if self.system_settings_cache.is_outdated: - system_settings_document = None - globals_document = None - docs = self.collection.find({ - # Use `$or` as system settings may have more filters in future - "$or": [ - {"type": GLOBAL_SETTINGS_KEY}, - {"type": SYSTEM_SETTINGS_KEY}, - ] + globals_document = self.collection.find_one({ + "type": GLOBAL_SETTINGS_KEY }) - for doc in docs: - doc_type = doc["type"] - if doc_type == GLOBAL_SETTINGS_KEY: - globals_document = doc - elif doc_type == SYSTEM_SETTINGS_KEY: - system_settings_document = doc + document = ( + self._get_studio_system_settings_overrides_for_version() + ) + if document is None: + document = self._find_closest_system_settings() + + version = None + if document: + if document["type"] == self._system_settings_key: + version = document["version"] + else: + version = LEGACY_SETTINGS_VERSION merged_document = self._apply_global_settings( - system_settings_document, globals_document + document, globals_document + ) + + self.system_settings_cache.update_from_document( + merged_document, version ) - self.system_settings_cache.update_from_document(merged_document) - return self.system_settings_cache.data_copy() + cache = self.system_settings_cache + data = cache.data_copy() + if return_version: + return data, cache.version + return data - def _get_project_settings_overrides(self, project_name): + def _get_project_settings_overrides(self, project_name, return_version): if self.project_settings_cache[project_name].is_outdated: - document_filter = { - "type": PROJECT_SETTINGS_KEY, - } - if project_name is None: - document_filter["is_default"] = True - else: - document_filter["project_name"] = project_name - document = self.collection.find_one(document_filter) + document = self._get_project_settings_overrides_for_version( + project_name + ) + if document is None: + document = self._find_closest_project_settings(project_name) + + version = None + if document: + if document["type"] == self._project_settings_key: + version = document["version"] + else: + version = LEGACY_SETTINGS_VERSION + self.project_settings_cache[project_name].update_from_document( - document + document, version ) - return self.project_settings_cache[project_name].data_copy() - def get_studio_project_settings_overrides(self): + cache = self.project_settings_cache[project_name] + data = cache.data_copy() + if return_version: + return data, cache.version + return data + + def get_studio_project_settings_overrides(self, return_version): """Studio overrides of default project settings.""" - return self._get_project_settings_overrides(None) + return self._get_project_settings_overrides(None, return_version) - def get_project_settings_overrides(self, project_name): + def get_project_settings_overrides(self, project_name, return_version): """Studio overrides of project settings for specific project. Args: @@ -577,8 +1080,12 @@ def get_project_settings_overrides(self, project_name): dict: Only overrides for entered project, may be empty dictionary. """ if not project_name: + if return_version: + return {}, None return {} - return self._get_project_settings_overrides(project_name) + return self._get_project_settings_overrides( + project_name, return_version + ) def project_doc_to_anatomy_data(self, project_doc): """Convert project document to anatomy data. @@ -620,29 +1127,39 @@ def project_doc_to_anatomy_data(self, project_doc): return output - def _get_project_anatomy_overrides(self, project_name): + def _get_project_anatomy_overrides(self, project_name, return_version): if self.project_anatomy_cache[project_name].is_outdated: if project_name is None: - document_filter = { - "type": PROJECT_ANATOMY_KEY, - "is_default": True - } - document = self.collection.find_one(document_filter) + document = self._get_project_anatomy_overrides_for_version() + if document is None: + document = self._find_closest_project_anatomy() + + version = None + if document: + if document["type"] == self._project_anatomy_key: + version = document["version"] + else: + version = LEGACY_SETTINGS_VERSION self.project_anatomy_cache[project_name].update_from_document( - document + document, version ) else: collection = self.avalon_db.database[project_name] project_doc = collection.find_one({"type": "project"}) self.project_anatomy_cache[project_name].update_data( - self.project_doc_to_anatomy_data(project_doc) + self.project_doc_to_anatomy_data(project_doc), + self._current_version ) - return self.project_anatomy_cache[project_name].data_copy() + cache = self.project_anatomy_cache[project_name] + data = cache.data_copy() + if return_version: + return data, cache.version + return data - def get_studio_project_anatomy_overrides(self): + def get_studio_project_anatomy_overrides(self, return_version): """Studio overrides of default project anatomy data.""" - return self._get_project_anatomy_overrides(None) + return self._get_project_anatomy_overrides(None, return_version) def get_project_anatomy_overrides(self, project_name): """Studio overrides of project anatomy for specific project. @@ -655,7 +1172,196 @@ def get_project_anatomy_overrides(self, project_name): """ if not project_name: return {} - return self._get_project_anatomy_overrides(project_name) + return self._get_project_anatomy_overrides(project_name, False) + + # Implementations of abstract methods to get overrides for version + def get_studio_system_settings_overrides_for_version(self, version): + doc = self._get_studio_system_settings_overrides_for_version(version) + if not doc: + return doc + return doc["data"] + + def get_studio_project_anatomy_overrides_for_version(self, version): + doc = self._get_project_anatomy_overrides_for_version(version) + if not doc: + return doc + return doc["data"] + + def get_studio_project_settings_overrides_for_version(self, version): + doc = self._get_project_settings_overrides_for_version(None, version) + if not doc: + return doc + return doc["data"] + + def get_project_settings_overrides_for_version( + self, project_name, version + ): + doc = self._get_project_settings_overrides_for_version( + project_name, version + ) + if not doc: + return doc + return doc["data"] + + # Implementations of abstract methods to clear overrides for version + def clear_studio_system_settings_overrides_for_version(self, version): + self.collection.delete_one({ + "type": self._system_settings_key, + "version": version + }) + + def clear_studio_project_settings_overrides_for_version(self, version): + self.collection.delete_one({ + "type": self._project_settings_key, + "version": version, + "is_default": True + }) + + def clear_studio_project_anatomy_overrides_for_version(self, version): + self.collection.delete_one({ + "type": self._project_anatomy_key, + "version": version + }) + + def clear_project_settings_overrides_for_version( + self, version, project_name + ): + self.collection.delete_one({ + "type": self._project_settings_key, + "version": version, + "project_name": project_name + }) + + def _sort_versions(self, versions): + """Sort versions. + + WARNING: + This method does not handle all possible issues so it should not be + used in logic which determine which settings are used. Is used for + sorting of available versions. + """ + if not versions: + return [] + + set_versions = set(versions) + contain_legacy = LEGACY_SETTINGS_VERSION in set_versions + if contain_legacy: + set_versions.remove(LEGACY_SETTINGS_VERSION) + + from openpype.lib.openpype_version import get_OpenPypeVersion + + OpenPypeVersion = get_OpenPypeVersion() + + # Skip if 'OpenPypeVersion' is not available + if OpenPypeVersion is not None: + obj_versions = sorted( + [OpenPypeVersion(version=version) for version in set_versions] + ) + sorted_versions = [str(version) for version in obj_versions] + if contain_legacy: + sorted_versions.insert(0, LEGACY_SETTINGS_VERSION) + return sorted_versions + + doc = self._get_versions_order_doc() + all_versions = doc.get(self._all_versions_keys) + if not all_versions: + return list(sorted(versions)) + + sorted_versions = [] + for version in all_versions: + if version in set_versions: + set_versions.remove(version) + sorted_versions.append(version) + + for version in sorted(set_versions): + sorted_versions.insert(0, version) + + if contain_legacy: + sorted_versions.insert(0, LEGACY_SETTINGS_VERSION) + return sorted_versions + + # Get available versions for settings type + def get_available_studio_system_settings_overrides_versions( + self, sorted=None + ): + docs = self.collection.find( + {"type": { + "$in": [self._system_settings_key, SYSTEM_SETTINGS_KEY] + }}, + {"type": True, "version": True} + ) + output = set() + for doc in docs: + if doc["type"] == self._system_settings_key: + output.add(doc["version"]) + else: + output.add(LEGACY_SETTINGS_VERSION) + if not sorted: + return output + return self._sort_versions(output) + + def get_available_studio_project_anatomy_overrides_versions( + self, sorted=None + ): + docs = self.collection.find( + {"type": { + "$in": [self._project_anatomy_key, PROJECT_ANATOMY_KEY] + }}, + {"type": True, "version": True} + ) + output = set() + for doc in docs: + if doc["type"] == self._project_anatomy_key: + output.add(doc["version"]) + else: + output.add(LEGACY_SETTINGS_VERSION) + if not sorted: + return output + return self._sort_versions(output) + + def get_available_studio_project_settings_overrides_versions( + self, sorted=None + ): + docs = self.collection.find( + { + "is_default": True, + "type": { + "$in": [self._project_settings_key, PROJECT_SETTINGS_KEY] + } + }, + {"type": True, "version": True} + ) + output = set() + for doc in docs: + if doc["type"] == self._project_settings_key: + output.add(doc["version"]) + else: + output.add(LEGACY_SETTINGS_VERSION) + if not sorted: + return output + return self._sort_versions(output) + + def get_available_project_settings_overrides_versions( + self, project_name, sorted=None + ): + docs = self.collection.find( + { + "project_name": project_name, + "type": { + "$in": [self._project_settings_key, PROJECT_SETTINGS_KEY] + } + }, + {"type": True, "version": True} + ) + output = set() + for doc in docs: + if doc["type"] == self._project_settings_key: + output.add(doc["version"]) + else: + output.add(LEGACY_SETTINGS_VERSION) + if not sorted: + return output + return self._sort_versions(output) class MongoLocalSettingsHandler(LocalSettingsHandler): diff --git a/openpype/settings/lib.py b/openpype/settings/lib.py index 1b5682536ad..d338615edf3 100644 --- a/openpype/settings/lib.py +++ b/openpype/settings/lib.py @@ -266,23 +266,31 @@ def save_project_anatomy(project_name, anatomy_data): @require_handler -def get_studio_system_settings_overrides(): - return _SETTINGS_HANDLER.get_studio_system_settings_overrides() +def get_studio_system_settings_overrides(return_version=False): + return _SETTINGS_HANDLER.get_studio_system_settings_overrides( + return_version + ) @require_handler -def get_studio_project_settings_overrides(): - return _SETTINGS_HANDLER.get_studio_project_settings_overrides() +def get_studio_project_settings_overrides(return_version=False): + return _SETTINGS_HANDLER.get_studio_project_settings_overrides( + return_version + ) @require_handler -def get_studio_project_anatomy_overrides(): - return _SETTINGS_HANDLER.get_studio_project_anatomy_overrides() +def get_studio_project_anatomy_overrides(return_version=False): + return _SETTINGS_HANDLER.get_studio_project_anatomy_overrides( + return_version + ) @require_handler -def get_project_settings_overrides(project_name): - return _SETTINGS_HANDLER.get_project_settings_overrides(project_name) +def get_project_settings_overrides(project_name, return_version=False): + return _SETTINGS_HANDLER.get_project_settings_overrides( + project_name, return_version + ) @require_handler @@ -290,6 +298,123 @@ def get_project_anatomy_overrides(project_name): return _SETTINGS_HANDLER.get_project_anatomy_overrides(project_name) +@require_handler +def get_studio_system_settings_overrides_for_version(version): + return ( + _SETTINGS_HANDLER + .get_studio_system_settings_overrides_for_version(version) + ) + + +@require_handler +def get_studio_project_anatomy_overrides_for_version(version): + return ( + _SETTINGS_HANDLER + .get_studio_project_anatomy_overrides_for_version(version) + ) + + +@require_handler +def get_studio_project_settings_overrides_for_version(version): + return ( + _SETTINGS_HANDLER + .get_studio_project_settings_overrides_for_version(version) + ) + + +@require_handler +def get_project_settings_overrides_for_version( + project_name, version +): + return ( + _SETTINGS_HANDLER + .get_project_settings_overrides_for_version(project_name, version) + ) + + +@require_handler +def get_available_studio_system_settings_overrides_versions(sorted=None): + return ( + _SETTINGS_HANDLER + .get_available_studio_system_settings_overrides_versions( + sorted=sorted + ) + ) + + +@require_handler +def get_available_studio_project_anatomy_overrides_versions(sorted=None): + return ( + _SETTINGS_HANDLER + .get_available_studio_project_anatomy_overrides_versions( + sorted=sorted + ) + ) + + +@require_handler +def get_available_studio_project_settings_overrides_versions(sorted=None): + return ( + _SETTINGS_HANDLER + .get_available_studio_project_settings_overrides_versions( + sorted=sorted + ) + ) + + +@require_handler +def get_available_project_settings_overrides_versions( + project_name, sorted=None +): + return ( + _SETTINGS_HANDLER + .get_available_project_settings_overrides_versions( + project_name, sorted=sorted + ) + ) + + +@require_handler +def find_closest_version_for_projects(project_names): + return ( + _SETTINGS_HANDLER + .find_closest_version_for_projects(project_names) + ) + + +@require_handler +def clear_studio_system_settings_overrides_for_version(version): + return ( + _SETTINGS_HANDLER + .clear_studio_system_settings_overrides_for_version(version) + ) + + +@require_handler +def clear_studio_project_settings_overrides_for_version(version): + return ( + _SETTINGS_HANDLER + .clear_studio_project_settings_overrides_for_version(version) + ) + + +@require_handler +def clear_studio_project_anatomy_overrides_for_version(version): + return ( + _SETTINGS_HANDLER + .clear_studio_project_anatomy_overrides_for_version(version) + ) + + +@require_handler +def clear_project_settings_overrides_for_version( + version, project_name +): + return _SETTINGS_HANDLER.clear_project_settings_overrides_for_version( + version, project_name + ) + + @require_local_handler def save_local_settings(data): return _LOCAL_SETTINGS_HANDLER.save_local_settings(data) diff --git a/openpype/style/data.json b/openpype/style/data.json index 2a97b66cc25..b8ccef8bbdd 100644 --- a/openpype/style/data.json +++ b/openpype/style/data.json @@ -118,7 +118,10 @@ "image-btn-hover": "#189aea", "image-btn-disabled": "#bfccd6", "version-exists": "#458056", - "version-not-found": "#ffc671" + "version-not-found": "#ffc671", + + "source-version": "#D3D8DE", + "source-version-outdated": "#ffc671" } } } diff --git a/openpype/style/style.css b/openpype/style/style.css index 5f2ac9499f4..c96e87aa022 100644 --- a/openpype/style/style.css +++ b/openpype/style/style.css @@ -1117,6 +1117,20 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { #ExpandLabel[state="invalid"]:hover, #SettingsLabel[state="invalid"]:hover { color: {color:settings:invalid-dark}; } +#SettingsOutdatedSourceVersion { + color: {color:settings:source-version-outdated}; +} +#SourceVersionLabel { + padding-left: 3px; + padding-right: 3px; +} + +#SourceVersionLabel[state="same"] { + color: {color:settings:source-version}; +} +#SourceVersionLabel[state="different"] { + color: {color:settings:source-version-outdated}; +} /* TODO Replace these with explicit widget types if possible */ #SettingsMainWidget QWidget[input-state="modified"] { @@ -1132,8 +1146,8 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { border-color: {color:settings:invalid-dark}; } -#GroupWidget { - border-bottom: 1px solid #21252B; +#SettingsFooter { + border-top: 1px solid #21252B; } #ProjectListWidget QLabel { @@ -1141,6 +1155,10 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { font-weight: bold; } +#ProjectListContentWidget { + background: {color:bg-view}; +} + #MultiSelectionComboBox { font-size: 12px; } diff --git a/openpype/tools/project_manager/project_manager/style.py b/openpype/tools/project_manager/project_manager/style.py index 9fa7a5520bc..d24fc7102fd 100644 --- a/openpype/tools/project_manager/project_manager/style.py +++ b/openpype/tools/project_manager/project_manager/style.py @@ -1,8 +1,8 @@ import os from Qt import QtCore, QtGui -from openpype.style import get_objected_colors from avalon.vendor import qtawesome +from openpype.tools.utils import paint_image_with_color class ResourceCache: @@ -91,17 +91,6 @@ def get_remove_icon(cls): icon.addPixmap(disabled_pix, QtGui.QIcon.Disabled, QtGui.QIcon.Off) return icon - @classmethod - def get_warning_pixmap(cls): - src_image = get_warning_image() - colors = get_objected_colors() - color_value = colors["delete-btn-bg"] - - return paint_image_with_color( - src_image, - color_value.get_qcolor() - ) - def get_remove_image(): image_path = os.path.join( @@ -110,36 +99,3 @@ def get_remove_image(): "bin.png" ) return QtGui.QImage(image_path) - - -def get_warning_image(): - image_path = os.path.join( - os.path.dirname(os.path.abspath(__file__)), - "images", - "warning.png" - ) - return QtGui.QImage(image_path) - - -def paint_image_with_color(image, color): - """TODO: This function should be imported from utils. - - At the moment of creation is not available yet. - """ - width = image.width() - height = image.height() - - alpha_mask = image.createAlphaMask() - alpha_region = QtGui.QRegion(QtGui.QBitmap.fromImage(alpha_mask)) - - pixmap = QtGui.QPixmap(width, height) - pixmap.fill(QtCore.Qt.transparent) - - painter = QtGui.QPainter(pixmap) - painter.setClipRegion(alpha_region) - painter.setPen(QtCore.Qt.NoPen) - painter.setBrush(color) - painter.drawRect(QtCore.QRect(0, 0, width, height)) - painter.end() - - return pixmap diff --git a/openpype/tools/project_manager/project_manager/widgets.py b/openpype/tools/project_manager/project_manager/widgets.py index 4b5aca35ef7..ebf344b387e 100644 --- a/openpype/tools/project_manager/project_manager/widgets.py +++ b/openpype/tools/project_manager/project_manager/widgets.py @@ -4,14 +4,16 @@ NAME_ALLOWED_SYMBOLS, NAME_REGEX ) -from .style import ResourceCache from openpype.lib import ( create_project, PROJECT_NAME_ALLOWED_SYMBOLS, PROJECT_NAME_REGEX ) from openpype.style import load_stylesheet -from openpype.tools.utils import PlaceholderLineEdit +from openpype.tools.utils import ( + PlaceholderLineEdit, + get_warning_pixmap +) from avalon.api import AvalonMongoDB from Qt import QtWidgets, QtCore, QtGui @@ -338,7 +340,7 @@ def __init__(self, project_name, parent): top_widget = QtWidgets.QWidget(self) - warning_pixmap = ResourceCache.get_warning_pixmap() + warning_pixmap = get_warning_pixmap() warning_icon_label = PixmapLabel(warning_pixmap, top_widget) message_label = QtWidgets.QLabel(top_widget) diff --git a/openpype/tools/settings/settings/categories.py b/openpype/tools/settings/settings/categories.py index adbde00bf1e..18a9764b34a 100644 --- a/openpype/tools/settings/settings/categories.py +++ b/openpype/tools/settings/settings/categories.py @@ -3,8 +3,10 @@ import traceback import contextlib from enum import Enum -from Qt import QtWidgets, QtCore, QtGui +from Qt import QtWidgets, QtCore +from openpype.lib import get_openpype_version +from openpype.tools.utils import set_style_property from openpype.settings.entities import ( SystemSettings, ProjectSettings, @@ -34,7 +36,10 @@ ) from openpype.settings import SaveWarningExc -from .widgets import ProjectListWidget +from .widgets import ( + ProjectListWidget, + VersionAction +) from .breadcrumbs_widget import ( BreadcrumbsAddressBar, SystemSettingsBreadcrumbs, @@ -88,6 +93,20 @@ class SettingsCategoryWidget(QtWidgets.QWidget): restart_required_trigger = QtCore.Signal() full_path_requested = QtCore.Signal(str, str) + require_restart_label_text = ( + "Your changes require restart of" + " all running OpenPype processes to take affect." + ) + outdated_version_label_text = ( + "Your settings are loaded from an older version." + ) + source_version_tooltip = "Using settings of current OpenPype version" + source_version_tooltip_outdated = ( + "Please check that all settings are still correct (blue colour\n" + "indicates potential changes in the new version) and save your\n" + "settings to update them to you current running OpenPype version." + ) + def __init__(self, user_role, parent=None): super(SettingsCategoryWidget, self).__init__(parent) @@ -98,6 +117,10 @@ def __init__(self, user_role, parent=None): self._state = CategoryState.Idle self._hide_studio_overrides = False + self._updating_root = False + self._use_version = None + self._current_version = get_openpype_version() + self.ignore_input_changes = IgnoreInputChangesObj(self) self.keys = [] @@ -183,77 +206,126 @@ def set_state(self, state): def initialize_attributes(self): return + @property + def is_modifying_defaults(self): + if self.modify_defaults_checkbox is None: + return False + return self.modify_defaults_checkbox.isChecked() + def create_ui(self): self.modify_defaults_checkbox = None - scroll_widget = QtWidgets.QScrollArea(self) - scroll_widget.setObjectName("GroupWidget") - content_widget = QtWidgets.QWidget(scroll_widget) + conf_wrapper_widget = QtWidgets.QWidget(self) + configurations_widget = QtWidgets.QWidget(conf_wrapper_widget) - breadcrumbs_label = QtWidgets.QLabel("Path:", content_widget) - breadcrumbs_widget = BreadcrumbsAddressBar(content_widget) + # Breadcrumbs/Path widget + breadcrumbs_widget = QtWidgets.QWidget(self) + breadcrumbs_label = QtWidgets.QLabel("Path:", breadcrumbs_widget) + breadcrumbs_bar = BreadcrumbsAddressBar(breadcrumbs_widget) + + refresh_icon = qtawesome.icon("fa.refresh", color="white") + refresh_btn = QtWidgets.QPushButton(breadcrumbs_widget) + refresh_btn.setIcon(refresh_icon) - breadcrumbs_layout = QtWidgets.QHBoxLayout() + breadcrumbs_layout = QtWidgets.QHBoxLayout(breadcrumbs_widget) breadcrumbs_layout.setContentsMargins(5, 5, 5, 5) breadcrumbs_layout.setSpacing(5) - breadcrumbs_layout.addWidget(breadcrumbs_label) - breadcrumbs_layout.addWidget(breadcrumbs_widget) + breadcrumbs_layout.addWidget(breadcrumbs_label, 0) + breadcrumbs_layout.addWidget(breadcrumbs_bar, 1) + breadcrumbs_layout.addWidget(refresh_btn, 0) + + # Widgets representing settings entities + scroll_widget = QtWidgets.QScrollArea(configurations_widget) + content_widget = QtWidgets.QWidget(scroll_widget) + scroll_widget.setWidgetResizable(True) + scroll_widget.setWidget(content_widget) content_layout = QtWidgets.QVBoxLayout(content_widget) content_layout.setContentsMargins(3, 3, 3, 3) content_layout.setSpacing(5) content_layout.setAlignment(QtCore.Qt.AlignTop) - scroll_widget.setWidgetResizable(True) - scroll_widget.setWidget(content_widget) + # Footer widget + footer_widget = QtWidgets.QWidget(self) + footer_widget.setObjectName("SettingsFooter") - refresh_icon = qtawesome.icon("fa.refresh", color="white") - refresh_btn = QtWidgets.QPushButton(self) - refresh_btn.setIcon(refresh_icon) + # Info labels + # TODO dynamic labels + labels_alignment = QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter + empty_label = QtWidgets.QLabel(footer_widget) + + outdated_version_label = QtWidgets.QLabel( + self.outdated_version_label_text, footer_widget + ) + outdated_version_label.setToolTip(self.source_version_tooltip_outdated) + outdated_version_label.setAlignment(labels_alignment) + outdated_version_label.setVisible(False) + outdated_version_label.setObjectName("SettingsOutdatedSourceVersion") + + require_restart_label = QtWidgets.QLabel( + self.require_restart_label_text, footer_widget + ) + require_restart_label.setAlignment(labels_alignment) + require_restart_label.setVisible(False) + + # Label showing source version of loaded settings + source_version_label = QtWidgets.QLabel("", footer_widget) + source_version_label.setObjectName("SourceVersionLabel") + set_style_property(source_version_label, "state", "") + source_version_label.setToolTip(self.source_version_tooltip) - footer_layout = QtWidgets.QHBoxLayout() + save_btn = QtWidgets.QPushButton("Save", footer_widget) + + footer_layout = QtWidgets.QHBoxLayout(footer_widget) footer_layout.setContentsMargins(5, 5, 5, 5) if self.user_role == "developer": - self._add_developer_ui(footer_layout) - - save_btn = QtWidgets.QPushButton("Save", self) - require_restart_label = QtWidgets.QLabel(self) - require_restart_label.setAlignment(QtCore.Qt.AlignCenter) + self._add_developer_ui(footer_layout, footer_widget) - footer_layout.addWidget(refresh_btn, 0) + footer_layout.addWidget(empty_label, 1) + footer_layout.addWidget(outdated_version_label, 1) footer_layout.addWidget(require_restart_label, 1) + footer_layout.addWidget(source_version_label, 0) footer_layout.addWidget(save_btn, 0) - configurations_layout = QtWidgets.QVBoxLayout() + configurations_layout = QtWidgets.QVBoxLayout(configurations_widget) configurations_layout.setContentsMargins(0, 0, 0, 0) configurations_layout.setSpacing(0) configurations_layout.addWidget(scroll_widget, 1) - configurations_layout.addLayout(footer_layout, 0) - conf_wrapper_layout = QtWidgets.QHBoxLayout() + conf_wrapper_layout = QtWidgets.QHBoxLayout(conf_wrapper_widget) conf_wrapper_layout.setContentsMargins(0, 0, 0, 0) conf_wrapper_layout.setSpacing(0) - conf_wrapper_layout.addLayout(configurations_layout, 1) + conf_wrapper_layout.addWidget(configurations_widget, 1) main_layout = QtWidgets.QVBoxLayout(self) main_layout.setContentsMargins(0, 0, 0, 0) main_layout.setSpacing(0) - main_layout.addLayout(breadcrumbs_layout, 0) - main_layout.addLayout(conf_wrapper_layout, 1) + main_layout.addWidget(breadcrumbs_widget, 0) + main_layout.addWidget(conf_wrapper_widget, 1) + main_layout.addWidget(footer_widget, 0) save_btn.clicked.connect(self._save) refresh_btn.clicked.connect(self._on_refresh) - breadcrumbs_widget.path_edited.connect(self._on_path_edit) + breadcrumbs_bar.path_edited.connect(self._on_path_edit) + + self._require_restart_label = require_restart_label + self._outdated_version_label = outdated_version_label + self._empty_label = empty_label + + self._is_loaded_version_outdated = False self.save_btn = save_btn - self.refresh_btn = refresh_btn - self.require_restart_label = require_restart_label + self._source_version_label = source_version_label + self.scroll_widget = scroll_widget self.content_layout = content_layout self.content_widget = content_widget - self.breadcrumbs_widget = breadcrumbs_widget + self.breadcrumbs_bar = breadcrumbs_bar + self.breadcrumbs_model = None + self.refresh_btn = refresh_btn + self.conf_wrapper_layout = conf_wrapper_layout self.main_layout = main_layout @@ -308,21 +380,17 @@ def set_category_path(self, category, path): pass def set_path(self, path): - self.breadcrumbs_widget.set_path(path) + self.breadcrumbs_bar.set_path(path) - def _add_developer_ui(self, footer_layout): - modify_defaults_widget = QtWidgets.QWidget() - modify_defaults_checkbox = QtWidgets.QCheckBox(modify_defaults_widget) + def _add_developer_ui(self, footer_layout, footer_widget): + modify_defaults_checkbox = QtWidgets.QCheckBox(footer_widget) modify_defaults_checkbox.setChecked(self._hide_studio_overrides) label_widget = QtWidgets.QLabel( - "Modify defaults", modify_defaults_widget + "Modify defaults", footer_widget ) - modify_defaults_layout = QtWidgets.QHBoxLayout(modify_defaults_widget) - modify_defaults_layout.addWidget(label_widget) - modify_defaults_layout.addWidget(modify_defaults_checkbox) - - footer_layout.addWidget(modify_defaults_widget, 0) + footer_layout.addWidget(label_widget, 0) + footer_layout.addWidget(modify_defaults_checkbox, 0) modify_defaults_checkbox.stateChanged.connect( self._on_modify_defaults @@ -361,6 +429,7 @@ def save(self): try: self.entity.save() + self._use_version = None # NOTE There are relations to previous entities and C++ callbacks # so it is easier to just use new entity and recreate UI but @@ -420,13 +489,7 @@ def _on_reset_start(self): return def _on_require_restart_change(self): - value = "" - if self.entity.require_restart: - value = ( - "Your changes require restart of" - " all running OpenPype processes to take affect." - ) - self.require_restart_label.setText(value) + self._update_labels_visibility() def reset(self): self.set_state(CategoryState.Working) @@ -444,6 +507,8 @@ def reset(self): widget.deleteLater() dialog = None + self._updating_root = True + source_version = "" try: self._create_root_entity() @@ -459,6 +524,7 @@ def reset(self): input_field.set_entity_value() self.ignore_input_changes.set_ignore(False) + source_version = self.entity.source_version except DefaultsNotDefined: dialog = QtWidgets.QMessageBox(self) @@ -502,6 +568,27 @@ def reset(self): spacer, layout.rowCount(), 0, 1, layout.columnCount() ) + self._updating_root = False + + # Update source version label + state_value = "" + tooltip = "" + outdated = False + if source_version: + if source_version != self._current_version: + state_value = "different" + tooltip = self.source_version_tooltip_outdated + outdated = True + else: + state_value = "same" + tooltip = self.source_version_tooltip + + self._is_loaded_version_outdated = outdated + self._source_version_label.setText(source_version) + self._source_version_label.setToolTip(tooltip) + set_style_property(self._source_version_label, "state", state_value) + self._update_labels_visibility() + self.set_state(CategoryState.Idle) if dialog: @@ -510,6 +597,36 @@ def reset(self): else: self._on_reset_success() + def _on_source_version_change(self, version): + if self._updating_root: + return + + if version == self._current_version: + version = None + + self._use_version = version + QtCore.QTimer.singleShot(20, self.reset) + + def add_context_actions(self, menu): + if not self.entity or self.is_modifying_defaults: + return + + versions = self.entity.get_available_studio_versions(sorted=True) + if not versions: + return + + submenu = QtWidgets.QMenu("Use settings from version", menu) + for version in reversed(versions): + action = VersionAction(version, submenu) + action.version_triggered.connect( + self._on_context_version_trigger + ) + submenu.addAction(action) + menu.addMenu(submenu) + + def _on_context_version_trigger(self, version): + self._on_source_version_change(version) + def _on_reset_crash(self): self.save_btn.setEnabled(False) @@ -521,10 +638,10 @@ def _on_reset_success(self): self.save_btn.setEnabled(True) if self.breadcrumbs_model is not None: - path = self.breadcrumbs_widget.path() - self.breadcrumbs_widget.set_path("") + path = self.breadcrumbs_bar.path() + self.breadcrumbs_bar.set_path("") self.breadcrumbs_model.set_entity(self.entity) - self.breadcrumbs_widget.change_path(path) + self.breadcrumbs_bar.change_path(path) def add_children_gui(self): for child_obj in self.entity.children: @@ -565,10 +682,7 @@ def on_saved(self, saved_tab_widget): def _save(self): # Don't trigger restart if defaults are modified - if ( - self.modify_defaults_checkbox - and self.modify_defaults_checkbox.isChecked() - ): + if self.is_modifying_defaults: require_restart = False else: require_restart = self.entity.require_restart @@ -584,7 +698,29 @@ def _save(self): if require_restart: self.restart_required_trigger.emit() - self.require_restart_label.setText("") + + def _update_labels_visibility(self): + visible_label = None + labels = { + self._empty_label, + self._outdated_version_label, + self._require_restart_label, + } + if self.entity.require_restart: + visible_label = self._require_restart_label + elif self._is_loaded_version_outdated: + visible_label = self._outdated_version_label + else: + visible_label = self._empty_label + + if visible_label.isVisible(): + return + + for label in labels: + if label is visible_label: + visible_label.setVisible(True) + else: + label.setVisible(False) def _on_refresh(self): self.reset() @@ -594,25 +730,29 @@ def _on_hide_studio_overrides(self, state): class SystemWidget(SettingsCategoryWidget): + def __init__(self, *args, **kwargs): + self._actions = [] + super(SystemWidget, self).__init__(*args, **kwargs) + def contain_category_key(self, category): if category == "system_settings": return True return False def set_category_path(self, category, path): - self.breadcrumbs_widget.change_path(path) + self.breadcrumbs_bar.change_path(path) def _create_root_entity(self): - self.entity = SystemSettings(set_studio_state=False) - self.entity.on_change_callbacks.append(self._on_entity_change) + entity = SystemSettings( + set_studio_state=False, source_version=self._use_version + ) + entity.on_change_callbacks.append(self._on_entity_change) + self.entity = entity try: - if ( - self.modify_defaults_checkbox - and self.modify_defaults_checkbox.isChecked() - ): - self.entity.set_defaults_state() + if self.is_modifying_defaults: + entity.set_defaults_state() else: - self.entity.set_studio_state() + entity.set_studio_state() if self.modify_defaults_checkbox: self.modify_defaults_checkbox.setEnabled(True) @@ -620,16 +760,16 @@ def _create_root_entity(self): if not self.modify_defaults_checkbox: raise - self.entity.set_defaults_state() + entity.set_defaults_state() self.modify_defaults_checkbox.setChecked(True) self.modify_defaults_checkbox.setEnabled(False) def ui_tweaks(self): self.breadcrumbs_model = SystemSettingsBreadcrumbs() - self.breadcrumbs_widget.set_model(self.breadcrumbs_model) + self.breadcrumbs_bar.set_model(self.breadcrumbs_model) def _on_modify_defaults(self): - if self.modify_defaults_checkbox.isChecked(): + if self.is_modifying_defaults: if not self.entity.is_in_defaults_state(): self.reset() else: @@ -638,6 +778,9 @@ def _on_modify_defaults(self): class ProjectWidget(SettingsCategoryWidget): + def __init__(self, *args, **kwargs): + super(ProjectWidget, self).__init__(*args, **kwargs) + def contain_category_key(self, category): if category in ("project_settings", "project_anatomy"): return True @@ -651,28 +794,28 @@ def set_category_path(self, category, path): else: path = category - self.breadcrumbs_widget.change_path(path) + self.breadcrumbs_bar.change_path(path) def initialize_attributes(self): self.project_name = None def ui_tweaks(self): self.breadcrumbs_model = ProjectSettingsBreadcrumbs() - self.breadcrumbs_widget.set_model(self.breadcrumbs_model) + self.breadcrumbs_bar.set_model(self.breadcrumbs_model) project_list_widget = ProjectListWidget(self) self.conf_wrapper_layout.insertWidget(0, project_list_widget, 0) project_list_widget.project_changed.connect(self._on_project_change) + project_list_widget.version_change_requested.connect( + self._on_source_version_change + ) self.project_list_widget = project_list_widget def get_project_names(self): - if ( - self.modify_defaults_checkbox - and self.modify_defaults_checkbox.isChecked() - ): + if self.is_modifying_defaults: return [] return self.project_list_widget.get_project_names() @@ -684,6 +827,10 @@ def on_saved(self, saved_tab_widget): if self is saved_tab_widget: return + def _on_context_version_trigger(self, version): + self.project_list_widget.select_project(None) + super(ProjectWidget, self)._on_context_version_trigger(version) + def _on_reset_start(self): self.project_list_widget.refresh() @@ -696,32 +843,29 @@ def _on_reset_success(self): super(ProjectWidget, self)._on_reset_success() def _set_enabled_project_list(self, enabled): - if ( - enabled - and self.modify_defaults_checkbox - and self.modify_defaults_checkbox.isChecked() - ): + if enabled and self.is_modifying_defaults: enabled = False if self.project_list_widget.isEnabled() != enabled: self.project_list_widget.setEnabled(enabled) def _create_root_entity(self): - self.entity = ProjectSettings(change_state=False) - self.entity.on_change_callbacks.append(self._on_entity_change) + entity = ProjectSettings( + change_state=False, source_version=self._use_version + ) + entity.on_change_callbacks.append(self._on_entity_change) + self.project_list_widget.set_entity(entity) + self.entity = entity try: - if ( - self.modify_defaults_checkbox - and self.modify_defaults_checkbox.isChecked() - ): + if self.is_modifying_defaults: self.entity.set_defaults_state() elif self.project_name is None: self.entity.set_studio_state() - elif self.project_name == self.entity.project_name: - self.entity.set_project_state() else: - self.entity.change_project(self.project_name) + self.entity.change_project( + self.project_name, self._use_version + ) if self.modify_defaults_checkbox: self.modify_defaults_checkbox.setEnabled(True) @@ -754,7 +898,7 @@ def _on_project_change(self): self.set_state(CategoryState.Idle) def _on_modify_defaults(self): - if self.modify_defaults_checkbox.isChecked(): + if self.is_modifying_defaults: self._set_enabled_project_list(False) if not self.entity.is_in_defaults_state(): self.reset() diff --git a/openpype/tools/settings/settings/constants.py b/openpype/tools/settings/settings/constants.py index 5c20bf1afef..9d6d7904d79 100644 --- a/openpype/tools/settings/settings/constants.py +++ b/openpype/tools/settings/settings/constants.py @@ -5,6 +5,7 @@ PROJECT_NAME_ROLE = QtCore.Qt.UserRole + 1 PROJECT_IS_ACTIVE_ROLE = QtCore.Qt.UserRole + 2 PROJECT_IS_SELECTED_ROLE = QtCore.Qt.UserRole + 3 +PROJECT_VERSION_ROLE = QtCore.Qt.UserRole + 4 __all__ = ( @@ -12,5 +13,6 @@ "PROJECT_NAME_ROLE", "PROJECT_IS_ACTIVE_ROLE", - "PROJECT_IS_SELECTED_ROLE" + "PROJECT_IS_SELECTED_ROLE", + "PROJECT_VERSION_ROLE", ) diff --git a/openpype/tools/settings/settings/widgets.py b/openpype/tools/settings/settings/widgets.py index b5c08ef79bc..f793aab057c 100644 --- a/openpype/tools/settings/settings/widgets.py +++ b/openpype/tools/settings/settings/widgets.py @@ -1,5 +1,6 @@ import os import copy +import uuid from Qt import QtWidgets, QtCore, QtGui from avalon.vendor import qtawesome from avalon.mongodb import ( @@ -12,8 +13,12 @@ from openpype.tools.utils.lib import paint_image_with_color from openpype.widgets.nice_checkbox import NiceCheckbox -from openpype.tools.utils import PlaceholderLineEdit -from openpype.settings.lib import get_system_settings +from openpype.tools.utils import ( + PlaceholderLineEdit, + DynamicQThread +) +from openpype.settings.lib import find_closest_version_for_projects +from openpype.lib import get_openpype_version from .images import ( get_pixmap, get_image @@ -21,11 +26,40 @@ from .constants import ( DEFAULT_PROJECT_LABEL, PROJECT_NAME_ROLE, + PROJECT_VERSION_ROLE, PROJECT_IS_ACTIVE_ROLE, PROJECT_IS_SELECTED_ROLE ) +class SettingsTabWidget(QtWidgets.QTabWidget): + context_menu_requested = QtCore.Signal(int) + + def __init__(self, *args, **kwargs): + super(SettingsTabWidget, self).__init__(*args, **kwargs) + self._right_click_tab_idx = None + + def mousePressEvent(self, event): + super(SettingsTabWidget, self).mousePressEvent(event) + if event.button() == QtCore.Qt.RightButton: + tab_bar = self.tabBar() + pos = tab_bar.mapFromGlobal(event.globalPos()) + tab_idx = tab_bar.tabAt(pos) + if tab_idx < 0: + tab_idx = None + self._right_click_tab_idx = tab_idx + + def mouseReleaseEvent(self, event): + super(SettingsTabWidget, self).mouseReleaseEvent(event) + if event.button() == QtCore.Qt.RightButton: + tab_bar = self.tabBar() + pos = tab_bar.mapFromGlobal(event.globalPos()) + tab_idx = tab_bar.tabAt(pos) + if tab_idx == self._right_click_tab_idx: + self.context_menu_requested.emit(tab_idx) + self._right_click_tab = None + + class CompleterFilter(QtCore.QSortFilterProxyModel): def __init__(self, *args, **kwargs): super(CompleterFilter, self).__init__(*args, **kwargs) @@ -603,7 +637,7 @@ class UnsavedChangesDialog(QtWidgets.QDialog): message = "You have unsaved changes. What do you want to do with them?" def __init__(self, parent=None): - super().__init__(parent) + super(UnsavedChangesDialog, self).__init__(parent) message_label = QtWidgets.QLabel(self.message) btns_widget = QtWidgets.QWidget(self) @@ -735,19 +769,65 @@ def mousePressEvent(self, event): class ProjectModel(QtGui.QStandardItemModel): + _update_versions = QtCore.Signal() + def __init__(self, only_active, *args, **kwargs): super(ProjectModel, self).__init__(*args, **kwargs) + self.setColumnCount(2) + self.dbcon = None self._only_active = only_active self._default_item = None self._items_by_name = {} + self._versions_by_project = {} + + colors = get_objected_colors() + font_color = colors["font"].get_qcolor() + font_color.setAlpha(67) + self._version_font_color = font_color + self._current_version = get_openpype_version() + + self._version_refresh_threads = [] + self._version_refresh_id = None + + self._update_versions.connect(self._on_update_versions_signal) + + def _on_update_versions_signal(self): + for project_name, version in self._versions_by_project.items(): + if project_name is None: + item = self._default_item + else: + item = self._items_by_name.get(project_name) + + if item and version != self._current_version: + item.setData(version, PROJECT_VERSION_ROLE) + + def _fetch_settings_versions(self): + """Used versions per project are loaded in thread to not stuck UI.""" + version_refresh_id = self._version_refresh_id + all_project_names = list(self._items_by_name.keys()) + all_project_names.append(None) + closest_by_project_name = find_closest_version_for_projects( + all_project_names + ) + if self._version_refresh_id == version_refresh_id: + self._versions_by_project = closest_by_project_name + self._update_versions.emit() + + def flags(self, index): + if index.column() == 1: + index = self.index(index.row(), 0, index.parent()) + return super(ProjectModel, self).flags(index) def set_dbcon(self, dbcon): self.dbcon = dbcon def refresh(self): + # Change id of versions refresh + self._version_refresh_id = uuid.uuid4() + new_items = [] if self._default_item is None: item = QtGui.QStandardItem(DEFAULT_PROJECT_LABEL) @@ -757,6 +837,7 @@ def refresh(self): new_items.append(item) self._default_item = item + self._default_item.setData("", PROJECT_VERSION_ROLE) project_names = set() if self.dbcon is not None: for project_doc in self.dbcon.projects( @@ -776,6 +857,7 @@ def refresh(self): is_active = project_doc.get("data", {}).get("active", True) item.setData(project_name, PROJECT_NAME_ROLE) item.setData(is_active, PROJECT_IS_ACTIVE_ROLE) + item.setData("", PROJECT_VERSION_ROLE) item.setData(False, PROJECT_IS_SELECTED_ROLE) if not is_active: @@ -792,15 +874,87 @@ def refresh(self): if new_items: root_item.appendRows(new_items) + # Fetch versions per project in thread + thread = DynamicQThread(self._fetch_settings_versions) + self._version_refresh_threads.append(thread) + thread.start() + + # Cleanup done threads + for thread in tuple(self._version_refresh_threads): + if thread.isFinished(): + self._version_refresh_threads.remove(thread) + + def data(self, index, role=QtCore.Qt.DisplayRole): + if index.column() == 1: + if role == QtCore.Qt.TextAlignmentRole: + return QtCore.Qt.AlignRight + if role == QtCore.Qt.ForegroundRole: + return self._version_font_color + index = self.index(index.row(), 0, index.parent()) + if role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole): + role = PROJECT_VERSION_ROLE + + return super(ProjectModel, self).data(index, role) + + def setData(self, index, value, role=QtCore.Qt.EditRole): + if index.column() == 1: + index = self.index(index.row(), 0, index.parent()) + if role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole): + role = PROJECT_VERSION_ROLE + return super(ProjectModel, self).setData(index, value, role) + + def headerData(self, section, orientation, role=QtCore.Qt.DisplayRole): + if role == QtCore.Qt.DisplayRole: + if section == 0: + return "Project name" + + elif section == 1: + return "Used version" + return "" + return super(ProjectModel, self).headerData( + section, orientation, role + ) + + +class VersionAction(QtWidgets.QAction): + version_triggered = QtCore.Signal(str) + + def __init__(self, version, *args, **kwargs): + super(VersionAction, self).__init__(version, *args, **kwargs) + self._version = version + self.triggered.connect(self._on_trigger) + + def _on_trigger(self): + self.version_triggered.emit(self._version) -class ProjectListView(QtWidgets.QListView): + +class ProjectView(QtWidgets.QTreeView): left_mouse_released_at = QtCore.Signal(QtCore.QModelIndex) + right_mouse_released_at = QtCore.Signal(QtCore.QModelIndex) + + def __init__(self, *args, **kwargs): + super(ProjectView, self).__init__(*args, **kwargs) + self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + self.setIndentation(0) + + # Do not allow editing + self.setEditTriggers( + QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers + ) + # Do not automatically handle selection + self.setSelectionMode(QtWidgets.QAbstractItemView.NoSelection) + self.setSelectionBehavior(QtWidgets.QAbstractItemView.SelectRows) def mouseReleaseEvent(self, event): if event.button() == QtCore.Qt.LeftButton: index = self.indexAt(event.pos()) self.left_mouse_released_at.emit(index) - super(ProjectListView, self).mouseReleaseEvent(event) + + elif event.button() == QtCore.Qt.RightButton: + index = self.indexAt(event.pos()) + self.right_mouse_released_at.emit(index) + + super(ProjectView, self).mouseReleaseEvent(event) class ProjectSortFilterProxy(QtCore.QSortFilterProxyModel): @@ -846,18 +1000,21 @@ def set_filter_enabled(self, value): class ProjectListWidget(QtWidgets.QWidget): project_changed = QtCore.Signal() + version_change_requested = QtCore.Signal(str) def __init__(self, parent, only_active=False): self._parent = parent + self._entity = None self.current_project = None super(ProjectListWidget, self).__init__(parent) self.setObjectName("ProjectListWidget") - label_widget = QtWidgets.QLabel("Projects") + content_frame = QtWidgets.QFrame(self) + content_frame.setObjectName("ProjectListContentWidget") - project_list = ProjectListView(self) + project_list = ProjectView(content_frame) project_model = ProjectModel(only_active) project_proxy = ProjectSortFilterProxy() @@ -865,33 +1022,37 @@ def __init__(self, parent, only_active=False): project_proxy.setSourceModel(project_model) project_list.setModel(project_proxy) - # Do not allow editing - project_list.setEditTriggers( - QtWidgets.QAbstractItemView.EditTrigger.NoEditTriggers - ) - # Do not automatically handle selection - project_list.setSelectionMode(QtWidgets.QAbstractItemView.NoSelection) + content_layout = QtWidgets.QVBoxLayout(content_frame) + content_layout.setContentsMargins(0, 0, 0, 0) + content_layout.setSpacing(0) + content_layout.addWidget(project_list, 1) - layout = QtWidgets.QVBoxLayout(self) - layout.setSpacing(3) - layout.addWidget(label_widget, 0) - layout.addWidget(project_list, 1) + inactive_chk = None + if not only_active: + checkbox_wrapper = QtWidgets.QWidget(content_frame) + checkbox_wrapper.setAttribute(QtCore.Qt.WA_TranslucentBackground) - if only_active: - inactive_chk = None - else: - inactive_chk = QtWidgets.QCheckBox(" Show Inactive Projects ") + inactive_chk = QtWidgets.QCheckBox( + "Show Inactive Projects", checkbox_wrapper + ) inactive_chk.setChecked(not project_proxy.is_filter_enabled()) - layout.addSpacing(5) - layout.addWidget(inactive_chk, 0) - layout.addSpacing(5) + wrapper_layout = QtWidgets.QHBoxLayout(checkbox_wrapper) + wrapper_layout.addWidget(inactive_chk, 1) + + content_layout.addWidget(checkbox_wrapper, 0) inactive_chk.stateChanged.connect(self.on_inactive_vis_changed) - project_list.left_mouse_released_at.connect(self.on_item_clicked) + layout = QtWidgets.QVBoxLayout(self) + # Margins '3' are matching to configurables widget scroll area on right + layout.setContentsMargins(5, 3, 3, 3) + layout.addWidget(content_frame, 1) - self._default_project_item = None + project_list.left_mouse_released_at.connect(self.on_item_clicked) + project_list.right_mouse_released_at.connect( + self._on_item_right_clicked + ) self.project_list = project_list self.project_proxy = project_proxy @@ -900,10 +1061,46 @@ def __init__(self, parent, only_active=False): self.dbcon = None + def set_entity(self, entity): + self._entity = entity + + def _on_item_right_clicked(self, index): + if not index.isValid(): + return + project_name = index.data(PROJECT_NAME_ROLE) + if project_name is None: + project_name = DEFAULT_PROJECT_LABEL + + if self.current_project != project_name: + self.on_item_clicked(index) + + if self.current_project != project_name: + return + + if not self._entity: + return + + versions = self._entity.get_available_source_versions(sorted=True) + if not versions: + return + + menu = QtWidgets.QMenu(self) + submenu = QtWidgets.QMenu("Use settings from version", menu) + for version in reversed(versions): + action = VersionAction(version, submenu) + action.version_triggered.connect( + self.version_change_requested + ) + submenu.addAction(action) + menu.addMenu(submenu) + menu.exec_(QtGui.QCursor.pos()) + def on_item_clicked(self, new_index): - new_project_name = new_index.data(QtCore.Qt.DisplayRole) - if new_project_name is None: + if not new_index.isValid(): return + new_project_name = new_index.data(PROJECT_NAME_ROLE) + if new_project_name is None: + new_project_name = DEFAULT_PROJECT_LABEL if self.current_project == new_project_name: return @@ -963,12 +1160,30 @@ def select_project(self, project_name): index = model.indexFromItem(found_items[0]) model.setData(index, True, PROJECT_IS_SELECTED_ROLE) - index = proxy.mapFromSource(index) - - self.project_list.selectionModel().clear() - self.project_list.selectionModel().setCurrentIndex( - index, QtCore.QItemSelectionModel.SelectionFlag.SelectCurrent - ) + src_indexes = [] + col_count = model.columnCount() + if col_count > 1: + for col in range(col_count): + src_indexes.append( + model.index(index.row(), col, index.parent()) + ) + dst_indexes = [] + for index in src_indexes: + dst_indexes.append(proxy.mapFromSource(index)) + + selection_model = self.project_list.selectionModel() + selection_model.clear() + + first = True + for index in dst_indexes: + if first: + selection_model.setCurrentIndex( + index, + QtCore.QItemSelectionModel.SelectionFlag.SelectCurrent + ) + first = False + continue + selection_model.select(index, QtCore.QItemSelectionModel.Select) def get_project_names(self): output = [] @@ -980,7 +1195,7 @@ def get_project_names(self): def refresh(self): selected_project = None for index in self.project_list.selectedIndexes(): - selected_project = index.data(QtCore.Qt.DisplayRole) + selected_project = index.data(PROJECT_NAME_ROLE) break mongo_url = os.environ["OPENPYPE_MONGO"] @@ -1008,5 +1223,6 @@ def refresh(self): self.select_project(selected_project) self.current_project = self.project_list.currentIndex().data( - QtCore.Qt.DisplayRole + PROJECT_NAME_ROLE ) + self.project_list.resizeColumnToContents(0) diff --git a/openpype/tools/settings/settings/window.py b/openpype/tools/settings/settings/window.py index 411e7b5e7ff..8a01bf1bce4 100644 --- a/openpype/tools/settings/settings/window.py +++ b/openpype/tools/settings/settings/window.py @@ -4,7 +4,11 @@ SystemWidget, ProjectWidget ) -from .widgets import ShadowWidget, RestartDialog +from .widgets import ( + ShadowWidget, + RestartDialog, + SettingsTabWidget +) from openpype import style from openpype.lib import is_admin_password_required @@ -34,7 +38,7 @@ def __init__(self, user_role, parent=None, reset_on_show=True): self.setStyleSheet(stylesheet) self.setWindowIcon(QtGui.QIcon(style.app_icon_path())) - header_tab_widget = QtWidgets.QTabWidget(parent=self) + header_tab_widget = SettingsTabWidget(parent=self) studio_widget = SystemWidget(user_role, header_tab_widget) project_widget = ProjectWidget(user_role, header_tab_widget) @@ -65,6 +69,10 @@ def __init__(self, user_role, parent=None, reset_on_show=True): ) tab_widget.full_path_requested.connect(self._on_full_path_request) + header_tab_widget.context_menu_requested.connect( + self._on_context_menu_request + ) + self._header_tab_widget = header_tab_widget self.tab_widgets = tab_widgets @@ -100,6 +108,18 @@ def _on_full_path_request(self, category, path): tab_widget.set_category_path(category, path) break + def _on_context_menu_request(self, tab_idx): + widget = self._header_tab_widget.widget(tab_idx) + if not widget: + return + + menu = QtWidgets.QMenu(self) + widget.add_context_actions(menu) + if menu.actions(): + result = menu.exec_(QtGui.QCursor.pos()) + if result is not None: + self._header_tab_widget.setCurrentIndex(tab_idx) + def showEvent(self, event): super(MainWidget, self).showEvent(event) if self._reset_on_show: diff --git a/openpype/tools/tray/pype_tray.py b/openpype/tools/tray/pype_tray.py index 9d4d0aa31b9..4e5db06a92a 100644 --- a/openpype/tools/tray/pype_tray.py +++ b/openpype/tools/tray/pype_tray.py @@ -33,7 +33,8 @@ ) from openpype.tools.utils import ( WrappedCallbackItem, - paint_image_with_color + paint_image_with_color, + get_warning_pixmap ) from .pype_info_widget import PypeInfoWidget @@ -76,7 +77,7 @@ def resizeEvent(self, event): super(PixmapLabel, self).resizeEvent(event) -class VersionDialog(QtWidgets.QDialog): +class VersionUpdateDialog(QtWidgets.QDialog): restart_requested = QtCore.Signal() ignore_requested = QtCore.Signal() @@ -84,7 +85,7 @@ class VersionDialog(QtWidgets.QDialog): _min_height = 130 def __init__(self, parent=None): - super(VersionDialog, self).__init__(parent) + super(VersionUpdateDialog, self).__init__(parent) icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) self.setWindowIcon(icon) @@ -152,11 +153,11 @@ def _get_gift_pixmap(self): ) def showEvent(self, event): - super().showEvent(event) + super(VersionUpdateDialog, self).showEvent(event) self._restart_accepted = False def closeEvent(self, event): - super().closeEvent(event) + super(VersionUpdateDialog, self).closeEvent(event) if self._restart_accepted or self._current_is_higher: return # Trigger ignore requested only if restart was not clicked and current @@ -202,6 +203,63 @@ def _on_reset(self): self.accept() +class BuildVersionDialog(QtWidgets.QDialog): + """Build/Installation version is too low for current OpenPype version. + + This dialog tells to user that it's build OpenPype is too old. + """ + def __init__(self, parent=None): + super(BuildVersionDialog, self).__init__(parent) + + icon = QtGui.QIcon(resources.get_openpype_icon_filepath()) + self.setWindowIcon(icon) + self.setWindowTitle("Outdated OpenPype installation") + self.setWindowFlags( + self.windowFlags() + | QtCore.Qt.WindowStaysOnTopHint + ) + + top_widget = QtWidgets.QWidget(self) + + warning_pixmap = get_warning_pixmap() + warning_icon_label = PixmapLabel(warning_pixmap, top_widget) + + message = ( + "Your installation of OpenPype does not match minimum" + " requirements.

Please update OpenPype installation" + " to newer version." + ) + content_label = QtWidgets.QLabel(message, self) + + 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 + ) + top_layout.addWidget(content_label, 1) + + footer_widget = QtWidgets.QWidget(self) + ok_btn = QtWidgets.QPushButton("I understand", footer_widget) + + footer_layout = QtWidgets.QHBoxLayout(footer_widget) + footer_layout.setContentsMargins(0, 0, 0, 0) + footer_layout.addStretch(1) + footer_layout.addWidget(ok_btn) + + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.addWidget(top_widget, 0) + main_layout.addStretch(1) + main_layout.addWidget(footer_widget, 0) + + self.setStyleSheet(style.load_stylesheet()) + + ok_btn.clicked.connect(self._on_ok_clicked) + + def _on_ok_clicked(self): + self.close() + + class TrayManager: """Cares about context of application. @@ -272,7 +330,7 @@ def validate_openpype_version(self): return if self._version_dialog is None: - self._version_dialog = VersionDialog() + self._version_dialog = VersionUpdateDialog() self._version_dialog.restart_requested.connect( self._restart_and_install ) @@ -383,6 +441,10 @@ def _startup_validations(self): self._validate_settings_defaults() + if not op_version_control_available(): + dialog = BuildVersionDialog() + dialog.exec_() + def _validate_settings_defaults(self): valid = True try: diff --git a/openpype/tools/utils/__init__.py b/openpype/tools/utils/__init__.py index ac935956826..46af0510694 100644 --- a/openpype/tools/utils/__init__.py +++ b/openpype/tools/utils/__init__.py @@ -10,7 +10,10 @@ from .error_dialog import ErrorMessageBox from .lib import ( WrappedCallbackItem, - paint_image_with_color + paint_image_with_color, + get_warning_pixmap, + set_style_property, + DynamicQThread, ) from .models import ( @@ -29,6 +32,9 @@ "WrappedCallbackItem", "paint_image_with_color", + "get_warning_pixmap", + "set_style_property", + "DynamicQThread", "RecursiveSortFilterProxyModel", ) diff --git a/openpype/tools/utils/delegates.py b/openpype/tools/utils/delegates.py index 1caed732d85..4ec6079bb7b 100644 --- a/openpype/tools/utils/delegates.py +++ b/openpype/tools/utils/delegates.py @@ -7,7 +7,6 @@ from Qt import QtWidgets, QtGui, QtCore from avalon.lib import HeroVersionType -from openpype.style import get_objected_colors from .models import TreeModel from . import lib diff --git a/openpype/tools/utils/lib.py b/openpype/tools/utils/lib.py index 84156fce0d1..01b9e25ef35 100644 --- a/openpype/tools/utils/lib.py +++ b/openpype/tools/utils/lib.py @@ -14,6 +14,8 @@ Logger ) from openpype.lib import filter_profiles +from openpype.style import get_objected_colors +from openpype.resources import get_image_path def center_window(window): @@ -28,6 +30,18 @@ def center_window(window): window.move(geo.topLeft()) +def set_style_property(widget, property_name, property_value): + """Set widget's property that may affect style. + + If current property value is different then style of widget is polished. + """ + cur_value = widget.property(property_name) + if cur_value == property_value: + return + widget.setProperty(property_name, property_value) + widget.style().polish(widget) + + def paint_image_with_color(image, color): """Redraw image with single color using it's alpha. @@ -670,3 +684,19 @@ def execute(self): finally: self._done = True + + +def get_warning_pixmap(color=None): + """Warning icon as QPixmap. + + Args: + color(QtGui.QColor): Color that will be used to paint warning icon. + """ + src_image_path = get_image_path("warning.png") + src_image = QtGui.QImage(src_image_path) + if color is None: + colors = get_objected_colors() + color_value = colors["delete-btn-bg"] + color = color_value.get_qcolor() + + return paint_image_with_color(src_image, color) diff --git a/openpype/tools/utils/models.py b/openpype/tools/utils/models.py index 2b5b156eeb2..8991614fe16 100644 --- a/openpype/tools/utils/models.py +++ b/openpype/tools/utils/models.py @@ -3,9 +3,6 @@ import Qt from Qt import QtCore, QtGui -from avalon.vendor import qtawesome -from avalon import style, io -from . import lib from .constants import ( PROJECT_IS_ACTIVE_ROLE, PROJECT_NAME_ROLE,