diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fae98ec11..537be94076 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,23 +1,39 @@ # Changelog -## [3.1.0-nightly.2](https://github.com/pypeclub/OpenPype/tree/HEAD) +## [3.1.0-nightly.3](https://github.com/pypeclub/OpenPype/tree/HEAD) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.0.0...HEAD) #### 🚀 Enhancements +- Sort applications and tools alphabetically in Settings UI [\#1689](https://github.com/pypeclub/OpenPype/pull/1689) +- \#683 - Validate Frame Range in Standalone Publisher [\#1683](https://github.com/pypeclub/OpenPype/pull/1683) +- Hiero: old container versions identify with red color [\#1682](https://github.com/pypeclub/OpenPype/pull/1682) +- Project Manger: Default name column width [\#1669](https://github.com/pypeclub/OpenPype/pull/1669) +- Remove outline in stylesheet [\#1667](https://github.com/pypeclub/OpenPype/pull/1667) +- TVPaint: Creator take layer name as default value for subset variant [\#1663](https://github.com/pypeclub/OpenPype/pull/1663) +- TVPaint custom subset template [\#1662](https://github.com/pypeclub/OpenPype/pull/1662) +- Feature Slack integration [\#1657](https://github.com/pypeclub/OpenPype/pull/1657) - Nuke - Publish simplification [\#1653](https://github.com/pypeclub/OpenPype/pull/1653) +- StandalonePublisher: adding exception for adding `delete` tag to repre [\#1650](https://github.com/pypeclub/OpenPype/pull/1650) - \#1333 - added tooltip hints to Pyblish buttons [\#1649](https://github.com/pypeclub/OpenPype/pull/1649) #### 🐛 Bug fixes +- Bad zip can break OpenPype start [\#1691](https://github.com/pypeclub/OpenPype/pull/1691) +- Ftrack subprocess handle of stdout/stderr [\#1675](https://github.com/pypeclub/OpenPype/pull/1675) +- Settings list race condifiton and mutable dict list conversion [\#1671](https://github.com/pypeclub/OpenPype/pull/1671) - Mac launch arguments fix [\#1660](https://github.com/pypeclub/OpenPype/pull/1660) - Fix missing dbm python module [\#1652](https://github.com/pypeclub/OpenPype/pull/1652) -- Transparent branches in view on Mac [\#1648](https://github.com/pypeclub/OpenPype/pull/1648) - Add asset on task item [\#1646](https://github.com/pypeclub/OpenPype/pull/1646) - Project manager save and queue [\#1645](https://github.com/pypeclub/OpenPype/pull/1645) - New project anatomy values [\#1644](https://github.com/pypeclub/OpenPype/pull/1644) +**Merged pull requests:** + +- Bump normalize-url from 4.5.0 to 4.5.1 in /website [\#1686](https://github.com/pypeclub/OpenPype/pull/1686) +- Add docstrings to Project manager tool [\#1556](https://github.com/pypeclub/OpenPype/pull/1556) + # Changelog diff --git a/igniter/bootstrap_repos.py b/igniter/bootstrap_repos.py index 7c4f8b4b69..6eaea27116 100644 --- a/igniter/bootstrap_repos.py +++ b/igniter/bootstrap_repos.py @@ -972,8 +972,12 @@ class BootstrapRepos: "openpype/version.py") as version_file: zip_version = {} exec(version_file.read(), zip_version) - version_check = OpenPypeVersion( - version=zip_version["__version__"]) + try: + version_check = OpenPypeVersion( + version=zip_version["__version__"]) + except ValueError as e: + self._print(str(e), True) + return False version_main = version_check.get_main_version() # noqa: E501 detected_main = detected_version.get_main_version() # noqa: E501 diff --git a/openpype/hosts/hiero/api/lib.py b/openpype/hosts/hiero/api/lib.py index d8a235be77..876fae5da9 100644 --- a/openpype/hosts/hiero/api/lib.py +++ b/openpype/hosts/hiero/api/lib.py @@ -190,7 +190,7 @@ def get_track_items( if not item.isEnabled(): continue if track_item_name: - if item.name() in track_item_name: + if track_item_name in item.name(): return item # make sure only track items with correct track names are added if track_name and track_name in track.name(): @@ -949,6 +949,54 @@ def sync_clip_name_to_data_asset(track_items_list): print("asset was changed in clip: {}".format(ti_name)) +def check_inventory_versions(): + """ + Actual version color idetifier of Loaded containers + + Check all track items and filter only + Loader nodes for its version. It will get all versions from database + and check if the node is having actual version. If not then it will color + it to red. + """ + from . import parse_container + from avalon import io + + # presets + clip_color_last = "green" + clip_color = "red" + + # get all track items from current timeline + for track_item in get_track_items(): + container = parse_container(track_item) + + if container: + # get representation from io + representation = io.find_one({ + "type": "representation", + "_id": io.ObjectId(container["representation"]) + }) + + # Get start frame from version data + version = io.find_one({ + "type": "version", + "_id": representation["parent"] + }) + + # get all versions in list + versions = io.find({ + "type": "version", + "parent": version["parent"] + }).distinct('name') + + max_version = max(versions) + + # set clip colour + if version.get("name") == max_version: + track_item.source().binItem().setColor(clip_color_last) + else: + track_item.source().binItem().setColor(clip_color) + + def selection_changed_timeline(event): """Callback on timeline to check if asset in data is the same as clip name. @@ -958,9 +1006,15 @@ def selection_changed_timeline(event): timeline_editor = event.sender selection = timeline_editor.selection() + selection = [ti for ti in selection + if isinstance(ti, hiero.core.TrackItem)] + # run checking function sync_clip_name_to_data_asset(selection) + # also mark old versions of loaded containers + check_inventory_versions() + def before_project_save(event): track_items = get_track_items( @@ -972,3 +1026,6 @@ def before_project_save(event): # run checking function sync_clip_name_to_data_asset(track_items) + + # also mark old versions of loaded containers + check_inventory_versions() diff --git a/openpype/hosts/tvpaint/plugins/create/create_render_layer.py b/openpype/hosts/tvpaint/plugins/create/create_render_layer.py index 3eb2f5871d..eeb7d32d50 100644 --- a/openpype/hosts/tvpaint/plugins/create/create_render_layer.py +++ b/openpype/hosts/tvpaint/plugins/create/create_render_layer.py @@ -1,9 +1,11 @@ +from avalon.api import CreatorError from avalon.tvpaint import ( pipeline, lib, CommunicationWrapper ) from openpype.hosts.tvpaint.api import plugin +from openpype.lib import prepare_template_data class CreateRenderlayer(plugin.Creator): @@ -15,13 +17,31 @@ class CreateRenderlayer(plugin.Creator): defaults = ["Main"] rename_group = True + render_pass = "beauty" - subset_template = "{family}_{name}" rename_script_template = ( "tv_layercolor \"setcolor\"" " {clip_id} {group_id} {r} {g} {b} \"{name}\"" ) + dynamic_subset_keys = ["render_pass", "render_layer", "group"] + + @classmethod + def get_dynamic_data( + cls, variant, task_name, asset_id, project_name, host_name + ): + dynamic_data = super(CreateRenderlayer, cls).get_dynamic_data( + variant, task_name, asset_id, project_name, host_name + ) + # Use render pass name from creator's plugin + dynamic_data["render_pass"] = cls.render_pass + # Add variant to render layer + dynamic_data["render_layer"] = variant + # Change family for subset name fill + dynamic_data["family"] = "render" + + return dynamic_data + @classmethod def get_default_variant(cls): """Default value for variant in Creator tool. @@ -70,34 +90,44 @@ class CreateRenderlayer(plugin.Creator): # Raise if there is no selection if not group_ids: - raise AssertionError("Nothing is selected.") + raise CreatorError("Nothing is selected.") # This creator should run only on one group if len(group_ids) > 1: - raise AssertionError("More than one group is in selection.") + raise CreatorError("More than one group is in selection.") group_id = tuple(group_ids)[0] # If group id is `0` it is `default` group which is invalid if group_id == 0: - raise AssertionError( + raise CreatorError( "Selection is not in group. Can't mark selection as Beauty." ) self.log.debug(f"Selected group id is \"{group_id}\".") self.data["group_id"] = group_id - family = self.data["family"] - # Extract entered name - name = self.data["subset"][len(family):] - self.log.info(f"Extracted name from subset name \"{name}\".") - self.data["name"] = name + group_data = lib.groups_data() + group_name = None + for group in group_data: + if group["group_id"] == group_id: + group_name = group["name"] + break - # Change subset name by template - subset_name = self.subset_template.format(**{ - "family": self.family, - "name": name - }) - self.log.info(f"New subset name \"{subset_name}\".") + if group_name is None: + raise AssertionError( + "Couldn't find group by id \"{}\"".format(group_id) + ) + + subset_name_fill_data = { + "group": group_name + } + + family = self.family = self.data["family"] + + # Fill dynamic key 'group' + subset_name = self.data["subset"].format( + **prepare_template_data(subset_name_fill_data) + ) self.data["subset"] = subset_name # Check for instances of same group @@ -153,7 +183,7 @@ class CreateRenderlayer(plugin.Creator): # Rename TVPaint group (keep color same) # - groups can't contain spaces - new_group_name = name.replace(" ", "_") + new_group_name = self.data["variant"].replace(" ", "_") rename_script = self.rename_script_template.format( clip_id=selected_group["clip_id"], group_id=selected_group["group_id"], diff --git a/openpype/hosts/tvpaint/plugins/create/create_render_pass.py b/openpype/hosts/tvpaint/plugins/create/create_render_pass.py index 58158685c7..ad06520210 100644 --- a/openpype/hosts/tvpaint/plugins/create/create_render_pass.py +++ b/openpype/hosts/tvpaint/plugins/create/create_render_pass.py @@ -1,9 +1,11 @@ +from avalon.api import CreatorError from avalon.tvpaint import ( pipeline, lib, CommunicationWrapper ) from openpype.hosts.tvpaint.api import plugin +from openpype.lib import prepare_template_data class CreateRenderPass(plugin.Creator): @@ -18,7 +20,19 @@ class CreateRenderPass(plugin.Creator): icon = "cube" defaults = ["Main"] - subset_template = "{family}_{render_layer}_{pass}" + dynamic_subset_keys = ["render_pass", "render_layer"] + + @classmethod + def get_dynamic_data( + cls, variant, task_name, asset_id, project_name, host_name + ): + dynamic_data = super(CreateRenderPass, cls).get_dynamic_data( + variant, task_name, asset_id, project_name, host_name + ) + dynamic_data["render_pass"] = variant + dynamic_data["family"] = "render" + + return dynamic_data @classmethod def get_default_variant(cls): @@ -66,11 +80,11 @@ class CreateRenderPass(plugin.Creator): # Raise if nothing is selected if not selected_layers: - raise AssertionError("Nothing is selected.") + raise CreatorError("Nothing is selected.") # Raise if layers from multiple groups are selected if len(group_ids) != 1: - raise AssertionError("More than one group is in selection.") + raise CreatorError("More than one group is in selection.") group_id = tuple(group_ids)[0] self.log.debug(f"Selected group id is \"{group_id}\".") @@ -87,34 +101,40 @@ class CreateRenderPass(plugin.Creator): # Beauty is required for this creator so raise if was not found if beauty_instance is None: - raise AssertionError("Beauty pass does not exist yet.") + raise CreatorError("Beauty pass does not exist yet.") - render_layer = beauty_instance["name"] + subset_name = self.data["subset"] + + subset_name_fill_data = {} + + # Backwards compatibility + # - beauty may be created with older creator where variant was not + # stored + if "variant" not in beauty_instance: + render_layer = beauty_instance["name"] + else: + render_layer = beauty_instance["variant"] + + subset_name_fill_data["render_layer"] = render_layer + + # Format dynamic keys in subset name + new_subset_name = subset_name.format( + **prepare_template_data(subset_name_fill_data) + ) + self.data["subset"] = new_subset_name + self.log.info(f"New subset name is \"{new_subset_name}\".") - # Extract entered name family = self.data["family"] - name = self.data["subset"] - # Is this right way how to get name? - name = name[len(family):] - self.log.info(f"Extracted name from subset name \"{name}\".") + variant = self.data["variant"] self.data["group_id"] = group_id - self.data["pass"] = name + self.data["pass"] = variant self.data["render_layer"] = render_layer # Collect selected layer ids to be stored into instance layer_names = [layer["name"] for layer in selected_layers] self.data["layer_names"] = layer_names - # Replace `beauty` in beauty's subset name with entered name - subset_name = self.subset_template.format(**{ - "family": family, - "render_layer": render_layer, - "pass": name - }) - self.data["subset"] = subset_name - self.log.info(f"New subset name is \"{subset_name}\".") - # Check if same instance already exists existing_instance = None existing_instance_idx = None @@ -122,7 +142,7 @@ class CreateRenderPass(plugin.Creator): if ( instance["family"] == family and instance["group_id"] == group_id - and instance["pass"] == name + and instance["pass"] == variant ): existing_instance = instance existing_instance_idx = idx @@ -131,7 +151,7 @@ class CreateRenderPass(plugin.Creator): if existing_instance is not None: self.log.info( f"Render pass instance for group id {group_id}" - f" and name \"{name}\" already exists, overriding." + f" and name \"{variant}\" already exists, overriding." ) instances[existing_instance_idx] = self.data else: diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_instances.py b/openpype/hosts/tvpaint/plugins/publish/collect_instances.py index 9b11f9fe80..4468bfae40 100644 --- a/openpype/hosts/tvpaint/plugins/publish/collect_instances.py +++ b/openpype/hosts/tvpaint/plugins/publish/collect_instances.py @@ -4,6 +4,8 @@ import copy import pyblish.api from avalon import io +from openpype.lib import get_subset_name + class CollectInstances(pyblish.api.ContextPlugin): label = "Collect Instances" @@ -62,9 +64,38 @@ class CollectInstances(pyblish.api.ContextPlugin): # Different instance creation based on family instance = None if family == "review": - # Change subset name + # Change subset name of review instance + + # Collect asset doc to get asset id + # - not sure if it's good idea to require asset id in + # get_subset_name? + asset_name = context.data["workfile_context"]["asset"] + asset_doc = io.find_one( + { + "type": "asset", + "name": asset_name + }, + {"_id": 1} + ) + asset_id = None + if asset_doc: + asset_id = asset_doc["_id"] + + # Project name from workfile context + project_name = context.data["workfile_context"]["project"] + # Host name from environemnt variable + host_name = os.environ["AVALON_APP"] + # Use empty variant value + variant = "" task_name = io.Session["AVALON_TASK"] - new_subset_name = "{}{}".format(family, task_name.capitalize()) + new_subset_name = get_subset_name( + family, + variant, + task_name, + asset_id, + project_name, + host_name + ) instance_data["subset"] = new_subset_name instance = context.create_instance(**instance_data) @@ -119,19 +150,23 @@ class CollectInstances(pyblish.api.ContextPlugin): name = instance_data["name"] # Change label subset_name = instance_data["subset"] - instance_data["label"] = "{}_Beauty".format(name) - # Change subset name - # Final family of an instance will be `render` - new_family = "render" - task_name = io.Session["AVALON_TASK"] - new_subset_name = "{}{}_{}_Beauty".format( - new_family, task_name.capitalize(), name - ) - instance_data["subset"] = new_subset_name - self.log.debug("Changed subset name \"{}\"->\"{}\"".format( - subset_name, new_subset_name - )) + # Backwards compatibility + # - subset names were not stored as final subset names during creation + if "variant" not in instance_data: + instance_data["label"] = "{}_Beauty".format(name) + + # Change subset name + # Final family of an instance will be `render` + new_family = "render" + task_name = io.Session["AVALON_TASK"] + new_subset_name = "{}{}_{}_Beauty".format( + new_family, task_name.capitalize(), name + ) + instance_data["subset"] = new_subset_name + self.log.debug("Changed subset name \"{}\"->\"{}\"".format( + subset_name, new_subset_name + )) # Get all layers for the layer layers_data = context.data["layersData"] @@ -163,20 +198,23 @@ class CollectInstances(pyblish.api.ContextPlugin): ) # Change label render_layer = instance_data["render_layer"] - instance_data["label"] = "{}_{}".format(render_layer, pass_name) - # Change subset name - # Final family of an instance will be `render` - new_family = "render" - old_subset_name = instance_data["subset"] - task_name = io.Session["AVALON_TASK"] - new_subset_name = "{}{}_{}_{}".format( - new_family, task_name.capitalize(), render_layer, pass_name - ) - instance_data["subset"] = new_subset_name - self.log.debug("Changed subset name \"{}\"->\"{}\"".format( - old_subset_name, new_subset_name - )) + # Backwards compatibility + # - subset names were not stored as final subset names during creation + if "variant" not in instance_data: + instance_data["label"] = "{}_{}".format(render_layer, pass_name) + # Change subset name + # Final family of an instance will be `render` + new_family = "render" + old_subset_name = instance_data["subset"] + task_name = io.Session["AVALON_TASK"] + new_subset_name = "{}{}_{}_{}".format( + new_family, task_name.capitalize(), render_layer, pass_name + ) + instance_data["subset"] = new_subset_name + self.log.debug("Changed subset name \"{}\"->\"{}\"".format( + old_subset_name, new_subset_name + )) layers_data = context.data["layersData"] layers_by_name = { diff --git a/openpype/hosts/tvpaint/plugins/publish/collect_workfile.py b/openpype/hosts/tvpaint/plugins/publish/collect_workfile.py index b059be90bf..b61fec895f 100644 --- a/openpype/hosts/tvpaint/plugins/publish/collect_workfile.py +++ b/openpype/hosts/tvpaint/plugins/publish/collect_workfile.py @@ -3,6 +3,8 @@ import json import pyblish.api from avalon import io +from openpype.lib import get_subset_name + class CollectWorkfile(pyblish.api.ContextPlugin): label = "Collect Workfile" @@ -20,8 +22,38 @@ class CollectWorkfile(pyblish.api.ContextPlugin): basename, ext = os.path.splitext(filename) instance = context.create_instance(name=basename) + # Get subset name of workfile instance + # Collect asset doc to get asset id + # - not sure if it's good idea to require asset id in + # get_subset_name? + family = "workfile" + asset_name = context.data["workfile_context"]["asset"] + asset_doc = io.find_one( + { + "type": "asset", + "name": asset_name + }, + {"_id": 1} + ) + asset_id = None + if asset_doc: + asset_id = asset_doc["_id"] + + # Project name from workfile context + project_name = context.data["workfile_context"]["project"] + # Host name from environemnt variable + host_name = os.environ["AVALON_APP"] + # Use empty variant value + variant = "" task_name = io.Session["AVALON_TASK"] - subset_name = "workfile" + task_name.capitalize() + subset_name = get_subset_name( + family, + variant, + task_name, + asset_id, + project_name, + host_name + ) # Create Workfile instance instance.data.update({ diff --git a/openpype/lib/plugin_tools.py b/openpype/lib/plugin_tools.py index a5254af0da..1f2fb7a46e 100644 --- a/openpype/lib/plugin_tools.py +++ b/openpype/lib/plugin_tools.py @@ -34,7 +34,8 @@ def get_subset_name( asset_id, project_name=None, host_name=None, - default_template=None + default_template=None, + dynamic_data=None ): if not family: return "" @@ -68,11 +69,16 @@ def get_subset_name( if not task_name and "{task" in template.lower(): raise TaskNotSetError() - fill_pairs = ( - ("variant", variant), - ("family", family), - ("task", task_name) - ) + fill_pairs = { + "variant": variant, + "family": family, + "task": task_name + } + if dynamic_data: + # Dynamic data may override default values + for key, value in dynamic_data.items(): + fill_pairs[key] = value + return template.format(**prepare_template_data(fill_pairs)) @@ -91,7 +97,8 @@ def prepare_template_data(fill_pairs): """ fill_data = {} - for key, value in fill_pairs: + regex = re.compile(r"[a-zA-Z0-9]") + for key, value in dict(fill_pairs).items(): # Handle cases when value is `None` (standalone publisher) if value is None: continue @@ -102,13 +109,18 @@ def prepare_template_data(fill_pairs): # Capitalize only first char of value # - conditions are because of possible index errors + # - regex is to skip symbols that are not chars or numbers + # - e.g. "{key}" which starts with curly bracket capitalized = "" - if value: - # Upper first character - capitalized += value[0].upper() - # Append rest of string if there is any - if len(value) > 1: - capitalized += value[1:] + for idx in range(len(value or "")): + char = value[idx] + if not regex.match(char): + capitalized += char + else: + capitalized += char.upper() + capitalized += value[idx + 1:] + break + fill_data[key.capitalize()] = capitalized return fill_data diff --git a/openpype/modules/clockify/clockify_api.py b/openpype/modules/clockify/clockify_api.py index 3f0a9799b4..6af911fffc 100644 --- a/openpype/modules/clockify/clockify_api.py +++ b/openpype/modules/clockify/clockify_api.py @@ -36,6 +36,7 @@ class ClockifyAPI: self._secure_registry = None + @property def secure_registry(self): if self._secure_registry is None: self._secure_registry = OpenPypeSecureRegistry("clockify") diff --git a/openpype/modules/clockify/widgets.py b/openpype/modules/clockify/widgets.py index 76f3a3f365..fc8e7fa8a3 100644 --- a/openpype/modules/clockify/widgets.py +++ b/openpype/modules/clockify/widgets.py @@ -1,6 +1,5 @@ from Qt import QtCore, QtGui, QtWidgets -from avalon import style -from openpype import resources +from openpype import resources, style class MessageWidget(QtWidgets.QWidget): @@ -22,14 +21,6 @@ class MessageWidget(QtWidgets.QWidget): QtCore.Qt.WindowMinimizeButtonHint ) - # Font - self.font = QtGui.QFont() - self.font.setFamily("DejaVu Sans Condensed") - self.font.setPointSize(9) - self.font.setBold(True) - self.font.setWeight(50) - self.font.setKerning(True) - # Size setting self.resize(self.SIZE_W, self.SIZE_H) self.setMinimumSize(QtCore.QSize(self.SIZE_W, self.SIZE_H)) @@ -53,7 +44,6 @@ class MessageWidget(QtWidgets.QWidget): labels = [] for message in messages: label = QtWidgets.QLabel(message) - label.setFont(self.font) label.setCursor(QtGui.QCursor(QtCore.Qt.ArrowCursor)) label.setTextFormat(QtCore.Qt.RichText) label.setWordWrap(True) @@ -103,84 +93,64 @@ class ClockifySettings(QtWidgets.QWidget): icon = QtGui.QIcon(resources.pype_icon_filepath()) self.setWindowIcon(icon) + self.setWindowTitle("Clockify settings") self.setWindowFlags( QtCore.Qt.WindowCloseButtonHint | QtCore.Qt.WindowMinimizeButtonHint ) - self._translate = QtCore.QCoreApplication.translate - - # Font - self.font = QtGui.QFont() - self.font.setFamily("DejaVu Sans Condensed") - self.font.setPointSize(9) - self.font.setBold(True) - self.font.setWeight(50) - self.font.setKerning(True) - # Size setting self.resize(self.SIZE_W, self.SIZE_H) self.setMinimumSize(QtCore.QSize(self.SIZE_W, self.SIZE_H)) self.setMaximumSize(QtCore.QSize(self.SIZE_W+100, self.SIZE_H+100)) self.setStyleSheet(style.load_stylesheet()) - self.setLayout(self._main()) - self.setWindowTitle('Clockify settings') + self._ui_init() - def _main(self): - self.main = QtWidgets.QVBoxLayout() - self.main.setObjectName("main") + def _ui_init(self): + label_api_key = QtWidgets.QLabel("Clockify API key:") - self.form = QtWidgets.QFormLayout() - self.form.setContentsMargins(10, 15, 10, 5) - self.form.setObjectName("form") + input_api_key = QtWidgets.QLineEdit() + input_api_key.setFrame(True) + input_api_key.setPlaceholderText("e.g. XX1XxXX2x3x4xXxx") - self.label_api_key = QtWidgets.QLabel("Clockify API key:") - self.label_api_key.setFont(self.font) - self.label_api_key.setCursor(QtGui.QCursor(QtCore.Qt.ArrowCursor)) - self.label_api_key.setTextFormat(QtCore.Qt.RichText) - self.label_api_key.setObjectName("label_api_key") + error_label = QtWidgets.QLabel("") + error_label.setTextFormat(QtCore.Qt.RichText) + error_label.setWordWrap(True) + error_label.hide() - self.input_api_key = QtWidgets.QLineEdit() - self.input_api_key.setEnabled(True) - self.input_api_key.setFrame(True) - self.input_api_key.setObjectName("input_api_key") - self.input_api_key.setPlaceholderText( - self._translate("main", "e.g. XX1XxXX2x3x4xXxx") - ) + form_layout = QtWidgets.QFormLayout() + form_layout.setContentsMargins(10, 15, 10, 5) + form_layout.addRow(label_api_key, input_api_key) + form_layout.addRow(error_label) - self.error_label = QtWidgets.QLabel("") - self.error_label.setFont(self.font) - self.error_label.setTextFormat(QtCore.Qt.RichText) - self.error_label.setObjectName("error_label") - self.error_label.setWordWrap(True) - self.error_label.hide() + btn_ok = QtWidgets.QPushButton("Ok") + btn_ok.setToolTip('Sets Clockify API Key so can Start/Stop timer') - self.form.addRow(self.label_api_key, self.input_api_key) - self.form.addRow(self.error_label) - - self.btn_group = QtWidgets.QHBoxLayout() - self.btn_group.addStretch(1) - self.btn_group.setObjectName("btn_group") - - self.btn_ok = QtWidgets.QPushButton("Ok") - self.btn_ok.setToolTip('Sets Clockify API Key so can Start/Stop timer') - self.btn_ok.clicked.connect(self.click_ok) - - self.btn_cancel = QtWidgets.QPushButton("Cancel") + btn_cancel = QtWidgets.QPushButton("Cancel") cancel_tooltip = 'Application won\'t start' if self.optional: cancel_tooltip = 'Close this window' - self.btn_cancel.setToolTip(cancel_tooltip) - self.btn_cancel.clicked.connect(self._close_widget) + btn_cancel.setToolTip(cancel_tooltip) - self.btn_group.addWidget(self.btn_ok) - self.btn_group.addWidget(self.btn_cancel) + btn_group = QtWidgets.QHBoxLayout() + btn_group.addStretch(1) + btn_group.addWidget(btn_ok) + btn_group.addWidget(btn_cancel) - self.main.addLayout(self.form) - self.main.addLayout(self.btn_group) + main_layout = QtWidgets.QVBoxLayout(self) + main_layout.addLayout(form_layout) + main_layout.addLayout(btn_group) - return self.main + btn_ok.clicked.connect(self.click_ok) + btn_cancel.clicked.connect(self._close_widget) + + self.label_api_key = label_api_key + self.input_api_key = input_api_key + self.error_label = error_label + + self.btn_ok = btn_ok + self.btn_cancel = btn_cancel def setError(self, msg): self.error_label.setText(msg) @@ -212,6 +182,17 @@ class ClockifySettings(QtWidgets.QWidget): "Entered invalid API key" ) + def showEvent(self, event): + super(ClockifySettings, self).showEvent(event) + + # Make btns same width + max_width = max( + self.btn_ok.sizeHint().width(), + self.btn_cancel.sizeHint().width() + ) + self.btn_ok.setMinimumWidth(max_width) + self.btn_cancel.setMinimumWidth(max_width) + def closeEvent(self, event): if self.optional is True: event.ignore() diff --git a/openpype/modules/ftrack/ftrack_server/socket_thread.py b/openpype/modules/ftrack/ftrack_server/socket_thread.py index fd407bb9f5..eb8ec4d06c 100644 --- a/openpype/modules/ftrack/ftrack_server/socket_thread.py +++ b/openpype/modules/ftrack/ftrack_server/socket_thread.py @@ -66,7 +66,16 @@ class SocketThread(threading.Thread): *self.additional_args, str(self.port) ) - self.subproc = subprocess.Popen(args, env=env, stdin=subprocess.PIPE) + kwargs = { + "env": env, + "stdin": subprocess.PIPE + } + if not sys.stdout: + # Redirect to devnull if stdout is None + kwargs["stdout"] = subprocess.DEVNULL + kwargs["stderr"] = subprocess.DEVNULL + + self.subproc = subprocess.Popen(args, **kwargs) # Listen for incoming connections sock.listen(1) diff --git a/openpype/modules/ftrack/tray/login_dialog.py b/openpype/modules/ftrack/tray/login_dialog.py index a6360a7380..cc5689bee5 100644 --- a/openpype/modules/ftrack/tray/login_dialog.py +++ b/openpype/modules/ftrack/tray/login_dialog.py @@ -1,6 +1,6 @@ import os import requests -from avalon import style +from openpype import style from openpype.modules.ftrack.lib import credentials from . import login_tools from openpype import resources @@ -46,8 +46,11 @@ class CredentialsDialog(QtWidgets.QDialog): self.user_label = QtWidgets.QLabel("Username:") self.api_label = QtWidgets.QLabel("API Key:") - self.ftsite_input = QtWidgets.QLineEdit() - self.ftsite_input.setReadOnly(True) + self.ftsite_input = QtWidgets.QLabel() + self.ftsite_input.setTextInteractionFlags( + QtCore.Qt.TextBrowserInteraction + ) + # self.ftsite_input.setReadOnly(True) self.ftsite_input.setCursor(QtGui.QCursor(QtCore.Qt.IBeamCursor)) self.user_input = QtWidgets.QLineEdit() diff --git a/openpype/modules/muster/widget_login.py b/openpype/modules/muster/widget_login.py index d9af4cb99f..231b52c6bd 100644 --- a/openpype/modules/muster/widget_login.py +++ b/openpype/modules/muster/widget_login.py @@ -1,13 +1,12 @@ import os from Qt import QtCore, QtGui, QtWidgets -from avalon import style -from openpype import resources +from openpype import resources, style class MusterLogin(QtWidgets.QWidget): SIZE_W = 300 - SIZE_H = 130 + SIZE_H = 150 loginSignal = QtCore.Signal(object, object, object) @@ -123,7 +122,6 @@ class MusterLogin(QtWidgets.QWidget): super().keyPressEvent(key_event) def setError(self, msg): - self.error_label.setText(msg) self.error_label.show() @@ -149,6 +147,17 @@ class MusterLogin(QtWidgets.QWidget): def save_credentials(self, username, password): self.module.get_auth_token(username, password) + def showEvent(self, event): + super(MusterLogin, self).showEvent(event) + + # Make btns same width + max_width = max( + self.btn_ok.sizeHint().width(), + self.btn_cancel.sizeHint().width() + ) + self.btn_ok.setMinimumWidth(max_width) + self.btn_cancel.setMinimumWidth(max_width) + def closeEvent(self, event): event.ignore() self._close_widget() diff --git a/openpype/modules/timers_manager/widget_user_idle.py b/openpype/modules/timers_manager/widget_user_idle.py index 8b614f6a13..25b4e56650 100644 --- a/openpype/modules/timers_manager/widget_user_idle.py +++ b/openpype/modules/timers_manager/widget_user_idle.py @@ -1,6 +1,5 @@ -from avalon import style from Qt import QtCore, QtGui, QtWidgets -from openpype import resources +from openpype import resources, style class WidgetUserIdle(QtWidgets.QWidget): diff --git a/openpype/plugin.py b/openpype/plugin.py index 8ade0f3825..45c9a08209 100644 --- a/openpype/plugin.py +++ b/openpype/plugin.py @@ -16,13 +16,59 @@ class PypeCreatorMixin: Mixin class must be used as first in inheritance order to override methods. """ + dynamic_subset_keys = [] + + @classmethod + def get_dynamic_data( + cls, variant, task_name, asset_id, project_name, host_name + ): + """Return dynamic data for current Creator plugin. + + By default return keys from `dynamic_subset_keys` attribute as mapping + to keep formatted template unchanged. + + ``` + dynamic_subset_keys = ["my_key"] + --- + output = { + "my_key": "{my_key}" + } + ``` + + Dynamic keys may override default Creator keys (family, task, asset, + ...) but do it wisely if you need. + + All of keys will be converted into 3 variants unchanged, capitalized + and all upper letters. Because of that are all keys lowered. + + This method can be modified to prefill some values just keep in mind it + is class method. + + Returns: + dict: Fill data for subset name template. + """ + dynamic_data = {} + for key in cls.dynamic_subset_keys: + key = key.lower() + dynamic_data[key] = "{" + key + "}" + return dynamic_data @classmethod def get_subset_name( cls, variant, task_name, asset_id, project_name, host_name=None ): + dynamic_data = cls.get_dynamic_data( + variant, task_name, asset_id, project_name, host_name + ) + return get_subset_name( - cls.family, variant, task_name, asset_id, project_name, host_name + cls.family, + variant, + task_name, + asset_id, + project_name, + host_name, + dynamic_data=dynamic_data ) diff --git a/openpype/plugins/publish/integrate_ftrack_component_overwrite.py b/openpype/plugins/publish/integrate_ftrack_component_overwrite.py deleted file mode 100644 index 047fd8462c..0000000000 --- a/openpype/plugins/publish/integrate_ftrack_component_overwrite.py +++ /dev/null @@ -1,21 +0,0 @@ -import pyblish.api - - -class IntegrateFtrackComponentOverwrite(pyblish.api.InstancePlugin): - """ - Set `component_overwrite` to True on all instances `ftrackComponentsList` - """ - - order = pyblish.api.IntegratorOrder + 0.49 - label = 'Overwrite ftrack created versions' - families = ["clip"] - optional = True - active = False - - def process(self, instance): - component_list = instance.data['ftrackComponentsList'] - - for cl in component_list: - cl['component_overwrite'] = True - self.log.debug('Component {} overwriting'.format( - cl['component_data']['name'])) diff --git a/openpype/plugins/publish/validate_editorial_asset_name.py b/openpype/plugins/publish/validate_editorial_asset_name.py new file mode 100644 index 0000000000..ccea42dc37 --- /dev/null +++ b/openpype/plugins/publish/validate_editorial_asset_name.py @@ -0,0 +1,112 @@ +import pyblish.api +from avalon import io +from pprint import pformat + + +class ValidateEditorialAssetName(pyblish.api.ContextPlugin): + """ Validating if editorial's asset names are not already created in db. + + Checking variations of names with different size of caps or with + or without underscores. + """ + + order = pyblish.api.ValidatorOrder + label = "Validate Asset Name" + + def process(self, context): + + asset_and_parents = self.get_parents(context) + + if not io.Session: + io.install() + + db_assets = list(io.find( + {"type": "asset"}, {"name": 1, "data.parents": 1})) + self.log.debug("__ db_assets: {}".format(db_assets)) + + asset_db_docs = { + str(e["name"]): e["data"]["parents"] for e in db_assets} + + self.log.debug("__ project_entities: {}".format( + pformat(asset_db_docs))) + + assets_missing_name = {} + assets_wrong_parent = {} + for asset in asset_and_parents.keys(): + if asset not in asset_db_docs.keys(): + # add to some nonexistent list for next layer of check + assets_missing_name.update({asset: asset_and_parents[asset]}) + continue + + if asset_and_parents[asset] != asset_db_docs[asset]: + # add to some nonexistent list for next layer of check + assets_wrong_parent.update({ + asset: { + "required": asset_and_parents[asset], + "already_in_db": asset_db_docs[asset] + } + }) + continue + + self.log.info("correct asset: {}".format(asset)) + + if assets_missing_name: + wrong_names = {} + self.log.debug( + ">> assets_missing_name: {}".format(assets_missing_name)) + for asset in assets_missing_name.keys(): + _asset = asset.lower().replace("_", "") + if _asset in [a.lower().replace("_", "") + for a in asset_db_docs.keys()]: + wrong_names.update({ + "required_name": asset, + "used_variants_in_db": [ + a for a in asset_db_docs.keys() + if a.lower().replace("_", "") == _asset + ] + }) + + if wrong_names: + self.log.debug( + ">> wrong_names: {}".format(wrong_names)) + raise Exception( + "Some already existing asset name variants `{}`".format( + wrong_names)) + + if assets_wrong_parent: + self.log.debug( + ">> assets_wrong_parent: {}".format(assets_wrong_parent)) + raise Exception( + "Wrong parents on assets `{}`".format(assets_wrong_parent)) + + def _get_all_assets(self, input_dict): + """ Returns asset names in list. + + List contains all asset names including parents + """ + for key in input_dict.keys(): + # check if child key is available + if input_dict[key].get("childs"): + # loop deeper + self._get_all_assets( + input_dict[key]["childs"]) + else: + self.all_testing_assets.append(key) + + def get_parents(self, context): + return_dict = {} + for instance in context: + asset = instance.data["asset"] + families = instance.data.get("families", []) + [ + instance.data["family"] + ] + # filter out non-shot families + if "shot" not in families: + continue + + parents = instance.data["parents"] + + return_dict.update({ + asset: [p["entity_name"] for p in parents] + }) + return return_dict diff --git a/openpype/settings/defaults/project_settings/global.json b/openpype/settings/defaults/project_settings/global.json index 5f779fccfa..4351f18a60 100644 --- a/openpype/settings/defaults/project_settings/global.json +++ b/openpype/settings/defaults/project_settings/global.json @@ -215,6 +215,17 @@ "hosts": [], "tasks": [], "template": "{family}{Task}{Variant}" + }, + { + "families": [ + "renderLayer", + "renderPass" + ], + "hosts": [ + "tvpaint" + ], + "tasks": [], + "template": "{family}{Task}_{Render_layer}_{Render_pass}" } ] }, diff --git a/openpype/settings/defaults/project_settings/hiero.json b/openpype/settings/defaults/project_settings/hiero.json index 44b27fc16f..1dff3aac51 100644 --- a/openpype/settings/defaults/project_settings/hiero.json +++ b/openpype/settings/defaults/project_settings/hiero.json @@ -34,7 +34,8 @@ "jpeg", "png", "h264", - "mov" + "mov", + "mp4" ], "clip_name_template": "{asset}_{subset}_{representation}" } diff --git a/openpype/settings/entities/enum_entity.py b/openpype/settings/entities/enum_entity.py index 5df365508c..0b0575a255 100644 --- a/openpype/settings/entities/enum_entity.py +++ b/openpype/settings/entities/enum_entity.py @@ -116,7 +116,7 @@ class AppsEnumEntity(BaseEnumEntity): system_settings_entity = self.get_entity_from_path("system_settings") valid_keys = set() - enum_items = [] + enum_items_list = [] applications_entity = system_settings_entity["applications"] for group_name, app_group in applications_entity.items(): enabled_entity = app_group.get("enabled") @@ -149,8 +149,12 @@ class AppsEnumEntity(BaseEnumEntity): full_label = variant_label full_name = "/".join((group_name, variant_name)) - enum_items.append({full_name: full_label}) + enum_items_list.append((full_name, full_label)) valid_keys.add(full_name) + + enum_items = [] + for key, value in sorted(enum_items_list, key=lambda item: item[1]): + enum_items.append({key: value}) return enum_items, valid_keys def set_override_state(self, *args, **kwargs): @@ -179,7 +183,7 @@ class ToolsEnumEntity(BaseEnumEntity): system_settings_entity = self.get_entity_from_path("system_settings") valid_keys = set() - enum_items = [] + enum_items_list = [] tool_groups_entity = system_settings_entity["tools"]["tool_groups"] for group_name, tool_group in tool_groups_entity.items(): # Try to get group label from entity @@ -204,8 +208,12 @@ class ToolsEnumEntity(BaseEnumEntity): else: tool_label = tool_name - enum_items.append({tool_name: tool_label}) + enum_items_list.append((tool_name, tool_label)) valid_keys.add(tool_name) + + enum_items = [] + for key, value in sorted(enum_items_list, key=lambda item: item[1]): + enum_items.append({key: value}) return enum_items, valid_keys def set_override_state(self, *args, **kwargs): diff --git a/openpype/tools/project_manager/__init__.py b/openpype/tools/project_manager/__init__.py index 62fa8af8aa..1c5bfdcbd5 100644 --- a/openpype/tools/project_manager/__init__.py +++ b/openpype/tools/project_manager/__init__.py @@ -1,3 +1,24 @@ +"""Project Manager tool + +Purpose of the tool is to be able create and modify hierarchy under project +ready for OpenPype pipeline usage. Tool also give ability to create new +projects. + +# Brief info +Project hierarchy consist of two types "asset" and "task". Assets can be +children of Project or other Asset. Task can be children of Asset. + +It is not possible to have duplicated Asset name across whole project. +It is not possible to have duplicated Task name under one Asset. + +Asset can't be moved or renamed if has or it's children has published content. + +Deleted assets are not deleted from database but their type is changed to +"archived_asset". + +Tool allows to modify Asset attributes like frame start/end, fps, etc. +""" + from .project_manager import ( ProjectManagerWindow, main diff --git a/openpype/tools/project_manager/project_manager/constants.py b/openpype/tools/project_manager/project_manager/constants.py index 6fb4b991ed..67dea79e59 100644 --- a/openpype/tools/project_manager/project_manager/constants.py +++ b/openpype/tools/project_manager/project_manager/constants.py @@ -2,12 +2,21 @@ import re from Qt import QtCore +# Item identifier (unique ID - uuid4 is used) IDENTIFIER_ROLE = QtCore.Qt.UserRole + 1 +# Item has duplicated name (Asset and Task items) DUPLICATED_ROLE = QtCore.Qt.UserRole + 2 +# It is possible to move and rename items +# - that is disabled if e.g. Asset has published content HIERARCHY_CHANGE_ABLE_ROLE = QtCore.Qt.UserRole + 3 +# Item is marked for deletion +# - item will be deleted after hitting save REMOVED_ROLE = QtCore.Qt.UserRole + 4 +# Item type in string ITEM_TYPE_ROLE = QtCore.Qt.UserRole + 5 +# Item has opened editor (per column) EDITOR_OPENED_ROLE = QtCore.Qt.UserRole + 6 +# Allowed symbols for any name NAME_ALLOWED_SYMBOLS = "a-zA-Z0-9_" NAME_REGEX = re.compile("^[" + NAME_ALLOWED_SYMBOLS + "]*$") diff --git a/openpype/tools/project_manager/project_manager/delegates.py b/openpype/tools/project_manager/project_manager/delegates.py index 51edff028f..842352cba1 100644 --- a/openpype/tools/project_manager/project_manager/delegates.py +++ b/openpype/tools/project_manager/project_manager/delegates.py @@ -8,6 +8,10 @@ from .multiselection_combobox import MultiSelectionComboBox class ResizeEditorDelegate(QtWidgets.QStyledItemDelegate): + """Implementation of private method from QStyledItemDelegate. + + Force editor to resize into item size. + """ @staticmethod def _q_smart_min_size(editor): min_size_hint = editor.minimumSizeHint() @@ -67,6 +71,16 @@ class ResizeEditorDelegate(QtWidgets.QStyledItemDelegate): class NumberDelegate(QtWidgets.QStyledItemDelegate): + """Delegate for number attributes. + + Editor correspond passed arguments. + + Args: + minimum(int, float): Minimum possible value. + maximum(int, float): Maximum possible value. + decimals(int): How many decimal points can be used. Float will be used + as value if is higher than 0. + """ def __init__(self, minimum, maximum, decimals, *args, **kwargs): super(NumberDelegate, self).__init__(*args, **kwargs) self.minimum = minimum @@ -80,10 +94,13 @@ class NumberDelegate(QtWidgets.QStyledItemDelegate): editor = QtWidgets.QSpinBox(parent) editor.setObjectName("NumberEditor") + # Set min/max editor.setMinimum(self.minimum) editor.setMaximum(self.maximum) + # Hide spinbox buttons editor.setButtonSymbols(QtWidgets.QSpinBox.NoButtons) + # Try to set value from item value = index.data(QtCore.Qt.EditRole) if value is not None: try: @@ -98,6 +115,8 @@ class NumberDelegate(QtWidgets.QStyledItemDelegate): class NameDelegate(QtWidgets.QStyledItemDelegate): + """Specific delegate for "name" key.""" + def createEditor(self, parent, option, index): editor = NameTextEdit(parent) editor.setObjectName("NameEditor") @@ -108,11 +127,26 @@ class NameDelegate(QtWidgets.QStyledItemDelegate): class TypeDelegate(QtWidgets.QStyledItemDelegate): + """Specific delegate for "type" key. + + It is expected that will be used only for TaskItem which has modifiable + type. Type values are defined with cached project document. + + Args: + project_doc_cache(ProjectDocCache): Project cache shared across all + delegates (kind of a struct pointer). + """ + def __init__(self, project_doc_cache, *args, **kwargs): self._project_doc_cache = project_doc_cache super(TypeDelegate, self).__init__(*args, **kwargs) def createEditor(self, parent, option, index): + """Editor is using filtrable combobox. + + Editor should not be possible to create new items or set values that + are not in this method. + """ editor = FilterComboBox(parent) editor.setObjectName("TypeEditor") editor.style().polish(editor) @@ -136,6 +170,18 @@ class TypeDelegate(QtWidgets.QStyledItemDelegate): class ToolsDelegate(QtWidgets.QStyledItemDelegate): + """Specific delegate for "tools_env" key. + + Expected that editor will be used only on AssetItem which is the only item + that can have `tools_env` (except project). + + Delegate requires tools cache which is shared across all ToolsDelegate + objects. + + Args: + tools_cache (ToolsCache): Possible values of tools. + """ + def __init__(self, tools_cache, *args, **kwargs): self._tools_cache = tools_cache super(ToolsDelegate, self).__init__(*args, **kwargs) diff --git a/openpype/tools/project_manager/project_manager/model.py b/openpype/tools/project_manager/project_manager/model.py index 370e8c3826..7ee43a6b61 100644 --- a/openpype/tools/project_manager/project_manager/model.py +++ b/openpype/tools/project_manager/project_manager/model.py @@ -20,7 +20,11 @@ from Qt import QtCore, QtGui class ProjectModel(QtGui.QStandardItemModel): - project_changed = QtCore.Signal() + """Load possible projects to modify from MongoDB. + + Mongo collection must contain project document with "type" "project" and + matching "name" value with name of collection. + """ def __init__(self, dbcon, *args, **kwargs): self.dbcon = dbcon @@ -30,6 +34,7 @@ class ProjectModel(QtGui.QStandardItemModel): super(ProjectModel, self).__init__(*args, **kwargs) def refresh(self): + """Reload projects.""" self.dbcon.Session["AVALON_PROJECT"] = None project_items = [] @@ -62,6 +67,12 @@ class ProjectModel(QtGui.QStandardItemModel): class HierarchySelectionModel(QtCore.QItemSelectionModel): + """Selection model with defined allowed multiselection columns. + + This model allows to select multiple rows and enter one of their + editors to edit value of all selected rows. + """ + def __init__(self, multiselection_columns, *args, **kwargs): super(HierarchySelectionModel, self).__init__(*args, **kwargs) self.multiselection_columns = multiselection_columns @@ -77,6 +88,21 @@ class HierarchySelectionModel(QtCore.QItemSelectionModel): class HierarchyModel(QtCore.QAbstractItemModel): + """Main model for hierarchy modification and value changes. + + Main part of ProjectManager. + + Model should be able to load existing entities, create new, handle their + validations like name duplication and validate if is possible to save it's + data. + + Args: + dbcon (AvalonMongoDB): Connection to MongoDB with set AVALON_PROJECT in + it's Session to current project. + """ + + # Definition of all possible columns with their labels in default order + # - order is important as column names are used as keys for column indexes _columns_def = [ ("name", "Name"), ("type", "Type"), @@ -92,6 +118,8 @@ class HierarchyModel(QtCore.QAbstractItemModel): ("pixelAspect", "Pixel aspect"), ("tools_env", "Tools") ] + # Columns allowing multiselection in edit mode + # - gives ability to set all of keys below on multiple items at once multiselection_columns = { "frameStart", "frameEnd", @@ -140,13 +168,19 @@ class HierarchyModel(QtCore.QAbstractItemModel): return self._items_by_id def _reset_root_item(self): + """Removes all previous content related to model.""" self._root_item = RootItem(self) def refresh_project(self): + """Reload project data and discard unsaved changes.""" self.set_project(self._current_project, True) @property def project_item(self): + """Access to current project item. + + Model can have 0-1 ProjectItems at once. + """ output = None for row in range(self._root_item.rowCount()): item = self._root_item.child(row) @@ -156,6 +190,14 @@ class HierarchyModel(QtCore.QAbstractItemModel): return output def set_project(self, project_name, force=False): + """Change project and discard unsaved changes. + + Args: + project_name(str): New project name. Or None if just clearing + content. + force(bool): Force to change project even if project name is same + as current project. + """ if self._current_project == project_name and not force: return @@ -166,19 +208,26 @@ class HierarchyModel(QtCore.QAbstractItemModel): self.clear() self._current_project = project_name + + # Skip if project is None if not project_name: return + # Find project'd document project_doc = self.dbcon.database[project_name].find_one( {"type": "project"}, ProjectItem.query_projection ) + # Skip if project document does not exist + # - this shouldn't happen using only UI elements if not project_doc: return + # Create project item project_item = ProjectItem(project_doc) self.add_item(project_item) + # Query all assets of the project asset_docs = self.dbcon.database[project_name].find( {"type": "asset"}, AssetItem.query_projection @@ -188,7 +237,8 @@ class HierarchyModel(QtCore.QAbstractItemModel): for asset_doc in asset_docs } - # Prepare booleans if asset item can be modified (name or hierarchy) + # Check if asset have published content and prepare booleans + # if asset item can be modified (name and hierarchy change) # - the same must be applied to all it's parents asset_ids = list(asset_docs_by_id.keys()) result = [] @@ -217,6 +267,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): count = item["count"] asset_modifiable[asset_id] = count < 1 + # Store assets by their visual parent to be able create their hierarchy asset_docs_by_parent_id = collections.defaultdict(list) for asset_doc in asset_docs_by_id.values(): parent_id = asset_doc["data"].get("visualParent") @@ -285,9 +336,11 @@ class HierarchyModel(QtCore.QAbstractItemModel): self.add_items(task_items, asset_item) + # Emit that project was successfully changed self.project_changed.emit() def rowCount(self, parent=None): + """Number of rows for passed parent.""" if parent is None or not parent.isValid(): parent_item = self._root_item else: @@ -295,9 +348,15 @@ class HierarchyModel(QtCore.QAbstractItemModel): return parent_item.rowCount() def columnCount(self, *args, **kwargs): + """Number of columns is static for this model.""" return self.columns_len def data(self, index, role): + """Access data for passed index and it's role. + + Model is using principles implemented in BaseItem so converts passed + index column into key and ask item to return value for passed role. + """ if not index.isValid(): return None @@ -308,18 +367,24 @@ class HierarchyModel(QtCore.QAbstractItemModel): return item.data(role, key) def setData(self, index, value, role=QtCore.Qt.EditRole): + """Store data to passed index under role. + + Pass values to corresponding item and behave by it's result. + """ if not index.isValid(): return False item = index.internalPointer() column = index.column() key = self.columns[column] + # Capture asset name changes for duplcated asset names validation. if ( key == "name" and role in (QtCore.Qt.EditRole, QtCore.Qt.DisplayRole) ): self._rename_asset(item, value) + # Pass values to item and by result emi dataChanged signal or not result = item.setData(value, role, key) if result: self.dataChanged.emit(index, index, [role]) @@ -327,6 +392,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): return result def headerData(self, section, orientation, role): + """Header labels.""" if role == QtCore.Qt.DisplayRole: if section < self.columnCount(): return self.column_labels[section] @@ -336,6 +402,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): ) def flags(self, index): + """Index flags are defined by corresponding item.""" item = index.internalPointer() if item is None: return QtCore.Qt.NoItemFlags @@ -344,6 +411,11 @@ class HierarchyModel(QtCore.QAbstractItemModel): return item.flags(key) def parent(self, index=None): + """Parent for passed index as QModelIndex. + + Args: + index(QModelIndex): Parent index. Root item is used if not passed. + """ if not index.isValid(): return QtCore.QModelIndex() @@ -357,7 +429,13 @@ class HierarchyModel(QtCore.QAbstractItemModel): return self.createIndex(parent_item.row(), 0, parent_item) def index(self, row, column, parent=None): - """Return index for row/column under parent""" + """Return index for row/column under parent. + + Args: + row(int): Row number. + column(int): Column number. + parent(QModelIndex): Parent index. Root item is used if not passed. + """ parent_item = None if parent is not None and parent.isValid(): parent_item = parent.internalPointer() @@ -365,11 +443,31 @@ class HierarchyModel(QtCore.QAbstractItemModel): return self.index_from_item(row, column, parent_item) def index_for_item(self, item, column=0): + """Index for passed item. + + This is for cases that index operations are required on specific item. + + Args: + item(BaseItem): Item from model that will be converted to + corresponding QModelIndex. + column(int): Which column will be part of returned index. By + default is used column 0. + """ return self.index_from_item( item.row(), column, item.parent() ) def index_from_item(self, row, column, parent=None): + """Index for passed row, column and parent item. + + Same implementation as `index` method but "parent" is one of + BaseItem objects instead of QModelIndex. + + Args: + row(int): Row number. + column(int): Column number. + parent(BaseItem): Parent item. Root item is used if not passed. + """ if parent is None: parent = self._root_item @@ -380,6 +478,12 @@ class HierarchyModel(QtCore.QAbstractItemModel): return QtCore.QModelIndex() def add_new_asset(self, source_index): + """Create new asset item in hierarchy. + + Args: + source_index(QModelIndex): Parent under which new asset will be + added. + """ item_id = source_index.data(IDENTIFIER_ROLE) item = self.items_by_id[item_id] @@ -389,9 +493,11 @@ class HierarchyModel(QtCore.QAbstractItemModel): if isinstance(item, (RootItem, ProjectItem)): name = "ep" new_row = None - else: + elif isinstance(item, AssetItem): name = None new_row = item.rowCount() + else: + return asset_data = {} if name: @@ -408,6 +514,13 @@ class HierarchyModel(QtCore.QAbstractItemModel): return result def add_new_task(self, parent_index): + """Create new TaskItem under passed parent index or it's parent. + + Args: + parent_index(QModelIndex): Index of parent AssetItem under which + will be task added. If index represents TaskItem it's parent is + used as parent. + """ item_id = parent_index.data(IDENTIFIER_ROLE) item = self.items_by_id[item_id] @@ -423,6 +536,18 @@ class HierarchyModel(QtCore.QAbstractItemModel): return self.add_item(new_child, parent) def add_items(self, items, parent=None, start_row=None): + """Add new items with definition of QAbstractItemModel. + + Trigger `beginInsertRows` and `endInsertRows` to trigger proper + callbacks in view or proxy model. + + Args: + items(list[BaseItem]): List of item that will be inserted in model. + parent(RootItem, ProjectItem, AssetItem): Parent of items under + which will be items added. Root item is used if not passed. + start_row(int): Define to which row will be items added. Next + available row of parent is used if not passed. + """ if parent is None: parent = self._root_item @@ -462,12 +587,25 @@ class HierarchyModel(QtCore.QAbstractItemModel): return indexes def add_item(self, item, parent=None, row=None): + """Add single item into model.""" result = self.add_items([item], parent, row) if result: return result[0] return None def remove_delete_flag(self, item_ids, with_children=True): + """Remove deletion flag from items with matching ids. + + The flag is also removed from all parents of passed children as it + wouldn't make sense to not to do so. + + Children of passed item ids are by default also unset for deletion. + + Args: + list(uuid4): Ids of model items where remove flag should be unset. + with_children(bool): Unset remove flag also on all children of + passed items. + """ items_by_id = {} for item_id in item_ids: if item_id in items_by_id: @@ -514,9 +652,11 @@ class HierarchyModel(QtCore.QAbstractItemModel): self._validate_asset_duplicity(name) def delete_index(self, index): + """Delete item of the index from model.""" return self.delete_indexes([index]) def delete_indexes(self, indexes): + """Delete items from model.""" items_by_id = {} processed_ids = set() for index in indexes: @@ -539,12 +679,26 @@ class HierarchyModel(QtCore.QAbstractItemModel): self._remove_item(item) def _remove_item(self, item): + """Remove item from model or mark item for deletion. + + Deleted items are using definition of QAbstractItemModel which call + `beginRemoveRows` and `endRemoveRows` to trigger proper view and proxy + model callbacks. + + Item is not just removed but is checked if can be removed from model or + just mark it for deletion for save. + + First of all will find all related children and based on their + attributes define if can be removed. + """ + # Skip if item is already marked for deletion is_removed = item.data(REMOVED_ROLE) if is_removed: return parent = item.parent() + # Find all descendants and store them by parent id all_descendants = collections.defaultdict(dict) all_descendants[parent.id][item.id] = item @@ -577,6 +731,8 @@ class HierarchyModel(QtCore.QAbstractItemModel): if isinstance(cur_item, AssetItem): self._rename_asset(cur_item, None) + # Process tasks as last because their logic is based on parent + # - tasks may be processed before parent check all asset children for task_item in task_children: _fill_children(_all_descendants, task_item, cur_item) return remove_item @@ -602,21 +758,29 @@ class HierarchyModel(QtCore.QAbstractItemModel): if not all_without_children: continue - parent_item = self._items_by_id[parent_id] + # Row ranges of items to remove + # - store tuples of row "start", "end" (can be the same) row_ranges = [] + # Predefine start, end variables start_row = end_row = None chilren_by_row = {} + parent_item = self._items_by_id[parent_id] for row in range(parent_item.rowCount()): child_item = parent_item.child(row) child_id = child_item.id + # Not sure if this can happend + # TODO validate this line it seems dangerous as start/end + # row is not changed if child_id not in children: continue chilren_by_row[row] = child_item children.pop(child_item.id) - remove_item = child_item.data(REMOVED_ROLE) - if not remove_item or not child_item.is_new: + removed_mark = child_item.data(REMOVED_ROLE) + if not removed_mark or not child_item.is_new: + # Skip row sequence store child for later processing + # and store current start/end row range modified_children.append(child_item) if end_row is not None: row_ranges.append((start_row, end_row)) @@ -630,11 +794,12 @@ class HierarchyModel(QtCore.QAbstractItemModel): if end_row is not None: row_ranges.append((start_row, end_row)) - parent_index = None - for start, end in row_ranges: - if parent_index is None: - parent_index = self.index_for_item(parent_item) + if not row_ranges: + continue + # Remove items from model + parent_index = self.index_for_item(parent_item) + for start, end in row_ranges: self.beginRemoveRows(parent_index, start, end) for idx in range(start, end + 1): @@ -647,6 +812,8 @@ class HierarchyModel(QtCore.QAbstractItemModel): self.endRemoveRows() + # Trigger data change to repaint items + # - `BackgroundRole` is random role without any specific reason for item in modified_children: s_index = self.index_for_item(item) e_index = self.index_for_item(item, column=self.columns_len - 1) @@ -1060,12 +1227,32 @@ class HierarchyModel(QtCore.QAbstractItemModel): self.index_moved.emit(new_index) def move_vertical(self, indexes, direction): + """Move item vertically in model to matching parent if possible. + + If passed indexes contain items that has parent<->child relation at any + hierarchy level only the top parent is actually moved. + + Example (items marked with star are passed in `indexes`): + - shots* + - ep01 + - ep01_sh0010* + - ep01_sh0020* + In this case only `shots` item will be moved vertically and + both "ep01_sh0010" "ep01_sh0020" will stay as children of "ep01". + + Args: + indexes(list[QModelIndex]): Indexes that should be moved + vertically. + direction(int): Which way will be moved -1 or 1 to determine. + """ if not indexes: return + # Convert single index to list of indexes if isinstance(indexes, QtCore.QModelIndex): indexes = [indexes] + # Just process single index if len(indexes) == 1: self._move_vertical_single(indexes[0], direction) return @@ -1100,6 +1287,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): self._move_vertical_single(index, direction) def child_removed(self, child): + """Callback for removed child.""" self._items_by_id.pop(child.id, None) def column_name(self, column): @@ -1109,11 +1297,19 @@ class HierarchyModel(QtCore.QAbstractItemModel): return None def clear(self): + """Reset model.""" self.beginResetModel() self._reset_root_item() self.endResetModel() def save(self): + """Save all changes from current project manager session. + + Will create new asset documents, update existing and asset documents + marked for deletion are removed from mongo if has published content or + their type is changed to `archived_asset` to not loose their data. + """ + # Check if all items are valid before save all_valid = True for item in self._items_by_id.values(): if not item.is_valid: @@ -1123,6 +1319,7 @@ class HierarchyModel(QtCore.QAbstractItemModel): if not all_valid: return + # Check project item and do not save without it project_item = None for _project_item in self._root_item.children(): project_item = _project_item @@ -1133,6 +1330,9 @@ class HierarchyModel(QtCore.QAbstractItemModel): project_name = project_item.name project_col = self.dbcon.database[project_name] + # Process asset items per one hierarchical level. + # - new assets are inserted per one parent + # - update and delete data are stored and processed at once at the end to_process = collections.deque() to_process.append(project_item) @@ -1253,6 +1453,14 @@ class HierarchyModel(QtCore.QAbstractItemModel): class BaseItem: + """Base item for HierarchyModel. + + Is not meant to be used as real item but as superclass for all items used + in HierarchyModel. + + TODO cleanup some attributes and methods related only to AssetItem and + TaskItem. + """ columns = [] # Use `set` for faster result editable_columns = set() @@ -1280,6 +1488,10 @@ class BaseItem: self._data[key] = value def name_icon(self): + """Icon shown next to name. + + Item must imlpement this method to change it. + """ return None @property @@ -1298,6 +1510,7 @@ class BaseItem: self._children.insert(row, item) def _get_global_data(self, role): + """Global data getter without column specification.""" if role == ITEM_TYPE_ROLE: return self.item_type @@ -1425,6 +1638,7 @@ class BaseItem: class RootItem(BaseItem): + """Invisible root item used as base item for model.""" item_type = "root" def __init__(self, model): @@ -1439,6 +1653,10 @@ class RootItem(BaseItem): class ProjectItem(BaseItem): + """Item representing project document in Mongo. + + Item is used only to read it's data. It is not possible to modify them. + """ item_type = "project" columns = { @@ -1482,21 +1700,32 @@ class ProjectItem(BaseItem): @property def project_id(self): + """Project Mongo ID.""" return self._mongo_id @property def asset_id(self): + """Should not be implemented. + + TODO Remove this method from ProjectItem. + """ return None @property def name(self): + """Project name""" return self._data["name"] def child_parents(self): + """Used by children AssetItems for filling `data.parents` key.""" return [] @classmethod def data_from_doc(cls, project_doc): + """Convert document data into item data. + + Project data are used as default value for it's children. + """ data = { "name": project_doc["name"], "type": project_doc["type"] @@ -1511,10 +1740,17 @@ class ProjectItem(BaseItem): return data def flags(self, *args, **kwargs): + """Project is enabled and selectable.""" return QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable class AssetItem(BaseItem): + """Item represent asset document. + + Item have ability to set all required and optional data for OpenPype + workflow. Some of them are not modifiable in specific cases e.g. when asset + has published content it is not possible to change it's name or parent. + """ item_type = "asset" columns = { @@ -1597,34 +1833,57 @@ class AssetItem(BaseItem): @property def project_id(self): + """Access to project "parent" id which is always set.""" if self._project_id is None: self._project_id = self.parent().project_id return self._project_id @property def asset_id(self): + """Property access to mongo id.""" return self.mongo_id @property def is_new(self): + """Item was created during current project manager session.""" return self.asset_id is None @property def is_valid(self): + """Item is invalid for saving.""" if self._is_duplicated or not self._data["name"]: return False return True @property def name(self): + """Asset name. + + Returns: + str: If name is set. + None: If name is not yet set in that case is AssetItem marked as + invalid. + """ return self._data["name"] def child_parents(self): + """Chilren AssetItem can use this method to get it's parent names. + + This is used for `data.parents` key on document. + """ parents = self.parent().child_parents() parents.append(self.name) return parents def to_doc(self): + """Convert item to Mongo document matching asset schema. + + Method does no validate if item is valid or children are valid. + + Returns: + dict: Document with all related data about asset item also + contains task children. + """ tasks = {} for item in self.children(): if isinstance(item, TaskItem): @@ -1659,6 +1918,22 @@ class AssetItem(BaseItem): return doc def update_data(self): + """Changes dictionary ready for Mongo's update. + + Method should be used on save. There is not other usage of this method. + + # Example + ```python + { + "$set": { + "name": "new_name" + } + } + ``` + + Returns: + dict: May be empty if item was not changed. + """ if not self.mongo_id: return {} @@ -1695,6 +1970,8 @@ class AssetItem(BaseItem): @classmethod def data_from_doc(cls, asset_doc): + """Convert asset document from Mongo to item data.""" + # Minimum required data for cases that it is new AssetItem withoud doc data = { "name": None, "type": "asset" @@ -1714,6 +1991,7 @@ class AssetItem(BaseItem): return data def name_icon(self): + """Icon shown next to name.""" if self.__class__._name_icons is None: self.__class__._name_icons = ResourceCache.get_icons()["asset"] @@ -1728,6 +2006,7 @@ class AssetItem(BaseItem): return self.__class__._name_icons[icon_type] def _get_global_data(self, role): + """Global data getter without column specification.""" if role == HIERARCHY_CHANGE_ABLE_ROLE: return self._hierarchy_changes_enabled @@ -1757,6 +2036,8 @@ class AssetItem(BaseItem): return super(AssetItem, self).data(role, key) def setData(self, value, role, key=None): + # Store information that column has opened editor + # - DisplayRole for the column will return empty string if role == EDITOR_OPENED_ROLE: if key not in self._edited_columns: return False @@ -1767,12 +2048,15 @@ class AssetItem(BaseItem): self._removed = value return True + # This can be set only on project load (or save) if role == HIERARCHY_CHANGE_ABLE_ROLE: if self._hierarchy_changes_enabled == value: return False self._hierarchy_changes_enabled = value return True + # Do not allow to change name if item is marked to not be able do any + # hierarchical changes. if ( role == QtCore.Qt.EditRole and key == "name" @@ -1820,6 +2104,8 @@ class AssetItem(BaseItem): _item.setData(False, DUPLICATED_ROLE) def _rename_task(self, item): + # Skip processing if item is marked for removing + # - item is not in any of attributes below if item.data(REMOVED_ROLE): return @@ -1851,9 +2137,22 @@ class AssetItem(BaseItem): self._task_name_by_item_id[item_id] = new_name def on_task_name_change(self, task_item): + """Method called from TaskItem children on name change. + + Helps to handle duplicated task name validations. + """ + self._rename_task(task_item) def on_task_remove_state_change(self, task_item): + """Method called from children TaskItem to handle name duplications. + + Method is called when TaskItem children is marked for deletion or + deletion was reversed. + + Name is removed/added to task item mapping attribute and removed/added + to `_task_items_by_name` used for determination of duplicated tasks. + """ is_removed = task_item.data(REMOVED_ROLE) item_id = task_item.data(IDENTIFIER_ROLE) if is_removed: @@ -1880,18 +2179,35 @@ class AssetItem(BaseItem): _item.setData(True, DUPLICATED_ROLE) def add_child(self, item, row=None): + """Add new children. + + Args: + item(AssetItem, TaskItem): New added item. + row(int): Optionally can be passed on which row (index) should be + children added. + """ if item in self._children: return super(AssetItem, self).add_child(item, row) + # Call inner method for checking task name duplications if isinstance(item, TaskItem): self._add_task(item) def remove_child(self, item): + """Remove one of children from AssetItem children. + + Skipped if item is not children of item. + + Args: + item(AssetItem, TaskItem): Child item. + """ if item not in self._children: return + # Call inner method to remove task from registered task name + # validations. if isinstance(item, TaskItem): self._remove_task(item) @@ -1899,6 +2215,16 @@ class AssetItem(BaseItem): class TaskItem(BaseItem): + """Item representing Task item on Asset document. + + Always should be AssetItem children and never should have any other + childrens. + + It's name value should be validated with it's parent which only knows if + has same name as other sibling under same parent. + """ + + # String representation of item item_type = "task" columns = { @@ -1927,10 +2253,12 @@ class TaskItem(BaseItem): @property def is_new(self): + """Task was created during current project manager session.""" return self._is_new @property def is_valid(self): + """Task valid for saving.""" if self._is_duplicated or not self._data["type"]: return False if not self.data(QtCore.Qt.EditRole, "name"): @@ -1938,6 +2266,7 @@ class TaskItem(BaseItem): return True def name_icon(self): + """Icon shown next to name.""" if self.__class__._name_icons is None: self.__class__._name_icons = ResourceCache.get_icons()["task"] @@ -1952,9 +2281,11 @@ class TaskItem(BaseItem): return self.__class__._name_icons[icon_type] def add_child(self, item, row=None): + """Reimplement `add_child` to avoid adding items under task.""" raise AssertionError("BUG: Can't add children to Task") def _get_global_data(self, role): + """Global data getter without column specification.""" if role == REMOVED_ROLE: return self._removed @@ -1973,6 +2304,12 @@ class TaskItem(BaseItem): return super(TaskItem, self)._get_global_data(role) def to_doc_data(self): + """Data for Asset document. + + Returns: + dict: May be empty if task is marked as removed or with single key + dict with name as key and task data as value. + """ if self._removed: return {} data = copy.deepcopy(self._data) @@ -1988,6 +2325,7 @@ class TaskItem(BaseItem): return False return self._edited_columns[key] + # Return empty string if column is edited if role == QtCore.Qt.DisplayRole and self._edited_columns.get(key): return "" @@ -1995,6 +2333,7 @@ class TaskItem(BaseItem): if key == "type": return self._data["type"] + # Always require task type filled if key == "name": if not self._data["type"]: if role == QtCore.Qt.DisplayRole: @@ -2007,6 +2346,8 @@ class TaskItem(BaseItem): return super(TaskItem, self).data(role, key) def setData(self, value, role, key=None): + # Store information that item on a column is edited + # - DisplayRole will return empty string in that case if role == EDITOR_OPENED_ROLE: if key not in self._edited_columns: return False @@ -2014,12 +2355,14 @@ class TaskItem(BaseItem): return True if role == REMOVED_ROLE: + # Skip value change if is same as already set value if value == self._removed: return False self._removed = value self.parent().on_task_remove_state_change(self) return True + # Convert empty string to None on EditRole if ( role == QtCore.Qt.EditRole and key == "name" @@ -2030,6 +2373,7 @@ class TaskItem(BaseItem): result = super(TaskItem, self).setData(value, role, key) if role == QtCore.Qt.EditRole: + # Trigger task name change of parent AssetItem if ( key == "name" or (key == "type" and not self._data["name"]) diff --git a/openpype/tools/project_manager/project_manager/window.py b/openpype/tools/project_manager/project_manager/window.py index 37092bc4a9..7c71f4b451 100644 --- a/openpype/tools/project_manager/project_manager/window.py +++ b/openpype/tools/project_manager/project_manager/window.py @@ -19,6 +19,8 @@ from avalon.api import AvalonMongoDB class ProjectManagerWindow(QtWidgets.QWidget): + """Main widget of Project Manager tool.""" + def __init__(self, parent=None): super(ProjectManagerWindow, self).__init__(parent) diff --git a/openpype/version.py b/openpype/version.py index d6d6a4544b..bf261d41b2 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.1.0-nightly.2" +__version__ = "3.1.0-nightly.3" diff --git a/website/yarn.lock b/website/yarn.lock index f1527f5b76..2d5ec103d4 100644 --- a/website/yarn.lock +++ b/website/yarn.lock @@ -5834,9 +5834,9 @@ normalize-range@^0.1.2: integrity sha1-LRDAa9/TEuqXd2laTShDlFa3WUI= normalize-url@^4.1.0, normalize-url@^4.5.0: - version "4.5.0" - resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-4.5.0.tgz#453354087e6ca96957bd8f5baf753f5982142129" - integrity sha512-2s47yzUxdexf1OhyRi4Em83iQk0aPvwTddtFz4hnSSw9dCEsLEGf6SwIO8ss/19S9iBb5sJaOuTvTGDeZI00BQ== + version "4.5.1" + resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-4.5.1.tgz#0dd90cf1288ee1d1313b87081c9a5932ee48518a" + integrity sha512-9UZCFRHQdNrfTpGg8+1INIg93B6zE0aXMVFkw1WFwvO4SlZywU6aLg5Of0Ap/PgcbSw4LNxvMWXMeugwMCX0AA== npm-run-path@^2.0.0: version "2.0.2"