Repository: ynput/OpenPype
Issue: Updating a look where the shader name changed leaves the geo without a shader

**Describe the bug**
Updating a look to latest version using scene inventory GUI leaves the geo without a shader, in cases where a name of the shader changed between the version. Even though the renamed shader has all correct id assignments.

**To Reproduce**
Steps to reproduce the behavior:
1. Publish a look for a model
2. Load the look onto a model in a different scene (setdress for example) using look manager
3. In the `lookdev` scene, change the name of one of the shaders and publish another version
4. In the "setdress" scene open scene inventory and update the look to latest version
5. The object that has the renamed look applied to it, ends up without any shader. 

**Expected behavior**
Look updating should work the same as all other families from the scene inventory. That means that we'll need to duplicate some of the logic from look assigned to the `update` method of the look loader


Changed files:
pype/plugins/maya/load/load_look.py, pype/widgets/message_window.py

diff --git a/pype/plugins/maya/load/load_look.py b/pype/plugins/maya/load/load_look.py
index b9c0d81104..c5b58c9bd5 100644
--- a/pype/plugins/maya/load/load_look.py
+++ b/pype/plugins/maya/load/load_look.py
@@ -3,6 +3,8 @@ from avalon import api, io
 import json
 import pype.hosts.maya.lib
 from collections import defaultdict
+from pype.widgets.message_window import ScrollMessageBox
+from Qt import QtWidgets
 
 
 class LookLoader(pype.hosts.maya.plugin.ReferenceLoader):
@@ -44,18 +46,33 @@ class LookLoader(pype.hosts.maya.plugin.ReferenceLoader):
         self.update(container, representation)
 
     def update(self, container, representation):
+        """
+            Called by Scene Inventory when look should be updated to current
+            version.
+            If any reference edits cannot be applied, eg. shader renamed and
+            material not present, reference is unloaded and cleaned.
+            All failed edits are highlighted to the user via message box.
 
+        Args:
+            container: object that has look to be updated
+            representation: (dict): relationship data to get proper
+                                       representation from DB and persisted
+                                       data in .json
+        Returns:
+            None
+        """
         import os
         from maya import cmds
-
         node = container["objectName"]
-
         path = api.get_representation_path(representation)
 
         # Get reference node from container members
         members = cmds.sets(node, query=True, nodesOnly=True)
         reference_node = self._get_reference_node(members)
 
