diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index d0853e74d6..82f9a6ae9d 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -47,7 +47,7 @@ jobs: enhancementLabel: '**πŸš€ Enhancements**' bugsLabel: '**πŸ› Bug fixes**' deprecatedLabel: '**⚠️ Deprecations**' - addSections: '{"documentation":{"prefix":"### πŸ“– Documentation","labels":["documentation"]},"tests":{"prefix":"### βœ… Testing","labels":["tests"]}}' + addSections: '{"documentation":{"prefix":"### πŸ“– Documentation","labels":["documentation"]},"tests":{"prefix":"### βœ… Testing","labels":["tests"]},"feature":{"prefix":"### πŸ†• New features","labels":["feature"]},}' issues: false issuesWoLabels: false sinceTag: "3.0.0" diff --git a/.gitmodules b/.gitmodules index 82fd194d26..28f164726d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -6,7 +6,7 @@ url = https://github.com/pypeclub/avalon-unreal-integration.git [submodule "openpype/modules/default_modules/ftrack/python2_vendor/arrow"] path = openpype/modules/default_modules/ftrack/python2_vendor/arrow - url = git@github.com:arrow-py/arrow.git + url = https://github.com/arrow-py/arrow.git [submodule "openpype/modules/default_modules/ftrack/python2_vendor/ftrack-python-api"] path = openpype/modules/default_modules/ftrack/python2_vendor/ftrack-python-api url = https://bitbucket.org/ftrack/ftrack-python-api.git diff --git a/CHANGELOG.md b/CHANGELOG.md index 5c55be842a..e1737458b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,15 +1,29 @@ # Changelog -## [3.4.0-nightly.2](https://github.com/pypeclub/OpenPype/tree/HEAD) +## [3.4.0-nightly.4](https://github.com/pypeclub/OpenPype/tree/HEAD) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.3.1...HEAD) **Merged pull requests:** +- Ftrack: Fix hosts attribute in collect ftrack username [\#1972](https://github.com/pypeclub/OpenPype/pull/1972) +- Removed deprecated submodules [\#1967](https://github.com/pypeclub/OpenPype/pull/1967) +- Launcher: Fix crashes on action click [\#1964](https://github.com/pypeclub/OpenPype/pull/1964) +- Settings: Minor fixes in UI and missing default values [\#1963](https://github.com/pypeclub/OpenPype/pull/1963) +- Blender: Toggle system console works on windows [\#1962](https://github.com/pypeclub/OpenPype/pull/1962) +- Resolve path when adding to zip [\#1960](https://github.com/pypeclub/OpenPype/pull/1960) +- Bump url-parse from 1.5.1 to 1.5.3 in /website [\#1958](https://github.com/pypeclub/OpenPype/pull/1958) +- Global: Avalon Host name collector [\#1949](https://github.com/pypeclub/OpenPype/pull/1949) +- Global: Define hosts in CollectSceneVersion [\#1948](https://github.com/pypeclub/OpenPype/pull/1948) - Maya: Add Xgen family support [\#1947](https://github.com/pypeclub/OpenPype/pull/1947) - Add face sets to exported alembics [\#1942](https://github.com/pypeclub/OpenPype/pull/1942) +- Bump path-parse from 1.0.6 to 1.0.7 in /website [\#1933](https://github.com/pypeclub/OpenPype/pull/1933) +- \#1894 - adds host to template\_name\_profiles for filtering [\#1915](https://github.com/pypeclub/OpenPype/pull/1915) - Environments: Tool environments in alphabetical order [\#1910](https://github.com/pypeclub/OpenPype/pull/1910) +- Disregard publishing time. [\#1888](https://github.com/pypeclub/OpenPype/pull/1888) +- Feature/webpublisher backend [\#1876](https://github.com/pypeclub/OpenPype/pull/1876) - Dynamic modules [\#1872](https://github.com/pypeclub/OpenPype/pull/1872) +- Houdini: add Camera, Point Cache, Composite, Redshift ROP and VDB Cache support [\#1821](https://github.com/pypeclub/OpenPype/pull/1821) ## [3.3.1](https://github.com/pypeclub/OpenPype/tree/3.3.1) (2021-08-20) @@ -72,21 +86,11 @@ - Maya: support for configurable `dirmap` πŸ—ΊοΈ [\#1859](https://github.com/pypeclub/OpenPype/pull/1859) - Maya: don't add reference members as connections to the container set πŸ“¦ [\#1855](https://github.com/pypeclub/OpenPype/pull/1855) - Settings list can use template or schema as object type [\#1815](https://github.com/pypeclub/OpenPype/pull/1815) -- Maya: expected files -\> render products βš™οΈ overhaul [\#1812](https://github.com/pypeclub/OpenPype/pull/1812) -- Settings error dialog on show [\#1798](https://github.com/pypeclub/OpenPype/pull/1798) ## [3.2.0](https://github.com/pypeclub/OpenPype/tree/3.2.0) (2021-07-13) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.2.0-nightly.7...3.2.0) -**Merged pull requests:** - -- Build: don't add Poetry to `PATH` [\#1808](https://github.com/pypeclub/OpenPype/pull/1808) -- Nuke: ftrack family plugin settings preset [\#1805](https://github.com/pypeclub/OpenPype/pull/1805) -- nuke: fixing wrong name of family folder when `used existing frames` [\#1803](https://github.com/pypeclub/OpenPype/pull/1803) -- Collect ftrack family bugs [\#1801](https://github.com/pypeclub/OpenPype/pull/1801) -- Standalone publisher last project [\#1799](https://github.com/pypeclub/OpenPype/pull/1799) - ## [2.18.4](https://github.com/pypeclub/OpenPype/tree/2.18.4) (2021-06-24) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/2.18.3...2.18.4) diff --git a/openpype/lib/__init__.py b/openpype/lib/__init__.py index 9bcd0f7587..3d392dc745 100644 --- a/openpype/lib/__init__.py +++ b/openpype/lib/__init__.py @@ -71,6 +71,8 @@ from .avalon_context import ( get_linked_assets, get_latest_version, + get_workfile_template_key, + get_workfile_template_key_from_context, get_workdir_data, get_workdir, get_workdir_with_workdir_data, @@ -189,6 +191,8 @@ __all__ = [ "get_linked_assets", "get_latest_version", + "get_workfile_template_key", + "get_workfile_template_key_from_context", "get_workdir_data", "get_workdir", "get_workdir_with_workdir_data", diff --git a/openpype/lib/applications.py b/openpype/lib/applications.py index a62bd99b8c..ee7adea6eb 100644 --- a/openpype/lib/applications.py +++ b/openpype/lib/applications.py @@ -28,7 +28,8 @@ from . import ( from .local_settings import get_openpype_username from .avalon_context import ( get_workdir_data, - get_workdir_with_workdir_data + get_workdir_with_workdir_data, + get_workfile_template_key_from_context ) from .python_module_tools import ( @@ -1236,8 +1237,18 @@ def prepare_context_environments(data): anatomy = data["anatomy"] + template_key = get_workfile_template_key_from_context( + asset_doc["name"], + task_name, + app.host_name, + project_name=project_name, + dbcon=data["dbcon"] + ) + try: - workdir = get_workdir_with_workdir_data(workdir_data, anatomy) + workdir = get_workdir_with_workdir_data( + workdir_data, anatomy, template_key=template_key + ) except Exception as exc: raise ApplicationLaunchFailed( diff --git a/openpype/lib/avalon_context.py b/openpype/lib/avalon_context.py index c4217cc6d5..497348af33 100644 --- a/openpype/lib/avalon_context.py +++ b/openpype/lib/avalon_context.py @@ -344,6 +344,127 @@ def get_latest_version(asset_name, subset_name, dbcon=None, project_name=None): return version_doc +def get_workfile_template_key_from_context( + asset_name, task_name, host_name, project_name=None, + dbcon=None, project_settings=None +): + """Helper function to get template key for workfile template. + + Do the same as `get_workfile_template_key` but returns value for "session + context". + + It is required to pass one of 'dbcon' with already set project name or + 'project_name' arguments. + + Args: + asset_name(str): Name of asset document. + task_name(str): Task name for which is template key retrieved. + Must be available on asset document under `data.tasks`. + host_name(str): Name of host implementation for which is workfile + used. + project_name(str): Project name where asset and task is. Not required + when 'dbcon' is passed. + dbcon(AvalonMongoDB): Connection to mongo with already set project + under `AVALON_PROJECT`. Not required when 'project_name' is passed. + project_settings(dict): Project settings for passed 'project_name'. + Not required at all but makes function faster. + Raises: + ValueError: When both 'dbcon' and 'project_name' were not + passed. + """ + if not dbcon: + if not project_name: + raise ValueError(( + "`get_workfile_template_key_from_context` requires to pass" + " one of 'dbcon' or 'project_name' arguments." + )) + from avalon.api import AvalonMongoDB + + dbcon = AvalonMongoDB() + dbcon.Session["AVALON_PROJECT"] = project_name + + elif not project_name: + project_name = dbcon.Session["AVALON_PROJECT"] + + asset_doc = dbcon.find_one( + { + "type": "asset", + "name": asset_name + }, + { + "data.tasks": 1 + } + ) + asset_tasks = asset_doc.get("data", {}).get("tasks") or {} + task_info = asset_tasks.get(task_name) or {} + task_type = task_info.get("type") + + return get_workfile_template_key( + task_type, host_name, project_name, project_settings + ) + + +def get_workfile_template_key( + task_type, host_name, project_name=None, project_settings=None +): + """Workfile template key which should be used to get workfile template. + + Function is using profiles from project settings to return right template + for passet task type and host name. + + One of 'project_name' or 'project_settings' must be passed it is preffered + to pass settings if are already available. + + Args: + task_type(str): Name of task type. + host_name(str): Name of host implementation (e.g. "maya", "nuke", ...) + project_name(str): Name of project in which context should look for + settings. Not required if `project_settings` are passed. + project_settings(dict): Prepare project settings for project name. + Not needed if `project_name` is passed. + + Raises: + ValueError: When both 'project_name' and 'project_settings' were not + passed. + """ + default = "work" + if not task_type or not host_name: + return default + + if not project_settings: + if not project_name: + raise ValueError(( + "`get_workfile_template_key` requires to pass" + " one of 'project_name' or 'project_settings' arguments." + )) + project_settings = get_project_settings(project_name) + + try: + profiles = ( + project_settings + ["global"] + ["tools"] + ["Workfiles"] + ["workfile_template_profiles"] + ) + except Exception: + profiles = [] + + if not profiles: + return default + + from .profiles_filtering import filter_profiles + + profile_filter = { + "task_types": task_type, + "hosts": host_name + } + profile = filter_profiles(profiles, profile_filter) + if profile: + return profile["workfile_template"] or default + return default + + def get_workdir_data(project_doc, asset_doc, task_name, host_name): """Prepare data for workdir template filling from entered information. @@ -373,7 +494,8 @@ def get_workdir_data(project_doc, asset_doc, task_name, host_name): def get_workdir_with_workdir_data( - workdir_data, anatomy=None, project_name=None, template_key=None + workdir_data, anatomy=None, project_name=None, + template_key=None, dbcon=None ): """Fill workdir path from entered data and project's anatomy. @@ -387,8 +509,10 @@ def get_workdir_with_workdir_data( `project_name` is entered. project_name (str): Project's name. Optional if `anatomy` is entered otherwise Anatomy object is created with using the project name. - template_key (str): Key of work templates in anatomy templates. By - default is seto to `"work"`. + template_key (str): Key of work templates in anatomy templates. If not + passed `get_workfile_template_key_from_context` is used to get it. + dbcon(AvalonMongoDB): Mongo connection. Required only if 'template_key' + and 'project_name' are not passed. Returns: TemplateResult: Workdir path. @@ -406,7 +530,13 @@ def get_workdir_with_workdir_data( anatomy = Anatomy(project_name) if not template_key: - template_key = "work" + template_key = get_workfile_template_key_from_context( + workdir_data["asset"], + workdir_data["task"], + workdir_data["app"], + project_name=workdir_data["project"]["name"], + dbcon=dbcon + ) anatomy_filled = anatomy.format(workdir_data) # Output is TemplateResult object which contain usefull data @@ -447,7 +577,9 @@ def get_workdir( project_doc, asset_doc, task_name, host_name ) # Output is TemplateResult object which contain usefull data - return get_workdir_with_workdir_data(workdir_data, anatomy, template_key) + return get_workdir_with_workdir_data( + workdir_data, anatomy, template_key=template_key + ) @with_avalon @@ -516,7 +648,9 @@ def create_workfile_doc(asset_doc, task_name, filename, workdir, dbcon=None): # Prepare anatomy anatomy = Anatomy(project_doc["name"]) # Get workdir path (result is anatomy.TemplateResult) - template_workdir = get_workdir_with_workdir_data(workdir_data, anatomy) + template_workdir = get_workdir_with_workdir_data( + workdir_data, anatomy, dbcon=dbcon + ) template_workdir_path = str(template_workdir).replace("\\", "/") # Replace slashses in workdir path where workfile is located diff --git a/openpype/modules/deadline/plugins/publish/submit_houdini_remote_publish.py b/openpype/modules/default_modules/deadline/plugins/publish/submit_houdini_remote_publish.py similarity index 100% rename from openpype/modules/deadline/plugins/publish/submit_houdini_remote_publish.py rename to openpype/modules/default_modules/deadline/plugins/publish/submit_houdini_remote_publish.py diff --git a/openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py b/openpype/modules/default_modules/deadline/plugins/publish/submit_houdini_render_deadline.py similarity index 100% rename from openpype/modules/deadline/plugins/publish/submit_houdini_render_deadline.py rename to openpype/modules/default_modules/deadline/plugins/publish/submit_houdini_render_deadline.py diff --git a/openpype/modules/default_modules/ftrack/plugins/publish/collect_username.py b/openpype/modules/default_modules/ftrack/plugins/publish/collect_username.py index 7a303a1608..39b7433e11 100644 --- a/openpype/modules/default_modules/ftrack/plugins/publish/collect_username.py +++ b/openpype/modules/default_modules/ftrack/plugins/publish/collect_username.py @@ -26,7 +26,7 @@ class CollectUsername(pyblish.api.ContextPlugin): """ order = pyblish.api.CollectorOrder - 0.488 label = "Collect ftrack username" - host = ["webpublisher"] + hosts = ["webpublisher"] _context = None diff --git a/openpype/plugins/publish/extract_jpeg_exr.py b/openpype/plugins/publish/extract_jpeg_exr.py index 8d9e48b634..ae691285b5 100644 --- a/openpype/plugins/publish/extract_jpeg_exr.py +++ b/openpype/plugins/publish/extract_jpeg_exr.py @@ -95,7 +95,7 @@ class ExtractJpegEXR(pyblish.api.InstancePlugin): # use same input args like with mov jpeg_items.extend(ffmpeg_args.get("input") or []) # input file - jpeg_items.append("-i {}".format(full_input_path)) + jpeg_items.append("-i \"{}\"".format(full_input_path)) # output arguments from presets jpeg_items.extend(ffmpeg_args.get("output") or []) @@ -104,7 +104,7 @@ class ExtractJpegEXR(pyblish.api.InstancePlugin): jpeg_items.append("-vframes 1") # output file - jpeg_items.append(full_output_path) + jpeg_items.append("\"{}\"".format(full_output_path)) subprocess_jpeg = " ".join(jpeg_items) diff --git a/openpype/settings/defaults/project_settings/global.json b/openpype/settings/defaults/project_settings/global.json index 0c87c915f9..a53ae14914 100644 --- a/openpype/settings/defaults/project_settings/global.json +++ b/openpype/settings/defaults/project_settings/global.json @@ -251,6 +251,13 @@ ] }, "Workfiles": { + "workfile_template_profiles": [ + { + "task_types": [], + "hosts": [], + "workfile_template": "work" + } + ], "last_workfile_on_startup": [ { "hosts": [], diff --git a/openpype/settings/entities/__init__.py b/openpype/settings/entities/__init__.py index 9cda702e9a..8c30d5044c 100644 --- a/openpype/settings/entities/__init__.py +++ b/openpype/settings/entities/__init__.py @@ -106,7 +106,8 @@ from .enum_entity import ( ToolsEnumEntity, TaskTypeEnumEntity, ProvidersEnum, - DeadlineUrlEnumEntity + DeadlineUrlEnumEntity, + AnatomyTemplatesEnumEntity ) from .list_entity import ListEntity @@ -162,6 +163,7 @@ __all__ = ( "TaskTypeEnumEntity", "ProvidersEnum", "DeadlineUrlEnumEntity", + "AnatomyTemplatesEnumEntity", "ListEntity", diff --git a/openpype/settings/entities/enum_entity.py b/openpype/settings/entities/enum_entity.py index ed5da5bd9a..cb532c5ae0 100644 --- a/openpype/settings/entities/enum_entity.py +++ b/openpype/settings/entities/enum_entity.py @@ -494,3 +494,69 @@ class DeadlineUrlEnumEntity(BaseEnumEntity): elif self._current_value not in self.valid_keys: self._current_value = tuple(self.valid_keys)[0] + + +class AnatomyTemplatesEnumEntity(BaseEnumEntity): + schema_types = ["anatomy-templates-enum"] + + def _item_initalization(self): + self.multiselection = False + + self.enum_items = [] + self.valid_keys = set() + + enum_default = self.schema_data.get("default") or "work" + + self.value_on_not_set = enum_default + self.valid_value_types = (STRING_TYPE,) + + # GUI attribute + self.placeholder = self.schema_data.get("placeholder") + + def _get_enum_values(self): + templates_entity = self.get_entity_from_path( + "project_anatomy/templates" + ) + + valid_keys = set() + enum_items_list = [] + + others_entity = None + for key, entity in templates_entity.items(): + # Skip defaults key + if key == "defaults": + continue + + if key == "others": + others_entity = entity + continue + + label = key + if hasattr(entity, "label"): + label = entity.label or label + + enum_items_list.append({key: label}) + valid_keys.add(key) + + if others_entity is not None: + get_child_label_func = getattr( + others_entity, "get_child_label", None + ) + for key, child_entity in others_entity.items(): + label = key + if callable(get_child_label_func): + label = get_child_label_func(child_entity) or label + + enum_items_list.append({key: label}) + valid_keys.add(key) + + return enum_items_list, valid_keys + + def set_override_state(self, *args, **kwargs): + super(AnatomyTemplatesEnumEntity, self).set_override_state( + *args, **kwargs + ) + + self.enum_items, self.valid_keys = self._get_enum_values() + if self._current_value not in self.valid_keys: + self._current_value = self.value_on_not_set diff --git a/openpype/settings/entities/schemas/README.md b/openpype/settings/entities/schemas/README.md index 2034d4e463..05605f8ce1 100644 --- a/openpype/settings/entities/schemas/README.md +++ b/openpype/settings/entities/schemas/README.md @@ -380,6 +380,20 @@ How output of the schema could look like on save: } ``` +### anatomy-templates-enum +- enumeration of all available anatomy template keys +- have only single selection mode +- it is possible to define default value `default` + - `"work"` is used if default value is not specified +``` +{ + "key": "host", + "label": "Host name", + "type": "anatomy-templates-enum", + "default": "publish" +} +``` + ### hosts-enum - enumeration of available hosts - multiselection can be allowed with setting key `"multiselection"` to `True` (Default: `False`) diff --git a/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_tools.json b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_tools.json index 9e39eeb39e..245560f115 100644 --- a/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_tools.json +++ b/openpype/settings/entities/schemas/projects_schema/schemas/schema_global_tools.json @@ -65,6 +65,37 @@ "key": "Workfiles", "label": "Workfiles", "children": [ + { + "type": "list", + "key": "workfile_template_profiles", + "label": "Workfile template profiles", + "use_label_wrap": true, + "object_type": { + "type": "dict", + "children": [ + { + "key": "task_types", + "label": "Task types", + "type": "task-types-enum" + }, + { + "type": "hosts-enum", + "key": "hosts", + "label": "Hosts", + "multiselection": true + }, + { + "type": "splitter" + }, + { + "key": "workfile_template", + "label": "Workfile template", + "type": "anatomy-templates-enum", + "multiselection": false + } + ] + } + }, { "type": "list", "key": "last_workfile_on_startup", diff --git a/openpype/tools/workfiles/app.py b/openpype/tools/workfiles/app.py index 42f0e422ae..b542e6e718 100644 --- a/openpype/tools/workfiles/app.py +++ b/openpype/tools/workfiles/app.py @@ -12,10 +12,15 @@ from avalon import style, io, api, pipeline from avalon.tools import lib as tools_lib from avalon.tools.widgets import AssetWidget -from avalon.tools.models import TasksModel from avalon.tools.delegates import PrettyTimeDelegate -from .model import FilesModel +from .model import ( + TASK_NAME_ROLE, + TASK_TYPE_ROLE, + FilesModel, + TasksModel, + TasksProxyModel +) from .view import FilesView from openpype.lib import ( @@ -23,7 +28,8 @@ from openpype.lib import ( get_workdir, get_workfile_doc, create_workfile_doc, - save_workfile_data_to_doc + save_workfile_data_to_doc, + get_workfile_template_key ) log = logging.getLogger(__name__) @@ -55,9 +61,13 @@ class NameWindow(QtWidgets.QDialog): # Set work file data for template formatting asset_name = session["AVALON_ASSET"] - project_doc = io.find_one({ - "type": "project" - }) + project_doc = io.find_one( + {"type": "project"}, + { + "name": True, + "data.code": True + } + ) self.data = { "project": { "name": project_doc["name"], @@ -126,10 +136,14 @@ class NameWindow(QtWidgets.QDialog): # for "{version". if "{version" in self.template: inputs_layout.addRow("Version:", version_widget) + else: + version_widget.setVisible(False) # Add subversion only if template containt `{comment}` if "{comment}" in self.template: inputs_layout.addRow("Subversion:", subversion_input) + else: + subversion_input.setVisible(False) inputs_layout.addRow("Extension:", ext_combo) inputs_layout.addRow("Preview:", preview_label) @@ -305,48 +319,46 @@ class TasksWidget(QtWidgets.QWidget): task_changed = QtCore.Signal() - def __init__(self, parent=None): + def __init__(self, dbcon=None, parent=None): super(TasksWidget, self).__init__(parent) - self.setContentsMargins(0, 0, 0, 0) - view = QtWidgets.QTreeView() - view.setIndentation(0) - model = TasksModel(io) - view.setModel(model) + tasks_view = QtWidgets.QTreeView(self) + tasks_view.setIndentation(0) + tasks_view.setSortingEnabled(True) + if dbcon is None: + dbcon = io + + tasks_model = TasksModel(dbcon) + tasks_proxy = TasksProxyModel() + tasks_proxy.setSourceModel(tasks_model) + tasks_view.setModel(tasks_proxy) layout = QtWidgets.QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(view) + layout.addWidget(tasks_view) - # Hide the default tasks "count" as we don't need that data here. - view.setColumnHidden(1, True) + selection_model = tasks_view.selectionModel() + selection_model.currentChanged.connect(self.task_changed) - selection = view.selectionModel() - selection.currentChanged.connect(self.task_changed) - - self.models = { - "tasks": model - } - - self.widgets = { - "view": view, - } + self._tasks_model = tasks_model + self._tasks_proxy = tasks_proxy + self._tasks_view = tasks_view self._last_selected_task = None - def set_asset(self, asset): - if asset is None: - # Asset deselected + def set_asset(self, asset_doc): + # Asset deselected + if asset_doc is None: return # Try and preserve the last selected task and reselect it # after switching assets. If there's no currently selected # asset keep whatever the "last selected" was prior to it. - current = self.get_current_task() + current = self.get_current_task_name() if current: self._last_selected_task = current - self.models["tasks"].set_assets(asset_docs=[asset]) + self._tasks_model.set_asset(asset_doc) if self._last_selected_task: self.select_task(self._last_selected_task) @@ -354,7 +366,7 @@ class TasksWidget(QtWidgets.QWidget): # Force a task changed emit. self.task_changed.emit() - def select_task(self, task): + def select_task(self, task_name): """Select a task by name. If the task does not exist in the current model then selection is only @@ -366,39 +378,40 @@ class TasksWidget(QtWidgets.QWidget): """ # Clear selection - view = self.widgets["view"] - model = view.model() - selection_model = view.selectionModel() + selection_model = self._tasks_view.selectionModel() selection_model.clearSelection() # Select the task mode = selection_model.Select | selection_model.Rows - for row in range(model.rowCount(QtCore.QModelIndex())): - index = model.index(row, 0, QtCore.QModelIndex()) - name = index.data(QtCore.Qt.DisplayRole) - if name == task: + for row in range(self._tasks_model.rowCount()): + index = self._tasks_model.index(row, 0) + name = index.data(TASK_NAME_ROLE) + if name == task_name: selection_model.select(index, mode) # Set the currently active index - view.setCurrentIndex(index) + self._tasks_view.setCurrentIndex(index) + break - def get_current_task(self): + def get_current_task_name(self): """Return name of task at current index (selected) Returns: str: Name of the current task. """ - view = self.widgets["view"] - index = view.currentIndex() - index = index.sibling(index.row(), 0) # ensure column zero for name + index = self._tasks_view.currentIndex() + selection_model = self._tasks_view.selectionModel() + if index.isValid() and selection_model.isSelected(index): + return index.data(TASK_NAME_ROLE) + return None - selection = view.selectionModel() - if selection.isSelected(index): - # Ignore when the current task is not selected as the "No task" - # placeholder might be the current index even though it's - # disallowed to be selected. So we only return if it is selected. - return index.data(QtCore.Qt.DisplayRole) + def get_current_task_type(self): + index = self._tasks_view.currentIndex() + selection_model = self._tasks_view.selectionModel() + if index.isValid() and selection_model.isSelected(index): + return index.data(TASK_TYPE_ROLE) + return None class FilesWidget(QtWidgets.QWidget): @@ -411,7 +424,8 @@ class FilesWidget(QtWidgets.QWidget): # Setup self._asset = None - self._task = None + self._task_name = None + self._task_type = None # Pype's anatomy object for current project self.anatomy = Anatomy(io.Session["AVALON_PROJECT"]) @@ -506,14 +520,15 @@ class FilesWidget(QtWidgets.QWidget): self.btn_browse = btn_browse self.btn_save = btn_save - def set_asset_task(self, asset, task): + def set_asset_task(self, asset, task_name, task_type): self._asset = asset - self._task = task + self._task_name = task_name + self._task_type = task_type # Define a custom session so we can query the work root # for a "Work area" that is not our current Session. # This way we can browse it even before we enter it. - if self._asset and self._task: + if self._asset and self._task_name and self._task_type: session = self._get_session() self.root = self.host.work_root(session) self.files_model.set_root(self.root) @@ -533,10 +548,16 @@ class FilesWidget(QtWidgets.QWidget): """Return a modified session for the current asset and task""" session = api.Session.copy() + self.template_key = get_workfile_template_key( + self._task_type, + session["AVALON_APP"], + project_name=session["AVALON_PROJECT"] + ) changes = pipeline.compute_session_changes( session, asset=self._asset, - task=self._task + task=self._task_name, + template_key=self.template_key ) session.update(changes) @@ -549,14 +570,19 @@ class FilesWidget(QtWidgets.QWidget): changes = pipeline.compute_session_changes( session, asset=self._asset, - task=self._task + task=self._task_name, + template_key=self.template_key ) if not changes: # Return early if we're already in the right Session context # to avoid any unwanted Task Changed callbacks to be triggered. return - api.update_current_task(asset=self._asset, task=self._task) + api.update_current_task( + asset=self._asset, + task=self._task_name, + template_key=self.template_key + ) def open_file(self, filepath): host = self.host @@ -606,7 +632,7 @@ class FilesWidget(QtWidgets.QWidget): result = messagebox.exec_() if result == messagebox.Yes: return True - elif result == messagebox.No: + if result == messagebox.No: return False return None @@ -700,7 +726,7 @@ class FilesWidget(QtWidgets.QWidget): self._enter_session() # Make sure we are in the right session self.host.save_file(file_path) - self.set_asset_task(self._asset, self._task) + self.set_asset_task(self._asset, self._task_name, self._task_type) pipeline.emit("after.workfile.save", [file_path]) @@ -727,7 +753,8 @@ class FilesWidget(QtWidgets.QWidget): changes = pipeline.compute_session_changes( session, asset=self._asset, - task=self._task + task=self._task_name, + template_key=self.template_key ) session.update(changes) @@ -750,7 +777,7 @@ class FilesWidget(QtWidgets.QWidget): # Force a full to the asset as opposed to just self.refresh() so # that it will actually check again whether the Work directory exists - self.set_asset_task(self._asset, self._task) + self.set_asset_task(self._asset, self._task_name, self._task_type) def refresh(self): """Refresh listed files for current selection in the interface""" @@ -927,7 +954,7 @@ class Window(QtWidgets.QMainWindow): assets_widget = AssetWidget(io, parent=home_body_widget) assets_widget.set_current_asset_btn_visibility(True) - tasks_widget = TasksWidget(home_body_widget) + tasks_widget = TasksWidget(io, home_body_widget) files_widget = FilesWidget(home_body_widget) side_panel = SidePanelWidget(home_body_widget) @@ -999,7 +1026,7 @@ class Window(QtWidgets.QMainWindow): if asset_docs: asset_doc = asset_docs[0] - task_name = self.tasks_widget.get_current_task() + task_name = self.tasks_widget.get_current_task_name() workfile_doc = None if asset_doc and task_name and filepath: @@ -1026,7 +1053,7 @@ class Window(QtWidgets.QMainWindow): def _get_current_workfile_doc(self, filepath=None): if filepath is None: filepath = self.files_widget._get_selected_filepath() - task_name = self.tasks_widget.get_current_task() + task_name = self.tasks_widget.get_current_task_name() asset_docs = self.assets_widget.get_selected_assets() if not task_name or not asset_docs or not filepath: return @@ -1046,7 +1073,7 @@ class Window(QtWidgets.QMainWindow): workdir, filename = os.path.split(filepath) asset_docs = self.assets_widget.get_selected_assets() asset_doc = asset_docs[0] - task_name = self.tasks_widget.get_current_task() + task_name = self.tasks_widget.get_current_task_name() create_workfile_doc(asset_doc, task_name, filename, workdir, io) def set_context(self, context): @@ -1065,7 +1092,6 @@ class Window(QtWidgets.QMainWindow): # Select the asset self.assets_widget.select_assets([asset], expand=True) - # Force a refresh on Tasks? self.tasks_widget.set_asset(asset_document) if "task" in context: @@ -1095,12 +1121,13 @@ class Window(QtWidgets.QMainWindow): asset = self.assets_widget.get_selected_assets() or None if asset is not None: asset = asset[0] - task = self.tasks_widget.get_current_task() + task_name = self.tasks_widget.get_current_task_name() + task_type = self.tasks_widget.get_current_task_type() self.tasks_widget.setEnabled(bool(asset)) - self.files_widget.setEnabled(all([bool(task), bool(asset)])) - self.files_widget.set_asset_task(asset, task) + self.files_widget.setEnabled(all([bool(task_name), bool(asset)])) + self.files_widget.set_asset_task(asset, task_name, task_type) self.files_widget.refresh() diff --git a/openpype/tools/workfiles/model.py b/openpype/tools/workfiles/model.py index 368988fd4e..92fbf76b95 100644 --- a/openpype/tools/workfiles/model.py +++ b/openpype/tools/workfiles/model.py @@ -1,7 +1,7 @@ import os import logging -from Qt import QtCore +from Qt import QtCore, QtGui from avalon import style from avalon.vendor import qtawesome @@ -9,6 +9,10 @@ from avalon.tools.models import TreeModel, Item log = logging.getLogger(__name__) +TASK_NAME_ROLE = QtCore.Qt.UserRole + 1 +TASK_TYPE_ROLE = QtCore.Qt.UserRole + 2 +TASK_ORDER_ROLE = QtCore.Qt.UserRole + 3 + class FilesModel(TreeModel): """Model listing files with specified extensions in a root folder""" @@ -151,3 +155,142 @@ class FilesModel(TreeModel): return "Date modified" return super(FilesModel, self).headerData(section, orientation, role) + + +class TasksProxyModel(QtCore.QSortFilterProxyModel): + def lessThan(self, x_index, y_index): + x_order = x_index.data(TASK_ORDER_ROLE) + y_order = y_index.data(TASK_ORDER_ROLE) + if x_order is not None and y_order is not None: + if x_order < y_order: + return True + if x_order > y_order: + return False + + elif x_order is None and y_order is not None: + return True + + elif y_order is None and x_order is not None: + return False + + x_name = x_index.data(QtCore.Qt.DisplayRole) + y_name = y_index.data(QtCore.Qt.DisplayRole) + if x_name == y_name: + return True + + if x_name == tuple(sorted((x_name, y_name)))[0]: + return False + return True + + +class TasksModel(QtGui.QStandardItemModel): + """A model listing the tasks combined for a list of assets""" + def __init__(self, dbcon, parent=None): + super(TasksModel, self).__init__(parent=parent) + self.dbcon = dbcon + self._default_icon = qtawesome.icon( + "fa.male", + color=style.colors.default + ) + self._no_tasks_icon = qtawesome.icon( + "fa.exclamation-circle", + color=style.colors.mid + ) + self._cached_icons = {} + self._project_task_types = {} + + self._refresh_task_types() + + def _refresh_task_types(self): + # Get the project configured icons from database + project = self.dbcon.find_one( + {"type": "project"}, + {"config.tasks"} + ) + tasks = project["config"].get("tasks") or {} + self._project_task_types = tasks + + def _try_get_awesome_icon(self, icon_name): + icon = None + if icon_name: + try: + icon = qtawesome.icon( + "fa.{}".format(icon_name), + color=style.colors.default + ) + + except Exception: + pass + return icon + + def headerData(self, section, orientation, role): + # Show nice labels in the header + if ( + role == QtCore.Qt.DisplayRole + and orientation == QtCore.Qt.Horizontal + ): + if section == 0: + return "Tasks" + + return super(TasksModel, self).headerData(section, orientation, role) + + def _get_icon(self, task_icon, task_type_icon): + if task_icon in self._cached_icons: + return self._cached_icons[task_icon] + + icon = self._try_get_awesome_icon(task_icon) + if icon is not None: + self._cached_icons[task_icon] = icon + return icon + + if task_type_icon in self._cached_icons: + icon = self._cached_icons[task_type_icon] + self._cached_icons[task_icon] = icon + return icon + + icon = self._try_get_awesome_icon(task_type_icon) + if icon is None: + icon = self._default_icon + + self._cached_icons[task_icon] = icon + self._cached_icons[task_type_icon] = icon + + return icon + + def set_asset(self, asset_doc): + """Set assets to track by their database id + + Arguments: + asset_doc (dict): Asset document from MongoDB. + """ + self.clear() + + if not asset_doc: + return + + asset_tasks = asset_doc.get("data", {}).get("tasks") or {} + items = [] + for task_name, task_info in asset_tasks.items(): + task_icon = task_info.get("icon") + task_type = task_info.get("type") + task_order = task_info.get("order") + task_type_info = self._project_task_types.get(task_type) or {} + task_type_icon = task_type_info.get("icon") + icon = self._get_icon(task_icon, task_type_icon) + + label = "{} ({})".format(task_name, task_type or "type N/A") + item = QtGui.QStandardItem(label) + item.setData(task_name, TASK_NAME_ROLE) + item.setData(task_type, TASK_TYPE_ROLE) + item.setData(task_order, TASK_ORDER_ROLE) + item.setData(icon, QtCore.Qt.DecorationRole) + item.setFlags(QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable) + items.append(item) + + if not items: + item = QtGui.QStandardItem("No task") + item.setData(self._no_tasks_icon, QtCore.Qt.DecorationRole) + item.setFlags(QtCore.Qt.NoItemFlags) + items.append(item) + + self.invisibleRootItem().appendRows(items) diff --git a/openpype/version.py b/openpype/version.py index 5fd6520953..17bd0ff892 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.4.0-nightly.2" +__version__ = "3.4.0-nightly.4" diff --git a/repos/avalon-core b/repos/avalon-core index 52e24a9993..f48fce09c0 160000 --- a/repos/avalon-core +++ b/repos/avalon-core @@ -1 +1 @@ -Subproject commit 52e24a9993e5223b0a719786e77a4b87e936e556 +Subproject commit f48fce09c0986c1fd7f6731de33907be46b436c5 diff --git a/tools/build_dependencies.py b/tools/build_dependencies.py index 3898450471..e5a430e220 100644 --- a/tools/build_dependencies.py +++ b/tools/build_dependencies.py @@ -135,6 +135,16 @@ progress_bar.close() # iterate over frozen libs and create list to delete libs_dir = build_dir / "lib" +# On Windows "python3.dll" is needed for PyQt5 from the build. +if platform.system().lower() == "windows": + src = Path(libs_dir / "PyQt5" / "python3.dll") + dst = Path(deps_dir / "PyQt5" / "python3.dll") + if src.exists(): + shutil.copyfile(src, dst) + else: + _print("Could not find {}".format(src), 1) + sys.exit(1) + to_delete = [] # _print("Finding duplicates ...") deps_items = list(deps_dir.iterdir()) diff --git a/tools/ci_tools.py b/tools/ci_tools.py index 436551c243..3c1aaae991 100644 --- a/tools/ci_tools.py +++ b/tools/ci_tools.py @@ -36,7 +36,7 @@ def get_log_since_tag(version): def release_type(log): regex_minor = ["feature/", "(feat)"] - regex_patch = ["bugfix/", "fix/", "(fix)"] + regex_patch = ["bugfix/", "fix/", "(fix)", "enhancement/"] for reg in regex_minor: if re.search(reg, log): return "minor" diff --git a/website/docs/admin_webserver_for_webpublisher.md b/website/docs/admin_webserver_for_webpublisher.md index dced825bdc..6e72ccaf32 100644 --- a/website/docs/admin_webserver_for_webpublisher.md +++ b/website/docs/admin_webserver_for_webpublisher.md @@ -40,14 +40,13 @@ Deploy OP build distribution (Openpype Igniter) on an OS of your choice. ```sh #!/usr/bin/env bash export OPENPYPE_DEBUG=3 -export WEBSERVER_HOST_IP=localhost export FTRACK_BOT_API_USER=YOUR_API_USER export FTRACK_BOT_API_KEY=YOUR_API_KEY export PYTHONDONTWRITEBYTECODE=1 export OPENPYPE_MONGO=YOUR_MONGODB_CONNECTION pushd /opt/openpype -./openpype_console webpublisherwebserver --upload_dir YOUR_SHARED_FOLDER_ON_HOST --executable /opt/openpype/openpype_console > /tmp/openpype.log 2>&1 +./openpype_console webpublisherwebserver --upload_dir YOUR_SHARED_FOLDER_ON_HOST --executable /opt/openpype/openpype_console --host YOUR_HOST_IP --port YOUR_HOST_PORT > /tmp/openpype.log 2>&1 ``` 1. create service file `sudo vi /etc/systemd/system/openpye-webserver.service` diff --git a/website/src/pages/index.js b/website/src/pages/index.js index 6a233ddb66..00cf002aec 100644 --- a/website/src/pages/index.js +++ b/website/src/pages/index.js @@ -120,7 +120,12 @@ const studios = [ title: "Bad Clay", image: "/img/badClay_logo.png", infoLink: "https://www.bad-clay.com/", - } + }, + { + title: "Moonrock Animation Studio", + image: "/img/moonrock_logo.png", + infoLink: "https://www.moonrock.eu/", + } ]; function Service({imageUrl, title, description}) { diff --git a/website/static/img/moonrock_logo.png b/website/static/img/moonrock_logo.png new file mode 100644 index 0000000000..249db7c247 Binary files /dev/null and b/website/static/img/moonrock_logo.png differ