diff --git a/.github/workflows/prerelease.yml b/.github/workflows/prerelease.yml index 60ce608b21..258458e2d4 100644 --- a/.github/workflows/prerelease.yml +++ b/.github/workflows/prerelease.yml @@ -33,7 +33,7 @@ jobs: id: version if: steps.version_type.outputs.type != 'skip' run: | - RESULT=$(python ./tools/ci_tools.py --nightly) + RESULT=$(python ./tools/ci_tools.py --nightly --github_token ${{ secrets.GITHUB_TOKEN }}) echo ::set-output name=next_tag::$RESULT diff --git a/CHANGELOG.md b/CHANGELOG.md index add7f53ae9..9a3571eca6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,18 +1,51 @@ # Changelog -## [3.6.0-nightly.5](https://github.com/pypeclub/OpenPype/tree/HEAD) +## [3.6.2-nightly.1](https://github.com/pypeclub/OpenPype/tree/HEAD) -[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.5.0...HEAD) - -**🆕 New features** - -- Maya : Colorspace configuration [\#2170](https://github.com/pypeclub/OpenPype/pull/2170) -- Blender: Added support for audio [\#2168](https://github.com/pypeclub/OpenPype/pull/2168) -- Flame: a host basic integration [\#2165](https://github.com/pypeclub/OpenPype/pull/2165) -- Houdini: simple HDA workflow [\#2072](https://github.com/pypeclub/OpenPype/pull/2072) +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/3.6.1...HEAD) **🚀 Enhancements** +- Tools: SceneInventory in OpenPype [\#2255](https://github.com/pypeclub/OpenPype/pull/2255) +- Tools: Tasks widget [\#2251](https://github.com/pypeclub/OpenPype/pull/2251) +- Added endpoint for configured extensions [\#2221](https://github.com/pypeclub/OpenPype/pull/2221) + +**🐛 Bug fixes** + +- Burnins: Support mxf metadata [\#2247](https://github.com/pypeclub/OpenPype/pull/2247) + +## [3.6.1](https://github.com/pypeclub/OpenPype/tree/3.6.1) (2021-11-16) + +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.6.1-nightly.1...3.6.1) + +**🐛 Bug fixes** + +- Loader doesn't allow changing of version before loading [\#2254](https://github.com/pypeclub/OpenPype/pull/2254) + +## [3.6.0](https://github.com/pypeclub/OpenPype/tree/3.6.0) (2021-11-15) + +[Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.6.0-nightly.6...3.6.0) + +### 📖 Documentation + +- Add alternative sites for Site Sync [\#2206](https://github.com/pypeclub/OpenPype/pull/2206) +- Add command line way of running site sync server [\#2188](https://github.com/pypeclub/OpenPype/pull/2188) + +**🆕 New features** + +- Add validate active site button to sync queue on a project [\#2176](https://github.com/pypeclub/OpenPype/pull/2176) +- Maya : Colorspace configuration [\#2170](https://github.com/pypeclub/OpenPype/pull/2170) +- Blender: Added support for audio [\#2168](https://github.com/pypeclub/OpenPype/pull/2168) + +**🚀 Enhancements** + +- Tools: Subset manager in OpenPype [\#2243](https://github.com/pypeclub/OpenPype/pull/2243) +- General: Skip module directories without init file [\#2239](https://github.com/pypeclub/OpenPype/pull/2239) +- General: Static interfaces [\#2238](https://github.com/pypeclub/OpenPype/pull/2238) +- Style: Fix transparent image in style [\#2235](https://github.com/pypeclub/OpenPype/pull/2235) +- Add a "following workfile versioning" option on publish [\#2225](https://github.com/pypeclub/OpenPype/pull/2225) +- Modules: Module can add cli commands [\#2224](https://github.com/pypeclub/OpenPype/pull/2224) +- Webpublisher: Separate webpublisher logic [\#2222](https://github.com/pypeclub/OpenPype/pull/2222) - Add both side availability on Site Sync sites to Loader [\#2220](https://github.com/pypeclub/OpenPype/pull/2220) - Tools: Center loader and library loader on show [\#2219](https://github.com/pypeclub/OpenPype/pull/2219) - Maya : Validate shape zero [\#2212](https://github.com/pypeclub/OpenPype/pull/2212) @@ -21,65 +54,46 @@ - Ftrack: Replace Queue with deque in event handlers logic [\#2204](https://github.com/pypeclub/OpenPype/pull/2204) - Tools: New select context dialog [\#2200](https://github.com/pypeclub/OpenPype/pull/2200) - Maya : Validate mesh ngons [\#2199](https://github.com/pypeclub/OpenPype/pull/2199) +- Dirmap in Nuke [\#2198](https://github.com/pypeclub/OpenPype/pull/2198) - Delivery: Check 'frame' key in template for sequence delivery [\#2196](https://github.com/pypeclub/OpenPype/pull/2196) +- Settings: Site sync project settings improvement [\#2193](https://github.com/pypeclub/OpenPype/pull/2193) - Usage of tools code [\#2185](https://github.com/pypeclub/OpenPype/pull/2185) - Settings: Dictionary based on project roots [\#2184](https://github.com/pypeclub/OpenPype/pull/2184) - Subset name: Be able to pass asset document to get subset name [\#2179](https://github.com/pypeclub/OpenPype/pull/2179) -- Tools: Experimental tools [\#2167](https://github.com/pypeclub/OpenPype/pull/2167) -- Loader: Refactor and use OpenPype stylesheets [\#2166](https://github.com/pypeclub/OpenPype/pull/2166) -- Add loader for linked smart objects in photoshop [\#2149](https://github.com/pypeclub/OpenPype/pull/2149) **🐛 Bug fixes** +- Ftrack: Sync project ftrack id cache issue [\#2250](https://github.com/pypeclub/OpenPype/pull/2250) +- Ftrack: Session creation and Prepare project [\#2245](https://github.com/pypeclub/OpenPype/pull/2245) +- Added queue for studio processing in PS [\#2237](https://github.com/pypeclub/OpenPype/pull/2237) +- Python 2: Unicode to string conversion [\#2236](https://github.com/pypeclub/OpenPype/pull/2236) +- Fix - enum for color coding in PS [\#2234](https://github.com/pypeclub/OpenPype/pull/2234) +- Pyblish Tool: Fix targets handling [\#2232](https://github.com/pypeclub/OpenPype/pull/2232) +- Ftrack: Base event fix of 'get\_project\_from\_entity' method [\#2214](https://github.com/pypeclub/OpenPype/pull/2214) - Maya : multiple subsets review broken [\#2210](https://github.com/pypeclub/OpenPype/pull/2210) - Fix - different command used for Linux and Mac OS [\#2207](https://github.com/pypeclub/OpenPype/pull/2207) - Tools: Workfiles tool don't use avalon widgets [\#2205](https://github.com/pypeclub/OpenPype/pull/2205) - Ftrack: Fill missing ftrack id on mongo project [\#2203](https://github.com/pypeclub/OpenPype/pull/2203) - Project Manager: Fix copying of tasks [\#2191](https://github.com/pypeclub/OpenPype/pull/2191) -- StandalonePublisher: Source validator don't expect representations [\#2190](https://github.com/pypeclub/OpenPype/pull/2190) - Blender: Fix trying to pack an image when the shader node has no texture [\#2183](https://github.com/pypeclub/OpenPype/pull/2183) -- MacOS: Launching of applications may cause Permissions error [\#2175](https://github.com/pypeclub/OpenPype/pull/2175) +- Maya: review viewport settings [\#2177](https://github.com/pypeclub/OpenPype/pull/2177) - Maya: Aspect ratio [\#2174](https://github.com/pypeclub/OpenPype/pull/2174) -- Blender: Fix 'Deselect All' with object not in 'Object Mode' [\#2163](https://github.com/pypeclub/OpenPype/pull/2163) -- Maya: Fix hotbox broken by scriptsmenu [\#2151](https://github.com/pypeclub/OpenPype/pull/2151) -- Added validator for source files for Standalone Publisher [\#2138](https://github.com/pypeclub/OpenPype/pull/2138) - -**Merged pull requests:** - -- Settings: Site sync project settings improvement [\#2193](https://github.com/pypeclub/OpenPype/pull/2193) -- Add validate active site button to sync queue on a project [\#2176](https://github.com/pypeclub/OpenPype/pull/2176) -- Bump pillow from 8.2.0 to 8.3.2 [\#2162](https://github.com/pypeclub/OpenPype/pull/2162) ## [3.5.0](https://github.com/pypeclub/OpenPype/tree/3.5.0) (2021-10-17) [Full Changelog](https://github.com/pypeclub/OpenPype/compare/CI/3.5.0-nightly.8...3.5.0) -**Deprecated:** - -- Maya: Change mayaAscii family to mayaScene [\#2106](https://github.com/pypeclub/OpenPype/pull/2106) - **🆕 New features** - Added project and task into context change message in Maya [\#2131](https://github.com/pypeclub/OpenPype/pull/2131) - Add ExtractBurnin to photoshop review [\#2124](https://github.com/pypeclub/OpenPype/pull/2124) - PYPE-1218 - changed namespace to contain subset name in Maya [\#2114](https://github.com/pypeclub/OpenPype/pull/2114) -- Added running configurable disk mapping command before start of OP [\#2091](https://github.com/pypeclub/OpenPype/pull/2091) -- SFTP provider [\#2073](https://github.com/pypeclub/OpenPype/pull/2073) **🚀 Enhancements** - Maya: make rig validators configurable in settings [\#2137](https://github.com/pypeclub/OpenPype/pull/2137) - Settings: Updated readme for entity types in settings [\#2132](https://github.com/pypeclub/OpenPype/pull/2132) - Nuke: unified clip loader [\#2128](https://github.com/pypeclub/OpenPype/pull/2128) -- Settings UI: Project model refreshing and sorting [\#2104](https://github.com/pypeclub/OpenPype/pull/2104) -- Create Read From Rendered - Disable Relative paths by default [\#2093](https://github.com/pypeclub/OpenPype/pull/2093) -- Added choosing different dirmap mapping if workfile synched locally [\#2088](https://github.com/pypeclub/OpenPype/pull/2088) -- General: Remove IdleManager module [\#2084](https://github.com/pypeclub/OpenPype/pull/2084) -- Tray UI: Message box about missing settings defaults [\#2080](https://github.com/pypeclub/OpenPype/pull/2080) -- Tray UI: Show menu where first click happened [\#2079](https://github.com/pypeclub/OpenPype/pull/2079) -- Global: add global validators to settings [\#2078](https://github.com/pypeclub/OpenPype/pull/2078) -- Use CRF for burnin when available [\#2070](https://github.com/pypeclub/OpenPype/pull/2070) -- Project manager: Filter first item after selection of project [\#2069](https://github.com/pypeclub/OpenPype/pull/2069) **🐛 Bug fixes** @@ -87,24 +101,6 @@ - Fix - oiiotool wasn't recognized even if present [\#2129](https://github.com/pypeclub/OpenPype/pull/2129) - General: Disk mapping group [\#2120](https://github.com/pypeclub/OpenPype/pull/2120) - Hiero: publishing effect first time makes wrong resources path [\#2115](https://github.com/pypeclub/OpenPype/pull/2115) -- Add startup script for Houdini Core. [\#2110](https://github.com/pypeclub/OpenPype/pull/2110) -- TVPaint: Behavior name of loop also accept repeat [\#2109](https://github.com/pypeclub/OpenPype/pull/2109) -- Ftrack: Project settings save custom attributes skip unknown attributes [\#2103](https://github.com/pypeclub/OpenPype/pull/2103) -- Blender: Fix NoneType error when animation\_data is missing for a rig [\#2101](https://github.com/pypeclub/OpenPype/pull/2101) -- Fix broken import in sftp provider [\#2100](https://github.com/pypeclub/OpenPype/pull/2100) -- Global: Fix docstring on publish plugin extract review [\#2097](https://github.com/pypeclub/OpenPype/pull/2097) -- Delivery Action Files Sequence fix [\#2096](https://github.com/pypeclub/OpenPype/pull/2096) -- General: Cloud mongo ca certificate issue [\#2095](https://github.com/pypeclub/OpenPype/pull/2095) -- TVPaint: Creator use context from workfile [\#2087](https://github.com/pypeclub/OpenPype/pull/2087) -- Blender: fix texture missing when publishing blend files [\#2085](https://github.com/pypeclub/OpenPype/pull/2085) -- General: Startup validations oiio tool path fix on linux [\#2083](https://github.com/pypeclub/OpenPype/pull/2083) -- Deadline: Collect deadline server does not check existence of deadline key [\#2082](https://github.com/pypeclub/OpenPype/pull/2082) -- Blender: fixed Curves with modifiers in Rigs [\#2081](https://github.com/pypeclub/OpenPype/pull/2081) -- Nuke UI scaling [\#2077](https://github.com/pypeclub/OpenPype/pull/2077) - -**Merged pull requests:** - -- Bump pywin32 from 300 to 301 [\#2086](https://github.com/pypeclub/OpenPype/pull/2086) ## [3.4.1](https://github.com/pypeclub/OpenPype/tree/3.4.1) (2021-09-23) diff --git a/openpype/hosts/photoshop/plugins/publish/closePS.py b/openpype/hosts/photoshop/plugins/publish/closePS.py index 19994a0db8..2f0eab0ee5 100644 --- a/openpype/hosts/photoshop/plugins/publish/closePS.py +++ b/openpype/hosts/photoshop/plugins/publish/closePS.py @@ -17,11 +17,10 @@ class ClosePS(pyblish.api.ContextPlugin): active = True hosts = ["photoshop"] + targets = ["remotepublish"] def process(self, context): self.log.info("ClosePS") - if not os.environ.get("IS_HEADLESS"): - return stub = photoshop.stub() self.log.info("Shutting down PS") diff --git a/openpype/hosts/photoshop/plugins/publish/collect_remote_instances.py b/openpype/hosts/photoshop/plugins/publish/collect_remote_instances.py index 12f9fa5ab5..c76e15484e 100644 --- a/openpype/hosts/photoshop/plugins/publish/collect_remote_instances.py +++ b/openpype/hosts/photoshop/plugins/publish/collect_remote_instances.py @@ -21,6 +21,7 @@ class CollectRemoteInstances(pyblish.api.ContextPlugin): label = "Instances" order = pyblish.api.CollectorOrder hosts = ["photoshop"] + targets = ["remotepublish"] # configurable by Settings color_code_mapping = [] @@ -28,9 +29,6 @@ class CollectRemoteInstances(pyblish.api.ContextPlugin): def process(self, context): self.log.info("CollectRemoteInstances") self.log.info("mapping:: {}".format(self.color_code_mapping)) - if not os.environ.get("IS_HEADLESS"): - self.log.debug("Not headless publishing, skipping.") - return # parse variant if used in webpublishing, comes from webpublisher batch batch_dir = os.environ.get("OPENPYPE_PUBLISH_DATA") diff --git a/openpype/hosts/webpublisher/webserver_service/webpublish_routes.py b/openpype/hosts/webpublisher/webserver_service/webpublish_routes.py index e34a899c4b..d474c96ff9 100644 --- a/openpype/hosts/webpublisher/webserver_service/webpublish_routes.py +++ b/openpype/hosts/webpublisher/webserver_service/webpublish_routes.py @@ -11,7 +11,8 @@ from avalon.api import AvalonMongoDB from openpype.lib import OpenPypeMongoConnection from openpype_modules.avalon_apps.rest_api import _RestApiEndpoint -from openpype.lib.plugin_tools import parse_json +from openpype.lib.remote_publish import get_task_data +from openpype.settings import get_project_settings from openpype.lib import PypeLogger @@ -39,6 +40,8 @@ class RestApiResource: return value.isoformat() if isinstance(value, ObjectId): return str(value) + if isinstance(value, set): + return list(value) raise TypeError(value) @classmethod @@ -205,7 +208,7 @@ class WebpublisherBatchPublishEndpoint(_RestApiEndpoint): # Make sure targets are set to None for cases that default # would change # - targets argument is not used in 'remotepublishfromapp' - "targets": None + "targets": ["remotepublish"] }, # does publish need to be handled by a queue, eg. only # single process running concurrently? @@ -213,7 +216,7 @@ class WebpublisherBatchPublishEndpoint(_RestApiEndpoint): } ] - batch_path = os.path.join(self.resource.upload_dir, content["batch"]) + batch_dir = os.path.join(self.resource.upload_dir, content["batch"]) # Default command and arguments command = "remotepublish" @@ -227,18 +230,9 @@ class WebpublisherBatchPublishEndpoint(_RestApiEndpoint): add_to_queue = False if content.get("studio_processing"): - log.info("Post processing called") + log.info("Post processing called for {}".format(batch_dir)) - batch_data = parse_json(os.path.join(batch_path, "manifest.json")) - if not batch_data: - raise ValueError( - "Cannot parse batch manifest in {}".format(batch_path)) - task_dir_name = batch_data["tasks"][0] - task_data = parse_json(os.path.join(batch_path, task_dir_name, - "manifest.json")) - if not task_data: - raise ValueError( - "Cannot parse task manifest in {}".format(task_data)) + task_data = get_task_data(batch_dir) for process_filter in studio_processing_filters: filter_extensions = process_filter.get("extensions") or [] @@ -257,7 +251,7 @@ class WebpublisherBatchPublishEndpoint(_RestApiEndpoint): args = [ openpype_app, command, - batch_path + batch_dir ] for key, value in add_args.items(): @@ -315,3 +309,36 @@ class PublishesStatusEndpoint(_RestApiEndpoint): body=self.resource.encode(output), content_type="application/json" ) + + +class ConfiguredExtensionsEndpoint(_RestApiEndpoint): + """Returns dict of extensions which have mapping to family. + + Returns: + { + "file_exts": [], + "sequence_exts": [] + } + """ + async def get(self, project_name=None) -> Response: + sett = get_project_settings(project_name) + + configured = { + "file_exts": set(), + "sequence_exts": set(), + # workfiles that could have "Studio Procesing" hardcoded for now + "studio_exts": set(["psd", "psb", "tvpp", "tvp"]) + } + collect_conf = sett["webpublisher"]["publish"]["CollectPublishedFiles"] + for _, mapping in collect_conf.get("task_type_to_family", {}).items(): + for _family, config in mapping.items(): + if config["is_sequence"]: + configured["sequence_exts"].update(config["extensions"]) + else: + configured["file_exts"].update(config["extensions"]) + + return Response( + status=200, + body=self.resource.encode(dict(configured)), + content_type="application/json" + ) diff --git a/openpype/hosts/webpublisher/webserver_service/webserver_cli.py b/openpype/hosts/webpublisher/webserver_service/webserver_cli.py index b784105461..c96ad8e110 100644 --- a/openpype/hosts/webpublisher/webserver_service/webserver_cli.py +++ b/openpype/hosts/webpublisher/webserver_service/webserver_cli.py @@ -16,7 +16,8 @@ from .webpublish_routes import ( WebpublisherHiearchyEndpoint, WebpublisherProjectsEndpoint, BatchStatusEndpoint, - PublishesStatusEndpoint + PublishesStatusEndpoint, + ConfiguredExtensionsEndpoint ) @@ -54,6 +55,13 @@ def run_webserver(*args, **kwargs): hiearchy_endpoint.dispatch ) + configured_ext_endpoint = ConfiguredExtensionsEndpoint(resource) + server_manager.add_route( + "GET", + "/api/webpublish/configured_ext/{project_name}", + configured_ext_endpoint.dispatch + ) + # triggers publish webpublisher_task_publish_endpoint = \ WebpublisherBatchPublishEndpoint(resource) diff --git a/openpype/lib/remote_publish.py b/openpype/lib/remote_publish.py index 51007cfad2..d7db4d1ab9 100644 --- a/openpype/lib/remote_publish.py +++ b/openpype/lib/remote_publish.py @@ -8,6 +8,7 @@ import pyblish.api from openpype import uninstall from openpype.lib.mongo import OpenPypeMongoConnection +from openpype.lib.plugin_tools import parse_json def get_webpublish_conn(): @@ -31,7 +32,8 @@ def start_webpublish_log(dbcon, batch_id, user): "batch_id": batch_id, "start_date": datetime.now(), "user": user, - "status": "in_progress" + "status": "in_progress", + "progress": 0.0 }).inserted_id @@ -157,3 +159,28 @@ def _get_close_plugin(close_plugin_name, log): return plugin log.warning("Close plugin not found, app might not close.") + + +def get_task_data(batch_dir): + """Return parsed data from first task manifest.json + + Used for `remotepublishfromapp` command where batch contains only + single task with publishable workfile. + + Returns: + (dict) + Throws: + (ValueError) if batch or task manifest not found or broken + """ + batch_data = parse_json(os.path.join(batch_dir, "manifest.json")) + if not batch_data: + raise ValueError( + "Cannot parse batch meta in {} folder".format(batch_dir)) + task_dir_name = batch_data["tasks"][0] + task_data = parse_json(os.path.join(batch_dir, task_dir_name, + "manifest.json")) + if not task_data: + raise ValueError( + "Cannot parse batch meta in {} folder".format(task_data)) + + return task_data diff --git a/openpype/modules/default_modules/ftrack/event_handlers_server/event_sync_to_avalon.py b/openpype/modules/default_modules/ftrack/event_handlers_server/event_sync_to_avalon.py index 178dfc74c7..a4982627ff 100644 --- a/openpype/modules/default_modules/ftrack/event_handlers_server/event_sync_to_avalon.py +++ b/openpype/modules/default_modules/ftrack/event_handlers_server/event_sync_to_avalon.py @@ -194,6 +194,7 @@ class SyncToAvalonEvent(BaseEvent): ftrack_id = proj["data"].get("ftrackId") if ftrack_id is None: ftrack_id = self._update_project_ftrack_id() + proj["data"]["ftrackId"] = ftrack_id self._avalon_ents_by_ftrack_id[ftrack_id] = proj for ent in ents: ftrack_id = ent["data"].get("ftrackId") @@ -584,6 +585,10 @@ class SyncToAvalonEvent(BaseEvent): continue ftrack_id = ftrack_id[0] + # Skip deleted projects + if action == "remove" and entityType == "show": + return True + # task modified, collect parent id of task, handle separately if entity_type.lower() == "task": changes = ent_info.get("changes") or {} 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 844a397066..a5187dd52b 100644 --- a/openpype/modules/default_modules/ftrack/plugins/publish/collect_username.py +++ b/openpype/modules/default_modules/ftrack/plugins/publish/collect_username.py @@ -27,16 +27,12 @@ class CollectUsername(pyblish.api.ContextPlugin): order = pyblish.api.CollectorOrder - 0.488 label = "Collect ftrack username" hosts = ["webpublisher", "photoshop"] + targets = ["remotepublish", "filespublish"] _context = None def process(self, context): self.log.info("CollectUsername") - # photoshop could be triggered remotely in webpublisher fashion - if os.environ["AVALON_APP"] == "photoshop": - if not os.environ.get("IS_HEADLESS"): - self.log.debug("Regular process, skipping") - return os.environ["FTRACK_API_USER"] = os.environ["FTRACK_BOT_API_USER"] os.environ["FTRACK_API_KEY"] = os.environ["FTRACK_BOT_API_KEY"] diff --git a/openpype/modules/default_modules/python_console_interpreter/window/widgets.py b/openpype/modules/default_modules/python_console_interpreter/window/widgets.py index 0e8dd2fb9b..ecf41eaf3e 100644 --- a/openpype/modules/default_modules/python_console_interpreter/window/widgets.py +++ b/openpype/modules/default_modules/python_console_interpreter/window/widgets.py @@ -176,6 +176,7 @@ class PythonCodeEditor(QtWidgets.QPlainTextEdit): class PythonTabWidget(QtWidgets.QWidget): + add_tab_requested = QtCore.Signal() before_execute = QtCore.Signal(str) def __init__(self, parent): @@ -185,11 +186,15 @@ class PythonTabWidget(QtWidgets.QWidget): self.setFocusProxy(code_input) + add_tab_btn = QtWidgets.QPushButton("Add tab...", self) + add_tab_btn.setToolTip("Add new tab") + execute_btn = QtWidgets.QPushButton("Execute", self) execute_btn.setToolTip("Execute command (Ctrl + Enter)") btns_layout = QtWidgets.QHBoxLayout() btns_layout.setContentsMargins(0, 0, 0, 0) + btns_layout.addWidget(add_tab_btn) btns_layout.addStretch(1) btns_layout.addWidget(execute_btn) @@ -198,12 +203,16 @@ class PythonTabWidget(QtWidgets.QWidget): layout.addWidget(code_input, 1) layout.addLayout(btns_layout, 0) + add_tab_btn.clicked.connect(self._on_add_tab_clicked) execute_btn.clicked.connect(self._on_execute_clicked) code_input.execute_requested.connect(self.execute) self._code_input = code_input self._interpreter = InteractiveInterpreter() + def _on_add_tab_clicked(self): + self.add_tab_requested.emit() + def _on_execute_clicked(self): self.execute() @@ -352,9 +361,6 @@ class PythonInterpreterWidget(QtWidgets.QWidget): tab_widget.setTabsClosable(False) tab_widget.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) - add_tab_btn = QtWidgets.QPushButton("+", tab_widget) - tab_widget.setCornerWidget(add_tab_btn, QtCore.Qt.TopLeftCorner) - widgets_splitter = QtWidgets.QSplitter(self) widgets_splitter.setOrientation(QtCore.Qt.Vertical) widgets_splitter.addWidget(output_widget) @@ -371,14 +377,12 @@ class PythonInterpreterWidget(QtWidgets.QWidget): line_check_timer.setInterval(200) line_check_timer.timeout.connect(self._on_timer_timeout) - add_tab_btn.clicked.connect(self._on_add_clicked) tab_bar.right_clicked.connect(self._on_tab_right_click) tab_bar.double_clicked.connect(self._on_tab_double_click) tab_bar.mid_clicked.connect(self._on_tab_mid_click) tab_widget.tabCloseRequested.connect(self._on_tab_close_req) self._widgets_splitter = widgets_splitter - self._add_tab_btn = add_tab_btn self._output_widget = output_widget self._tab_widget = tab_widget self._line_check_timer = line_check_timer @@ -459,14 +463,41 @@ class PythonInterpreterWidget(QtWidgets.QWidget): return menu = QtWidgets.QMenu(self._tab_widget) - menu.addAction("Rename") + + add_tab_action = QtWidgets.QAction("Add tab...", menu) + add_tab_action.setToolTip("Add new tab") + + rename_tab_action = QtWidgets.QAction("Rename...", menu) + rename_tab_action.setToolTip("Rename tab") + + duplicate_tab_action = QtWidgets.QAction("Duplicate...", menu) + duplicate_tab_action.setToolTip("Duplicate code to new tab") + + close_tab_action = QtWidgets.QAction("Close", menu) + close_tab_action.setToolTip("Close tab and lose content") + close_tab_action.setEnabled(self._tab_widget.tabsClosable()) + + menu.addAction(add_tab_action) + menu.addAction(rename_tab_action) + menu.addAction(duplicate_tab_action) + menu.addAction(close_tab_action) + result = menu.exec_(global_point) if result is None: return - if result.text() == "Rename": + if result is rename_tab_action: self._rename_tab_req(tab_idx) + elif result is add_tab_action: + self._on_add_requested() + + elif result is duplicate_tab_action: + self._duplicate_requested(tab_idx) + + elif result is close_tab_action: + self._on_tab_close_req(tab_idx) + def _rename_tab_req(self, tab_idx): dialog = TabNameDialog(self) dialog.set_tab_name(self._tab_widget.tabText(tab_idx)) @@ -475,6 +506,16 @@ class PythonInterpreterWidget(QtWidgets.QWidget): if tab_name: self._tab_widget.setTabText(tab_idx, tab_name) + def _duplicate_requested(self, tab_idx=None): + if tab_idx is None: + tab_idx = self._tab_widget.currentIndex() + + src_widget = self._tab_widget.widget(tab_idx) + dst_widget = self._add_tab() + if dst_widget is None: + return + dst_widget.set_code(src_widget.get_code()) + def _on_tab_mid_click(self, global_point): point = self._tab_widget.mapFromGlobal(global_point) tab_bar = self._tab_widget.tabBar() @@ -525,12 +566,17 @@ class PythonInterpreterWidget(QtWidgets.QWidget): lines.append(self.ansi_escape.sub("", line)) self._append_lines(lines) - def _on_add_clicked(self): + def _on_add_requested(self): + self._add_tab() + + def _add_tab(self): dialog = TabNameDialog(self) dialog.exec_() tab_name = dialog.result() if tab_name: - self.add_tab(tab_name) + return self.add_tab(tab_name) + + return None def _on_before_execute(self, code_text): at_max = self._output_widget.vertical_scroll_at_max() @@ -562,6 +608,7 @@ class PythonInterpreterWidget(QtWidgets.QWidget): def add_tab(self, tab_name, index=None): widget = PythonTabWidget(self) widget.before_execute.connect(self._on_before_execute) + widget.add_tab_requested.connect(self._on_add_requested) if index is None: if self._tab_widget.count() > 0: index = self._tab_widget.currentIndex() + 1 diff --git a/openpype/pype_commands.py b/openpype/pype_commands.py index f72c7404f9..f0ba9a997e 100644 --- a/openpype/pype_commands.py +++ b/openpype/pype_commands.py @@ -13,7 +13,8 @@ from openpype.lib.remote_publish import ( start_webpublish_log, publish_and_log, fail_batch, - find_variant_key + find_variant_key, + get_task_data ) @@ -160,7 +161,8 @@ class PypeCommands: log.info("Publish finished.") @staticmethod - def remotepublishfromapp(project, batch_dir, host, user, targets=None): + def remotepublishfromapp(project, batch_dir, host_name, + user, targets=None): """Opens installed variant of 'host' and run remote publish there. Currently implemented and tested for Photoshop where customer @@ -175,9 +177,7 @@ class PypeCommands: Runs publish process as user would, in automatic fashion. """ - SLEEP = 5 # seconds for another loop check for concurrently runs - WAIT_FOR = 300 # seconds to wait for conc. runs - + import pyblish.api from openpype.api import Logger from openpype.lib import ApplicationManager @@ -185,54 +185,29 @@ class PypeCommands: log.info("remotepublishphotoshop command") - application_manager = ApplicationManager() - - found_variant_key = find_variant_key(application_manager, host) - - app_name = "{}/{}".format(host, found_variant_key) - - batch_data = None - if batch_dir and os.path.exists(batch_dir): - batch_data = parse_json(os.path.join(batch_dir, "manifest.json")) - - if not batch_data: - raise ValueError( - "Cannot parse batch meta in {} folder".format(batch_dir)) - - asset, task_name, _task_type = get_batch_asset_task_info( - batch_data["context"]) - - # processing from app expects JUST ONE task in batch and 1 workfile - task_dir_name = batch_data["tasks"][0] - task_data = parse_json(os.path.join(batch_dir, task_dir_name, - "manifest.json")) + task_data = get_task_data(batch_dir) workfile_path = os.path.join(batch_dir, - task_dir_name, + task_data["task"], task_data["files"][0]) print("workfile_path {}".format(workfile_path)) - _, batch_id = os.path.split(batch_dir) + batch_id = task_data["batch"] dbcon = get_webpublish_conn() # safer to start logging here, launch might be broken altogether _id = start_webpublish_log(dbcon, batch_id, user) - in_progress = True - slept_times = 0 - while in_progress: - batches_in_progress = list(dbcon.find({ - "status": "in_progress" - })) - if len(batches_in_progress) > 1: - if slept_times * SLEEP >= WAIT_FOR: - fail_batch(_id, batches_in_progress, dbcon) + batches_in_progress = list(dbcon.find({"status": "in_progress"})) + if len(batches_in_progress) > 1: + fail_batch(_id, batches_in_progress, dbcon) + print("Another batch running, probably stuck, ask admin for help") - print("Another batch running, sleeping for a bit") - time.sleep(SLEEP) - slept_times += 1 - else: - in_progress = False + asset, task_name, _ = get_batch_asset_task_info(task_data["context"]) + + application_manager = ApplicationManager() + found_variant_key = find_variant_key(application_manager, host_name) + app_name = "{}/{}".format(host_name, found_variant_key) # must have for proper launch of app env = get_app_environments_for_context( @@ -244,9 +219,21 @@ class PypeCommands: os.environ.update(env) os.environ["OPENPYPE_PUBLISH_DATA"] = batch_dir - os.environ["IS_HEADLESS"] = "true" # must pass identifier to update log lines for a batch os.environ["BATCH_LOG_ID"] = str(_id) + os.environ["HEADLESS_PUBLISH"] = 'true' # to use in app lib + + pyblish.api.register_host(host_name) + if targets: + if isinstance(targets, str): + targets = [targets] + current_targets = os.environ.get("PYBLISH_TARGETS", "").split( + os.pathsep) + for target in targets: + current_targets.append(target) + + os.environ["PYBLISH_TARGETS"] = os.pathsep.join( + set(current_targets)) data = { "last_workfile_path": workfile_path, diff --git a/openpype/style/data.json b/openpype/style/data.json index b92ee61764..977de50be2 100644 --- a/openpype/style/data.json +++ b/openpype/style/data.json @@ -18,7 +18,6 @@ "green-light": "hsl(155, 80%, 80%)" }, "color": { - "font": "#D3D8DE", "font-hover": "#F0F2F5", "font-disabled": "#99A3B2", @@ -50,7 +49,16 @@ "border": "#373D48", "border-hover": "rgba(168, 175, 189, .3)", - "border-focus": "hsl(200, 60%, 60%)", + "border-focus": "rgb(92, 173, 214)", + + "tab-widget": { + "bg": "#21252B", + "bg-selected": "#434a56", + "bg-hover": "#373D48", + "color": "#99A3B2", + "color-selected": "#F0F2F5", + "color-hover": "#F0F2F5" + }, "loader": { "asset-view": { diff --git a/openpype/style/style.css b/openpype/style/style.css index 89458fd117..2a2f4e572e 100644 --- a/openpype/style/style.css +++ b/openpype/style/style.css @@ -325,47 +325,38 @@ QTabWidget::pane { /* move to the right to not mess with borders of widget underneath */ QTabWidget::tab-bar { - left: 2px; + alignment: left; } QTabBar::tab { - padding: 5px; - border-left: 3px solid transparent; border-top: 1px solid {color:border}; + border-left: 1px solid {color:border}; border-right: 1px solid {color:border}; - /* must be single like because of Nuke*/ - background: qlineargradient(x1: 0, y1: 1, x2: 0, y2: 0,stop: 0.5 {color:bg}, stop: 1.0 {color:bg-inputs}); + padding: 5px; + background: {color:tab-widget:bg}; + color: {color:tab-widget:color}; } QTabBar::tab:selected { - background: {color:grey-lighter}; - border-left: 3px solid {color:border-focus}; - /* must be single like because of Nuke*/ - background: qlineargradient(x1: 0, y1: 1, x2: 0, y2: 0,stop: 0.5 {color:bg}, stop: 1.0 {color:border}); -} - -QTabBar::tab:!selected { - background: {color:grey-light}; + border-left-color: {color:tab-widget:bg-selected}; + border-right-color: {color:tab-widget:bg-selected}; + border-top-color: {color:border-focus}; + background: {color:tab-widget:bg-selected}; + color: {color:tab-widget:color-selected}; } +QTabBar::tab:!selected {} QTabBar::tab:!selected:hover { - background: {color:grey-lighter}; + background: {color:tab-widget:bg-hover}; + color: {color:tab-widget:color-hover}; } -QTabBar::tab:first { - border-left: 1px solid {color:border}; -} -QTabBar::tab:first:selected { - margin-left: 0; - border-left: 3px solid {color:border-focus}; -} - -QTabBar::tab:last:selected { - margin-right: 0; -} - -QTabBar::tab:only-one { - margin: 0; +QTabBar::tab:first {} +QTabBar::tab:first:selected {} +QTabBar::tab:last:!selected { + border-right: 1px solid {color:border}; } +QTabBar::tab:last:selected {} +QTabBar::tab:only-one {} QHeaderView { border: 0px solid {color:border}; @@ -774,6 +765,7 @@ QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { /* Python console interpreter */ #PythonInterpreterOutput, #PythonCodeEditor { font-family: "Roboto Mono"; + border-radius: 0px; } #SubsetView::item, #RepresentationView:item { diff --git a/openpype/tools/context_dialog/window.py b/openpype/tools/context_dialog/window.py index 124a1beda3..7f3ac75445 100644 --- a/openpype/tools/context_dialog/window.py +++ b/openpype/tools/context_dialog/window.py @@ -8,14 +8,12 @@ from openpype import style from openpype.tools.utils.lib import center_window from openpype.tools.utils.widgets import AssetWidget from openpype.tools.utils.constants import ( - TASK_NAME_ROLE, PROJECT_NAME_ROLE ) +from openpype.tools.utils.tasks_widget import TasksWidget from openpype.tools.utils.models import ( ProjectModel, - ProjectSortFilterProxy, - TasksModel, - TasksProxyModel + ProjectSortFilterProxy ) @@ -77,15 +75,11 @@ class ContextDialog(QtWidgets.QDialog): left_side_layout.addWidget(assets_widget) # Right side of window contains only tasks - task_view = QtWidgets.QListView(main_splitter) - task_model = TasksModel(dbcon) - task_proxy = TasksProxyModel() - task_proxy.setSourceModel(task_model) - task_view.setModel(task_proxy) + tasks_widget = TasksWidget(dbcon, main_splitter) # Add widgets to main splitter main_splitter.addWidget(left_side_widget) - main_splitter.addWidget(task_view) + main_splitter.addWidget(tasks_widget) # Set stretch of both sides main_splitter.setStretchFactor(0, 7) @@ -119,9 +113,7 @@ class ContextDialog(QtWidgets.QDialog): assets_widget.selection_changed.connect(self._on_asset_change) assets_widget.refresh_triggered.connect(self._on_asset_refresh_trigger) assets_widget.refreshed.connect(self._on_asset_widget_refresh_finished) - task_view.selectionModel().selectionChanged.connect( - self._on_task_change - ) + tasks_widget.task_changed.connect(self._on_task_change) ok_btn.clicked.connect(self._on_ok_click) self._dbcon = dbcon @@ -133,9 +125,7 @@ class ContextDialog(QtWidgets.QDialog): self._assets_widget = assets_widget - self._task_view = task_view - self._task_model = task_model - self._task_proxy = task_proxy + self._tasks_widget = tasks_widget self._ok_btn = ok_btn @@ -279,15 +269,13 @@ class ContextDialog(QtWidgets.QDialog): self._dbcon.Session["AVALON_ASSET"] = self._set_context_asset self._assets_widget.setEnabled(False) self._assets_widget.select_assets(self._set_context_asset) - self._set_asset_to_task_model() + self._set_asset_to_tasks_widget() else: self._assets_widget.setEnabled(True) self._assets_widget.set_current_asset_btn_visibility(False) # Refresh tasks - self._task_model.refresh() - # Sort tasks - self._task_proxy.sort(0, QtCore.Qt.AscendingOrder) + self._tasks_widget.refresh() self._ignore_value_changes = False @@ -314,20 +302,19 @@ class ContextDialog(QtWidgets.QDialog): """Selected assets have changed""" if self._ignore_value_changes: return - self._set_asset_to_task_model() + self._set_asset_to_tasks_widget() def _on_task_change(self): self._validate_strict() - def _set_asset_to_task_model(self): + def _set_asset_to_tasks_widget(self): # filter None docs they are silo asset_docs = self._assets_widget.get_selected_assets() asset_ids = [asset_doc["_id"] for asset_doc in asset_docs] asset_id = None if asset_ids: asset_id = asset_ids[0] - self._task_model.set_asset_id(asset_id) - self._task_proxy.sort(0, QtCore.Qt.AscendingOrder) + self._tasks_widget.set_asset_id(asset_id) def _confirm_values(self): """Store values to output.""" @@ -355,11 +342,7 @@ class ContextDialog(QtWidgets.QDialog): def get_selected_task(self): """Currently selected task.""" - task_name = None - index = self._task_view.selectionModel().currentIndex() - if index.isValid(): - task_name = index.data(TASK_NAME_ROLE) - return task_name + return self._tasks_widget.get_selected_task_name() def _validate_strict(self): if not self._strict: diff --git a/openpype/tools/launcher/models.py b/openpype/tools/launcher/models.py index f87871409e..427475cb4b 100644 --- a/openpype/tools/launcher/models.py +++ b/openpype/tools/launcher/models.py @@ -19,102 +19,6 @@ from openpype.lib import ApplicationManager log = logging.getLogger(__name__) -class TaskModel(QtGui.QStandardItemModel): - """A model listing the tasks combined for a list of assets""" - - def __init__(self, dbcon, parent=None): - super(TaskModel, self).__init__(parent=parent) - self.dbcon = dbcon - - self._num_assets = 0 - - self.default_icon = qtawesome.icon( - "fa.male", color=style.colors.default - ) - self.no_task_icon = qtawesome.icon( - "fa.exclamation-circle", color=style.colors.mid - ) - - self._icons = {} - - self._get_task_icons() - - def _get_task_icons(self): - if not self.dbcon.Session.get("AVALON_PROJECT"): - return - - # Get the project configured icons from database - project = self.dbcon.find_one({"type": "project"}) - for task in project["config"].get("tasks") or []: - icon_name = task.get("icon") - if icon_name: - self._icons[task["name"]] = qtawesome.icon( - "fa.{}".format(icon_name), color=style.colors.default - ) - - def set_assets(self, asset_ids=None, asset_docs=None): - """Set assets to track by their database id - - Arguments: - asset_ids (list): List of asset ids. - asset_docs (list): List of asset entities from MongoDB. - - """ - - if asset_docs is None and asset_ids is not None: - # find assets in db by query - asset_docs = list(self.dbcon.find({ - "type": "asset", - "_id": {"$in": asset_ids} - })) - db_assets_ids = tuple(asset_doc["_id"] for asset_doc in asset_docs) - - # check if all assets were found - not_found = tuple( - str(asset_id) - for asset_id in asset_ids - if asset_id not in db_assets_ids - ) - - assert not not_found, "Assets not found by id: {0}".format( - ", ".join(not_found) - ) - - self.clear() - - if not asset_docs: - return - - task_names = set() - for asset_doc in asset_docs: - asset_tasks = asset_doc.get("data", {}).get("tasks") or set() - task_names.update(asset_tasks) - - self.beginResetModel() - - if not task_names: - item = QtGui.QStandardItem(self.no_task_icon, "No task") - item.setEnabled(False) - self.appendRow(item) - - else: - for task_name in sorted(task_names): - icon = self._icons.get(task_name, self.default_icon) - item = QtGui.QStandardItem(icon, task_name) - self.appendRow(item) - - self.endResetModel() - - def headerData(self, section, orientation, role): - if ( - role == QtCore.Qt.DisplayRole - and orientation == QtCore.Qt.Horizontal - and section == 0 - ): - return "Tasks" - return super(TaskModel, self).headerData(section, orientation, role) - - class ActionModel(QtGui.QStandardItemModel): def __init__(self, dbcon, parent=None): super(ActionModel, self).__init__(parent=parent) diff --git a/openpype/tools/launcher/widgets.py b/openpype/tools/launcher/widgets.py index 5e01488ae6..edda8d08b5 100644 --- a/openpype/tools/launcher/widgets.py +++ b/openpype/tools/launcher/widgets.py @@ -6,7 +6,7 @@ from avalon.vendor import qtawesome from .delegates import ActionDelegate from . import lib -from .models import TaskModel, ActionModel +from .models import ActionModel from openpype.tools.flickcharm import FlickCharm from .constants import ( ACTION_ROLE, @@ -90,9 +90,6 @@ class ActionBar(QtWidgets.QWidget): self.project_handler = project_handler self.dbcon = dbcon - layout = QtWidgets.QHBoxLayout(self) - layout.setContentsMargins(8, 0, 8, 0) - view = QtWidgets.QListView(self) view.setProperty("mode", "icon") view.setObjectName("IconView") @@ -116,6 +113,8 @@ class ActionBar(QtWidgets.QWidget): ) view.setItemDelegate(delegate) + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(view) self.model = model @@ -261,92 +260,6 @@ class ActionBar(QtWidgets.QWidget): self.action_clicked.emit(action) -class TasksWidget(QtWidgets.QWidget): - """Widget showing active Tasks""" - - task_changed = QtCore.Signal() - selection_mode = ( - QtCore.QItemSelectionModel.Select | QtCore.QItemSelectionModel.Rows - ) - - def __init__(self, dbcon, parent=None): - super(TasksWidget, self).__init__(parent) - - self.dbcon = dbcon - - view = QtWidgets.QTreeView(self) - view.setIndentation(0) - view.setEditTriggers(QtWidgets.QTreeView.NoEditTriggers) - model = TaskModel(self.dbcon) - view.setModel(model) - - layout = QtWidgets.QVBoxLayout(self) - layout.addWidget(view) - - view.selectionModel().selectionChanged.connect(self.task_changed) - - self.model = model - self.view = view - - self._last_selected_task = None - - def set_asset(self, asset_id): - if asset_id is None: - # Asset deselected - self.model.set_assets() - 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() - if current: - self._last_selected_task = current - - self.model.set_assets([asset_id]) - - if self._last_selected_task: - self.select_task(self._last_selected_task) - - # Force a task changed emit. - self.task_changed.emit() - - 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 - cleared. - - Args: - task (str): Name of the task to select. - - """ - - # Clear selection - self.view.selectionModel().clearSelection() - - # Select the task - for row in range(self.model.rowCount()): - index = self.model.index(row, 0) - _task_name = index.data(QtCore.Qt.DisplayRole) - if _task_name == task_name: - self.view.selectionModel().select(index, self.selection_mode) - # Set the currently active index - self.view.setCurrentIndex(index) - break - - def get_current_task(self): - """Return name of task at current index (selected) - - Returns: - str: Name of the current task. - - """ - index = self.view.currentIndex() - if self.view.selectionModel().isSelected(index): - return index.data(QtCore.Qt.DisplayRole) - - class ActionHistory(QtWidgets.QPushButton): trigger_history = QtCore.Signal(tuple) diff --git a/openpype/tools/launcher/window.py b/openpype/tools/launcher/window.py index 454445824e..8d6b609282 100644 --- a/openpype/tools/launcher/window.py +++ b/openpype/tools/launcher/window.py @@ -9,13 +9,14 @@ from openpype import style from openpype.api import resources from openpype.tools.utils.widgets import AssetWidget +from openpype.tools.utils.tasks_widget import TasksWidget + from avalon.vendor import qtawesome from .models import ProjectModel from .lib import get_action_label, ProjectHandler from .widgets import ( ProjectBar, ActionBar, - TasksWidget, ActionHistory, SlidePageWidget ) @@ -91,8 +92,6 @@ class ProjectsPanel(QtWidgets.QWidget): def __init__(self, project_handler, parent=None): super(ProjectsPanel, self).__init__(parent=parent) - layout = QtWidgets.QVBoxLayout(self) - view = ProjectIconView(parent=self) view.setSelectionMode(QtWidgets.QListView.NoSelection) flick = FlickCharm(parent=self) @@ -100,6 +99,8 @@ class ProjectsPanel(QtWidgets.QWidget): view.setModel(project_handler.model) + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(view) view.clicked.connect(self.on_clicked) @@ -123,28 +124,21 @@ class AssetsPanel(QtWidgets.QWidget): self.dbcon = dbcon - # project bar - project_bar_widget = QtWidgets.QWidget(self) - - layout = QtWidgets.QHBoxLayout(project_bar_widget) - layout.setSpacing(4) - + # Project bar btn_back_icon = qtawesome.icon("fa.angle-left", color="white") - btn_back = QtWidgets.QPushButton(project_bar_widget) + btn_back = QtWidgets.QPushButton(self) btn_back.setIcon(btn_back_icon) - project_bar = ProjectBar(project_handler, project_bar_widget) + project_bar = ProjectBar(project_handler, self) - layout.addWidget(btn_back) - layout.addWidget(project_bar) + project_bar_layout = QtWidgets.QHBoxLayout() + project_bar_layout.setContentsMargins(0, 0, 0, 0) + project_bar_layout.setSpacing(4) + project_bar_layout.addWidget(btn_back) + project_bar_layout.addWidget(project_bar) - # assets - assets_proxy_widgets = QtWidgets.QWidget(self) - assets_proxy_widgets.setContentsMargins(0, 0, 0, 0) - assets_layout = QtWidgets.QVBoxLayout(assets_proxy_widgets) - assets_widget = AssetWidget( - dbcon=self.dbcon, parent=assets_proxy_widgets - ) + # Assets widget + assets_widget = AssetWidget(dbcon=self.dbcon, parent=self) # Make assets view flickable flick = FlickCharm(parent=self) @@ -152,18 +146,19 @@ class AssetsPanel(QtWidgets.QWidget): assets_widget.view.setVerticalScrollMode( assets_widget.view.ScrollPerPixel ) - assets_layout.addWidget(assets_widget) - # tasks + # Tasks widget tasks_widget = TasksWidget(self.dbcon, self) - body = QtWidgets.QSplitter() + + # Body + body = QtWidgets.QSplitter(self) body.setContentsMargins(0, 0, 0, 0) body.setSizePolicy( QtWidgets.QSizePolicy.Expanding, QtWidgets.QSizePolicy.Expanding ) body.setOrientation(QtCore.Qt.Horizontal) - body.addWidget(assets_proxy_widgets) + body.addWidget(assets_widget) body.addWidget(tasks_widget) body.setStretchFactor(0, 100) body.setStretchFactor(1, 65) @@ -171,22 +166,21 @@ class AssetsPanel(QtWidgets.QWidget): # main layout layout = QtWidgets.QVBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(0) - layout.addWidget(project_bar_widget) + layout.addLayout(project_bar_layout) layout.addWidget(body) # signals - project_handler.project_changed.connect(self.on_project_changed) - assets_widget.selection_changed.connect(self.on_asset_changed) - assets_widget.refreshed.connect(self.on_asset_changed) - tasks_widget.task_changed.connect(self.on_task_change) + project_handler.project_changed.connect(self._on_project_changed) + assets_widget.selection_changed.connect(self._on_asset_changed) + assets_widget.refreshed.connect(self._on_asset_changed) + tasks_widget.task_changed.connect(self._on_task_change) btn_back.clicked.connect(self.back_clicked) self.project_handler = project_handler self.project_bar = project_bar self.assets_widget = assets_widget - self.tasks_widget = tasks_widget + self._tasks_widget = tasks_widget self._btn_back = btn_back def showEvent(self, event): @@ -197,12 +191,16 @@ class AssetsPanel(QtWidgets.QWidget): btn_size = self.project_bar.height() self._btn_back.setFixedSize(QtCore.QSize(btn_size, btn_size)) - def on_project_changed(self): + def select_task_name(self, task_name): + self._on_asset_changed() + self._tasks_widget.select_task_name(task_name) + + def _on_project_changed(self): self.session_changed.emit() self.assets_widget.refresh() - def on_asset_changed(self): + def _on_asset_changed(self): """Callback on asset selection changed This updates the task view. @@ -237,16 +235,17 @@ class AssetsPanel(QtWidgets.QWidget): asset_id = None if asset_doc: asset_id = asset_doc["_id"] - self.tasks_widget.set_asset(asset_id) + self._tasks_widget.set_asset_id(asset_id) - def on_task_change(self): - task_name = self.tasks_widget.get_current_task() + def _on_task_change(self): + task_name = self._tasks_widget.get_selected_task_name() self.dbcon.Session["AVALON_TASK"] = task_name self.session_changed.emit() class LauncherWindow(QtWidgets.QDialog): """Launcher interface""" + message_timeout = 5000 def __init__(self, parent=None): super(LauncherWindow, self).__init__(parent) @@ -283,20 +282,17 @@ class LauncherWindow(QtWidgets.QDialog): actions_bar = ActionBar(project_handler, self.dbcon, self) # statusbar - statusbar = QtWidgets.QWidget() - layout = QtWidgets.QHBoxLayout(statusbar) + message_label = QtWidgets.QLabel(self) - message_label = QtWidgets.QLabel() - message_label.setFixedHeight(15) - - action_history = ActionHistory() + action_history = ActionHistory(self) action_history.setStatusTip("Show Action History") - layout.addWidget(message_label) - layout.addWidget(action_history) + status_layout = QtWidgets.QHBoxLayout() + status_layout.addWidget(message_label, 1) + status_layout.addWidget(action_history, 0) # Vertically split Pages and Actions - body = QtWidgets.QSplitter() + body = QtWidgets.QSplitter(self) body.setContentsMargins(0, 0, 0, 0) body.setSizePolicy( QtWidgets.QSizePolicy.Expanding, @@ -314,19 +310,13 @@ class LauncherWindow(QtWidgets.QDialog): layout = QtWidgets.QVBoxLayout(self) layout.addWidget(body) - layout.addWidget(statusbar) - layout.setSpacing(0) - layout.setContentsMargins(0, 0, 0, 0) + layout.addLayout(status_layout) - self.project_handler = project_handler + message_timer = QtCore.QTimer() + message_timer.setInterval(self.message_timeout) + message_timer.setSingleShot(True) - self.message_label = message_label - self.project_panel = project_panel - self.asset_panel = asset_panel - self.actions_bar = actions_bar - self.action_history = action_history - self.page_slider = page_slider - self._page = 0 + message_timer.timeout.connect(self._on_message_timeout) # signals actions_bar.action_clicked.connect(self.on_action_clicked) @@ -338,6 +328,19 @@ class LauncherWindow(QtWidgets.QDialog): self.resize(520, 740) + self._page = 0 + + self._message_timer = message_timer + + self.project_handler = project_handler + + self._message_label = message_label + self.project_panel = project_panel + self.asset_panel = asset_panel + self.actions_bar = actions_bar + self.action_history = action_history + self.page_slider = page_slider + def showEvent(self, event): self.project_handler.set_active(True) self.project_handler.start_timer(True) @@ -363,9 +366,12 @@ class LauncherWindow(QtWidgets.QDialog): self._page = page self.page_slider.slide_view(page, direction=direction) + def _on_message_timeout(self): + self._message_label.setText("") + def echo(self, message): - self.message_label.setText(str(message)) - QtCore.QTimer.singleShot(5000, lambda: self.message_label.setText("")) + self._message_label.setText(str(message)) + self._message_timer.start() self.log.debug(message) def on_session_changed(self): @@ -448,5 +454,4 @@ class LauncherWindow(QtWidgets.QDialog): if task_name: # requires a forced refresh first - self.asset_panel.on_asset_changed() - self.asset_panel.tasks_widget.select_task(task_name) + self.asset_panel.select_task_name(task_name) diff --git a/openpype/tools/loader/model.py b/openpype/tools/loader/model.py index d81fc11cf2..96a52fce97 100644 --- a/openpype/tools/loader/model.py +++ b/openpype/tools/loader/model.py @@ -243,9 +243,9 @@ class SubsetsModel(TreeModel, BaseRepresentationModel): # update availability on active site when version changes if self.sync_server.enabled and version: - site = self.active_site query = self._repre_per_version_pipeline([version["_id"]], - site) + self.active_site, + self.remote_site) docs = list(self.dbcon.aggregate(query)) if docs: repre = docs.pop() @@ -801,47 +801,63 @@ class SubsetsModel(TreeModel, BaseRepresentationModel): {"$unwind": "$files"}, {'$addFields': { 'order_local': { - '$filter': {'input': '$files.sites', 'as': 'p', - 'cond': {'$eq': ['$$p.name', active_site]} - }} + '$filter': { + 'input': '$files.sites', 'as': 'p', + 'cond': {'$eq': ['$$p.name', active_site]} + } + } }}, {'$addFields': { 'order_remote': { - '$filter': {'input': '$files.sites', 'as': 'p', - 'cond': {'$eq': ['$$p.name', remote_site]} - }} + '$filter': { + 'input': '$files.sites', 'as': 'p', + 'cond': {'$eq': ['$$p.name', remote_site]} + } + } }}, {'$addFields': { 'progress_local': {"$arrayElemAt": [{ - '$cond': [{'$size': "$order_local.progress"}, - "$order_local.progress", - # if exists created_dt count is as available - {'$cond': [ - {'$size': "$order_local.created_dt"}, - [1], - [0] - ]} - ]}, 0]} + '$cond': [ + {'$size': "$order_local.progress"}, + "$order_local.progress", + # if exists created_dt count is as available + {'$cond': [ + {'$size': "$order_local.created_dt"}, + [1], + [0] + ]} + ]}, + 0 + ]} }}, {'$addFields': { 'progress_remote': {"$arrayElemAt": [{ - '$cond': [{'$size': "$order_remote.progress"}, - "$order_remote.progress", - # if exists created_dt count is as available - {'$cond': [ - {'$size': "$order_remote.created_dt"}, - [1], - [0] - ]} - ]}, 0]} + '$cond': [ + {'$size': "$order_remote.progress"}, + "$order_remote.progress", + # if exists created_dt count is as available + {'$cond': [ + {'$size': "$order_remote.created_dt"}, + [1], + [0] + ]} + ]}, + 0 + ]} }}, {'$group': { # first group by repre '_id': '$_id', 'parent': {'$first': '$parent'}, - 'avail_ratio_local': {'$first': { - '$divide': [{'$sum': "$progress_local"}, {'$sum': 1}]}}, - 'avail_ratio_remote': {'$first': { - '$divide': [{'$sum': "$progress_remote"}, {'$sum': 1}]}} + 'avail_ratio_local': { + '$first': { + '$divide': [{'$sum': "$progress_local"}, {'$sum': 1}] + } + }, + 'avail_ratio_remote': { + '$first': { + '$divide': [{'$sum': "$progress_remote"}, {'$sum': 1}] + } + } }}, {'$group': { # second group by parent, eg version_id '_id': '$parent', diff --git a/openpype/tools/mayalookassigner/app.py b/openpype/tools/mayalookassigner/app.py index d723387f2d..fb99333f87 100644 --- a/openpype/tools/mayalookassigner/app.py +++ b/openpype/tools/mayalookassigner/app.py @@ -38,6 +38,7 @@ class App(QtWidgets.QWidget): # Store callback references self._callbacks = [] + self._connections_set_up = False filename = get_workfile() @@ -46,17 +47,10 @@ class App(QtWidgets.QWidget): self.setWindowFlags(QtCore.Qt.Window) self.setParent(parent) - # Force to delete the window on close so it triggers - # closeEvent only once. Otherwise it's retriggered when - # the widget gets garbage collected. - self.setAttribute(QtCore.Qt.WA_DeleteOnClose) - self.resize(750, 500) self.setup_ui() - self.setup_connections() - # Force refresh check on initialization self._on_renderlayer_switch() @@ -111,6 +105,16 @@ class App(QtWidgets.QWidget): asset_outliner.view.setColumnWidth(0, 200) look_outliner.view.setColumnWidth(0, 150) + asset_outliner.selection_changed.connect( + self.on_asset_selection_changed) + + asset_outliner.refreshed.connect( + lambda: self.echo("Loaded assets..") + ) + + look_outliner.menu_apply_action.connect(self.on_process_selected) + remove_unused_btn.clicked.connect(remove_unused_looks) + # Open widgets self.asset_outliner = asset_outliner self.look_outliner = look_outliner @@ -123,15 +127,8 @@ class App(QtWidgets.QWidget): def setup_connections(self): """Connect interactive widgets with actions""" - - self.asset_outliner.selection_changed.connect( - self.on_asset_selection_changed) - - self.asset_outliner.refreshed.connect( - lambda: self.echo("Loaded assets..")) - - self.look_outliner.menu_apply_action.connect(self.on_process_selected) - self.remove_unused.clicked.connect(remove_unused_looks) + if self._connections_set_up: + return # Maya renderlayer switch callback callback = om.MEventMessage.addEventCallback( @@ -139,14 +136,23 @@ class App(QtWidgets.QWidget): self._on_renderlayer_switch ) self._callbacks.append(callback) + self._connections_set_up = True - def closeEvent(self, event): - + def remove_connection(self): # Delete callbacks for callback in self._callbacks: om.MMessage.removeCallback(callback) - return super(App, self).closeEvent(event) + self._callbacks = [] + self._connections_set_up = False + + def showEvent(self, event): + self.setup_connections() + super(App, self).showEvent(event) + + def closeEvent(self, event): + self.remove_connection() + super(App, self).closeEvent(event) def _on_renderlayer_switch(self, *args): """Callback that updates on Maya renderlayer switch""" diff --git a/openpype/tools/sceneinventory/__init__.py b/openpype/tools/sceneinventory/__init__.py new file mode 100644 index 0000000000..410b52e5fe --- /dev/null +++ b/openpype/tools/sceneinventory/__init__.py @@ -0,0 +1,9 @@ +from .window import ( + show, + SceneInventoryWindow +) + +__all__ = ( + "show", + "SceneInventoryWindow" +) diff --git a/openpype/tools/sceneinventory/lib.py b/openpype/tools/sceneinventory/lib.py new file mode 100644 index 0000000000..7653e1da89 --- /dev/null +++ b/openpype/tools/sceneinventory/lib.py @@ -0,0 +1,82 @@ +import os +from openpype_modules import sync_server + +from Qt import QtGui + + +def walk_hierarchy(node): + """Recursively yield group node.""" + for child in node.children(): + if child.get("isGroupNode"): + yield child + + for _child in walk_hierarchy(child): + yield _child + + +def get_site_icons(): + resource_path = os.path.join( + os.path.dirname(sync_server.sync_server_module.__file__), + "providers", + "resources" + ) + icons = {} + # TODO get from sync module + for provider in ["studio", "local_drive", "gdrive"]: + pix_url = "{}/{}.png".format(resource_path, provider) + icons[provider] = QtGui.QIcon(pix_url) + + return icons + + +def get_progress_for_repre(repre_doc, active_site, remote_site): + """ + Calculates average progress for representation. + + If site has created_dt >> fully available >> progress == 1 + + Could be calculated in aggregate if it would be too slow + Args: + repre_doc(dict): representation dict + Returns: + (dict) with active and remote sites progress + {'studio': 1.0, 'gdrive': -1} - gdrive site is not present + -1 is used to highlight the site should be added + {'studio': 1.0, 'gdrive': 0.0} - gdrive site is present, not + uploaded yet + """ + progress = {active_site: -1, remote_site: -1} + if not repre_doc: + return progress + + files = {active_site: 0, remote_site: 0} + doc_files = repre_doc.get("files") or [] + for doc_file in doc_files: + if not isinstance(doc_file, dict): + continue + + sites = doc_file.get("sites") or [] + for site in sites: + if ( + # Pype 2 compatibility + not isinstance(site, dict) + # Check if site name is one of progress sites + or site["name"] not in progress + ): + continue + + files[site["name"]] += 1 + norm_progress = max(progress[site["name"]], 0) + if site.get("created_dt"): + progress[site["name"]] = norm_progress + 1 + elif site.get("progress"): + progress[site["name"]] = norm_progress + site["progress"] + else: # site exists, might be failed, do not add again + progress[site["name"]] = 0 + + # for example 13 fully avail. files out of 26 >> 13/26 = 0.5 + avg_progress = { + active_site: progress[active_site] / max(files[active_site], 1), + remote_site: progress[remote_site] / max(files[remote_site], 1) + } + return avg_progress diff --git a/openpype/tools/sceneinventory/model.py b/openpype/tools/sceneinventory/model.py new file mode 100644 index 0000000000..d2b7f8b70f --- /dev/null +++ b/openpype/tools/sceneinventory/model.py @@ -0,0 +1,576 @@ +import re +import logging + +from collections import defaultdict + +from Qt import QtCore, QtGui +from avalon import api, io, style, schema +from avalon.vendor import qtawesome + +from avalon.lib import HeroVersionType +from avalon.tools.models import TreeModel, Item + +from .lib import ( + get_site_icons, + walk_hierarchy, + get_progress_for_repre +) + +from openpype.modules import ModulesManager + + +class InventoryModel(TreeModel): + """The model for the inventory""" + + Columns = ["Name", "version", "count", "family", "loader", "objectName"] + + OUTDATED_COLOR = QtGui.QColor(235, 30, 30) + CHILD_OUTDATED_COLOR = QtGui.QColor(200, 160, 30) + GRAYOUT_COLOR = QtGui.QColor(160, 160, 160) + + UniqueRole = QtCore.Qt.UserRole + 2 # unique label role + + def __init__(self, family_config_cache, parent=None): + super(InventoryModel, self).__init__(parent) + self.log = logging.getLogger(self.__class__.__name__) + + self.family_config_cache = family_config_cache + + self._hierarchy_view = False + + manager = ModulesManager() + sync_server = manager.modules_by_name["sync_server"] + self.sync_enabled = sync_server.enabled + self._site_icons = {} + self.active_site = self.remote_site = None + self.active_provider = self.remote_provider = None + + if not self.sync_enabled: + return + + project_name = io.Session["AVALON_PROJECT"] + active_site = sync_server.get_active_site(project_name) + remote_site = sync_server.get_remote_site(project_name) + + active_provider = "studio" + remote_provider = "studio" + if active_site != "studio": + # sanitized for icon + active_provider = sync_server.get_provider_for_site( + project_name, active_site + ) + + if remote_site != "studio": + remote_provider = sync_server.get_provider_for_site( + project_name, remote_site + ) + + # self.sync_server = sync_server + self.active_site = active_site + self.active_provider = active_provider + self.remote_site = remote_site + self.remote_provider = remote_provider + self._site_icons = get_site_icons() + if "active_site" not in self.Columns: + self.Columns.append("active_site") + if "remote_site" not in self.Columns: + self.Columns.append("remote_site") + + def outdated(self, item): + value = item.get("version") + if isinstance(value, HeroVersionType): + return False + + if item.get("version") == item.get("highest_version"): + return False + return True + + def data(self, index, role): + if not index.isValid(): + return + + item = index.internalPointer() + + if role == QtCore.Qt.FontRole: + # Make top-level entries bold + if item.get("isGroupNode") or item.get("isNotSet"): # group-item + font = QtGui.QFont() + font.setBold(True) + return font + + if role == QtCore.Qt.ForegroundRole: + # Set the text color to the OUTDATED_COLOR when the + # collected version is not the same as the highest version + key = self.Columns[index.column()] + if key == "version": # version + if item.get("isGroupNode"): # group-item + if self.outdated(item): + return self.OUTDATED_COLOR + + if self._hierarchy_view: + # If current group is not outdated, check if any + # outdated children. + for _node in walk_hierarchy(item): + if self.outdated(_node): + return self.CHILD_OUTDATED_COLOR + else: + + if self._hierarchy_view: + # Although this is not a group item, we still need + # to distinguish which one contain outdated child. + for _node in walk_hierarchy(item): + if self.outdated(_node): + return self.CHILD_OUTDATED_COLOR.darker(150) + + return self.GRAYOUT_COLOR + + if key == "Name" and not item.get("isGroupNode"): + return self.GRAYOUT_COLOR + + # Add icons + if role == QtCore.Qt.DecorationRole: + if index.column() == 0: + # Override color + color = item.get("color", style.colors.default) + if item.get("isGroupNode"): # group-item + return qtawesome.icon("fa.folder", color=color) + if item.get("isNotSet"): + return qtawesome.icon("fa.exclamation-circle", color=color) + + return qtawesome.icon("fa.file-o", color=color) + + if index.column() == 3: + # Family icon + return item.get("familyIcon", None) + + if item.get("isGroupNode"): + column_name = self.Columns[index.column()] + if column_name == "active_site": + provider = item.get("active_site_provider") + return self._site_icons.get(provider) + + if column_name == "remote_site": + provider = item.get("remote_site_provider") + return self._site_icons.get(provider) + + if role == QtCore.Qt.DisplayRole and item.get("isGroupNode"): + column_name = self.Columns[index.column()] + progress = None + if column_name == 'active_site': + progress = item.get("active_site_progress", 0) + elif column_name == 'remote_site': + progress = item.get("remote_site_progress", 0) + if progress is not None: + return "{}%".format(max(progress, 0) * 100) + + if role == self.UniqueRole: + return item["representation"] + item.get("objectName", "") + + return super(InventoryModel, self).data(index, role) + + def set_hierarchy_view(self, state): + """Set whether to display subsets in hierarchy view.""" + state = bool(state) + + if state != self._hierarchy_view: + self._hierarchy_view = state + + def refresh(self, selected=None, items=None): + """Refresh the model""" + + host = api.registered_host() + if not items: # for debugging or testing, injecting items from outside + items = host.ls() + + self.clear() + + if self._hierarchy_view and selected: + + if not hasattr(host.pipeline, "update_hierarchy"): + # If host doesn't support hierarchical containers, then + # cherry-pick only. + self.add_items((item for item in items + if item["objectName"] in selected)) + + # Update hierarchy info for all containers + items_by_name = {item["objectName"]: item + for item in host.pipeline.update_hierarchy(items)} + + selected_items = set() + + def walk_children(names): + """Select containers and extend to chlid containers""" + for name in [n for n in names if n not in selected_items]: + selected_items.add(name) + item = items_by_name[name] + yield item + + for child in walk_children(item["children"]): + yield child + + items = list(walk_children(selected)) # Cherry-picked and extended + + # Cut unselected upstream containers + for item in items: + if not item.get("parent") in selected_items: + # Parent not in selection, this is root item. + item["parent"] = None + + parents = [self._root_item] + + # The length of `items` array is the maximum depth that a + # hierarchy could be. + # Take this as an easiest way to prevent looping forever. + maximum_loop = len(items) + count = 0 + while items: + if count > maximum_loop: + self.log.warning("Maximum loop count reached, possible " + "missing parent node.") + break + + _parents = list() + for parent in parents: + _unparented = list() + + def _children(): + """Child item provider""" + for item in items: + if item.get("parent") == parent.get("objectName"): + # (NOTE) + # Since `self._root_node` has no "objectName" + # entry, it will be paired with root item if + # the value of key "parent" is None, or not + # having the key. + yield item + else: + # Not current parent's child, try next + _unparented.append(item) + + self.add_items(_children(), parent) + + items[:] = _unparented + + # Parents of next level + for group_node in parent.children(): + _parents += group_node.children() + + parents[:] = _parents + count += 1 + + else: + self.add_items(items) + + def add_items(self, items, parent=None): + """Add the items to the model. + + The items should be formatted similar to `api.ls()` returns, an item + is then represented as: + {"filename_v001.ma": [full/filename/of/loaded/filename_v001.ma, + full/filename/of/loaded/filename_v001.ma], + "nodetype" : "reference", + "node": "referenceNode1"} + + Note: When performing an additional call to `add_items` it will *not* + group the new items with previously existing item groups of the + same type. + + Args: + items (generator): the items to be processed as returned by `ls()` + parent (Item, optional): Set this item as parent for the added + items when provided. Defaults to the root of the model. + + Returns: + node.Item: root node which has children added based on the data + """ + + self.beginResetModel() + + # Group by representation + grouped = defaultdict(lambda: {"items": list()}) + for item in items: + grouped[item["representation"]]["items"].append(item) + + # Add to model + not_found = defaultdict(list) + not_found_ids = [] + for repre_id, group_dict in sorted(grouped.items()): + group_items = group_dict["items"] + # Get parenthood per group + representation = io.find_one({"_id": io.ObjectId(repre_id)}) + if not representation: + not_found["representation"].append(group_items) + not_found_ids.append(repre_id) + continue + + version = io.find_one({"_id": representation["parent"]}) + if not version: + not_found["version"].append(group_items) + not_found_ids.append(repre_id) + continue + + elif version["type"] == "hero_version": + _version = io.find_one({ + "_id": version["version_id"] + }) + version["name"] = HeroVersionType(_version["name"]) + version["data"] = _version["data"] + + subset = io.find_one({"_id": version["parent"]}) + if not subset: + not_found["subset"].append(group_items) + not_found_ids.append(repre_id) + continue + + asset = io.find_one({"_id": subset["parent"]}) + if not asset: + not_found["asset"].append(group_items) + not_found_ids.append(repre_id) + continue + + grouped[repre_id].update({ + "representation": representation, + "version": version, + "subset": subset, + "asset": asset + }) + + for id in not_found_ids: + grouped.pop(id) + + for where, group_items in not_found.items(): + # create the group header + group_node = Item() + name = "< NOT FOUND - {} >".format(where) + group_node["Name"] = name + group_node["representation"] = name + group_node["count"] = len(group_items) + group_node["isGroupNode"] = False + group_node["isNotSet"] = True + + self.add_child(group_node, parent=parent) + + for _group_items in group_items: + item_node = Item() + item_node["Name"] = ", ".join( + [item["objectName"] for item in _group_items] + ) + self.add_child(item_node, parent=group_node) + + for repre_id, group_dict in sorted(grouped.items()): + group_items = group_dict["items"] + representation = grouped[repre_id]["representation"] + version = grouped[repre_id]["version"] + subset = grouped[repre_id]["subset"] + asset = grouped[repre_id]["asset"] + + # Get the primary family + no_family = "" + maj_version, _ = schema.get_schema_version(subset["schema"]) + if maj_version < 3: + prim_family = version["data"].get("family") + if not prim_family: + families = version["data"].get("families") + prim_family = families[0] if families else no_family + else: + families = subset["data"].get("families") or [] + prim_family = families[0] if families else no_family + + # Get the label and icon for the family if in configuration + family_config = self.family_config_cache.family_config(prim_family) + family = family_config.get("label", prim_family) + family_icon = family_config.get("icon", None) + + # Store the highest available version so the model can know + # whether current version is currently up-to-date. + highest_version = io.find_one({ + "type": "version", + "parent": version["parent"] + }, sort=[("name", -1)]) + + # create the group header + group_node = Item() + group_node["Name"] = "%s_%s: (%s)" % (asset["name"], + subset["name"], + representation["name"]) + group_node["representation"] = repre_id + group_node["version"] = version["name"] + group_node["highest_version"] = highest_version["name"] + group_node["family"] = family + group_node["familyIcon"] = family_icon + group_node["count"] = len(group_items) + group_node["isGroupNode"] = True + + if self.sync_enabled: + progress = get_progress_for_repre( + representation, self.active_site, self.remote_site + ) + group_node["active_site"] = self.active_site + group_node["active_site_provider"] = self.active_provider + group_node["remote_site"] = self.remote_site + group_node["remote_site_provider"] = self.remote_provider + group_node["active_site_progress"] = progress[self.active_site] + group_node["remote_site_progress"] = progress[self.remote_site] + + self.add_child(group_node, parent=parent) + + for item in group_items: + item_node = Item() + item_node.update(item) + + # store the current version on the item + item_node["version"] = version["name"] + + # Remapping namespace to item name. + # Noted that the name key is capital "N", by doing this, we + # can view namespace in GUI without changing container data. + item_node["Name"] = item["namespace"] + + self.add_child(item_node, parent=group_node) + + self.endResetModel() + + return self._root_item + + +class FilterProxyModel(QtCore.QSortFilterProxyModel): + """Filter model to where key column's value is in the filtered tags""" + + def __init__(self, *args, **kwargs): + super(FilterProxyModel, self).__init__(*args, **kwargs) + self._filter_outdated = False + self._hierarchy_view = False + + def filterAcceptsRow(self, row, parent): + model = self.sourceModel() + source_index = model.index(row, self.filterKeyColumn(), parent) + + # Always allow bottom entries (individual containers), since their + # parent group hidden if it wouldn't have been validated. + rows = model.rowCount(source_index) + if not rows: + return True + + # Filter by regex + if not self.filterRegExp().isEmpty(): + pattern = re.escape(self.filterRegExp().pattern()) + + if not self._matches(row, parent, pattern): + return False + + if self._filter_outdated: + # When filtering to outdated we filter the up to date entries + # thus we "allow" them when they are outdated + if not self._is_outdated(row, parent): + return False + + return True + + def set_filter_outdated(self, state): + """Set whether to show the outdated entries only.""" + state = bool(state) + + if state != self._filter_outdated: + self._filter_outdated = bool(state) + self.invalidateFilter() + + def set_hierarchy_view(self, state): + state = bool(state) + + if state != self._hierarchy_view: + self._hierarchy_view = state + + def _is_outdated(self, row, parent): + """Return whether row is outdated. + + A row is considered outdated if it has "version" and "highest_version" + data and in the internal data structure, and they are not of an + equal value. + + """ + def outdated(node): + version = node.get("version", None) + highest = node.get("highest_version", None) + + # Always allow indices that have no version data at all + if version is None and highest is None: + return True + + # If either a version or highest is present but not the other + # consider the item invalid. + if not self._hierarchy_view: + # Skip this check if in hierarchy view, or the child item + # node will be hidden even it's actually outdated. + if version is None or highest is None: + return False + return version != highest + + index = self.sourceModel().index(row, self.filterKeyColumn(), parent) + + # The scene contents are grouped by "representation", e.g. the same + # "representation" loaded twice is grouped under the same header. + # Since the version check filters these parent groups we skip that + # check for the individual children. + has_parent = index.parent().isValid() + if has_parent and not self._hierarchy_view: + return True + + # Filter to those that have the different version numbers + node = index.internalPointer() + if outdated(node): + return True + + if self._hierarchy_view: + for _node in walk_hierarchy(node): + if outdated(_node): + return True + + return False + + def _matches(self, row, parent, pattern): + """Return whether row matches regex pattern. + + Args: + row (int): row number in model + parent (QtCore.QModelIndex): parent index + pattern (regex.pattern): pattern to check for in key + + Returns: + bool + + """ + model = self.sourceModel() + column = self.filterKeyColumn() + role = self.filterRole() + + def matches(row, parent, pattern): + index = model.index(row, column, parent) + key = model.data(index, role) + if re.search(pattern, key, re.IGNORECASE): + return True + + if matches(row, parent, pattern): + return True + + # Also allow if any of the children matches + source_index = model.index(row, column, parent) + rows = model.rowCount(source_index) + + if any( + matches(idx, source_index, pattern) + for idx in range(rows) + ): + return True + + if not self._hierarchy_view: + return False + + for idx in range(rows): + child_index = model.index(idx, column, source_index) + child_rows = model.rowCount(child_index) + return any( + self._matches(child_idx, child_index, pattern) + for child_idx in range(child_rows) + ) + + return True diff --git a/openpype/tools/sceneinventory/switch_dialog.py b/openpype/tools/sceneinventory/switch_dialog.py new file mode 100644 index 0000000000..ecad8eac0a --- /dev/null +++ b/openpype/tools/sceneinventory/switch_dialog.py @@ -0,0 +1,993 @@ +import collections +import logging +from Qt import QtWidgets, QtCore + +from avalon import io, api +from avalon.vendor import qtawesome + +from .widgets import SearchComboBox + +log = logging.getLogger("SwitchAssetDialog") + + +class ValidationState: + def __init__(self): + self.asset_ok = True + self.subset_ok = True + self.repre_ok = True + + @property + def all_ok(self): + return ( + self.asset_ok + and self.subset_ok + and self.repre_ok + ) + + +class SwitchAssetDialog(QtWidgets.QDialog): + """Widget to support asset switching""" + + MIN_WIDTH = 550 + + switched = QtCore.Signal() + + def __init__(self, parent=None, items=None): + super(SwitchAssetDialog, self).__init__(parent) + + self.setWindowTitle("Switch selected items ...") + + # Force and keep focus dialog + self.setModal(True) + + assets_combox = SearchComboBox(self) + subsets_combox = SearchComboBox(self) + repres_combobox = SearchComboBox(self) + + assets_combox.set_placeholder("") + subsets_combox.set_placeholder("") + repres_combobox.set_placeholder("") + + asset_label = QtWidgets.QLabel(self) + subset_label = QtWidgets.QLabel(self) + repre_label = QtWidgets.QLabel(self) + + current_asset_btn = QtWidgets.QPushButton("Use current asset") + + accept_icon = qtawesome.icon("fa.check", color="white") + accept_btn = QtWidgets.QPushButton(self) + accept_btn.setIcon(accept_icon) + + main_layout = QtWidgets.QGridLayout(self) + # Asset column + main_layout.addWidget(current_asset_btn, 0, 0) + main_layout.addWidget(assets_combox, 1, 0) + main_layout.addWidget(asset_label, 2, 0) + # Subset column + main_layout.addWidget(subsets_combox, 1, 1) + main_layout.addWidget(subset_label, 2, 1) + # Representation column + main_layout.addWidget(repres_combobox, 1, 2) + main_layout.addWidget(repre_label, 2, 2) + # Btn column + main_layout.addWidget(accept_btn, 1, 3) + main_layout.setColumnStretch(0, 1) + main_layout.setColumnStretch(1, 1) + main_layout.setColumnStretch(2, 1) + main_layout.setColumnStretch(3, 0) + + assets_combox.currentIndexChanged.connect( + self._combobox_value_changed + ) + subsets_combox.currentIndexChanged.connect( + self._combobox_value_changed + ) + repres_combobox.currentIndexChanged.connect( + self._combobox_value_changed + ) + accept_btn.clicked.connect(self._on_accept) + current_asset_btn.clicked.connect(self._on_current_asset) + + self._current_asset_btn = current_asset_btn + + self._assets_box = assets_combox + self._subsets_box = subsets_combox + self._representations_box = repres_combobox + + self._asset_label = asset_label + self._subset_label = subset_label + self._repre_label = repre_label + + self._accept_btn = accept_btn + + self._init_asset_name = None + self._init_subset_name = None + self._init_repre_name = None + + self._fill_check = False + + self._items = items + self._prepare_content_data() + self.refresh(True) + + self.setMinimumWidth(self.MIN_WIDTH) + + # Set default focus to accept button so you don't directly type in + # first asset field, this also allows to see the placeholder value. + accept_btn.setFocus() + + def _prepare_content_data(self): + repre_ids = [ + io.ObjectId(item["representation"]) + for item in self._items + ] + repres = list(io.find({ + "type": {"$in": ["representation", "archived_representation"]}, + "_id": {"$in": repre_ids} + })) + repres_by_id = {repre["_id"]: repre for repre in repres} + + # stash context values, works only for single representation + if len(repres) == 1: + self._init_asset_name = repres[0]["context"]["asset"] + self._init_subset_name = repres[0]["context"]["subset"] + self._init_repre_name = repres[0]["context"]["representation"] + + content_repres = {} + archived_repres = [] + missing_repres = [] + version_ids = [] + for repre_id in repre_ids: + if repre_id not in repres_by_id: + missing_repres.append(repre_id) + elif repres_by_id[repre_id]["type"] == "archived_representation": + repre = repres_by_id[repre_id] + archived_repres.append(repre) + version_ids.append(repre["parent"]) + else: + repre = repres_by_id[repre_id] + content_repres[repre_id] = repres_by_id[repre_id] + version_ids.append(repre["parent"]) + + versions = io.find({ + "type": {"$in": ["version", "hero_version"]}, + "_id": {"$in": list(set(version_ids))} + }) + content_versions = {} + hero_version_ids = set() + for version in versions: + content_versions[version["_id"]] = version + if version["type"] == "hero_version": + hero_version_ids.add(version["_id"]) + + missing_versions = [] + subset_ids = [] + for version_id in version_ids: + if version_id not in content_versions: + missing_versions.append(version_id) + else: + subset_ids.append(content_versions[version_id]["parent"]) + + subsets = io.find({ + "type": {"$in": ["subset", "archived_subset"]}, + "_id": {"$in": subset_ids} + }) + subsets_by_id = {sub["_id"]: sub for sub in subsets} + + asset_ids = [] + archived_subsets = [] + missing_subsets = [] + content_subsets = {} + for subset_id in subset_ids: + if subset_id not in subsets_by_id: + missing_subsets.append(subset_id) + elif subsets_by_id[subset_id]["type"] == "archived_subset": + subset = subsets_by_id[subset_id] + asset_ids.append(subset["parent"]) + archived_subsets.append(subset) + else: + subset = subsets_by_id[subset_id] + asset_ids.append(subset["parent"]) + content_subsets[subset_id] = subset + + assets = io.find({ + "type": {"$in": ["asset", "archived_asset"]}, + "_id": {"$in": list(asset_ids)} + }) + assets_by_id = {asset["_id"]: asset for asset in assets} + + missing_assets = [] + archived_assets = [] + content_assets = {} + for asset_id in asset_ids: + if asset_id not in assets_by_id: + missing_assets.append(asset_id) + elif assets_by_id[asset_id]["type"] == "archived_asset": + archived_assets.append(assets_by_id[asset_id]) + else: + content_assets[asset_id] = assets_by_id[asset_id] + + self.content_assets = content_assets + self.content_subsets = content_subsets + self.content_versions = content_versions + self.content_repres = content_repres + + self.hero_version_ids = hero_version_ids + + self.missing_assets = missing_assets + self.missing_versions = missing_versions + self.missing_subsets = missing_subsets + self.missing_repres = missing_repres + self.missing_docs = ( + bool(missing_assets) + or bool(missing_versions) + or bool(missing_subsets) + or bool(missing_repres) + ) + + self.archived_assets = archived_assets + self.archived_subsets = archived_subsets + self.archived_repres = archived_repres + + def _combobox_value_changed(self, *args, **kwargs): + self.refresh() + + def refresh(self, init_refresh=False): + """Build the need comboboxes with content""" + if not self._fill_check and not init_refresh: + return + + self._fill_check = False + + if init_refresh: + asset_values = self._get_asset_box_values() + self._fill_combobox(asset_values, "asset") + + validation_state = ValidationState() + + # Set other comboboxes to empty if any document is missing or any asset + # of loaded representations is archived. + self._is_asset_ok(validation_state) + if validation_state.asset_ok: + subset_values = self._get_subset_box_values() + self._fill_combobox(subset_values, "subset") + self._is_subset_ok(validation_state) + + if validation_state.asset_ok and validation_state.subset_ok: + repre_values = sorted(self._representations_box_values()) + self._fill_combobox(repre_values, "repre") + self._is_repre_ok(validation_state) + + # Fill comboboxes with values + self.set_labels() + self.apply_validations(validation_state) + + if init_refresh: # pre select context if possible + self._assets_box.set_valid_value(self._init_asset_name) + self._subsets_box.set_valid_value(self._init_subset_name) + self._representations_box.set_valid_value(self._init_repre_name) + + self._fill_check = True + + def _get_loaders(self, representations): + if not representations: + return list() + + available_loaders = filter( + lambda l: not (hasattr(l, "is_utility") and l.is_utility), + api.discover(api.Loader) + ) + + loaders = set() + + for representation in representations: + for loader in api.loaders_from_representation( + available_loaders, + representation + ): + loaders.add(loader) + + return loaders + + def _fill_combobox(self, values, combobox_type): + if combobox_type == "asset": + combobox_widget = self._assets_box + elif combobox_type == "subset": + combobox_widget = self._subsets_box + elif combobox_type == "repre": + combobox_widget = self._representations_box + else: + return + selected_value = combobox_widget.get_valid_value() + + # Fill combobox + if values is not None: + combobox_widget.populate(list(sorted(values))) + if selected_value and selected_value in values: + index = None + for idx in range(combobox_widget.count()): + if selected_value == str(combobox_widget.itemText(idx)): + index = idx + break + if index is not None: + combobox_widget.setCurrentIndex(index) + + def set_labels(self): + asset_label = self._assets_box.get_valid_value() + subset_label = self._subsets_box.get_valid_value() + repre_label = self._representations_box.get_valid_value() + + default = "*No changes" + self._asset_label.setText(asset_label or default) + self._subset_label.setText(subset_label or default) + self._repre_label.setText(repre_label or default) + + def apply_validations(self, validation_state): + error_msg = "*Please select" + error_sheet = "border: 1px solid red;" + success_sheet = "border: 1px solid green;" + + asset_sheet = None + subset_sheet = None + repre_sheet = None + accept_sheet = None + if validation_state.asset_ok is False: + asset_sheet = error_sheet + self._asset_label.setText(error_msg) + elif validation_state.subset_ok is False: + subset_sheet = error_sheet + self._subset_label.setText(error_msg) + elif validation_state.repre_ok is False: + repre_sheet = error_sheet + self._repre_label.setText(error_msg) + + if validation_state.all_ok: + accept_sheet = success_sheet + + self._assets_box.setStyleSheet(asset_sheet or "") + self._subsets_box.setStyleSheet(subset_sheet or "") + self._representations_box.setStyleSheet(repre_sheet or "") + + self._accept_btn.setEnabled(validation_state.all_ok) + self._accept_btn.setStyleSheet(accept_sheet or "") + + def _get_asset_box_values(self): + asset_docs = io.find( + {"type": "asset"}, + {"_id": 1, "name": 1} + ) + asset_names_by_id = { + asset_doc["_id"]: asset_doc["name"] + for asset_doc in asset_docs + } + subsets = io.find( + { + "type": "subset", + "parent": {"$in": list(asset_names_by_id.keys())} + }, + { + "parent": 1 + } + ) + + filtered_assets = [] + for subset in subsets: + asset_name = asset_names_by_id[subset["parent"]] + if asset_name not in filtered_assets: + filtered_assets.append(asset_name) + return sorted(filtered_assets) + + def _get_subset_box_values(self): + selected_asset = self._assets_box.get_valid_value() + if selected_asset: + asset_doc = io.find_one({"type": "asset", "name": selected_asset}) + asset_ids = [asset_doc["_id"]] + else: + asset_ids = list(self.content_assets.keys()) + + subsets = io.find( + { + "type": "subset", + "parent": {"$in": asset_ids} + }, + { + "parent": 1, + "name": 1 + } + ) + + subset_names_by_parent_id = collections.defaultdict(set) + for subset in subsets: + subset_names_by_parent_id[subset["parent"]].add(subset["name"]) + + possible_subsets = None + for subset_names in subset_names_by_parent_id.values(): + if possible_subsets is None: + possible_subsets = subset_names + else: + possible_subsets = (possible_subsets & subset_names) + + if not possible_subsets: + break + + return list(possible_subsets or list()) + + def _representations_box_values(self): + # NOTE hero versions are not used because it is expected that + # hero version has same representations as latests + selected_asset = self._assets_box.currentText() + selected_subset = self._subsets_box.currentText() + + # If nothing is selected + # [ ] [ ] [?] + if not selected_asset and not selected_subset: + # Find all representations of selection's subsets + possible_repres = list(io.find( + { + "type": "representation", + "parent": {"$in": list(self.content_versions.keys())} + }, + { + "parent": 1, + "name": 1 + } + )) + + possible_repres_by_parent = collections.defaultdict(set) + for repre in possible_repres: + possible_repres_by_parent[repre["parent"]].add(repre["name"]) + + output_repres = None + for repre_names in possible_repres_by_parent.values(): + if output_repres is None: + output_repres = repre_names + else: + output_repres = (output_repres & repre_names) + + if not output_repres: + break + + return list(output_repres or list()) + + # [x] [x] [?] + if selected_asset and selected_subset: + asset_doc = io.find_one( + {"type": "asset", "name": selected_asset}, + {"_id": 1} + ) + subset_doc = io.find_one( + { + "type": "subset", + "name": selected_subset, + "parent": asset_doc["_id"] + }, + {"_id": 1} + ) + subset_id = subset_doc["_id"] + last_versions_by_subset_id = self.find_last_versions([subset_id]) + version_doc = last_versions_by_subset_id.get(subset_id) + repre_docs = io.find( + { + "type": "representation", + "parent": version_doc["_id"] + }, + { + "name": 1 + } + ) + return [ + repre_doc["name"] + for repre_doc in repre_docs + ] + + # [x] [ ] [?] + # If asset only is selected + if selected_asset: + asset_doc = io.find_one( + {"type": "asset", "name": selected_asset}, + {"_id": 1} + ) + if not asset_doc: + return list() + + # Filter subsets by subset names from content + subset_names = set() + for subset_doc in self.content_subsets.values(): + subset_names.add(subset_doc["name"]) + subset_docs = io.find( + { + "type": "subset", + "parent": asset_doc["_id"], + "name": {"$in": list(subset_names)} + }, + {"_id": 1} + ) + subset_ids = [ + subset_doc["_id"] + for subset_doc in subset_docs + ] + if not subset_ids: + return list() + + last_versions_by_subset_id = self.find_last_versions(subset_ids) + subset_id_by_version_id = {} + for subset_id, last_version in last_versions_by_subset_id.items(): + version_id = last_version["_id"] + subset_id_by_version_id[version_id] = subset_id + + if not subset_id_by_version_id: + return list() + + repre_docs = list(io.find( + { + "type": "representation", + "parent": {"$in": list(subset_id_by_version_id.keys())} + }, + { + "name": 1, + "parent": 1 + } + )) + if not repre_docs: + return list() + + repre_names_by_parent = collections.defaultdict(set) + for repre_doc in repre_docs: + repre_names_by_parent[repre_doc["parent"]].add( + repre_doc["name"] + ) + + available_repres = None + for repre_names in repre_names_by_parent.values(): + if available_repres is None: + available_repres = repre_names + continue + + available_repres = available_repres.intersection(repre_names) + + return list(available_repres) + + # [ ] [x] [?] + subset_docs = list(io.find( + { + "type": "subset", + "parent": {"$in": list(self.content_assets.keys())}, + "name": selected_subset + }, + {"_id": 1, "parent": 1} + )) + if not subset_docs: + return list() + + subset_docs_by_id = { + subset_doc["_id"]: subset_doc + for subset_doc in subset_docs + } + last_versions_by_subset_id = self.find_last_versions( + subset_docs_by_id.keys() + ) + + subset_id_by_version_id = {} + for subset_id, last_version in last_versions_by_subset_id.items(): + version_id = last_version["_id"] + subset_id_by_version_id[version_id] = subset_id + + if not subset_id_by_version_id: + return list() + + repre_docs = list(io.find( + { + "type": "representation", + "parent": {"$in": list(subset_id_by_version_id.keys())} + }, + { + "name": 1, + "parent": 1 + } + )) + if not repre_docs: + return list() + + repre_names_by_asset_id = {} + for repre_doc in repre_docs: + subset_id = subset_id_by_version_id[repre_doc["parent"]] + asset_id = subset_docs_by_id[subset_id]["parent"] + if asset_id not in repre_names_by_asset_id: + repre_names_by_asset_id[asset_id] = set() + repre_names_by_asset_id[asset_id].add(repre_doc["name"]) + + available_repres = None + for repre_names in repre_names_by_asset_id.values(): + if available_repres is None: + available_repres = repre_names + continue + + available_repres = available_repres.intersection(repre_names) + + return list(available_repres) + + def _is_asset_ok(self, validation_state): + selected_asset = self._assets_box.get_valid_value() + if ( + selected_asset is None + and (self.missing_docs or self.archived_assets) + ): + validation_state.asset_ok = False + + def _is_subset_ok(self, validation_state): + selected_asset = self._assets_box.get_valid_value() + selected_subset = self._subsets_box.get_valid_value() + + # [?] [x] [?] + # If subset is selected then must be ok + if selected_subset is not None: + return + + # [ ] [ ] [?] + if selected_asset is None: + # If there were archived subsets and asset is not selected + if self.archived_subsets: + validation_state.subset_ok = False + return + + # [x] [ ] [?] + asset_doc = io.find_one( + {"type": "asset", "name": selected_asset}, + {"_id": 1} + ) + subset_docs = io.find( + {"type": "subset", "parent": asset_doc["_id"]}, + {"name": 1} + ) + subset_names = set( + subset_doc["name"] + for subset_doc in subset_docs + ) + + for subset_doc in self.content_subsets.values(): + if subset_doc["name"] not in subset_names: + validation_state.subset_ok = False + break + + def find_last_versions(self, subset_ids): + _pipeline = [ + # Find all versions of those subsets + {"$match": { + "type": "version", + "parent": {"$in": list(subset_ids)} + }}, + # Sorting versions all together + {"$sort": {"name": 1}}, + # Group them by "parent", but only take the last + {"$group": { + "_id": "$parent", + "_version_id": {"$last": "$_id"}, + "type": {"$last": "$type"} + }} + ] + last_versions_by_subset_id = dict() + for doc in io.aggregate(_pipeline): + doc["parent"] = doc["_id"] + doc["_id"] = doc.pop("_version_id") + last_versions_by_subset_id[doc["parent"]] = doc + return last_versions_by_subset_id + + def _is_repre_ok(self, validation_state): + selected_asset = self._assets_box.get_valid_value() + selected_subset = self._subsets_box.get_valid_value() + selected_repre = self._representations_box.get_valid_value() + + # [?] [?] [x] + # If subset is selected then must be ok + if selected_repre is not None: + return + + # [ ] [ ] [ ] + if selected_asset is None and selected_subset is None: + if ( + self.archived_repres + or self.missing_versions + or self.missing_repres + ): + validation_state.repre_ok = False + return + + # [x] [x] [ ] + if selected_asset is not None and selected_subset is not None: + asset_doc = io.find_one( + {"type": "asset", "name": selected_asset}, + {"_id": 1} + ) + subset_doc = io.find_one( + { + "type": "subset", + "parent": asset_doc["_id"], + "name": selected_subset + }, + {"_id": 1} + ) + last_versions_by_subset_id = self.find_last_versions( + [subset_doc["_id"]] + ) + last_version = last_versions_by_subset_id.get(subset_doc["_id"]) + if not last_version: + validation_state.repre_ok = False + return + + repre_docs = io.find( + { + "type": "representation", + "parent": last_version["_id"] + }, + {"name": 1} + ) + + repre_names = set( + repre_doc["name"] + for repre_doc in repre_docs + ) + for repre_doc in self.content_repres.values(): + if repre_doc["name"] not in repre_names: + validation_state.repre_ok = False + break + return + + # [x] [ ] [ ] + if selected_asset is not None: + asset_doc = io.find_one( + {"type": "asset", "name": selected_asset}, + {"_id": 1} + ) + subset_docs = list(io.find( + { + "type": "subset", + "parent": asset_doc["_id"] + }, + {"_id": 1, "name": 1} + )) + + subset_name_by_id = {} + subset_ids = set() + for subset_doc in subset_docs: + subset_id = subset_doc["_id"] + subset_ids.add(subset_id) + subset_name_by_id[subset_id] = subset_doc["name"] + + last_versions_by_subset_id = self.find_last_versions(subset_ids) + + subset_id_by_version_id = {} + for subset_id, last_version in last_versions_by_subset_id.items(): + version_id = last_version["_id"] + subset_id_by_version_id[version_id] = subset_id + + repre_docs = io.find( + { + "type": "representation", + "parent": {"$in": list(subset_id_by_version_id.keys())} + }, + { + "name": 1, + "parent": 1 + } + ) + repres_by_subset_name = {} + for repre_doc in repre_docs: + subset_id = subset_id_by_version_id[repre_doc["parent"]] + subset_name = subset_name_by_id[subset_id] + if subset_name not in repres_by_subset_name: + repres_by_subset_name[subset_name] = set() + repres_by_subset_name[subset_name].add(repre_doc["name"]) + + for repre_doc in self.content_repres.values(): + version_doc = self.content_versions[repre_doc["parent"]] + subset_doc = self.content_subsets[version_doc["parent"]] + repre_names = ( + repres_by_subset_name.get(subset_doc["name"]) or [] + ) + if repre_doc["name"] not in repre_names: + validation_state.repre_ok = False + break + return + + # [ ] [x] [ ] + # Subset documents + subset_docs = io.find( + { + "type": "subset", + "parent": {"$in": list(self.content_assets.keys())}, + "name": selected_subset + }, + {"_id": 1, "name": 1, "parent": 1} + ) + + subset_docs_by_id = {} + for subset_doc in subset_docs: + subset_docs_by_id[subset_doc["_id"]] = subset_doc + + last_versions_by_subset_id = self.find_last_versions( + subset_docs_by_id.keys() + ) + subset_id_by_version_id = {} + for subset_id, last_version in last_versions_by_subset_id.items(): + version_id = last_version["_id"] + subset_id_by_version_id[version_id] = subset_id + + repre_docs = io.find( + { + "type": "representation", + "parent": {"$in": list(subset_id_by_version_id.keys())} + }, + { + "name": 1, + "parent": 1 + } + ) + repres_by_asset_id = {} + for repre_doc in repre_docs: + subset_id = subset_id_by_version_id[repre_doc["parent"]] + asset_id = subset_docs_by_id[subset_id]["parent"] + if asset_id not in repres_by_asset_id: + repres_by_asset_id[asset_id] = set() + repres_by_asset_id[asset_id].add(repre_doc["name"]) + + for repre_doc in self.content_repres.values(): + version_doc = self.content_versions[repre_doc["parent"]] + subset_doc = self.content_subsets[version_doc["parent"]] + asset_id = subset_doc["parent"] + repre_names = ( + repres_by_asset_id.get(asset_id) or [] + ) + if repre_doc["name"] not in repre_names: + validation_state.repre_ok = False + break + + def _on_current_asset(self): + # Set initial asset as current. + asset_name = io.Session["AVALON_ASSET"] + index = self._assets_box.findText( + asset_name, QtCore.Qt.MatchFixedString + ) + if index >= 0: + print("Setting asset to {}".format(asset_name)) + self._assets_box.setCurrentIndex(index) + + def _on_accept(self): + # Use None when not a valid value or when placeholder value + selected_asset = self._assets_box.get_valid_value() + selected_subset = self._subsets_box.get_valid_value() + selected_representation = self._representations_box.get_valid_value() + + if selected_asset: + asset_doc = io.find_one({"type": "asset", "name": selected_asset}) + asset_docs_by_id = {asset_doc["_id"]: asset_doc} + else: + asset_docs_by_id = self.content_assets + + asset_docs_by_name = { + asset_doc["name"]: asset_doc + for asset_doc in asset_docs_by_id.values() + } + + asset_ids = list(asset_docs_by_id.keys()) + + subset_query = { + "type": "subset", + "parent": {"$in": asset_ids} + } + if selected_subset: + subset_query["name"] = selected_subset + + subset_docs = list(io.find(subset_query)) + subset_ids = [] + subset_docs_by_parent_and_name = collections.defaultdict(dict) + for subset in subset_docs: + subset_ids.append(subset["_id"]) + parent_id = subset["parent"] + name = subset["name"] + subset_docs_by_parent_and_name[parent_id][name] = subset + + # versions + version_docs = list(io.find({ + "type": "version", + "parent": {"$in": subset_ids} + }, sort=[("name", -1)])) + + hero_version_docs = list(io.find({ + "type": "hero_version", + "parent": {"$in": subset_ids} + })) + + version_ids = list() + + version_docs_by_parent_id = {} + for version_doc in version_docs: + parent_id = version_doc["parent"] + if parent_id not in version_docs_by_parent_id: + version_ids.append(version_doc["_id"]) + version_docs_by_parent_id[parent_id] = version_doc + + hero_version_docs_by_parent_id = {} + for hero_version_doc in hero_version_docs: + version_ids.append(hero_version_doc["_id"]) + parent_id = hero_version_doc["parent"] + hero_version_docs_by_parent_id[parent_id] = hero_version_doc + + repre_docs = io.find({ + "type": "representation", + "parent": {"$in": version_ids} + }) + repre_docs_by_parent_id_by_name = collections.defaultdict(dict) + for repre_doc in repre_docs: + parent_id = repre_doc["parent"] + name = repre_doc["name"] + repre_docs_by_parent_id_by_name[parent_id][name] = repre_doc + + for container in self._items: + container_repre_id = io.ObjectId(container["representation"]) + container_repre = self.content_repres[container_repre_id] + container_repre_name = container_repre["name"] + + container_version_id = container_repre["parent"] + container_version = self.content_versions[container_version_id] + + container_subset_id = container_version["parent"] + container_subset = self.content_subsets[container_subset_id] + container_subset_name = container_subset["name"] + + container_asset_id = container_subset["parent"] + container_asset = self.content_assets[container_asset_id] + container_asset_name = container_asset["name"] + + if selected_asset: + asset_doc = asset_docs_by_name[selected_asset] + else: + asset_doc = asset_docs_by_name[container_asset_name] + + subsets_by_name = subset_docs_by_parent_and_name[asset_doc["_id"]] + if selected_subset: + subset_doc = subsets_by_name[selected_subset] + else: + subset_doc = subsets_by_name[container_subset_name] + + repre_doc = None + subset_id = subset_doc["_id"] + if container_version["type"] == "hero_version": + hero_version = hero_version_docs_by_parent_id.get( + subset_id + ) + if hero_version: + _repres = repre_docs_by_parent_id_by_name.get( + hero_version["_id"] + ) + if selected_representation: + repre_doc = _repres.get(selected_representation) + else: + repre_doc = _repres.get(container_repre_name) + + if not repre_doc: + version_doc = version_docs_by_parent_id[subset_id] + version_id = version_doc["_id"] + repres_by_name = repre_docs_by_parent_id_by_name[version_id] + if selected_representation: + repre_doc = repres_by_name[selected_representation] + else: + repre_doc = repres_by_name[container_repre_name] + + try: + api.switch(container, repre_doc) + except Exception: + msg = ( + "Couldn't switch asset." + "See traceback for more information." + ) + log.warning(msg, exc_info=True) + dialog = QtWidgets.QMessageBox(self) + dialog.setWindowTitle("Switch asset failed") + dialog.setText( + "Switch asset failed. Search console log for more details" + ) + dialog.exec_() + + self.switched.emit() + + self.close() diff --git a/openpype/tools/sceneinventory/view.py b/openpype/tools/sceneinventory/view.py new file mode 100644 index 0000000000..80f26a881d --- /dev/null +++ b/openpype/tools/sceneinventory/view.py @@ -0,0 +1,794 @@ +import collections +import logging +from functools import partial + +from Qt import QtWidgets, QtCore + +from avalon import io, api, style +from avalon.vendor import qtawesome +from avalon.lib import HeroVersionType +from avalon.tools import lib as tools_lib + +from openpype.modules import ModulesManager + +from .switch_dialog import SwitchAssetDialog +from .model import InventoryModel + + +DEFAULT_COLOR = "#fb9c15" + +log = logging.getLogger("SceneInventory") + + +class SceneInvetoryView(QtWidgets.QTreeView): + data_changed = QtCore.Signal() + hierarchy_view_changed = QtCore.Signal(bool) + + def __init__(self, parent=None): + super(SceneInvetoryView, self).__init__(parent=parent) + + # view settings + self.setIndentation(12) + self.setAlternatingRowColors(True) + self.setSortingEnabled(True) + self.setSelectionMode(self.ExtendedSelection) + self.setContextMenuPolicy(QtCore.Qt.CustomContextMenu) + self.customContextMenuRequested.connect(self._show_right_mouse_menu) + self._hierarchy_view = False + self._selected = None + + manager = ModulesManager() + self.sync_server = manager.modules_by_name["sync_server"] + self.sync_enabled = self.sync_server.enabled + + def _set_hierarchy_view(self, enabled): + if enabled == self._hierarchy_view: + return + self._hierarchy_view = enabled + self.hierarchy_view_changed.emit(enabled) + + def _enter_hierarchy(self, items): + self._selected = set(i["objectName"] for i in items) + self._set_hierarchy_view(True) + self.data_changed.emit() + self.expandToDepth(1) + self.setStyleSheet(""" + QTreeView { + border-color: #fb9c15; + } + """) + + def _leave_hierarchy(self): + self._set_hierarchy_view(False) + self.data_changed.emit() + self.setStyleSheet("QTreeView {}") + + def _build_item_menu_for_selection(self, items, menu): + if not items: + return + + repre_ids = [] + for item in items: + item_id = io.ObjectId(item["representation"]) + if item_id not in repre_ids: + repre_ids.append(item_id) + + repre_docs = io.find( + { + "type": "representation", + "_id": {"$in": repre_ids} + }, + {"parent": 1} + ) + + version_ids = [] + for repre_doc in repre_docs: + version_id = repre_doc["parent"] + if version_id not in version_ids: + version_ids.append(version_id) + + loaded_versions = io.find({ + "_id": {"$in": version_ids}, + "type": {"$in": ["version", "hero_version"]} + }) + + loaded_hero_versions = [] + versions_by_parent_id = collections.defaultdict(list) + version_parents = [] + for version in loaded_versions: + if version["type"] == "hero_version": + loaded_hero_versions.append(version) + else: + parent_id = version["parent"] + versions_by_parent_id[parent_id].append(version) + if parent_id not in version_parents: + version_parents.append(parent_id) + + all_versions = io.find({ + "type": {"$in": ["hero_version", "version"]}, + "parent": {"$in": version_parents} + }) + hero_versions = [] + versions = [] + for version in all_versions: + if version["type"] == "hero_version": + hero_versions.append(version) + else: + versions.append(version) + + has_loaded_hero_versions = len(loaded_hero_versions) > 0 + has_available_hero_version = len(hero_versions) > 0 + has_outdated = False + + for version in versions: + parent_id = version["parent"] + current_versions = versions_by_parent_id[parent_id] + for current_version in current_versions: + if current_version["name"] < version["name"]: + has_outdated = True + break + + if has_outdated: + break + + switch_to_versioned = None + if has_loaded_hero_versions: + def _on_switch_to_versioned(items): + repre_ids = [] + for item in items: + item_id = io.ObjectId(item["representation"]) + if item_id not in repre_ids: + repre_ids.append(item_id) + + repre_docs = io.find( + { + "type": "representation", + "_id": {"$in": repre_ids} + }, + {"parent": 1} + ) + + version_ids = [] + version_id_by_repre_id = {} + for repre_doc in repre_docs: + version_id = repre_doc["parent"] + version_id_by_repre_id[repre_doc["_id"]] = version_id + if version_id not in version_ids: + version_ids.append(version_id) + hero_versions = io.find( + { + "_id": {"$in": version_ids}, + "type": "hero_version" + }, + {"version_id": 1} + ) + version_ids = set() + for hero_version in hero_versions: + version_id = hero_version["version_id"] + version_ids.add(version_id) + hero_version_id = hero_version["_id"] + for _repre_id, current_version_id in ( + version_id_by_repre_id.items() + ): + if current_version_id == hero_version_id: + version_id_by_repre_id[_repre_id] = version_id + + version_docs = io.find( + { + "_id": {"$in": list(version_ids)}, + "type": "version" + }, + {"name": 1} + ) + version_name_by_id = {} + for version_doc in version_docs: + version_name_by_id[version_doc["_id"]] = \ + version_doc["name"] + + for item in items: + repre_id = io.ObjectId(item["representation"]) + version_id = version_id_by_repre_id.get(repre_id) + version_name = version_name_by_id.get(version_id) + if version_name is not None: + try: + api.update(item, version_name) + except AssertionError: + self._show_version_error_dialog( + version_name, [item] + ) + log.warning("Update failed", exc_info=True) + + self.data_changed.emit() + + update_icon = qtawesome.icon( + "fa.asterisk", + color=DEFAULT_COLOR + ) + switch_to_versioned = QtWidgets.QAction( + update_icon, + "Switch to versioned", + menu + ) + switch_to_versioned.triggered.connect( + lambda: _on_switch_to_versioned(items) + ) + + update_to_latest_action = None + if has_outdated or has_loaded_hero_versions: + # update to latest version + def _on_update_to_latest(items): + for item in items: + try: + api.update(item, -1) + except AssertionError: + self._show_version_error_dialog(None, [item]) + log.warning("Update failed", exc_info=True) + self.data_changed.emit() + + update_icon = qtawesome.icon( + "fa.angle-double-up", + color=DEFAULT_COLOR + ) + update_to_latest_action = QtWidgets.QAction( + update_icon, + "Update to latest", + menu + ) + update_to_latest_action.triggered.connect( + lambda: _on_update_to_latest(items) + ) + + change_to_hero = None + if has_available_hero_version: + # change to hero version + def _on_update_to_hero(items): + for item in items: + try: + api.update(item, HeroVersionType(-1)) + except AssertionError: + self._show_version_error_dialog('hero', [item]) + log.warning("Update failed", exc_info=True) + self.data_changed.emit() + + # TODO change icon + change_icon = qtawesome.icon( + "fa.asterisk", + color="#00b359" + ) + change_to_hero = QtWidgets.QAction( + change_icon, + "Change to hero", + menu + ) + change_to_hero.triggered.connect( + lambda: _on_update_to_hero(items) + ) + + # set version + set_version_icon = qtawesome.icon("fa.hashtag", color=DEFAULT_COLOR) + set_version_action = QtWidgets.QAction( + set_version_icon, + "Set version", + menu + ) + set_version_action.triggered.connect( + lambda: self._show_version_dialog(items)) + + # switch asset + switch_asset_icon = qtawesome.icon("fa.sitemap", color=DEFAULT_COLOR) + switch_asset_action = QtWidgets.QAction( + switch_asset_icon, + "Switch Asset", + menu + ) + switch_asset_action.triggered.connect( + lambda: self._show_switch_dialog(items)) + + # remove + remove_icon = qtawesome.icon("fa.remove", color=DEFAULT_COLOR) + remove_action = QtWidgets.QAction(remove_icon, "Remove items", menu) + remove_action.triggered.connect( + lambda: self._show_remove_warning_dialog(items)) + + # add the actions + if switch_to_versioned: + menu.addAction(switch_to_versioned) + + if update_to_latest_action: + menu.addAction(update_to_latest_action) + + if change_to_hero: + menu.addAction(change_to_hero) + + menu.addAction(set_version_action) + menu.addAction(switch_asset_action) + + menu.addSeparator() + + menu.addAction(remove_action) + + self._handle_sync_server(menu, repre_ids) + + def _handle_sync_server(self, menu, repre_ids): + """ + Adds actions for download/upload when SyncServer is enabled + + Args: + menu (OptionMenu) + repre_ids (list) of object_ids + Returns: + (OptionMenu) + """ + if not self.sync_enabled: + return + + menu.addSeparator() + + download_icon = qtawesome.icon("fa.download", color=DEFAULT_COLOR) + download_active_action = QtWidgets.QAction( + download_icon, + "Download", + menu + ) + download_active_action.triggered.connect( + lambda: self._add_sites(repre_ids, 'active_site')) + + upload_icon = qtawesome.icon("fa.upload", color=DEFAULT_COLOR) + upload_remote_action = QtWidgets.QAction( + upload_icon, + "Upload", + menu + ) + upload_remote_action.triggered.connect( + lambda: self._add_sites(repre_ids, 'remote_site')) + + menu.addAction(download_active_action) + menu.addAction(upload_remote_action) + + def _add_sites(self, repre_ids, side): + """ + (Re)sync all 'repre_ids' to specific site. + + It checks if opposite site has fully available content to limit + accidents. (ReSync active when no remote >> losing active content) + + Args: + repre_ids (list) + side (str): 'active_site'|'remote_site' + """ + project_name = io.Session["AVALON_PROJECT"] + active_site = self.sync_server.get_active_site(project_name) + remote_site = self.sync_server.get_remote_site(project_name) + + repre_docs = io.find({ + "type": "representation", + "_id": {"$in": repre_ids} + }) + repre_docs_by_id = { + repre_doc["_id"]: repre_doc + for repre_doc in repre_docs + } + for repre_id in repre_ids: + repre_doc = repre_docs_by_id.get(repre_id) + if not repre_doc: + continue + + progress = tools_lib.get_progress_for_repre( + repre_doc, + active_site, + remote_site + ) + if side == "active_site": + # check opposite from added site, must be 1 or unable to sync + check_progress = progress[remote_site] + site = active_site + else: + check_progress = progress[active_site] + site = remote_site + + if check_progress == 1: + self.sync_server.add_site( + project_name, repre_id, site, force=True + ) + + self.data_changed.emit() + + def _build_item_menu(self, items=None): + """Create menu for the selected items""" + + if not items: + items = [] + + menu = QtWidgets.QMenu(self) + + # add the actions + self._build_item_menu_for_selection(items, menu) + + # These two actions should be able to work without selection + # expand all items + expandall_action = QtWidgets.QAction(menu, text="Expand all items") + expandall_action.triggered.connect(self.expandAll) + + # collapse all items + collapse_action = QtWidgets.QAction(menu, text="Collapse all items") + collapse_action.triggered.connect(self.collapseAll) + + menu.addAction(expandall_action) + menu.addAction(collapse_action) + + custom_actions = self._get_custom_actions(containers=items) + if custom_actions: + submenu = QtWidgets.QMenu("Actions", self) + for action in custom_actions: + color = action.color or DEFAULT_COLOR + icon = qtawesome.icon("fa.%s" % action.icon, color=color) + action_item = QtWidgets.QAction(icon, action.label, submenu) + action_item.triggered.connect( + partial(self._process_custom_action, action, items)) + + submenu.addAction(action_item) + + menu.addMenu(submenu) + + # go back to flat view + if self._hierarchy_view: + back_to_flat_icon = qtawesome.icon("fa.list", color=DEFAULT_COLOR) + back_to_flat_action = QtWidgets.QAction( + back_to_flat_icon, + "Back to Full-View", + menu + ) + back_to_flat_action.triggered.connect(self._leave_hierarchy) + + # send items to hierarchy view + enter_hierarchy_icon = qtawesome.icon("fa.indent", color="#d8d8d8") + enter_hierarchy_action = QtWidgets.QAction( + enter_hierarchy_icon, + "Cherry-Pick (Hierarchy)", + menu + ) + enter_hierarchy_action.triggered.connect( + lambda: self._enter_hierarchy(items)) + + if items: + menu.addAction(enter_hierarchy_action) + + if self._hierarchy_view: + menu.addAction(back_to_flat_action) + + return menu + + def _get_custom_actions(self, containers): + """Get the registered Inventory Actions + + Args: + containers(list): collection of containers + + Returns: + list: collection of filter and initialized actions + """ + + def sorter(Plugin): + """Sort based on order attribute of the plugin""" + return Plugin.order + + # Fedd an empty dict if no selection, this will ensure the compat + # lookup always work, so plugin can interact with Scene Inventory + # reversely. + containers = containers or [dict()] + + # Check which action will be available in the menu + Plugins = api.discover(api.InventoryAction) + compatible = [p() for p in Plugins if + any(p.is_compatible(c) for c in containers)] + + return sorted(compatible, key=sorter) + + def _process_custom_action(self, action, containers): + """Run action and if results are returned positive update the view + + If the result is list or dict, will select view items by the result. + + Args: + action (InventoryAction): Inventory Action instance + containers (list): Data of currently selected items + + Returns: + None + """ + + result = action.process(containers) + if result: + self.data_changed.emit() + + if isinstance(result, (list, set)): + self._select_items_by_action(result) + + if isinstance(result, dict): + self._select_items_by_action( + result["objectNames"], result["options"] + ) + + def _select_items_by_action(self, object_names, options=None): + """Select view items by the result of action + + Args: + object_names (list or set): A list/set of container object name + options (dict): GUI operation options. + + Returns: + None + + """ + options = options or dict() + + if options.get("clear", True): + self.clearSelection() + + object_names = set(object_names) + if ( + self._hierarchy_view + and not self._selected.issuperset(object_names) + ): + # If any container not in current cherry-picked view, update + # view before selecting them. + self._selected.update(object_names) + self.data_changed.emit() + + model = self.model() + selection_model = self.selectionModel() + + select_mode = { + "select": selection_model.Select, + "deselect": selection_model.Deselect, + "toggle": selection_model.Toggle, + }[options.get("mode", "select")] + + for item in tools_lib.iter_model_rows(model, 0): + item = item.data(InventoryModel.ItemRole) + if item.get("isGroupNode"): + continue + + name = item.get("objectName") + if name in object_names: + self.scrollTo(item) # Ensure item is visible + flags = select_mode | selection_model.Rows + selection_model.select(item, flags) + + object_names.remove(name) + + if len(object_names) == 0: + break + + def _show_right_mouse_menu(self, pos): + """Display the menu when at the position of the item clicked""" + + globalpos = self.viewport().mapToGlobal(pos) + + if not self.selectionModel().hasSelection(): + print("No selection") + # Build menu without selection, feed an empty list + menu = self._build_item_menu() + menu.exec_(globalpos) + return + + active = self.currentIndex() # index under mouse + active = active.sibling(active.row(), 0) # get first column + + # move index under mouse + indices = self.get_indices() + if active in indices: + indices.remove(active) + + indices.append(active) + + # Extend to the sub-items + all_indices = self._extend_to_children(indices) + items = [dict(i.data(InventoryModel.ItemRole)) for i in all_indices + if i.parent().isValid()] + + if self._hierarchy_view: + # Ensure no group item + items = [n for n in items if not n.get("isGroupNode")] + + menu = self._build_item_menu(items) + menu.exec_(globalpos) + + def get_indices(self): + """Get the selected rows""" + selection_model = self.selectionModel() + return selection_model.selectedRows() + + def _extend_to_children(self, indices): + """Extend the indices to the children indices. + + Top-level indices are extended to its children indices. Sub-items + are kept as is. + + Args: + indices (list): The indices to extend. + + Returns: + list: The children indices + + """ + def get_children(i): + model = i.model() + rows = model.rowCount(parent=i) + for row in range(rows): + child = model.index(row, 0, parent=i) + yield child + + subitems = set() + for i in indices: + valid_parent = i.parent().isValid() + if valid_parent and i not in subitems: + subitems.add(i) + + if self._hierarchy_view: + # Assume this is a group item + for child in get_children(i): + subitems.add(child) + else: + # is top level item + for child in get_children(i): + subitems.add(child) + + return list(subitems) + + def _show_version_dialog(self, items): + """Create a dialog with the available versions for the selected file + + Args: + items (list): list of items to run the "set_version" for + + Returns: + None + """ + + active = items[-1] + + # Get available versions for active representation + representation_id = io.ObjectId(active["representation"]) + representation = io.find_one({"_id": representation_id}) + version = io.find_one({ + "_id": representation["parent"] + }) + + versions = list(io.find( + { + "parent": version["parent"], + "type": "version" + }, + sort=[("name", 1)] + )) + + hero_version = io.find_one({ + "parent": version["parent"], + "type": "hero_version" + }) + if hero_version: + _version_id = hero_version["version_id"] + for _version in versions: + if _version["_id"] != _version_id: + continue + + hero_version["name"] = HeroVersionType( + _version["name"] + ) + hero_version["data"] = _version["data"] + break + + # Get index among the listed versions + current_item = None + current_version = active["version"] + if isinstance(current_version, HeroVersionType): + current_item = hero_version + else: + for version in versions: + if version["name"] == current_version: + current_item = version + break + + all_versions = [] + if hero_version: + all_versions.append(hero_version) + all_versions.extend(reversed(versions)) + + if current_item: + index = all_versions.index(current_item) + else: + index = 0 + + versions_by_label = dict() + labels = [] + for version in all_versions: + is_hero = version["type"] == "hero_version" + label = tools_lib.format_version(version["name"], is_hero) + labels.append(label) + versions_by_label[label] = version["name"] + + label, state = QtWidgets.QInputDialog.getItem( + self, + "Set version..", + "Set version number to", + labels, + current=index, + editable=False + ) + if not state: + return + + if label: + version = versions_by_label[label] + for item in items: + try: + api.update(item, version) + except AssertionError: + self._show_version_error_dialog(version, [item]) + log.warning("Update failed", exc_info=True) + # refresh model when done + self.data_changed.emit() + + def _show_switch_dialog(self, items): + """Display Switch dialog""" + dialog = SwitchAssetDialog(self, items) + dialog.switched.connect(self.data_changed.emit) + dialog.show() + + def _show_remove_warning_dialog(self, items): + """Prompt a dialog to inform the user the action will remove items""" + + accept = QtWidgets.QMessageBox.Ok + buttons = accept | QtWidgets.QMessageBox.Cancel + + state = QtWidgets.QMessageBox.question( + self, + "Are you sure?", + "Are you sure you want to remove {} item(s)".format(len(items)), + buttons=buttons, + defaultButton=accept + ) + + if state != accept: + return + + for item in items: + api.remove(item) + self.data_changed.emit() + + def _show_version_error_dialog(self, version, items): + """Shows QMessageBox when version switch doesn't work + + Args: + version: str or int or None + """ + if not version: + version_str = "latest" + elif version == "hero": + version_str = "hero" + elif isinstance(version, int): + version_str = "v{:03d}".format(version) + else: + version_str = version + + dialog = QtWidgets.QMessageBox() + dialog.setIcon(QtWidgets.QMessageBox.Warning) + dialog.setStyleSheet(style.load_stylesheet()) + dialog.setWindowTitle("Update failed") + + switch_btn = dialog.addButton( + "Switch Asset", + QtWidgets.QMessageBox.ActionRole + ) + switch_btn.clicked.connect(lambda: self._show_switch_dialog(items)) + + dialog.addButton(QtWidgets.QMessageBox.Cancel) + + msg = ( + "Version update to '{}' failed as representation doesn't exist." + "\n\nPlease update to version with a valid representation" + " OR \n use 'Switch Asset' button to change asset." + ).format(version_str) + dialog.setText(msg) + dialog.exec_() diff --git a/openpype/tools/sceneinventory/widgets.py b/openpype/tools/sceneinventory/widgets.py new file mode 100644 index 0000000000..6bb74d2d1b --- /dev/null +++ b/openpype/tools/sceneinventory/widgets.py @@ -0,0 +1,51 @@ +from Qt import QtWidgets, QtCore + + +class SearchComboBox(QtWidgets.QComboBox): + """Searchable ComboBox with empty placeholder value as first value""" + + def __init__(self, parent=None): + super(SearchComboBox, self).__init__(parent) + + self.setEditable(True) + self.setInsertPolicy(self.NoInsert) + + # Apply completer settings + completer = self.completer() + completer.setCompletionMode(completer.PopupCompletion) + completer.setCaseSensitivity(QtCore.Qt.CaseInsensitive) + + # Force style sheet on popup menu + # It won't take the parent stylesheet for some reason + # todo: better fix for completer popup stylesheet + # if module.window: + # popup = completer.popup() + # popup.setStyleSheet(module.window.styleSheet()) + + def set_placeholder(self, placeholder): + self.lineEdit().setPlaceholderText(placeholder) + + def populate(self, items): + self.clear() + self.addItems([""]) # ensure first item is placeholder + self.addItems(items) + + def get_valid_value(self): + """Return the current text if it's a valid value else None + + Note: The empty placeholder value is valid and returns as "" + + """ + + text = self.currentText() + lookup = set(self.itemText(i) for i in range(self.count())) + if text not in lookup: + return None + + return text or None + + def set_valid_value(self, value): + """Try to locate 'value' and pre-select it in dropdown.""" + index = self.findText(value) + if index > -1: + self.setCurrentIndex(index) diff --git a/openpype/tools/sceneinventory/window.py b/openpype/tools/sceneinventory/window.py new file mode 100644 index 0000000000..e71af6a93d --- /dev/null +++ b/openpype/tools/sceneinventory/window.py @@ -0,0 +1,203 @@ +import os +import sys + +from Qt import QtWidgets, QtCore +from avalon.vendor import qtawesome +from avalon import io, api + +from openpype import style +from openpype.tools.utils.delegates import VersionDelegate +from openpype.tools.utils.lib import ( + qt_app_context, + preserve_expanded_rows, + preserve_selection, + FamilyConfigCache +) + +from .model import ( + InventoryModel, + FilterProxyModel +) +from .view import SceneInvetoryView + + +module = sys.modules[__name__] +module.window = None + + +class SceneInventoryWindow(QtWidgets.QDialog): + """Scene Inventory window""" + + def __init__(self, parent=None): + super(SceneInventoryWindow, self).__init__(parent) + + if not parent: + self.setWindowFlags( + self.windowFlags() | QtCore.Qt.WindowStaysOnTopHint + ) + + project_name = os.getenv("AVALON_PROJECT") or "" + self.setWindowTitle("Scene Inventory 1.0 - {}".format(project_name)) + self.setObjectName("SceneInventory") + # Maya only property + self.setProperty("saveWindowPref", True) + + self.resize(1100, 480) + + # region control + filter_label = QtWidgets.QLabel("Search", self) + text_filter = QtWidgets.QLineEdit(self) + + outdated_only_checkbox = QtWidgets.QCheckBox( + "Filter to outdated", self + ) + outdated_only_checkbox.setToolTip("Show outdated files only") + outdated_only_checkbox.setChecked(False) + + icon = qtawesome.icon("fa.refresh", color="white") + refresh_button = QtWidgets.QPushButton(self) + refresh_button.setIcon(icon) + + control_layout = QtWidgets.QHBoxLayout() + control_layout.addWidget(filter_label) + control_layout.addWidget(text_filter) + control_layout.addWidget(outdated_only_checkbox) + control_layout.addWidget(refresh_button) + + # endregion control + family_config_cache = FamilyConfigCache(io) + + model = InventoryModel(family_config_cache) + proxy = FilterProxyModel() + proxy.setSourceModel(model) + proxy.setDynamicSortFilter(True) + proxy.setFilterCaseSensitivity(QtCore.Qt.CaseInsensitive) + + view = SceneInvetoryView(self) + view.setModel(proxy) + + # set some nice default widths for the view + view.setColumnWidth(0, 250) # name + view.setColumnWidth(1, 55) # version + view.setColumnWidth(2, 55) # count + view.setColumnWidth(3, 150) # family + view.setColumnWidth(4, 100) # namespace + + # apply delegates + version_delegate = VersionDelegate(io, self) + column = model.Columns.index("version") + view.setItemDelegateForColumn(column, version_delegate) + + layout = QtWidgets.QVBoxLayout(self) + layout.addLayout(control_layout) + layout.addWidget(view) + + # signals + text_filter.textChanged.connect(self._on_text_filter_change) + outdated_only_checkbox.stateChanged.connect( + self._on_outdated_state_change + ) + view.hierarchy_view_changed.connect( + self._on_hiearchy_view_change + ) + view.data_changed.connect(self.refresh) + refresh_button.clicked.connect(self.refresh) + + self._outdated_only_checkbox = outdated_only_checkbox + self._view = view + self._model = model + self._proxy = proxy + self._version_delegate = version_delegate + self._family_config_cache = family_config_cache + + self._first_show = True + + family_config_cache.refresh() + + def showEvent(self, event): + super(SceneInventoryWindow, self).showEvent(event) + if self._first_show: + self._first_show = False + self.setStyleSheet(style.load_stylesheet()) + + def keyPressEvent(self, event): + """Custom keyPressEvent. + + Override keyPressEvent to do nothing so that Maya's panels won't + take focus when pressing "SHIFT" whilst mouse is over viewport or + outliner. This way users don't accidently perform Maya commands + whilst trying to name an instance. + + """ + + def refresh(self, items=None): + with preserve_expanded_rows( + tree_view=self._view, + role=self._model.UniqueRole + ): + with preserve_selection( + tree_view=self._view, + role=self._model.UniqueRole, + current_index=False + ): + kwargs = {"items": items} + # TODO do not touch view's inner attribute + if self._view._hierarchy_view: + kwargs["selected"] = self._view._selected + self._model.refresh(**kwargs) + + def _on_hiearchy_view_change(self, enabled): + self._proxy.set_hierarchy_view(enabled) + self._model.set_hierarchy_view(enabled) + + def _on_text_filter_change(self, text_filter): + self._proxy.setFilterRegExp(text_filter) + + def _on_outdated_state_change(self): + self._proxy.set_filter_outdated( + self._outdated_only_checkbox.isChecked() + ) + + +def show(root=None, debug=False, parent=None, items=None): + """Display Scene Inventory GUI + + Arguments: + debug (bool, optional): Run in debug-mode, + defaults to False + parent (QtCore.QObject, optional): When provided parent the interface + to this QObject. + items (list) of dictionaries - for injection of items for standalone + testing + + """ + + try: + module.window.close() + del module.window + except (RuntimeError, AttributeError): + pass + + if debug is True: + io.install() + + if not os.environ.get("AVALON_PROJECT"): + any_project = next( + project for project in io.projects() + if project.get("active", True) is not False + ) + + api.Session["AVALON_PROJECT"] = any_project["name"] + else: + api.Session["AVALON_PROJECT"] = os.environ.get("AVALON_PROJECT") + + with qt_app_context(): + window = SceneInventoryWindow(parent) + window.show() + window.refresh(items=items) + + module.window = window + + # Pull window to the front. + module.window.raise_() + module.window.activateWindow() diff --git a/openpype/tools/utils/constants.py b/openpype/tools/utils/constants.py index 5b6f4126c9..33bdf43c08 100644 --- a/openpype/tools/utils/constants.py +++ b/openpype/tools/utils/constants.py @@ -7,7 +7,7 @@ PROJECT_IS_ACTIVE_ROLE = QtCore.Qt.UserRole + 102 TASK_NAME_ROLE = QtCore.Qt.UserRole + 301 TASK_TYPE_ROLE = QtCore.Qt.UserRole + 302 -TASK_ORDER_ROLE = QtCore.Qt.UserRole + 403 +TASK_ORDER_ROLE = QtCore.Qt.UserRole + 303 LOCAL_PROVIDER_ROLE = QtCore.Qt.UserRole + 500 # provider of active site REMOTE_PROVIDER_ROLE = QtCore.Qt.UserRole + 501 # provider of remote site diff --git a/openpype/tools/utils/host_tools.py b/openpype/tools/utils/host_tools.py index d5e4792c94..e87da7f0b4 100644 --- a/openpype/tools/utils/host_tools.py +++ b/openpype/tools/utils/host_tools.py @@ -154,21 +154,20 @@ class HostToolsHelper: def get_scene_inventory_tool(self, parent): """Create, cache and return scene inventory tool window.""" if self._scene_inventory_tool is None: - from avalon.tools.sceneinventory.app import Window + from openpype.tools.sceneinventory import SceneInventoryWindow - scene_inventory_window = Window(parent=parent or self._parent) + scene_inventory_window = SceneInventoryWindow( + parent=parent or self._parent + ) self._scene_inventory_tool = scene_inventory_window return self._scene_inventory_tool def show_scene_inventory(self, parent=None): """Show tool maintain loaded containers.""" - from avalon import style - scene_inventory_tool = self.get_scene_inventory_tool(parent) scene_inventory_tool.show() scene_inventory_tool.refresh() - scene_inventory_tool.setStyleSheet(style.load_stylesheet()) # Pull window to the front. scene_inventory_tool.raise_() diff --git a/openpype/tools/utils/tasks_widget.py b/openpype/tools/utils/tasks_widget.py new file mode 100644 index 0000000000..9ffcde2885 --- /dev/null +++ b/openpype/tools/utils/tasks_widget.py @@ -0,0 +1,299 @@ +from Qt import QtWidgets, QtCore, QtGui + +from avalon import style +from avalon.vendor import qtawesome + +from .views import DeselectableTreeView +from .constants import ( + TASK_ORDER_ROLE, + TASK_TYPE_ROLE, + TASK_NAME_ROLE +) + + +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.setHeaderData( + 0, QtCore.Qt.Horizontal, "Tasks", QtCore.Qt.DisplayRole + ) + 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._empty_tasks_item = None + self._last_asset_id = None + self._loaded_project_name = None + + def _context_is_valid(self): + if self.dbcon.Session.get("AVALON_PROJECT"): + return True + return False + + def refresh(self): + self._refresh_task_types() + self.set_asset_id(self._last_asset_id) + + def _refresh_task_types(self): + # Get the project configured icons from database + task_types = {} + if self._context_is_valid(): + project = self.dbcon.find_one( + {"type": "project"}, + {"config.tasks"} + ) + task_types = project["config"].get("tasks") or task_types + self._project_task_types = task_types + + 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=None): + if role is None: + role = QtCore.Qt.EditRole + # Show nice labels in the header + if section == 0: + if ( + role in (QtCore.Qt.DisplayRole, QtCore.Qt.EditRole) + and orientation == QtCore.Qt.Horizontal + ): + 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_id(self, asset_id): + asset_doc = None + if self._context_is_valid(): + asset_doc = self.dbcon.find_one( + {"_id": asset_id}, + {"data.tasks": True} + ) + self._set_asset(asset_doc) + + def _get_empty_task_item(self): + if self._empty_tasks_item is None: + item = QtGui.QStandardItem("No task") + item.setData(self._no_tasks_icon, QtCore.Qt.DecorationRole) + item.setFlags(QtCore.Qt.NoItemFlags) + self._empty_tasks_item = item + return self._empty_tasks_item + + def _set_asset(self, asset_doc): + """Set assets to track by their database id + + Arguments: + asset_doc (dict): Asset document from MongoDB. + """ + asset_tasks = {} + self._last_asset_id = None + if asset_doc: + asset_tasks = asset_doc.get("data", {}).get("tasks") or {} + self._last_asset_id = asset_doc["_id"] + + root_item = self.invisibleRootItem() + root_item.removeRows(0, root_item.rowCount()) + + 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) + + root_item.appendRows(items) + + +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 True + return False + + +class TasksWidget(QtWidgets.QWidget): + """Widget showing active Tasks""" + + task_changed = QtCore.Signal() + + def __init__(self, dbcon, parent=None): + super(TasksWidget, self).__init__(parent) + + tasks_view = DeselectableTreeView(self) + tasks_view.setIndentation(0) + tasks_view.setSortingEnabled(True) + tasks_view.setEditTriggers(QtWidgets.QTreeView.NoEditTriggers) + + header_view = tasks_view.header() + header_view.setSortIndicator(0, QtCore.Qt.AscendingOrder) + + 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(tasks_view) + + selection_model = tasks_view.selectionModel() + selection_model.selectionChanged.connect(self._on_task_change) + + self._tasks_model = tasks_model + self._tasks_proxy = tasks_proxy + self._tasks_view = tasks_view + + self._last_selected_task_name = None + + def refresh(self): + self._tasks_model.refresh() + + def set_asset_id(self, asset_id): + # Asset deselected + if asset_id 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_selected_task_name() + if current: + self._last_selected_task_name = current + + self._tasks_model.set_asset_id(asset_id) + + if self._last_selected_task_name: + self.select_task_name(self._last_selected_task_name) + + # Force a task changed emit. + self.task_changed.emit() + + def select_task_name(self, task_name): + """Select a task by name. + + If the task does not exist in the current model then selection is only + cleared. + + Args: + task (str): Name of the task to select. + + """ + task_view_model = self._tasks_view.model() + if not task_view_model: + return + + # Clear selection + selection_model = self._tasks_view.selectionModel() + selection_model.clearSelection() + + # Select the task + mode = selection_model.Select | selection_model.Rows + for row in range(task_view_model.rowCount()): + index = task_view_model.index(row, 0) + name = index.data(TASK_NAME_ROLE) + if name == task_name: + selection_model.select(index, mode) + + # Set the currently active index + self._tasks_view.setCurrentIndex(index) + break + + def get_selected_task_name(self): + """Return name of task at current index (selected) + + Returns: + str: Name of the current task. + + """ + 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 + + def get_selected_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 + + def _on_task_change(self): + self.task_changed.emit() diff --git a/openpype/tools/workfiles/app.py b/openpype/tools/workfiles/app.py index 4135eeccc9..aa98e67158 100644 --- a/openpype/tools/workfiles/app.py +++ b/openpype/tools/workfiles/app.py @@ -15,16 +15,9 @@ from openpype.tools.utils.lib import ( schedule, qt_app_context ) from openpype.tools.utils.widgets import AssetWidget +from openpype.tools.utils.tasks_widget import TasksWidget from openpype.tools.utils.delegates import PrettyTimeDelegate -from openpype.tools.utils.constants import ( - TASK_NAME_ROLE, - TASK_TYPE_ROLE -) -from openpype.tools.utils.models import ( - TasksModel, - TasksProxyModel -) from .model import FilesModel from .view import FilesView @@ -323,110 +316,6 @@ class NameWindow(QtWidgets.QDialog): ) -class TasksWidget(QtWidgets.QWidget): - """Widget showing active Tasks""" - - task_changed = QtCore.Signal() - - def __init__(self, dbcon=None, parent=None): - super(TasksWidget, self).__init__(parent) - - 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(tasks_view) - - selection_model = tasks_view.selectionModel() - selection_model.currentChanged.connect(self.task_changed) - - 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_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_name() - if current: - self._last_selected_task = current - - self._tasks_model.set_asset(asset_doc) - self._tasks_proxy.sort(0, QtCore.Qt.AscendingOrder) - - if self._last_selected_task: - self.select_task(self._last_selected_task) - - # Force a task changed emit. - self.task_changed.emit() - - 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 - cleared. - - Args: - task (str): Name of the task to select. - - """ - task_view_model = self._tasks_view.model() - if not task_view_model: - return - - # Clear selection - selection_model = self._tasks_view.selectionModel() - selection_model.clearSelection() - - # Select the task - mode = selection_model.Select | selection_model.Rows - for row in range(task_view_model.rowCount()): - index = task_view_model.index(row, 0) - name = index.data(TASK_NAME_ROLE) - if name == task_name: - selection_model.select(index, mode) - - # Set the currently active index - self._tasks_view.setCurrentIndex(index) - break - - def get_current_task_name(self): - """Return name of task at current index (selected) - - Returns: - str: Name of the current task. - - """ - 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 - - 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): """A widget displaying files that allows to save and open files.""" file_selected = QtCore.Signal(str) @@ -1052,7 +941,7 @@ class Window(QtWidgets.QMainWindow): if asset_docs: asset_doc = asset_docs[0] - task_name = self.tasks_widget.get_current_task_name() + task_name = self.tasks_widget.get_selected_task_name() workfile_doc = None if asset_doc and task_name and filepath: @@ -1082,7 +971,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_name() + task_name = self.tasks_widget.get_selected_task_name() asset_docs = self.assets_widget.get_selected_assets() if not task_name or not asset_docs or not filepath: return @@ -1102,7 +991,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_name() + task_name = self.tasks_widget.get_selected_task_name() create_workfile_doc(asset_doc, task_name, filename, workdir, io) def set_context(self, context): @@ -1113,18 +1002,16 @@ class Window(QtWidgets.QMainWindow): "name": asset, "type": "asset" }, - { - "data.tasks": 1 - } - ) + {"_id": 1} + ) or {} # Select the asset self.assets_widget.select_assets([asset], expand=True) - self.tasks_widget.set_asset(asset_document) + self.tasks_widget.set_asset_id(asset_document.get("_id")) if "task" in context: - self.tasks_widget.select_task(context["task"]) + self.tasks_widget.select_task_name(context["task"]) def refresh(self): # Refresh asset widget @@ -1134,7 +1021,7 @@ class Window(QtWidgets.QMainWindow): def _on_asset_changed(self): asset = self.assets_widget.get_selected_assets() or None - + asset_id = None if not asset: # Force disable the other widgets if no # active selection @@ -1142,16 +1029,17 @@ class Window(QtWidgets.QMainWindow): self.files_widget.setEnabled(False) else: asset = asset[0] + asset_id = asset.get("_id") self.tasks_widget.setEnabled(True) - self.tasks_widget.set_asset(asset) + self.tasks_widget.set_asset_id(asset_id) def _on_task_changed(self): asset = self.assets_widget.get_selected_assets() or None if asset is not None: asset = asset[0] - task_name = self.tasks_widget.get_current_task_name() - task_type = self.tasks_widget.get_current_task_type() + task_name = self.tasks_widget.get_selected_task_name() + task_type = self.tasks_widget.get_selected_task_type() self.tasks_widget.setEnabled(bool(asset)) diff --git a/openpype/version.py b/openpype/version.py index 7f85931698..ef4bbe505b 100644 --- a/openpype/version.py +++ b/openpype/version.py @@ -1,3 +1,3 @@ # -*- coding: utf-8 -*- """Package declaring Pype version.""" -__version__ = "3.6.0-nightly.5" +__version__ = "3.6.2-nightly.1" diff --git a/pyproject.toml b/pyproject.toml index 8dd8664eae..cfe7422d49 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "OpenPype" -version = "3.6.0-nightly.5" # OpenPype +version = "3.6.2-nightly.1" # OpenPype description = "Open VFX and Animation pipeline with support." authors = ["OpenPype Team "] license = "MIT License"