+        shader_nodes = cmds.ls(members, type='shadingEngine')
+        orig_nodes = set(self._get_nodes_with_shader(shader_nodes))
+
         file_type = {
             "ma": "mayaAscii",
             "mb": "mayaBinary",
@@ -66,35 +83,7 @@ class LookLoader(pype.hosts.maya.plugin.ReferenceLoader):
 
         assert os.path.exists(path), "%s does not exist." % path
 
-        try:
-            content = cmds.file(path,
-                                loadReference=reference_node,
-                                type=file_type,
-                                returnNewNodes=True)
-        except RuntimeError as exc:
-            # When changing a reference to a file that has load errors the
-            # command will raise an error even if the file is still loaded
-            # correctly (e.g. when raising errors on Arnold attributes)
-            # When the file is loaded and has content, we consider it's fine.
-            if not cmds.referenceQuery(reference_node, isLoaded=True):
-                raise
-
-            content = cmds.referenceQuery(reference_node,
-                                          nodes=True,
-                                          dagPath=True)
-            if not content:
-                raise
-
-            self.log.warning("Ignoring file read error:\\n%s", exc)
-
-        # Fix PLN-40 for older containers created with Avalon that had the
-        # `.verticesOnlySet` set to True.
-        if cmds.getAttr("{}.verticesOnlySet".format(node)):
-            self.log.info("Setting %s.verticesOnlySet to False", node)
-            cmds.setAttr("{}.verticesOnlySet".format(node), False)
-
-        # Add new nodes of the reference to the container
-        cmds.sets(content, forceElement=node)
+        self._load_reference(file_type, node, path, reference_node)
 
         # Remove any placeHolderList attribute entries from the set that
         # are remaining from nodes being removed from the referenced file.
@@ -103,18 +92,9 @@ class LookLoader(pype.hosts.maya.plugin.ReferenceLoader):
         if invalid:
             cmds.sets(invalid, remove=node)
 
-        # Get container members
+        # get new applied shaders and nodes from new version
         shader_nodes = cmds.ls(members, type='shadingEngine')
-
-        nodes_list = []
-        for shader in shader_nodes:
-            connections = cmds.listConnections(cmds.listHistory(shader, f=1),
-                                               type='mesh')
-            if connections:
-                for connection in connections:
-                    nodes_list.extend(cmds.listRelatives(connection,
-                                                         shapes=True))
-        nodes = set(nodes_list)
+        nodes = set(self._get_nodes_with_shader(shader_nodes))
 
         json_representation = io.find_one({
             "type": "representation",
@@ -127,16 +107,104 @@ class LookLoader(pype.hosts.maya.plugin.ReferenceLoader):
         with open(shader_relation, "r") as f:
             relationships = json.load(f)
 
+        # update of reference could result in failed edits - material is not
+        # present because of renaming etc.
+        failed_edits = cmds.referenceQuery(reference_node,
+                                           editStrings=True,
+                                           failedEdits=True,
+                                           successfulEdits=False)
+
+        # highlight failed edits to user
+        if failed_edits:
+            # clean references - removes failed reference edits
+            cmds.file(cr=reference_node)  # cleanReference
+
+            # reapply shading groups from json representation on orig nodes
+            pype.hosts.maya.lib.apply_shaders(relationships,
+                                              shader_nodes,
+                                              orig_nodes)
+
+            msg = ["During reference update some edits failed.",
+                   "All successful edits were kept intact.\\n",
+                   "Failed and removed edits:"]
+            msg.extend(failed_edits)
+            msg = ScrollMessageBox(QtWidgets.QMessageBox.Warning,
+                                   "Some reference edit failed",
+                                   msg)
+            msg.exec_()
+
         attributes = relationships.get("attributes", [])
 
         # region compute lookup
         nodes_by_id = defaultdict(list)
         for n in nodes:
             nodes_by_id[pype.hosts.maya.lib.get_id(n)].append(n)
-
         pype.hosts.maya.lib.apply_attributes(attributes, nodes_by_id)
 
         # Update metadata
         cmds.setAttr("{}.representation".format(node),
                      str(representation["_id"]),
                      type="string")
+
+    def _get_nodes_with_shader(self, shader_nodes):
+        """
+            Returns list of nodes belonging to specific shaders
+        Args:
+            shader_nodes: <list> of Shader groups
+        Returns
+            <list> node names
+        """
+        import maya.cmds as cmds
+        # Get container members
+
+        nodes_list = []
+        for shader in shader_nodes:
+            connections = cmds.listConnections(cmds.listHistory(shader, f=1),
+                                               type='mesh')
+            if connections:
+                for connection in connections:
+                    nodes_list.extend(cmds.listRelatives(connection,
+                                                         shapes=True))
+        return nodes_list
+
+    def _load_reference(self, file_type, node, path, reference_node):
+        """
+            Load reference from 'path' on 'reference_node'. Used when change
+            of look (version/update) is triggered.
+        Args:
+            file_type: extension of referenced file
+            node:
+            path: (string) location of referenced file
+            reference_node: (string) - name of node that should be applied
+                                          on
+        Returns:
+            None
+        """
+        import maya.cmds as cmds
+        try:
+            content = cmds.file(path,
+                                loadReference=reference_node,
+                                type=file_type,
+                                returnNewNodes=True)
+        except RuntimeError as exc:
+            # When changing a reference to a file that has load errors the
+            # command will raise an error even if the file is still loaded
+            # correctly (e.g. when raising errors on Arnold attributes)
+            # When the file is loaded and has content, we consider it's fine.
+            if not cmds.referenceQuery(reference_node, isLoaded=True):
+                raise
+
+            content = cmds.referenceQuery(reference_node,
+                                          nodes=True,
+                                          dagPath=True)
+            if not content:
+                raise
+
+            self.log.warning("Ignoring file read error:\\n%s", exc)
+        # Fix PLN-40 for older containers created with Avalon that had the
+        # `.verticesOnlySet` set to True.
+        if cmds.getAttr("{}.verticesOnlySet".format(node)):
+            self.log.info("Setting %s.verticesOnlySet to False", node)
+            cmds.setAttr("{}.verticesOnlySet".format(node), False)
+        # Add new nodes of the reference to the container
+        cmds.sets(content, forceElement=node)
diff --git a/pype/widgets/message_window.py b/pype/widgets/message_window.py
index 41c709b933..969d6ccdd1 100644
--- a/pype/widgets/message_window.py
+++ b/pype/widgets/message_window.py
@@ -1,4 +1,4 @@
-from Qt import QtWidgets
+from Qt import QtWidgets, QtCore
 import sys
 import logging
 
@@ -49,6 +49,17 @@ class Window(QtWidgets.QWidget):
 
 
 def message(title=None, message=None, level="info", parent=None):
+    """
+        Produces centered dialog with specific level denoting severity
+    Args:
+        title: (string) dialog title
+        message: (string) message
+        level: (string) info|warning|critical
+        parent: (QtWidgets.QApplication)
+
+    Returns:
+         None
+    """
     app = parent
     if not app:
         app = QtWidgets.QApplication(sys.argv)
@@ -68,3 +79,60 @@ def message(title=None, message=None, level="info", parent=None):
         # skip all possible issues that may happen feature is not crutial
         log.warning("Couldn't center message.", exc_info=True)
     # sys.exit(app.exec_())
+
+
+class ScrollMessageBox(QtWidgets.QDialog):
+    """
+        Basic version of scrollable QMessageBox. No other existing dialog
+        implementation is scrollable.
+        Args:
+            icon: <QtWidgets.QMessageBox.Icon>
+            title: <string>
+            messages: <list> of messages
+            cancelable: <boolean> - True if Cancel button should be added
+    """
+    def __init__(self, icon, title, messages, cancelable=False):
+        super(ScrollMessageBox, self).__init__()
+        self.setWindowTitle(title)
+        self.icon = icon
+
+        self.setWindowFlags(QtCore.Qt.WindowTitleHint)
+
+        layout = QtWidgets.QVBoxLayout(self)
+
+        scroll_widget = QtWidgets.QScrollArea(self)
+        scroll_widget.setWidgetResizable(True)
+        content_widget = QtWidgets.QWidget(self)
+        scroll_widget.setWidget(content_widget)
+
+        max_len = 0
+        content_layout = QtWidgets.QVBoxLayout(content_widget)
+        for message in messages:
+            label_widget = QtWidgets.QLabel(message, content_widget)
+            content_layout.addWidget(label_widget)
+            max_len = max(max_len, len(message))
+
+        # guess size of scrollable area
+        max_width = QtWidgets.QApplication.desktop().availableGeometry().width
+        scroll_widget.setMinimumWidth(min(max_width, max_len * 6))
+        layout.addWidget(scroll_widget)
+
+        if not cancelable:  # if no specific buttons OK only
+            buttons = QtWidgets.QDialogButtonBox.Ok
+        else:
+            buttons = QtWidgets.QDialogButtonBox.Ok | \\
+                      QtWidgets.QDialogButtonBox.Cancel
+
+        btn_box = QtWidgets.QDialogButtonBox(buttons)
+        btn_box.accepted.connect(self.accept)
+
+        if cancelable:
+            btn_box.reject.connect(self.reject)
+
+        btn = QtWidgets.QPushButton('Copy to clipboard')
+        btn.clicked.connect(lambda: QtWidgets.QApplication.
+                            clipboard().setText("\\n".join(messages)))
+        btn_box.addButton(btn, QtWidgets.QDialogButtonBox.NoRole)
+
+        layout.addWidget(btn_box)
+        self.show()