From a73d19b612c6c4d423a8784b0598e71263213c9f Mon Sep 17 00:00:00 2001 From: Jakub Trllo <43494761+iLLiCiTiT@users.noreply.github.com> Date: Tue, 23 May 2023 18:16:05 +0200 Subject: [PATCH] Publisher: Show instances in report page (#4915) * renamed 'validations_widget.py' to 'report_page.py' * Implemented base logic and widgets for logs * make one report page * added missing imports * added missing constants * move and rename 'VerticallScrollArea' to 'VerticalScrollArea' * Validation erro item have id * use 'ReportPageWidget' in window * change 'bg-button-hover' key to 'bg-buttons-hover' in style colors * move publish actions widgets * Refactored how validation error title is showed * remove item id from validation error item but add id to group items * remove margins from actions widget * shrink publish frame on finished publishing * fix dash line draw * add missing styles * fix dash line in thumbnail widget * added crash widget and changed layout a little * added infor overlay message * export and copy report happens in main window * fix docstrings * added per plugin filtering for validation errors * added implementation of 'FlowLayout' * actions buttons are in flow layout * fix actions order * implemented expanding text edit widget * expand button has some signals and properties * description and details are separated widgets * fix typo * added constans to '__all__' * parse icon def is a function * change layout of widgets * fix log filtering * added state icon to instances * fix pyside6 issues * implemented 'ClassicExpandBtnLabel' with arrow images * modified details separator * added some spacing to layouts * fix syle of description inputs and progress color * removed unused import * add 'is_validation_error' to errored result * validation error has different icon in logs view * added plugin name to ValueError if happens * spacer before detail inputs moved out of detals widget * fix actions visible in craash report * ignore pyblish base classes * filter base plugins in discovery * use 'is' comparison instead of '__eq__' * fix action error handling * Fix handling of 'None' values in comparison * formatting fix * Report instance card have same margins as in create mode * publish instances are grouped by family * log messages are rstripped --- openpype/pipeline/publish/lib.py | 8 + openpype/style/data.json | 9 +- openpype/style/style.css | 64 +- openpype/tools/attribute_defs/files_widget.py | 24 +- openpype/tools/publisher/constants.py | 6 + openpype/tools/publisher/control.py | 41 +- .../publish_report_viewer/widgets.py | 6 +- openpype/tools/publisher/widgets/__init__.py | 4 +- .../publisher/widgets/card_view_widgets.py | 6 +- .../tools/publisher/widgets/images/error.png | Bin 0 -> 14667 bytes .../publisher/widgets/images/success.png | Bin 0 -> 14514 bytes .../publisher/widgets/images/warning.png | Bin 9748 -> 11546 bytes .../tools/publisher/widgets/publish_frame.py | 39 +- .../tools/publisher/widgets/report_page.py | 1876 +++++++++++++++++ .../publisher/widgets/thumbnail_widget.py | 21 +- .../publisher/widgets/validations_widget.py | 715 ------- openpype/tools/publisher/widgets/widgets.py | 65 +- openpype/tools/publisher/window.py | 58 +- openpype/tools/utils/__init__.py | 7 + openpype/tools/utils/layouts.py | 150 ++ openpype/tools/utils/widgets.py | 108 +- 21 files changed, 2373 insertions(+), 834 deletions(-) create mode 100644 openpype/tools/publisher/widgets/images/error.png create mode 100644 openpype/tools/publisher/widgets/images/success.png create mode 100644 openpype/tools/publisher/widgets/report_page.py delete mode 100644 openpype/tools/publisher/widgets/validations_widget.py create mode 100644 openpype/tools/utils/layouts.py diff --git a/openpype/pipeline/publish/lib.py b/openpype/pipeline/publish/lib.py index 080f93e514..40186238aa 100644 --- a/openpype/pipeline/publish/lib.py +++ b/openpype/pipeline/publish/lib.py @@ -320,6 +320,14 @@ def publish_plugins_discover(paths=None): continue for plugin in pyblish.plugin.plugins_from_module(module): + # Ignore base plugin classes + # NOTE 'pyblish.api.discover' does not ignore them! + if ( + plugin is pyblish.api.Plugin + or plugin is pyblish.api.ContextPlugin + or plugin is pyblish.api.InstancePlugin + ): + continue if not allow_duplicates and plugin.__name__ in plugin_names: result.duplicated_plugins.append(plugin) log.debug("Duplicate plug-in found: %s", plugin) diff --git a/openpype/style/data.json b/openpype/style/data.json index bea2a3d407..7389387d97 100644 --- a/openpype/style/data.json +++ b/openpype/style/data.json @@ -26,8 +26,8 @@ "bg": "#2C313A", "bg-inputs": "#21252B", - "bg-buttons": "#434a56", - "bg-button-hover": "rgb(81, 86, 97)", + "bg-buttons": "rgb(67, 74, 86)", + "bg-buttons-hover": "rgb(81, 86, 97)", "bg-inputs-disabled": "#2C313A", "bg-buttons-disabled": "#434a56", @@ -66,7 +66,9 @@ "bg-success": "#458056", "bg-success-hover": "#55a066", "bg-error": "#AD2E2E", - "bg-error-hover": "#C93636" + "bg-error-hover": "#C93636", + "bg-info": "rgb(63, 98, 121)", + "bg-info-hover": "rgb(81, 146, 181)" }, "tab-widget": { "bg": "#21252B", @@ -94,6 +96,7 @@ "crash": "#FF6432", "success": "#458056", "warning": "#ffc671", + "progress": "rgb(194, 226, 236)", "tab-bg": "#16191d", "list-view-group": { "bg": "#434a56", diff --git a/openpype/style/style.css b/openpype/style/style.css index 827b103f94..5ce55aa658 100644 --- a/openpype/style/style.css +++ b/openpype/style/style.css @@ -136,7 +136,7 @@ QPushButton { } QPushButton:hover { - background: {color:bg-button-hover}; + background: {color:bg-buttons-hover}; color: {color:font-hover}; } @@ -166,7 +166,7 @@ QToolButton { } QToolButton:hover { - background: {color:bg-button-hover}; + background: {color:bg-buttons-hover}; color: {color:font-hover}; } @@ -722,6 +722,13 @@ OverlayMessageWidget[type="error"]:hover { background: {color:overlay-messages:bg-error-hover}; } +OverlayMessageWidget[type="info"] { + background: {color:overlay-messages:bg-info}; +} +OverlayMessageWidget[type="info"]:hover { + background: {color:overlay-messages:bg-info-hover}; +} + OverlayMessageWidget QWidget { background: transparent; } @@ -749,10 +756,11 @@ OverlayMessageWidget QWidget { } #InfoText { - padding-left: 30px; - padding-top: 20px; + padding-left: 0px; + padding-top: 0px; + padding-right: 20px; background: transparent; - border: 1px solid {color:border}; + border: none; } #TypeEditor, #ToolEditor, #NameEditor, #NumberEditor { @@ -914,7 +922,7 @@ PixmapButton{ background: {color:bg-buttons}; } PixmapButton:hover { - background: {color:bg-button-hover}; + background: {color:bg-buttons-hover}; } PixmapButton:disabled { background: {color:bg-buttons-disabled}; @@ -925,7 +933,7 @@ PixmapButton:disabled { background: {color:bg-view}; } #ThumbnailPixmapHoverButton:hover { - background: {color:bg-button-hover}; + background: {color:bg-buttons-hover}; } #CreatorDetailedDescription { @@ -946,7 +954,7 @@ PixmapButton:disabled { } #CreateDialogHelpButton:hover { - background: {color:bg-button-hover}; + background: {color:bg-buttons-hover}; } #CreateDialogHelpButton QWidget { background: transparent; @@ -1005,7 +1013,7 @@ PixmapButton:disabled { border-radius: 0.2em; } #CardViewWidget:hover { - background: {color:bg-button-hover}; + background: {color:bg-buttons-hover}; } #CardViewWidget[state="selected"] { background: {color:bg-view-selection}; @@ -1032,7 +1040,7 @@ PixmapButton:disabled { } #PublishInfoFrame[state="3"], #PublishInfoFrame[state="4"] { - background: rgb(194, 226, 236); + background: {color:publisher:progress}; } #PublishInfoFrame QLabel { @@ -1040,6 +1048,11 @@ PixmapButton:disabled { font-style: bold; } +#PublishReportHeader { + font-size: 14pt; + font-weight: bold; +} + #PublishInfoMainLabel { font-size: 12pt; } @@ -1060,7 +1073,7 @@ ValidationArtistMessage QLabel { } #ValidationActionButton:hover { - background: {color:bg-button-hover}; + background: {color:bg-buttons-hover}; color: {color:font-hover}; } @@ -1090,6 +1103,35 @@ ValidationArtistMessage QLabel { border-left: 1px solid {color:border}; } +#PublishInstancesDetails { + border: 1px solid {color:border}; + border-radius: 0.3em; +} + +#InstancesLogsView { + border: 1px solid {color:border}; + background: {color:bg-view}; + border-radius: 0.3em; +} + +#PublishLogMessage { + font-family: "Noto Sans Mono"; +} + +#PublishInstanceLogsLabel { + font-weight: bold; +} + +#PublishCrashMainLabel{ + font-weight: bold; + font-size: 16pt; +} + +#PublishCrashReportLabel { + font-weight: bold; + font-size: 13pt; +} + #AssetNameInputWidget { background: {color:bg-inputs}; border: 1px solid {color:border}; diff --git a/openpype/tools/attribute_defs/files_widget.py b/openpype/tools/attribute_defs/files_widget.py index 067866035f..076b33fb7c 100644 --- a/openpype/tools/attribute_defs/files_widget.py +++ b/openpype/tools/attribute_defs/files_widget.py @@ -198,29 +198,33 @@ class DropEmpty(QtWidgets.QWidget): def paintEvent(self, event): super(DropEmpty, self).paintEvent(event) - painter = QtGui.QPainter(self) + pen = QtGui.QPen() - pen.setWidth(1) pen.setBrush(QtCore.Qt.darkGray) pen.setStyle(QtCore.Qt.DashLine) - painter.setPen(pen) - content_margins = self.layout().contentsMargins() + pen.setWidth(1) - left_m = content_margins.left() - top_m = content_margins.top() - rect = QtCore.QRect( + content_margins = self.layout().contentsMargins() + rect = self.rect() + left_m = content_margins.left() + pen.width() + top_m = content_margins.top() + pen.width() + new_rect = QtCore.QRect( left_m, top_m, ( - self.rect().width() + rect.width() - (left_m + content_margins.right() + pen.width()) ), ( - self.rect().height() + rect.height() - (top_m + content_margins.bottom() + pen.width()) ) ) - painter.drawRect(rect) + + painter = QtGui.QPainter(self) + painter.setRenderHint(QtGui.QPainter.Antialiasing) + painter.setPen(pen) + painter.drawRect(new_rect) class FilesModel(QtGui.QStandardItemModel): diff --git a/openpype/tools/publisher/constants.py b/openpype/tools/publisher/constants.py index 660fccecf1..4630eb144b 100644 --- a/openpype/tools/publisher/constants.py +++ b/openpype/tools/publisher/constants.py @@ -35,9 +35,13 @@ ResetKeySequence = QtGui.QKeySequence( __all__ = ( "CONTEXT_ID", + "CONTEXT_LABEL", "VARIANT_TOOLTIP", + "INPUTS_LAYOUT_HSPACING", + "INPUTS_LAYOUT_VSPACING", + "INSTANCE_ID_ROLE", "SORT_VALUE_ROLE", "IS_GROUP_ROLE", @@ -47,4 +51,6 @@ __all__ = ( "FAMILY_ROLE", "GROUP_ROLE", "CONVERTER_IDENTIFIER_ROLE", + + "ResetKeySequence", ) diff --git a/openpype/tools/publisher/control.py b/openpype/tools/publisher/control.py index 4b083d4bc8..8095d00103 100644 --- a/openpype/tools/publisher/control.py +++ b/openpype/tools/publisher/control.py @@ -47,6 +47,7 @@ PLUGIN_ORDER_OFFSET = 0.5 class CardMessageTypes: standard = None + info = "info" error = "error" @@ -220,7 +221,12 @@ class PublishReportMaker: def _add_plugin_data_item(self, plugin): if plugin in self._stored_plugins: - raise ValueError("Plugin is already stored") + # A plugin would be processed more than once. What can cause it: + # - there is a bug in controller + # - plugin class is imported into multiple files + # - this can happen even with base classes from 'pyblish' + raise ValueError( + "Plugin '{}' is already stored".format(str(plugin))) self._stored_plugins.append(plugin) @@ -239,6 +245,7 @@ class PublishReportMaker: label = plugin.label return { + "id": plugin.id, "name": plugin.__name__, "label": label, "order": plugin.order, @@ -324,7 +331,7 @@ class PublishReportMaker: "instances": instances_details, "context": self._extract_context_data(self._current_context), "crashed_file_paths": crashed_file_paths, - "id": str(uuid.uuid4()), + "id": uuid.uuid4().hex, "report_version": "1.0.0" } @@ -342,7 +349,9 @@ class PublishReportMaker: "label": instance.data.get("label"), "family": instance.data["family"], "families": instance.data.get("families") or [], - "exists": exists + "exists": exists, + "creator_identifier": instance.data.get("creator_identifier"), + "instance_id": instance.data.get("instance_id"), } def _extract_instance_log_items(self, result): @@ -388,8 +397,11 @@ class PublishReportMaker: exception = result.get("error") if exception: fname, line_no, func, exc = exception.traceback + # Action result does not have 'is_validation_error' + is_validation_error = result.get("is_validation_error", False) output.append({ "type": "error", + "is_validation_error": is_validation_error, "msg": str(exception), "filename": str(fname), "lineno": str(line_no), @@ -426,13 +438,15 @@ class PublishPluginsProxy: plugin_id = plugin.id plugins_by_id[plugin_id] = plugin - action_ids = set() + action_ids = [] action_ids_by_plugin_id[plugin_id] = action_ids actions = getattr(plugin, "actions", None) or [] for action in actions: action_id = action.id - action_ids.add(action_id) + if action_id in actions_by_id: + continue + action_ids.append(action_id) actions_by_id[action_id] = action self._plugins_by_id = plugins_by_id @@ -461,7 +475,7 @@ class PublishPluginsProxy: return plugin.id def get_plugin_action_items(self, plugin_id): - """Get plugin action items for plugin by it's id. + """Get plugin action items for plugin by its id. Args: plugin_id (str): Publish plugin id. @@ -568,7 +582,7 @@ class ValidationErrorItem: context_validation, title, description, - detail, + detail ): self.instance_id = instance_id self.instance_label = instance_label @@ -677,6 +691,8 @@ class PublishValidationErrorsReport: for title in titles: grouped_error_items.append({ + "id": uuid.uuid4().hex, + "plugin_id": plugin_id, "plugin_action_items": list(plugin_action_items), "error_items": error_items_by_title[title], "title": title @@ -2379,7 +2395,8 @@ class PublisherController(BasePublisherController): yield MainThreadItem(self.stop_publish) # Add plugin to publish report - self._publish_report.add_plugin_iter(plugin, self._publish_context) + self._publish_report.add_plugin_iter( + plugin, self._publish_context) # WARNING This is hack fix for optional plugins if not self._is_publish_plugin_active(plugin): @@ -2461,14 +2478,14 @@ class PublisherController(BasePublisherController): plugin, self._publish_context, instance ) - self._publish_report.add_result(result) - exception = result.get("error") if exception: + has_validation_error = False if ( isinstance(exception, PublishValidationError) and not self.publish_has_validated ): + has_validation_error = True self._add_validation_error(result) else: @@ -2482,6 +2499,10 @@ class PublisherController(BasePublisherController): self.publish_error_msg = msg self.publish_has_crashed = True + result["is_validation_error"] = has_validation_error + + self._publish_report.add_result(result) + self._publish_next_process() diff --git a/openpype/tools/publisher/publish_report_viewer/widgets.py b/openpype/tools/publisher/publish_report_viewer/widgets.py index dc449b6b69..02c9b63a4e 100644 --- a/openpype/tools/publisher/publish_report_viewer/widgets.py +++ b/openpype/tools/publisher/publish_report_viewer/widgets.py @@ -163,7 +163,11 @@ class ZoomPlainText(QtWidgets.QPlainTextEdit): super(ZoomPlainText, self).wheelEvent(event) return - degrees = float(event.delta()) / 8 + if hasattr(event, "angleDelta"): + delta = event.angleDelta().y() + else: + delta = event.delta() + degrees = float(delta) / 8 steps = int(ceil(degrees / 5)) self._scheduled_scalings += steps if (self._scheduled_scalings * steps < 0): diff --git a/openpype/tools/publisher/widgets/__init__.py b/openpype/tools/publisher/widgets/__init__.py index f18e6cc61e..87a5f3914a 100644 --- a/openpype/tools/publisher/widgets/__init__.py +++ b/openpype/tools/publisher/widgets/__init__.py @@ -18,7 +18,7 @@ from .help_widget import ( from .publish_frame import PublishFrame from .tabs_widget import PublisherTabsWidget from .overview_widget import OverviewWidget -from .validations_widget import ValidationsWidget +from .report_page import ReportPageWidget __all__ = ( @@ -40,5 +40,5 @@ __all__ = ( "PublisherTabsWidget", "OverviewWidget", - "ValidationsWidget", + "ReportPageWidget", ) diff --git a/openpype/tools/publisher/widgets/card_view_widgets.py b/openpype/tools/publisher/widgets/card_view_widgets.py index 13715bc73c..eae8e0420a 100644 --- a/openpype/tools/publisher/widgets/card_view_widgets.py +++ b/openpype/tools/publisher/widgets/card_view_widgets.py @@ -93,7 +93,7 @@ class BaseGroupWidget(QtWidgets.QWidget): return self._group def get_widget_by_item_id(self, item_id): - """Get instance widget by it's id.""" + """Get instance widget by its id.""" return self._widgets_by_id.get(item_id) @@ -702,8 +702,8 @@ class InstanceCardView(AbstractInstanceView): for group_name in sorted_group_names: group_icons = { - idenfier: self._controller.get_creator_icon(idenfier) - for idenfier in identifiers_by_group[group_name] + identifier: self._controller.get_creator_icon(identifier) + for identifier in identifiers_by_group[group_name] } if group_name in self._widgets_by_group: group_widget = self._widgets_by_group[group_name] diff --git a/openpype/tools/publisher/widgets/images/error.png b/openpype/tools/publisher/widgets/images/error.png new file mode 100644 index 0000000000000000000000000000000000000000..7b09a57d7dff44b31b5f18746fa22a42b7d9f15d GIT binary patch literal 14667 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%0DjRy{e1C?ePOpy6!-*e$z;|Xnn(?D zpXQkrLy+DjASj+-wx6t?@ebwUR`0uxctB97l~{FM{J+PyxI!(i%57)%H{wYr^v%Sc zuD@pzD{>YcX>>qj2wnglaUl5^|1)Jgx$pYJQuoBT27 z!HS(8dUMi^=z60EypoUky_~5%M$mxL8 z2R1h>A<69oL)PUk=F_}VsHXhV+s);w-(V*MX|aT0=GY2jm{4v#fZN-9d$Km~UqOX; zb4%faGqpee0khM`5FU*t_mgJ9$((#;S(c_SEOkqj11tove#5k(1E=MV zrt04k$G+Mx3_*o})b*Wd0Tx^67_|8Kbh~i57CU1&5hHe!NzG}N+55G7pG6cut$r(O z4`N7-ISsy2R9lg_^yq^L@IC1WEefb-@J^4rG0Uu=!QK&opnN zp9Ov5g677MUB?iLBadN4ze7gkKA(A3dw?-YbCO;^yE3E{k;+^}a=ZkyG3Er=WF2o% z_YoURGSAYFvox+4-E*~yQ2uE&P1>*h6y9kqk)6)^p_|)Dos;y%7NLy3$FvKj-p6qF zKbBh{;^VXEG-<+f=CI~1SATBqWEYk1_F^4+7DpOS@4{m7{!fL6P`W!M?$F{i_asl~ zQ852cI~rG;wI*`@!?9b@rR6o>c!JHN9iNnJ$}t!@#|ub$Plbj!n|9?_*e1zVzXp!< zkGb~@8NgR?I-Mzs=&?{Y0oZz`a za)sOnG`CdsbCC;y2$yf?K)_2})y=DD(K9a^h>ay?D@VxO4xY8!JkA6P@!|`(Naao) zz=C{oXdbkqw0X)xO)=U?V-Aq#6o8F5y{N`k=dA#023iQ}db)3j3&;G{ z?w@D<_AUX&p~aE|uZ zR?*dVYJ($J-9kr$N(2W5bWQNVL%zu=$)*07y{&LyzIKC)?Z}!t!CuxTQ*X5&j8||HAB_g{)ipOgnE^QtdUK57#q#rTJRX|9ioH!!{ zT42+F$UZ!5EC^V$_fW~>rJ=mnhS2w^$-D?E#Vted$&DFTMVO8X&uaM zFKtAPP<8iv?z{TS(6+$8 zsG#&F1r|-_tV=odOOeSW_LJ@!0E+tM{=d1zt>}-+*Psc&qiuBPmd10&gDOl7c{?AV zwaDkqZ91*NP3n#wTOg=7svLK5C8N{PfyeU zIJHO?boR3Z+?3n^qF?ayxa3#KoZzuXH{$0#PEr?1fcd!LTk;LR=?OfLov4PjNr0}N z(+5#zu)TKSRR`EJcWL2!Tcs{Jd-9^I^vgph=Q=_;71;2hO z5~HB@*;fYUBlW5atjaO=P&6pEr^WE-0=uFwD_VXpOD!(SK;?C;M zCdwc~`WSCt`V{cn=~>ZG%jk1YaP2~jBBQ1j zq`W5VQ201AOgVy8RU99DCzDjeXPFh?dnBw;>&Y-RE+$92Bo%Gf98o5Yp9m?a*lG-dU4LeVWjy^;ab* zkMsD z#hFH5zT)j5cpQB2jvMqK#D}Fdc|U8TZFkw}W+B|ApQ}lwMdno*|L)h&`WHq^VC$!m zef-xD(*iat+noRFk`41}j&uLAG{=P-Ki)VVua-Hy8I>Nl;qc>4&P*WUwqt{kBOBd3 zgZmL#)RHUi;-%8RJ?OcvOc~7{q|t?Rm4y&Bf7V!LQeGB|}rpv{T`-Q!kF=hAMZ=WmoNg-)j+rm`& z1nqA*`7e=1A|K8G-su!9bYkSz%{vybEp1(G7Tjin1#aG{(5i#7tCr$RJOx_?3$E|2 zx+H-)EqQpgws;E#-YSFpN;BGsA>JX<(1V}m@cN0i>YUw*!-_Ozzo=rgFKD5$kAV*T zZd(x$%bnWt(o6^mCGKL6@Pe3!pW9s7c@RAO#hbPz!yOZv?fUrwcAzY<3RBD3j}#JQ zd3bGY9_@P!l`lIbzqr18a(gW|A%07PQ+LHYA%X42(EsQD81vPM&7IXwpVa_16%lan O4Z@q-n^l_-F8mKWCTVE^ literal 0 HcmV?d00001 diff --git a/openpype/tools/publisher/widgets/images/warning.png b/openpype/tools/publisher/widgets/images/warning.png index 76d1e34b6ccf951b166db53cd59d8183cded7586..531f62b741804a2fa48769c17f9e29fbb0cd5429 100644 GIT binary patch literal 11546 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 E3l)7W-~a#s diff --git a/openpype/tools/publisher/widgets/publish_frame.py b/openpype/tools/publisher/widgets/publish_frame.py index e4e6740532..d21130deff 100644 --- a/openpype/tools/publisher/widgets/publish_frame.py +++ b/openpype/tools/publisher/widgets/publish_frame.py @@ -468,45 +468,14 @@ class PublishFrame(QtWidgets.QWidget): widget.setProperty("state", state) widget.style().polish(widget) - def _copy_report(self): - logs = self._controller.get_publish_report() - logs_string = json.dumps(logs, indent=4) - - mime_data = QtCore.QMimeData() - mime_data.setText(logs_string) - QtWidgets.QApplication.instance().clipboard().setMimeData( - mime_data - ) - - def _export_report(self): - default_filename = "publish-report-{}".format( - time.strftime("%y%m%d-%H-%M") - ) - default_filepath = os.path.join( - os.path.expanduser("~"), - default_filename - ) - new_filepath, ext = QtWidgets.QFileDialog.getSaveFileName( - self, "Save report", default_filepath, ".json" - ) - if not ext or not new_filepath: - return - - logs = self._controller.get_publish_report() - full_path = new_filepath + ext - dir_path = os.path.dirname(full_path) - if not os.path.exists(dir_path): - os.makedirs(dir_path) - - with open(full_path, "w") as file_stream: - json.dump(logs, file_stream) - def _on_report_triggered(self, identifier): if identifier == "export_report": - self._export_report() + self._controller.event_system.emit( + "export_report.request", {}, "publish_frame") elif identifier == "copy_report": - self._copy_report() + self._controller.event_system.emit( + "copy_report.request", {}, "publish_frame") elif identifier == "go_to_report": self.details_page_requested.emit() diff --git a/openpype/tools/publisher/widgets/report_page.py b/openpype/tools/publisher/widgets/report_page.py new file mode 100644 index 0000000000..50a619f0a8 --- /dev/null +++ b/openpype/tools/publisher/widgets/report_page.py @@ -0,0 +1,1876 @@ +# -*- coding: utf-8 -*- +import collections +import logging + +try: + import commonmark +except Exception: + commonmark = None + +from qtpy import QtWidgets, QtCore, QtGui + +from openpype.style import get_objected_colors +from openpype.tools.utils import ( + BaseClickableFrame, + ClickableFrame, + ExpandingTextEdit, + FlowLayout, + ClassicExpandBtn, + paint_image_with_color, + SeparatorWidget, +) +from .widgets import IconValuePixmapLabel +from .icons import ( + get_pixmap, + get_image, +) +from ..constants import ( + INSTANCE_ID_ROLE, + CONTEXT_ID, + CONTEXT_LABEL, +) + +LOG_DEBUG_VISIBLE = 1 << 0 +LOG_INFO_VISIBLE = 1 << 1 +LOG_WARNING_VISIBLE = 1 << 2 +LOG_ERROR_VISIBLE = 1 << 3 +LOG_CRITICAL_VISIBLE = 1 << 4 +ERROR_VISIBLE = 1 << 5 +INFO_VISIBLE = 1 << 6 + + +class VerticalScrollArea(QtWidgets.QScrollArea): + """Scroll area for validation error titles. + + The biggest difference is that the scroll area has scroll bar on left side + and resize of content will also resize scrollarea itself. + + Resize if deferred by 100ms because at the moment of resize are not yet + propagated sizes and visibility of scroll bars. + """ + + def __init__(self, *args, **kwargs): + super(VerticalScrollArea, self).__init__(*args, **kwargs) + + self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) + self.setLayoutDirection(QtCore.Qt.RightToLeft) + + self.setAttribute(QtCore.Qt.WA_TranslucentBackground) + # Background of scrollbar will be transparent + scrollbar_bg = self.verticalScrollBar().parent() + if scrollbar_bg: + scrollbar_bg.setAttribute(QtCore.Qt.WA_TranslucentBackground) + self.setViewportMargins(0, 0, 0, 0) + + self.verticalScrollBar().installEventFilter(self) + + # Timer with 100ms offset after changing size + size_changed_timer = QtCore.QTimer() + size_changed_timer.setInterval(100) + size_changed_timer.setSingleShot(True) + + size_changed_timer.timeout.connect(self._on_timer_timeout) + self._size_changed_timer = size_changed_timer + + def setVerticalScrollBar(self, widget): + old_widget = self.verticalScrollBar() + if old_widget: + old_widget.removeEventFilter(self) + + super(VerticalScrollArea, self).setVerticalScrollBar(widget) + if widget: + widget.installEventFilter(self) + + def setWidget(self, widget): + old_widget = self.widget() + if old_widget: + old_widget.removeEventFilter(self) + + super(VerticalScrollArea, self).setWidget(widget) + if widget: + widget.installEventFilter(self) + + def _on_timer_timeout(self): + width = self.widget().width() + if self.verticalScrollBar().isVisible(): + width += self.verticalScrollBar().width() + self.setMinimumWidth(width) + + def eventFilter(self, obj, event): + if ( + event.type() == QtCore.QEvent.Resize + and (obj is self.widget() or obj is self.verticalScrollBar()) + ): + self._size_changed_timer.start() + return super(VerticalScrollArea, self).eventFilter(obj, event) + + +# --- Publish actions widget --- +class ActionButton(BaseClickableFrame): + """Plugin's action callback button. + + Action may have label or icon or both. + + Args: + plugin_action_item (PublishPluginActionItem): Action item that can be + triggered by its id. + """ + + action_clicked = QtCore.Signal(str, str) + + def __init__(self, plugin_action_item, parent): + super(ActionButton, self).__init__(parent) + + self.setObjectName("ValidationActionButton") + + self.plugin_action_item = plugin_action_item + + action_label = plugin_action_item.label + action_icon = plugin_action_item.icon + label_widget = QtWidgets.QLabel(action_label, self) + icon_label = None + if action_icon: + icon_label = IconValuePixmapLabel(action_icon, self) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(5, 0, 5, 0) + layout.addWidget(label_widget, 1) + if icon_label: + layout.addWidget(icon_label, 0) + + self.setSizePolicy( + QtWidgets.QSizePolicy.Minimum, + self.sizePolicy().verticalPolicy() + ) + + def _mouse_release_callback(self): + self.action_clicked.emit( + self.plugin_action_item.plugin_id, + self.plugin_action_item.action_id + ) + + +class ValidateActionsWidget(QtWidgets.QFrame): + """Wrapper widget for plugin actions. + + Change actions based on selected validation error. + """ + + def __init__(self, controller, parent): + super(ValidateActionsWidget, self).__init__(parent) + + self.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + content_widget = QtWidgets.QWidget(self) + content_layout = FlowLayout(content_widget) + content_layout.setContentsMargins(0, 0, 0, 0) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(content_widget) + + self._controller = controller + self._content_widget = content_widget + self._content_layout = content_layout + + self._actions_mapping = {} + + self._visible_mode = True + + def _update_visibility(self): + self.setVisible( + self._visible_mode + and self._content_layout.count() > 0 + ) + + def set_visible_mode(self, visible): + if self._visible_mode is visible: + return + self._visible_mode = visible + self._update_visibility() + + def _clear(self): + """Remove actions from widget.""" + while self._content_layout.count(): + item = self._content_layout.takeAt(0) + widget = item.widget() + if widget: + widget.setVisible(False) + widget.deleteLater() + self._actions_mapping = {} + + def set_error_info(self, error_info): + """Set selected plugin and show it's actions. + + Clears current actions from widget and recreate them from the plugin. + + Args: + Dict[str, Any]: Object holding error items, title and possible + actions to run. + """ + + self._clear() + + if not error_info: + self.setVisible(False) + return + + plugin_action_items = error_info["plugin_action_items"] + for plugin_action_item in plugin_action_items: + if not plugin_action_item.active: + continue + + if plugin_action_item.on_filter not in ("failed", "all"): + continue + + action_id = plugin_action_item.action_id + self._actions_mapping[action_id] = plugin_action_item + + action_btn = ActionButton(plugin_action_item, self._content_widget) + action_btn.action_clicked.connect(self._on_action_click) + self._content_layout.addWidget(action_btn) + + self._update_visibility() + + def _on_action_click(self, plugin_id, action_id): + self._controller.run_action(plugin_id, action_id) + + +# --- Validation error titles --- +class ValidationErrorInstanceList(QtWidgets.QListView): + """List of publish instances that caused a validation error. + + Instances are collected per plugin's validation error title. + """ + def __init__(self, *args, **kwargs): + super(ValidationErrorInstanceList, self).__init__(*args, **kwargs) + + self.setObjectName("ValidationErrorInstanceList") + + self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) + + def minimumSizeHint(self): + return self.sizeHint() + + def sizeHint(self): + result = super(ValidationErrorInstanceList, self).sizeHint() + row_count = self.model().rowCount() + height = 0 + if row_count > 0: + height = self.sizeHintForRow(0) * row_count + result.setHeight(height) + return result + + +class ValidationErrorTitleWidget(QtWidgets.QWidget): + """Title of validation error. + + Widget is used as radio button so requires clickable functionality and + changing style on selection/deselection. + + Has toggle button to show/hide instances on which validation error happened + if there is a list (Valdation error may happen on context). + """ + + selected = QtCore.Signal(str) + instance_changed = QtCore.Signal(str) + + def __init__(self, title_id, error_info, parent): + super(ValidationErrorTitleWidget, self).__init__(parent) + + self._title_id = title_id + self._error_info = error_info + self._selected = False + + title_frame = ClickableFrame(self) + title_frame.setObjectName("ValidationErrorTitleFrame") + + toggle_instance_btn = QtWidgets.QToolButton(title_frame) + toggle_instance_btn.setObjectName("ArrowBtn") + toggle_instance_btn.setArrowType(QtCore.Qt.RightArrow) + toggle_instance_btn.setMaximumWidth(14) + + label_widget = QtWidgets.QLabel(error_info["title"], title_frame) + + title_frame_layout = QtWidgets.QHBoxLayout(title_frame) + title_frame_layout.addWidget(label_widget, 1) + title_frame_layout.addWidget(toggle_instance_btn, 0) + + instances_model = QtGui.QStandardItemModel() + + instance_ids = [] + + items = [] + context_validation = False + for error_item in error_info["error_items"]: + context_validation = error_item.context_validation + if context_validation: + toggle_instance_btn.setArrowType(QtCore.Qt.NoArrow) + instance_ids.append(CONTEXT_ID) + # Add fake item to have minimum size hint of view widget + items.append(QtGui.QStandardItem(CONTEXT_LABEL)) + continue + + label = error_item.instance_label + item = QtGui.QStandardItem(label) + item.setFlags( + QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable + ) + item.setData(label, QtCore.Qt.ToolTipRole) + item.setData(error_item.instance_id, INSTANCE_ID_ROLE) + items.append(item) + instance_ids.append(error_item.instance_id) + + if items: + root_item = instances_model.invisibleRootItem() + root_item.appendRows(items) + + instances_view = ValidationErrorInstanceList(self) + instances_view.setModel(instances_model) + + self.setLayoutDirection(QtCore.Qt.LeftToRight) + + view_widget = QtWidgets.QWidget(self) + view_layout = QtWidgets.QHBoxLayout(view_widget) + view_layout.setContentsMargins(0, 0, 0, 0) + view_layout.setSpacing(0) + view_layout.addSpacing(14) + view_layout.addWidget(instances_view, 0) + + layout = QtWidgets.QVBoxLayout(self) + layout.setSpacing(0) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(title_frame, 0) + layout.addWidget(view_widget, 0) + view_widget.setVisible(False) + + if not context_validation: + toggle_instance_btn.clicked.connect(self._on_toggle_btn_click) + + title_frame.clicked.connect(self._mouse_release_callback) + instances_view.selectionModel().selectionChanged.connect( + self._on_selection_change + ) + + self._title_frame = title_frame + + self._toggle_instance_btn = toggle_instance_btn + + self._view_widget = view_widget + + self._instances_model = instances_model + self._instances_view = instances_view + + self._context_validation = context_validation + + self._instance_ids = instance_ids + self._expanded = False + + def sizeHint(self): + result = super(ValidationErrorTitleWidget, self).sizeHint() + expected_width = max( + self._view_widget.minimumSizeHint().width(), + self._view_widget.sizeHint().width() + ) + + if expected_width < 200: + expected_width = 200 + + if result.width() < expected_width: + result.setWidth(expected_width) + + return result + + def minimumSizeHint(self): + return self.sizeHint() + + def _mouse_release_callback(self): + """Mark this widget as selected on click.""" + + self.set_selected(True) + + @property + def is_selected(self): + """Is widget marked a selected. + + Returns: + bool: Item is selected or not. + """ + + return self._selected + + @property + def id(self): + return self._title_id + + def _change_style_property(self, selected): + """Change style of widget based on selection.""" + + value = "1" if selected else "" + self._title_frame.setProperty("selected", value) + self._title_frame.style().polish(self._title_frame) + + def set_selected(self, selected=None): + """Change selected state of widget.""" + + if selected is None: + selected = not self._selected + + # Clear instance view selection on deselect + if not selected: + self._instances_view.clearSelection() + + # Skip if has same value + if selected == self._selected: + return + + self._selected = selected + self._change_style_property(selected) + if selected: + self.selected.emit(self._title_id) + self._set_expanded(True) + + def _on_toggle_btn_click(self): + """Show/hide instances list.""" + + self._set_expanded() + + def _set_expanded(self, expanded=None): + if expanded is None: + expanded = not self._expanded + + elif expanded is self._expanded: + return + + if expanded and self._context_validation: + return + + self._expanded = expanded + self._view_widget.setVisible(expanded) + if expanded: + self._toggle_instance_btn.setArrowType(QtCore.Qt.DownArrow) + else: + self._toggle_instance_btn.setArrowType(QtCore.Qt.RightArrow) + + def _on_selection_change(self): + self.instance_changed.emit(self._title_id) + + def get_selected_instances(self): + if self._context_validation: + return [CONTEXT_ID] + sel_model = self._instances_view.selectionModel() + return [ + index.data(INSTANCE_ID_ROLE) + for index in sel_model.selectedIndexes() + if index.isValid() + ] + + def get_available_instances(self): + return list(self._instance_ids) + + +class ValidationArtistMessage(QtWidgets.QWidget): + def __init__(self, message, parent): + super(ValidationArtistMessage, self).__init__(parent) + + artist_msg_label = QtWidgets.QLabel(message, self) + artist_msg_label.setAlignment(QtCore.Qt.AlignCenter) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget( + artist_msg_label, 1, QtCore.Qt.AlignCenter + ) + + +class ValidationErrorsView(QtWidgets.QWidget): + selection_changed = QtCore.Signal() + + def __init__(self, parent): + super(ValidationErrorsView, self).__init__(parent) + + errors_scroll = VerticalScrollArea(self) + errors_scroll.setWidgetResizable(True) + + errors_widget = QtWidgets.QWidget(errors_scroll) + errors_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + errors_scroll.setWidget(errors_widget) + + errors_layout = QtWidgets.QVBoxLayout(errors_widget) + errors_layout.setContentsMargins(0, 0, 0, 0) + + layout = QtWidgets.QVBoxLayout(self) + layout.addWidget(errors_scroll, 1) + + self._errors_widget = errors_widget + self._errors_layout = errors_layout + self._title_widgets = {} + self._previous_select = None + + def _clear(self): + """Delete all dynamic widgets and hide all wrappers.""" + + self._title_widgets = {} + self._previous_select = None + while self._errors_layout.count(): + item = self._errors_layout.takeAt(0) + widget = item.widget() + if widget: + widget.deleteLater() + + def set_errors(self, grouped_error_items): + """Set errors into context and created titles. + + Args: + validation_error_report (PublishValidationErrorsReport): Report + with information about validation errors and publish plugin + actions. + """ + + self._clear() + + first_id = None + for title_item in grouped_error_items: + title_id = title_item["id"] + if first_id is None: + first_id = title_id + widget = ValidationErrorTitleWidget(title_id, title_item, self) + widget.selected.connect(self._on_select) + widget.instance_changed.connect(self._on_instance_change) + self._errors_layout.addWidget(widget) + self._title_widgets[title_id] = widget + + self._errors_layout.addStretch(1) + + if first_id: + self._title_widgets[first_id].set_selected(True) + else: + self.selection_changed.emit() + + self.updateGeometry() + + def _on_select(self, title_id): + if self._previous_select: + if self._previous_select.id == title_id: + return + self._previous_select.set_selected(False) + + self._previous_select = self._title_widgets[title_id] + self.selection_changed.emit() + + def _on_instance_change(self, title_id): + if self._previous_select and self._previous_select.id != title_id: + self._title_widgets[title_id].set_selected(True) + else: + self.selection_changed.emit() + + def get_selected_items(self): + if not self._previous_select: + return None, [] + + title_id = self._previous_select.id + instance_ids = self._previous_select.get_selected_instances() + if not instance_ids: + instance_ids = self._previous_select.get_available_instances() + return title_id, instance_ids + + +# ----- Publish instance report ----- +class _InstanceItem: + """Publish instance item for report UI. + + Contains only data related to an instance in publishing. Has implemented + sorting methods and prepares information, e.g. if contains error or + warnings. + """ + + _attrs = ( + "creator_identifier", + "family", + "label", + "name", + ) + + def __init__( + self, + instance_id, + creator_identifier, + family, + name, + label, + exists, + logs, + errored, + warned + ): + self.id = instance_id + self.creator_identifier = creator_identifier + self.family = family + self.name = name + self.label = label + self.exists = exists + self.logs = logs + self.errored = errored + self.warned = warned + + def __eq__(self, other): + for attr in self._attrs: + if getattr(self, attr) != getattr(other, attr): + return False + return True + + def __ne__(self, other): + return not self.__eq__(other) + + def __gt__(self, other): + for attr in self._attrs: + self_value = getattr(self, attr) + other_value = getattr(other, attr) + if self_value == other_value: + continue + values = [self_value, other_value] + values.sort() + return values[0] == other_value + return None + + def __lt__(self, other): + for attr in self._attrs: + self_value = getattr(self, attr) + other_value = getattr(other, attr) + if self_value == other_value: + continue + if self_value is None: + return False + if other_value is None: + return True + values = [self_value, other_value] + values.sort() + return values[0] == self_value + return None + + def __ge__(self, other): + if self == other: + return True + return self.__gt__(other) + + def __le__(self, other): + if self == other: + return True + return self.__lt__(other) + + @classmethod + def from_report(cls, instance_id, instance_data, logs): + errored, warned = cls.extract_basic_log_info(logs) + + return cls( + instance_id, + instance_data["creator_identifier"], + instance_data["family"], + instance_data["name"], + instance_data["label"], + instance_data["exists"], + logs, + errored, + warned, + ) + + @classmethod + def create_context_item(cls, context_label, logs): + errored, warned = cls.extract_basic_log_info(logs) + return cls( + CONTEXT_ID, + None, + "", + CONTEXT_LABEL, + context_label, + True, + logs, + errored, + warned + ) + + @staticmethod + def extract_basic_log_info(logs): + warned = False + errored = False + for log in logs: + if log["type"] == "error": + errored = True + elif log["type"] == "record": + level_no = log["levelno"] + if level_no and level_no >= logging.WARNING: + warned = True + + if warned and errored: + break + return errored, warned + + +class FamilyGroupLabel(QtWidgets.QWidget): + def __init__(self, family, parent): + super(FamilyGroupLabel, self).__init__(parent) + + self.setLayoutDirection(QtCore.Qt.LeftToRight) + + label_widget = QtWidgets.QLabel(family, self) + + line_widget = QtWidgets.QWidget(self) + line_widget.setObjectName("Separator") + line_widget.setMinimumHeight(2) + line_widget.setMaximumHeight(2) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setAlignment(QtCore.Qt.AlignVCenter) + main_layout.setSpacing(10) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(label_widget, 0) + main_layout.addWidget(line_widget, 1) + + +class PublishInstanceCardWidget(BaseClickableFrame): + selection_requested = QtCore.Signal(str) + + _warning_pix = None + _error_pix = None + _success_pix = None + _in_progress_pix = None + + def __init__(self, instance, icon, publish_finished, parent): + super(PublishInstanceCardWidget, self).__init__(parent) + + self.setObjectName("CardViewWidget") + + icon_widget = IconValuePixmapLabel(icon, self) + icon_widget.setObjectName("FamilyIconLabel") + + label_widget = QtWidgets.QLabel(instance.label, self) + + if instance.errored: + state_pix = self.get_error_pix() + elif instance.warned: + state_pix = self.get_warning_pix() + elif publish_finished: + state_pix = self.get_success_pix() + else: + state_pix = self.get_in_progress_pix() + + state_label = IconValuePixmapLabel(state_pix, self) + + layout = QtWidgets.QHBoxLayout(self) + layout.setContentsMargins(10, 7, 10, 7) + layout.addWidget(icon_widget, 0) + layout.addWidget(label_widget, 1) + layout.addWidget(state_label, 0) + + # Change direction -> parent is scroll area where scrolls are on + # left side + self.setLayoutDirection(QtCore.Qt.LeftToRight) + + self._id = instance.id + + self._selected = False + + self._update_style_state() + + @classmethod + def _prepare_pixes(cls): + publisher_colors = get_objected_colors("publisher") + cls._warning_pix = paint_image_with_color( + get_image("warning"), + publisher_colors["warning"].get_qcolor() + ) + cls._error_pix = paint_image_with_color( + get_image("error"), + publisher_colors["error"].get_qcolor() + ) + cls._success_pix = paint_image_with_color( + get_image("success"), + publisher_colors["success"].get_qcolor() + ) + cls._in_progress_pix = paint_image_with_color( + get_image("success"), + publisher_colors["progress"].get_qcolor() + ) + + @classmethod + def get_warning_pix(cls): + if cls._warning_pix is None: + cls._prepare_pixes() + return cls._warning_pix + + @classmethod + def get_error_pix(cls): + if cls._error_pix is None: + cls._prepare_pixes() + return cls._error_pix + + @classmethod + def get_success_pix(cls): + if cls._success_pix is None: + cls._prepare_pixes() + return cls._success_pix + + @classmethod + def get_in_progress_pix(cls): + if cls._in_progress_pix is None: + cls._prepare_pixes() + return cls._in_progress_pix + + @property + def id(self): + """Id of card. + + Returns: + str: Id of item. + """ + + return self._id + + @property + def is_selected(self): + """Is card selected. + + Returns: + bool: Item widget is marked as selected. + """ + + return self._selected + + def set_selected(self, selected): + """Set card as selected. + + Args: + selected (bool): Item should be marked as selected. + """ + + if selected == self._selected: + return + self._selected = selected + self._update_style_state() + + def _update_style_state(self): + state = "" + if self._selected: + state = "selected" + + self.setProperty("state", state) + self.style().polish(self) + + def _mouse_release_callback(self): + """Trigger selected signal.""" + + self.selection_requested.emit(self.id) + + +class PublishInstancesViewWidget(QtWidgets.QWidget): + # Sane minimum width of instance cards - size calulated using font metrics + _min_width_measure_string = 24 * "O" + selection_changed = QtCore.Signal() + + def __init__(self, controller, parent): + super(PublishInstancesViewWidget, self).__init__(parent) + + scroll_area = VerticalScrollArea(self) + scroll_area.setWidgetResizable(True) + scroll_area.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + scroll_area.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) + scrollbar_bg = scroll_area.verticalScrollBar().parent() + if scrollbar_bg: + scrollbar_bg.setAttribute(QtCore.Qt.WA_TranslucentBackground) + scroll_area.setViewportMargins(0, 0, 0, 0) + + instance_view = QtWidgets.QWidget(scroll_area) + + scroll_area.setWidget(instance_view) + + instance_layout = QtWidgets.QVBoxLayout(instance_view) + instance_layout.setContentsMargins(0, 0, 0, 0) + instance_layout.addStretch(1) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(scroll_area, 1) + + self._controller = controller + self._scroll_area = scroll_area + self._instance_view = instance_view + self._instance_layout = instance_layout + + self._context_widget = None + + self._widgets_by_instance_id = {} + self._group_widgets = [] + self._ordered_widgets = [] + + self._explicitly_selected_instance_ids = [] + + self.setSizePolicy( + QtWidgets.QSizePolicy.Minimum, + self.sizePolicy().verticalPolicy() + ) + + def sizeHint(self): + """Modify sizeHint based on visibility of scroll bars.""" + # Calculate width hint by content widget and vertical scroll bar + scroll_bar = self._scroll_area.verticalScrollBar() + view_size = self._instance_view.sizeHint().width() + fm = self._instance_view.fontMetrics() + width = ( + max(view_size, fm.width(self._min_width_measure_string)) + + scroll_bar.sizeHint().width() + ) + + result = super(PublishInstancesViewWidget, self).sizeHint() + result.setWidth(width) + return result + + def _get_selected_widgets(self): + return [ + widget + for widget in self._ordered_widgets + if widget.is_selected + ] + + def get_selected_instance_ids(self): + return [ + widget.id + for widget in self._get_selected_widgets() + ] + + def clear(self): + """Remove actions from widget.""" + while self._instance_layout.count(): + item = self._instance_layout.takeAt(0) + widget = item.widget() + if widget: + widget.setVisible(False) + widget.deleteLater() + self._ordered_widgets = [] + self._group_widgets = [] + self._widgets_by_instance_id = {} + + def update_instances(self, instance_items): + self.clear() + identifiers = { + instance_item.creator_identifier + for instance_item in instance_items + } + identifier_icons = { + identifier: self._controller.get_creator_icon(identifier) + for identifier in identifiers + } + + widgets = [] + group_widgets = [] + + publish_finished = ( + self._controller.publish_has_crashed + or self._controller.publish_has_validation_errors + or self._controller.publish_has_finished + ) + instances_by_family = collections.defaultdict(list) + for instance_item in instance_items: + if not instance_item.exists: + continue + instances_by_family[instance_item.family].append(instance_item) + + sorted_by_family = sorted( + instances_by_family.items(), key=lambda i: i[0] + ) + for family, instance_items in sorted_by_family: + # Only instance without family is context + if family: + group_widget = FamilyGroupLabel(family, self._instance_view) + self._instance_layout.addWidget(group_widget, 0) + group_widgets.append(group_widget) + + sorted_items = sorted(instance_items, key=lambda i: i.label) + for instance_item in sorted_items: + icon = identifier_icons[instance_item.creator_identifier] + + widget = PublishInstanceCardWidget( + instance_item, icon, publish_finished, self._instance_view + ) + widget.selection_requested.connect(self._on_selection_request) + self._instance_layout.addWidget(widget, 0) + + widgets.append(widget) + self._widgets_by_instance_id[widget.id] = widget + self._instance_layout.addStretch(1) + self._ordered_widgets = widgets + self._group_widgets = group_widgets + + def _on_selection_request(self, instance_id): + instance_widget = self._widgets_by_instance_id[instance_id] + selected_widgets = self._get_selected_widgets() + if instance_widget in selected_widgets: + instance_widget.set_selected(False) + else: + instance_widget.set_selected(True) + for widget in selected_widgets: + widget.set_selected(False) + self.selection_changed.emit() + + +class LogIconFrame(QtWidgets.QFrame): + """Draw log item icon next to message. + + Todos: + Paint event could be slow, maybe we could cache the image into pixmaps + so each item does not have to redraw it again. + """ + + info_color = QtGui.QColor("#ffffff") + error_color = QtGui.QColor("#ff4a4a") + level_to_color = dict(( + (10, QtGui.QColor("#ff66e8")), + (20, QtGui.QColor("#66abff")), + (30, QtGui.QColor("#ffba66")), + (40, QtGui.QColor("#ff4d58")), + (50, QtGui.QColor("#ff4f75")), + )) + _error_pix = None + _validation_error_pix = None + + def __init__(self, parent, log_type, log_level, is_validation_error): + super(LogIconFrame, self).__init__(parent) + + self.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + self._is_record = log_type == "record" + self._is_error = log_type == "error" + self._is_validation_error = bool(is_validation_error) + self._log_color = self.level_to_color.get(log_level) + + @classmethod + def get_validation_error_icon(cls): + if cls._validation_error_pix is None: + cls._validation_error_pix = get_pixmap("warning") + return cls._validation_error_pix + + @classmethod + def get_error_icon(cls): + if cls._error_pix is None: + cls._error_pix = get_pixmap("error") + return cls._error_pix + + def minimumSizeHint(self): + fm = self.fontMetrics() + size = fm.height() + return QtCore.QSize(size, size) + + def paintEvent(self, event): + painter = QtGui.QPainter(self) + painter.setRenderHints( + QtGui.QPainter.Antialiasing + | QtGui.QPainter.SmoothPixmapTransform + ) + painter.setPen(QtCore.Qt.NoPen) + rect = self.rect() + new_size = min(rect.width(), rect.height()) + new_rect = QtCore.QRect(1, 1, new_size - 2, new_size - 2) + if self._is_error: + if self._is_validation_error: + error_icon = self.get_validation_error_icon() + else: + error_icon = self.get_error_icon() + scaled_error_icon = error_icon.scaled( + new_rect.size(), + QtCore.Qt.KeepAspectRatio, + QtCore.Qt.SmoothTransformation + ) + painter.drawPixmap(new_rect, scaled_error_icon) + + else: + if self._is_record: + color = self._log_color + else: + color = QtGui.QColor(255, 255, 255) + painter.setBrush(color) + painter.drawEllipse(new_rect) + painter.end() + + +class LogItemWidget(QtWidgets.QWidget): + log_level_to_flag = { + 10: LOG_DEBUG_VISIBLE, + 20: LOG_INFO_VISIBLE, + 30: LOG_WARNING_VISIBLE, + 40: LOG_ERROR_VISIBLE, + 50: LOG_CRITICAL_VISIBLE, + } + + def __init__(self, log, parent): + super(LogItemWidget, self).__init__(parent) + + type_flag, level_n = self._get_log_info(log) + icon_label = LogIconFrame( + self, log["type"], level_n, log.get("is_validation_error")) + message_label = QtWidgets.QLabel(log["msg"].rstrip(), self) + message_label.setObjectName("PublishLogMessage") + message_label.setTextInteractionFlags( + QtCore.Qt.TextBrowserInteraction) + message_label.setCursor(QtGui.QCursor(QtCore.Qt.IBeamCursor)) + message_label.setWordWrap(True) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.setSpacing(8) + main_layout.addWidget(icon_label, 0) + main_layout.addWidget(message_label, 1) + + self._type_flag = type_flag + self._plugin_id = log["plugin_id"] + self._log_type_filtered = False + self._plugin_filtered = False + + @property + def type_flag(self): + return self._type_flag + + @property + def plugin_id(self): + return self._plugin_id + + def _get_log_info(self, log): + log_type = log["type"] + if log_type == "error": + return ERROR_VISIBLE, None + + if log_type != "record": + return INFO_VISIBLE, None + + level_n = log["levelno"] + if level_n < 10: + level_n = 10 + elif level_n % 10 != 0: + level_n -= (level_n % 10) + 10 + + flag = self.log_level_to_flag.get(level_n, LOG_CRITICAL_VISIBLE) + return flag, level_n + + def _update_visibility(self): + self.setVisible( + not self._log_type_filtered + and not self._plugin_filtered + ) + + def set_log_type_filtered(self, filtered): + if filtered is self._log_type_filtered: + return + self._log_type_filtered = filtered + self._update_visibility() + + def set_plugin_filtered(self, filtered): + if filtered is self._plugin_filtered: + return + self._plugin_filtered = filtered + self._update_visibility() + + +class LogsWithIconsView(QtWidgets.QWidget): + """Show logs in a grid with 2 columns. + + First column is for icon second is for message. + + Todos: + Add filtering by type (exception, debug, info, etc.). + """ + + def __init__(self, logs, parent): + super(LogsWithIconsView, self).__init__(parent) + self.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + logs_layout = QtWidgets.QVBoxLayout(self) + logs_layout.setContentsMargins(0, 0, 0, 0) + logs_layout.setSpacing(4) + + widgets_by_flag = collections.defaultdict(list) + widgets_by_plugins_id = collections.defaultdict(list) + + for log in logs: + widget = LogItemWidget(log, self) + widgets_by_flag[widget.type_flag].append(widget) + widgets_by_plugins_id[widget.plugin_id].append(widget) + logs_layout.addWidget(widget, 0) + + self._widgets_by_flag = widgets_by_flag + self._widgets_by_plugins_id = widgets_by_plugins_id + + self._visibility_by_flags = { + LOG_DEBUG_VISIBLE: True, + LOG_INFO_VISIBLE: True, + LOG_WARNING_VISIBLE: True, + LOG_ERROR_VISIBLE: True, + LOG_CRITICAL_VISIBLE: True, + ERROR_VISIBLE: True, + INFO_VISIBLE: True, + } + self._flags_filter = sum(self._visibility_by_flags.keys()) + self._plugin_ids_filter = None + + def _update_flags_filtering(self): + for flag in ( + LOG_DEBUG_VISIBLE, + LOG_INFO_VISIBLE, + LOG_WARNING_VISIBLE, + LOG_ERROR_VISIBLE, + LOG_CRITICAL_VISIBLE, + ERROR_VISIBLE, + INFO_VISIBLE, + ): + visible = (self._flags_filter & flag) != 0 + if visible is not self._visibility_by_flags[flag]: + self._visibility_by_flags[flag] = visible + for widget in self._widgets_by_flag[flag]: + widget.set_log_type_filtered(not visible) + + def _update_plugin_filtering(self): + if self._plugin_ids_filter is None: + for widgets in self._widgets_by_plugins_id.values(): + for widget in widgets: + widget.set_plugin_filtered(False) + + else: + for plugin_id, widgets in self._widgets_by_plugins_id.items(): + filtered = plugin_id not in self._plugin_ids_filter + for widget in widgets: + widget.set_plugin_filtered(filtered) + + def set_log_filters(self, visibility_filter, plugin_ids): + if self._flags_filter != visibility_filter: + self._flags_filter = visibility_filter + self._update_flags_filtering() + + if self._plugin_ids_filter != plugin_ids: + if plugin_ids is not None: + plugin_ids = set(plugin_ids) + self._plugin_ids_filter = plugin_ids + self._update_plugin_filtering() + + +class InstanceLogsWidget(QtWidgets.QWidget): + """Widget showing logs of one publish instance. + + Args: + instance (_InstanceItem): Item of instance used as data source. + parent (QtWidgets.QWidget): Parent widget. + """ + + def __init__(self, instance, parent): + super(InstanceLogsWidget, self).__init__(parent) + + self.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + label_widget = QtWidgets.QLabel(instance.label, self) + label_widget.setObjectName("PublishInstanceLogsLabel") + logs_grid = LogsWithIconsView(instance.logs, self) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(label_widget, 0) + layout.addWidget(logs_grid, 0) + + self._logs_grid = logs_grid + + def set_log_filters(self, visibility_filter, plugin_ids): + """Change logs filter. + + Args: + visibility_filter (int): Number contained of flags for each log + type and level. + plugin_ids (Iterable[str]): Plugin ids to which are logs filtered. + """ + + self._logs_grid.set_log_filters(visibility_filter, plugin_ids) + + +class InstancesLogsView(QtWidgets.QFrame): + """Publish instances logs view widget.""" + + def __init__(self, parent): + super(InstancesLogsView, self).__init__(parent) + self.setObjectName("InstancesLogsView") + + scroll_area = QtWidgets.QScrollArea(self) + scroll_area.setWidgetResizable(True) + scroll_area.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + scroll_area.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) + scroll_area.setAttribute(QtCore.Qt.WA_TranslucentBackground) + scrollbar_bg = scroll_area.verticalScrollBar().parent() + if scrollbar_bg: + scrollbar_bg.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + content_wrap_widget = QtWidgets.QWidget(scroll_area) + content_wrap_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + content_widget = QtWidgets.QWidget(content_wrap_widget) + content_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) + content_layout = QtWidgets.QVBoxLayout(content_widget) + content_layout.setSpacing(15) + + scroll_area.setWidget(content_wrap_widget) + + content_wrap_layout = QtWidgets.QVBoxLayout(content_wrap_widget) + content_wrap_layout.setContentsMargins(0, 0, 0, 0) + content_wrap_layout.addWidget(content_widget, 0) + content_wrap_layout.addStretch(1) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(scroll_area, 1) + + self._visible_filters = ( + LOG_INFO_VISIBLE + | LOG_WARNING_VISIBLE + | LOG_ERROR_VISIBLE + | LOG_CRITICAL_VISIBLE + | ERROR_VISIBLE + | INFO_VISIBLE + ) + + self._content_widget = content_widget + self._content_layout = content_layout + + self._instances_order = [] + self._instances_by_id = {} + self._views_by_instance_id = {} + self._is_showed = False + self._clear_needed = False + self._update_needed = False + self._instance_ids_filter = [] + self._plugin_ids_filter = None + + def showEvent(self, event): + super(InstancesLogsView, self).showEvent(event) + self._is_showed = True + self._update_instances() + + def hideEvent(self, event): + super(InstancesLogsView, self).hideEvent(event) + self._is_showed = False + + def closeEvent(self, event): + super(InstancesLogsView, self).closeEvent(event) + self._is_showed = False + + def _update_instances(self): + if not self._is_showed: + return + + if self._clear_needed: + self._clear_widgets() + self._clear_needed = False + + if not self._update_needed: + return + self._update_needed = False + + instance_ids = self._instance_ids_filter + to_hide = set() + if not instance_ids: + instance_ids = self._instances_by_id + else: + to_hide = set(self._instances_by_id) - set(instance_ids) + + for instance_id in instance_ids: + widget = self._views_by_instance_id.get(instance_id) + if widget is None: + instance = self._instances_by_id[instance_id] + widget = InstanceLogsWidget(instance, self._content_widget) + self._views_by_instance_id[instance_id] = widget + self._content_layout.addWidget(widget, 0) + + widget.setVisible(True) + widget.set_log_filters( + self._visible_filters, self._plugin_ids_filter + ) + + for instance_id in to_hide: + widget = self._views_by_instance_id.get(instance_id) + if widget is not None: + widget.setVisible(False) + + def _clear_widgets(self): + """Remove all widgets from layout and from cache.""" + + while self._content_layout.count(): + item = self._content_layout.takeAt(0) + widget = item.widget() + if widget: + widget.setVisible(False) + widget.deleteLater() + self._views_by_instance_id = {} + + def update_instances(self, instances): + """Update publish instance from report. + + Args: + instances (list[_InstanceItem]): Instance data from report. + """ + + self._instances_order = [ + instance.id for instance in instances + ] + self._instances_by_id = { + instance.id: instance + for instance in instances + } + self._instance_ids_filter = [] + self._plugin_ids_filter = None + self._clear_needed = True + self._update_needed = True + self._update_instances() + + def set_instances_filter(self, instance_ids=None): + """Set instance filter. + + Args: + instance_ids (Optional[list[str]]): List of instances to keep + visible. Pass empty list to hide all items. + """ + + self._instance_ids_filter = instance_ids + self._update_needed = True + self._update_instances() + + def set_plugins_filter(self, plugin_ids=None): + if self._plugin_ids_filter == plugin_ids: + return + self._plugin_ids_filter = plugin_ids + self._update_needed = True + self._update_instances() + + +class CrashWidget(QtWidgets.QWidget): + """Widget shown when publishing crashes. + + Contains only minimal information for artist with easy access to report + actions. + """ + + def __init__(self, controller, parent): + super(CrashWidget, self).__init__(parent) + + main_label = QtWidgets.QLabel("This is not your fault", self) + main_label.setAlignment(QtCore.Qt.AlignCenter) + main_label.setObjectName("PublishCrashMainLabel") + + report_label = QtWidgets.QLabel( + ( + "Please report the error to your pipeline support" + " using one of the options below." + ), + self + ) + report_label.setAlignment(QtCore.Qt.AlignCenter) + report_label.setWordWrap(True) + report_label.setObjectName("PublishCrashReportLabel") + + btns_widget = QtWidgets.QWidget(self) + copy_clipboard_btn = QtWidgets.QPushButton( + "Copy to clipboard", btns_widget) + save_to_disk_btn = QtWidgets.QPushButton( + "Save to disk", btns_widget) + + btns_layout = QtWidgets.QHBoxLayout(btns_widget) + btns_layout.addStretch(1) + btns_layout.addWidget(copy_clipboard_btn, 0) + btns_layout.addSpacing(20) + btns_layout.addWidget(save_to_disk_btn, 0) + btns_layout.addStretch(1) + + layout = QtWidgets.QVBoxLayout(self) + layout.addStretch(1) + layout.addWidget(main_label, 0) + layout.addSpacing(20) + layout.addWidget(report_label, 0) + layout.addSpacing(20) + layout.addWidget(btns_widget, 0) + layout.addStretch(2) + + copy_clipboard_btn.clicked.connect(self._on_copy_to_clipboard) + save_to_disk_btn.clicked.connect(self._on_save_to_disk_click) + + self._controller = controller + + def _on_copy_to_clipboard(self): + self._controller.event_system.emit( + "copy_report.request", {}, "report_page") + + def _on_save_to_disk_click(self): + self._controller.event_system.emit( + "export_report.request", {}, "report_page") + + +class ErrorDetailsWidget(QtWidgets.QWidget): + def __init__(self, parent): + super(ErrorDetailsWidget, self).__init__(parent) + + inputs_widget = QtWidgets.QWidget(self) + # Error 'Description' input + error_description_input = ExpandingTextEdit(inputs_widget) + error_description_input.setObjectName("InfoText") + error_description_input.setTextInteractionFlags( + QtCore.Qt.TextBrowserInteraction + ) + + # Error 'Details' widget -> Collapsible + error_details_widget = QtWidgets.QWidget(inputs_widget) + + error_details_top = ClickableFrame(error_details_widget) + + error_details_expand_btn = ClassicExpandBtn(error_details_top) + error_details_expand_label = QtWidgets.QLabel( + "Details", error_details_top) + + line_widget = SeparatorWidget(1, parent=error_details_top) + + error_details_top_l = QtWidgets.QHBoxLayout(error_details_top) + error_details_top_l.setContentsMargins(0, 0, 10, 0) + error_details_top_l.addWidget(error_details_expand_btn, 0) + error_details_top_l.addWidget(error_details_expand_label, 0) + error_details_top_l.addWidget(line_widget, 1) + + error_details_input = ExpandingTextEdit(error_details_widget) + error_details_input.setObjectName("InfoText") + error_details_input.setTextInteractionFlags( + QtCore.Qt.TextBrowserInteraction + ) + error_details_input.setVisible(not error_details_expand_btn.collapsed) + + error_details_layout = QtWidgets.QVBoxLayout(error_details_widget) + error_details_layout.setContentsMargins(0, 0, 0, 0) + error_details_layout.addWidget(error_details_top, 0) + error_details_layout.addWidget(error_details_input, 0) + error_details_layout.addStretch(1) + + # Description and Details layout + inputs_layout = QtWidgets.QVBoxLayout(inputs_widget) + inputs_layout.setContentsMargins(0, 0, 0, 0) + inputs_layout.setSpacing(10) + inputs_layout.addWidget(error_description_input, 0) + inputs_layout.addWidget(error_details_widget, 1) + + main_layout = QtWidgets.QHBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(inputs_widget, 1) + + error_details_top.clicked.connect(self._on_detail_toggle) + + self._error_details_widget = error_details_widget + self._error_description_input = error_description_input + self._error_details_expand_btn = error_details_expand_btn + self._error_details_input = error_details_input + + def _on_detail_toggle(self): + self._error_details_expand_btn.set_collapsed() + self._error_details_input.setVisible( + not self._error_details_expand_btn.collapsed) + + def set_error_item(self, error_item): + detail = "" + description = "" + if error_item: + description = error_item.description or description + detail = error_item.detail or detail + + if commonmark: + self._error_description_input.setHtml( + commonmark.commonmark(description) + ) + self._error_details_input.setHtml( + commonmark.commonmark(detail) + ) + + elif hasattr(self._error_details_input, "setMarkdown"): + self._error_description_input.setMarkdown(description) + self._error_details_input.setMarkdown(detail) + + else: + self._error_description_input.setText(description) + self._error_details_input.setText(detail) + + self._error_details_widget.setVisible(bool(detail)) + + +class ReportsWidget(QtWidgets.QWidget): + """ + # Crash layout + ┌──────┬─────────┬─────────┐ + │Views │ Logs │ Details │ + │ │ │ │ + │ │ │ │ + └──────┴─────────┴─────────┘ + # Success layout + ┌──────┬───────────────────┐ + │View │ Logs │ + │ │ │ + │ │ │ + └──────┴───────────────────┘ + # Validation errors layout + ┌──────┬─────────┬─────────┐ + │Views │ Actions │ │ + │ ├─────────┤ Details │ + │ │ Logs │ │ + │ │ │ │ + └──────┴─────────┴─────────┘ + """ + + def __init__(self, controller, parent): + super(ReportsWidget, self).__init__(parent) + + # Instances view + views_widget = QtWidgets.QWidget(self) + + instances_view = PublishInstancesViewWidget(controller, views_widget) + + validation_error_view = ValidationErrorsView(views_widget) + + views_layout = QtWidgets.QStackedLayout(views_widget) + views_layout.setContentsMargins(0, 0, 0, 0) + views_layout.addWidget(instances_view) + views_layout.addWidget(validation_error_view) + + views_layout.setCurrentWidget(instances_view) + + # Error description with actions and optional detail + details_widget = QtWidgets.QFrame(self) + details_widget.setObjectName("PublishInstancesDetails") + + # Actions widget + actions_widget = ValidateActionsWidget(controller, details_widget) + + pages_widget = QtWidgets.QWidget(details_widget) + + # Logs view + logs_view = InstancesLogsView(pages_widget) + + # Validation details + # Description and details inputs are in scroll + # - single scroll for both inputs, they are forced to not use theirs + detail_inputs_spacer = QtWidgets.QWidget(pages_widget) + detail_inputs_spacer.setMinimumWidth(30) + detail_inputs_spacer.setMaximumWidth(30) + + detail_input_scroll = QtWidgets.QScrollArea(pages_widget) + + detail_inputs_widget = ErrorDetailsWidget(detail_input_scroll) + detail_inputs_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) + + detail_input_scroll.setWidget(detail_inputs_widget) + detail_input_scroll.setWidgetResizable(True) + detail_input_scroll.setViewportMargins(0, 0, 0, 0) + + # Crash information + crash_widget = CrashWidget(controller, details_widget) + + # Layout pages + pages_layout = QtWidgets.QHBoxLayout(pages_widget) + pages_layout.setContentsMargins(0, 0, 0, 0) + pages_layout.addWidget(logs_view, 1) + pages_layout.addWidget(detail_inputs_spacer, 0) + pages_layout.addWidget(detail_input_scroll, 1) + pages_layout.addWidget(crash_widget, 1) + + details_layout = QtWidgets.QVBoxLayout(details_widget) + margins = details_layout.contentsMargins() + margins.setTop(margins.top() * 2) + margins.setBottom(margins.bottom() * 2) + details_layout.setContentsMargins(margins) + details_layout.setSpacing(margins.top()) + details_layout.addWidget(actions_widget, 0) + details_layout.addWidget(pages_widget, 1) + + content_layout = QtWidgets.QHBoxLayout(self) + content_layout.setContentsMargins(0, 0, 0, 0) + content_layout.addWidget(views_widget, 0) + content_layout.addWidget(details_widget, 1) + + instances_view.selection_changed.connect(self._on_instance_selection) + validation_error_view.selection_changed.connect( + self._on_error_selection) + + self._views_layout = views_layout + self._instances_view = instances_view + self._validation_error_view = validation_error_view + + self._actions_widget = actions_widget + self._detail_inputs_widget = detail_inputs_widget + self._logs_view = logs_view + self._detail_inputs_spacer = detail_inputs_spacer + self._detail_input_scroll = detail_input_scroll + self._crash_widget = crash_widget + + self._controller = controller + + self._validation_errors_by_id = {} + + def _get_instance_items(self): + report = self._controller.get_publish_report() + context_label = report["context"]["label"] or CONTEXT_LABEL + instances_by_id = report["instances"] + plugins_info = report["plugins_data"] + logs_by_instance_id = collections.defaultdict(list) + for plugin_info in plugins_info: + plugin_id = plugin_info["id"] + for instance_info in plugin_info["instances_data"]: + instance_id = instance_info["id"] or CONTEXT_ID + for log in instance_info["logs"]: + log["plugin_id"] = plugin_id + logs_by_instance_id[instance_id].extend(instance_info["logs"]) + + context_item = _InstanceItem.create_context_item( + context_label, logs_by_instance_id[CONTEXT_ID]) + instance_items = [ + _InstanceItem.from_report( + instance_id, instance, logs_by_instance_id[instance_id] + ) + for instance_id, instance in instances_by_id.items() + if instance["exists"] + ] + instance_items.sort() + instance_items.insert(0, context_item) + return instance_items + + def update_data(self): + view = self._instances_view + validation_error_mode = False + if ( + not self._controller.publish_has_crashed + and self._controller.publish_has_validation_errors + ): + view = self._validation_error_view + validation_error_mode = True + + self._actions_widget.set_visible_mode(validation_error_mode) + self._detail_inputs_spacer.setVisible(validation_error_mode) + self._detail_input_scroll.setVisible(validation_error_mode) + self._views_layout.setCurrentWidget(view) + + self._crash_widget.setVisible(self._controller.publish_has_crashed) + self._logs_view.setVisible(not self._controller.publish_has_crashed) + + # Instance view & logs update + instance_items = self._get_instance_items() + self._instances_view.update_instances(instance_items) + self._logs_view.update_instances(instance_items) + + # Validation errors + validation_errors = self._controller.get_validation_errors() + grouped_error_items = validation_errors.group_items_by_title() + + validation_errors_by_id = { + title_item["id"]: title_item + for title_item in grouped_error_items + } + + self._validation_errors_by_id = validation_errors_by_id + self._validation_error_view.set_errors(grouped_error_items) + + def _on_instance_selection(self): + instance_ids = self._instances_view.get_selected_instance_ids() + self._logs_view.set_instances_filter(instance_ids) + + def _on_error_selection(self): + title_id, instance_ids = ( + self._validation_error_view.get_selected_items()) + error_info = self._validation_errors_by_id.get(title_id) + if error_info is None: + self._actions_widget.set_error_info(None) + self._detail_inputs_widget.set_error_item(None) + return + + self._logs_view.set_instances_filter(instance_ids) + self._logs_view.set_plugins_filter([error_info["plugin_id"]]) + + match_error_item = None + for error_item in error_info["error_items"]: + instance_id = error_item.instance_id or CONTEXT_ID + if instance_id in instance_ids: + match_error_item = error_item + break + + self._actions_widget.set_error_info(error_info) + self._detail_inputs_widget.set_error_item(match_error_item) + + +class ReportPageWidget(QtWidgets.QFrame): + """Widgets showing report for artis. + + There are 5 possible states: + 1. Publishing did not start yet. > Only label. + 2. Publishing is paused. ┐ + 3. Publishing successfully finished. │> Instances with logs. + 4. Publishing crashed. ┘ + 5. Crashed because of validation error. > Errors with logs. + + This widget is shown if validation errors happened during validation part. + + Shows validation error titles with instances on which they happened + and validation error detail with possible actions (repair). + """ + + def __init__(self, controller, parent): + super(ReportPageWidget, self).__init__(parent) + + header_label = QtWidgets.QLabel(self) + header_label.setAlignment(QtCore.Qt.AlignCenter) + header_label.setObjectName("PublishReportHeader") + + publish_instances_widget = ReportsWidget(controller, self) + + layout = QtWidgets.QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(header_label, 0) + layout.addWidget(publish_instances_widget, 0) + + controller.event_system.add_callback( + "publish.process.started", self._on_publish_start + ) + controller.event_system.add_callback( + "publish.reset.finished", self._on_publish_reset + ) + controller.event_system.add_callback( + "publish.process.stopped", self._on_publish_stop + ) + + self._header_label = header_label + self._publish_instances_widget = publish_instances_widget + + self._controller = controller + + def _update_label(self): + if not self._controller.publish_has_started: + # This probably never happen when this widget is visible + header_label = "Nothing to report until you run publish" + elif self._controller.publish_has_crashed: + header_label = "Publish error report" + elif self._controller.publish_has_validation_errors: + header_label = "Publish validation report" + elif self._controller.publish_has_finished: + header_label = "Publish success report" + else: + header_label = "Publish report" + self._header_label.setText(header_label) + + def _update_state(self): + self._update_label() + publish_started = self._controller.publish_has_started + self._publish_instances_widget.setVisible(publish_started) + if publish_started: + self._publish_instances_widget.update_data() + + self.updateGeometry() + + def _on_publish_start(self): + self._update_state() + + def _on_publish_reset(self): + self._update_state() + + def _on_publish_stop(self): + self._update_state() diff --git a/openpype/tools/publisher/widgets/thumbnail_widget.py b/openpype/tools/publisher/widgets/thumbnail_widget.py index e234f4cdc1..b17ca0adc8 100644 --- a/openpype/tools/publisher/widgets/thumbnail_widget.py +++ b/openpype/tools/publisher/widgets/thumbnail_widget.py @@ -75,6 +75,7 @@ class ThumbnailPainterWidget(QtWidgets.QWidget): painter = QtGui.QPainter() painter.begin(self) + painter.setRenderHint(QtGui.QPainter.Antialiasing) painter.drawPixmap(0, 0, self._cached_pix) painter.end() @@ -183,6 +184,18 @@ class ThumbnailPainterWidget(QtWidgets.QWidget): backgrounded_images.append(new_pix) return backgrounded_images + def _paint_dash_line(self, painter, rect): + pen = QtGui.QPen() + pen.setWidth(1) + pen.setBrush(QtCore.Qt.darkGray) + pen.setStyle(QtCore.Qt.DashLine) + + new_rect = rect.adjusted(1, 1, -1, -1) + painter.setPen(pen) + painter.setBrush(QtCore.Qt.transparent) + # painter.drawRect(rect) + painter.drawRect(new_rect) + def _cache_pix(self): rect = self.rect() rect_width = rect.width() @@ -264,13 +277,7 @@ class ThumbnailPainterWidget(QtWidgets.QWidget): # Draw drop enabled dashes if used_default_pix: - pen = QtGui.QPen() - pen.setWidth(1) - pen.setBrush(QtCore.Qt.darkGray) - pen.setStyle(QtCore.Qt.DashLine) - final_painter.setPen(pen) - final_painter.setBrush(QtCore.Qt.transparent) - final_painter.drawRect(rect) + self._paint_dash_line(final_painter, rect) final_painter.end() diff --git a/openpype/tools/publisher/widgets/validations_widget.py b/openpype/tools/publisher/widgets/validations_widget.py deleted file mode 100644 index 0abe85c0b8..0000000000 --- a/openpype/tools/publisher/widgets/validations_widget.py +++ /dev/null @@ -1,715 +0,0 @@ -# -*- coding: utf-8 -*- -try: - import commonmark -except Exception: - commonmark = None - -from qtpy import QtWidgets, QtCore, QtGui - -from openpype.tools.utils import BaseClickableFrame, ClickableFrame -from .widgets import ( - IconValuePixmapLabel -) -from ..constants import ( - INSTANCE_ID_ROLE -) - - -class ValidationErrorInstanceList(QtWidgets.QListView): - """List of publish instances that caused a validation error. - - Instances are collected per plugin's validation error title. - """ - def __init__(self, *args, **kwargs): - super(ValidationErrorInstanceList, self).__init__(*args, **kwargs) - - self.setObjectName("ValidationErrorInstanceList") - - self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) - self.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) - - def minimumSizeHint(self): - return self.sizeHint() - - def sizeHint(self): - result = super(ValidationErrorInstanceList, self).sizeHint() - row_count = self.model().rowCount() - height = 0 - if row_count > 0: - height = self.sizeHintForRow(0) * row_count - result.setHeight(height) - return result - - -class ValidationErrorTitleWidget(QtWidgets.QWidget): - """Title of validation error. - - Widget is used as radio button so requires clickable functionality and - changing style on selection/deselection. - - Has toggle button to show/hide instances on which validation error happened - if there is a list (Valdation error may happen on context). - """ - - selected = QtCore.Signal(int) - instance_changed = QtCore.Signal(int) - - def __init__(self, index, error_info, parent): - super(ValidationErrorTitleWidget, self).__init__(parent) - - self._index = index - self._error_info = error_info - self._selected = False - - title_frame = ClickableFrame(self) - title_frame.setObjectName("ValidationErrorTitleFrame") - - toggle_instance_btn = QtWidgets.QToolButton(title_frame) - toggle_instance_btn.setObjectName("ArrowBtn") - toggle_instance_btn.setArrowType(QtCore.Qt.RightArrow) - toggle_instance_btn.setMaximumWidth(14) - - label_widget = QtWidgets.QLabel(error_info["title"], title_frame) - - title_frame_layout = QtWidgets.QHBoxLayout(title_frame) - title_frame_layout.addWidget(label_widget, 1) - title_frame_layout.addWidget(toggle_instance_btn, 0) - - instances_model = QtGui.QStandardItemModel() - - help_text_by_instance_id = {} - - items = [] - context_validation = False - for error_item in error_info["error_items"]: - context_validation = error_item.context_validation - if context_validation: - toggle_instance_btn.setArrowType(QtCore.Qt.NoArrow) - description = self._prepare_description(error_item) - help_text_by_instance_id[None] = description - # Add fake item to have minimum size hint of view widget - items.append(QtGui.QStandardItem("Context")) - continue - - label = error_item.instance_label - item = QtGui.QStandardItem(label) - item.setFlags( - QtCore.Qt.ItemIsEnabled | QtCore.Qt.ItemIsSelectable - ) - item.setData(label, QtCore.Qt.ToolTipRole) - item.setData(error_item.instance_id, INSTANCE_ID_ROLE) - items.append(item) - description = self._prepare_description(error_item) - help_text_by_instance_id[error_item.instance_id] = description - - if items: - root_item = instances_model.invisibleRootItem() - root_item.appendRows(items) - - instances_view = ValidationErrorInstanceList(self) - instances_view.setModel(instances_model) - - self.setLayoutDirection(QtCore.Qt.LeftToRight) - - view_widget = QtWidgets.QWidget(self) - view_layout = QtWidgets.QHBoxLayout(view_widget) - view_layout.setContentsMargins(0, 0, 0, 0) - view_layout.setSpacing(0) - view_layout.addSpacing(14) - view_layout.addWidget(instances_view, 0) - - layout = QtWidgets.QVBoxLayout(self) - layout.setSpacing(0) - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(title_frame, 0) - layout.addWidget(view_widget, 0) - view_widget.setVisible(False) - - if not context_validation: - toggle_instance_btn.clicked.connect(self._on_toggle_btn_click) - - title_frame.clicked.connect(self._mouse_release_callback) - instances_view.selectionModel().selectionChanged.connect( - self._on_seleciton_change - ) - - self._title_frame = title_frame - - self._toggle_instance_btn = toggle_instance_btn - - self._view_widget = view_widget - - self._instances_model = instances_model - self._instances_view = instances_view - - self._context_validation = context_validation - self._help_text_by_instance_id = help_text_by_instance_id - - self._expanded = False - - def sizeHint(self): - result = super(ValidationErrorTitleWidget, self).sizeHint() - expected_width = max( - self._view_widget.minimumSizeHint().width(), - self._view_widget.sizeHint().width() - ) - - if expected_width < 200: - expected_width = 200 - - if result.width() < expected_width: - result.setWidth(expected_width) - - return result - - def minimumSizeHint(self): - return self.sizeHint() - - def _prepare_description(self, error_item): - """Prepare description text for detail intput. - - Args: - error_item (ValidationErrorItem): Item which hold information about - validation error. - - Returns: - str: Prepared detailed description. - """ - - dsc = error_item.description - detail = error_item.detail - if detail: - dsc += "

{}".format(detail) - - description = dsc - if commonmark: - description = commonmark.commonmark(dsc) - return description - - def _mouse_release_callback(self): - """Mark this widget as selected on click.""" - - self.set_selected(True) - - def current_description_text(self): - if self._context_validation: - return self._help_text_by_instance_id[None] - index = self._instances_view.currentIndex() - # TODO make sure instance is selected - if not index.isValid(): - index = self._instances_model.index(0, 0) - - indence_id = index.data(INSTANCE_ID_ROLE) - return self._help_text_by_instance_id[indence_id] - - @property - def is_selected(self): - """Is widget marked a selected. - - Returns: - bool: Item is selected or not. - """ - - return self._selected - - @property - def index(self): - """Widget's index set by parent. - - Returns: - int: Index of widget. - """ - - return self._index - - def set_index(self, index): - """Set index of widget (called by parent). - - Args: - int: New index of widget. - """ - - self._index = index - - def _change_style_property(self, selected): - """Change style of widget based on selection.""" - - value = "1" if selected else "" - self._title_frame.setProperty("selected", value) - self._title_frame.style().polish(self._title_frame) - - def set_selected(self, selected=None): - """Change selected state of widget.""" - - if selected is None: - selected = not self._selected - - # Clear instance view selection on deselect - if not selected: - self._instances_view.clearSelection() - - # Skip if has same value - if selected == self._selected: - return - - self._selected = selected - self._change_style_property(selected) - if selected: - self.selected.emit(self._index) - self._set_expanded(True) - - def _on_toggle_btn_click(self): - """Show/hide instances list.""" - - self._set_expanded() - - def _set_expanded(self, expanded=None): - if expanded is None: - expanded = not self._expanded - - elif expanded is self._expanded: - return - - if expanded and self._context_validation: - return - - self._expanded = expanded - self._view_widget.setVisible(expanded) - if expanded: - self._toggle_instance_btn.setArrowType(QtCore.Qt.DownArrow) - else: - self._toggle_instance_btn.setArrowType(QtCore.Qt.RightArrow) - - def _on_seleciton_change(self): - sel_model = self._instances_view.selectionModel() - if sel_model.selectedIndexes(): - self.instance_changed.emit(self._index) - - -class ActionButton(BaseClickableFrame): - """Plugin's action callback button. - - Action may have label or icon or both. - - Args: - plugin_action_item (PublishPluginActionItem): Action item that can be - triggered by it's id. - """ - - action_clicked = QtCore.Signal(str, str) - - def __init__(self, plugin_action_item, parent): - super(ActionButton, self).__init__(parent) - - self.setObjectName("ValidationActionButton") - - self.plugin_action_item = plugin_action_item - - action_label = plugin_action_item.label - action_icon = plugin_action_item.icon - label_widget = QtWidgets.QLabel(action_label, self) - icon_label = None - if action_icon: - icon_label = IconValuePixmapLabel(action_icon, self) - - layout = QtWidgets.QHBoxLayout(self) - layout.setContentsMargins(5, 0, 5, 0) - layout.addWidget(label_widget, 1) - if icon_label: - layout.addWidget(icon_label, 0) - - self.setSizePolicy( - QtWidgets.QSizePolicy.Minimum, - self.sizePolicy().verticalPolicy() - ) - - def _mouse_release_callback(self): - self.action_clicked.emit( - self.plugin_action_item.plugin_id, - self.plugin_action_item.action_id - ) - - -class ValidateActionsWidget(QtWidgets.QFrame): - """Wrapper widget for plugin actions. - - Change actions based on selected validation error. - """ - - def __init__(self, controller, parent): - super(ValidateActionsWidget, self).__init__(parent) - - self.setAttribute(QtCore.Qt.WA_TranslucentBackground) - - content_widget = QtWidgets.QWidget(self) - content_layout = QtWidgets.QVBoxLayout(content_widget) - - layout = QtWidgets.QHBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.addWidget(content_widget) - - self._controller = controller - self._content_widget = content_widget - self._content_layout = content_layout - self._actions_mapping = {} - - def clear(self): - """Remove actions from widget.""" - while self._content_layout.count(): - item = self._content_layout.takeAt(0) - widget = item.widget() - if widget: - widget.setVisible(False) - widget.deleteLater() - self._actions_mapping = {} - - def set_error_item(self, error_item): - """Set selected plugin and show it's actions. - - Clears current actions from widget and recreate them from the plugin. - - Args: - Dict[str, Any]: Object holding error items, title and possible - actions to run. - """ - - self.clear() - - if not error_item: - self.setVisible(False) - return - - plugin_action_items = error_item["plugin_action_items"] - for plugin_action_item in plugin_action_items: - if not plugin_action_item.active: - continue - - if plugin_action_item.on_filter not in ("failed", "all"): - continue - - action_id = plugin_action_item.action_id - self._actions_mapping[action_id] = plugin_action_item - - action_btn = ActionButton(plugin_action_item, self._content_widget) - action_btn.action_clicked.connect(self._on_action_click) - self._content_layout.addWidget(action_btn) - - if self._content_layout.count() > 0: - self.setVisible(True) - self._content_layout.addStretch(1) - else: - self.setVisible(False) - - def _on_action_click(self, plugin_id, action_id): - self._controller.run_action(plugin_id, action_id) - - -class VerticallScrollArea(QtWidgets.QScrollArea): - """Scroll area for validation error titles. - - The biggest difference is that the scroll area has scroll bar on left side - and resize of content will also resize scrollarea itself. - - Resize if deferred by 100ms because at the moment of resize are not yet - propagated sizes and visibility of scroll bars. - """ - - def __init__(self, *args, **kwargs): - super(VerticallScrollArea, self).__init__(*args, **kwargs) - - self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) - self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAsNeeded) - self.setLayoutDirection(QtCore.Qt.RightToLeft) - - self.setAttribute(QtCore.Qt.WA_TranslucentBackground) - # Background of scrollbar will be transparent - scrollbar_bg = self.verticalScrollBar().parent() - if scrollbar_bg: - scrollbar_bg.setAttribute(QtCore.Qt.WA_TranslucentBackground) - self.setViewportMargins(0, 0, 0, 0) - - self.verticalScrollBar().installEventFilter(self) - - # Timer with 100ms offset after changing size - size_changed_timer = QtCore.QTimer() - size_changed_timer.setInterval(100) - size_changed_timer.setSingleShot(True) - - size_changed_timer.timeout.connect(self._on_timer_timeout) - self._size_changed_timer = size_changed_timer - - def setVerticalScrollBar(self, widget): - old_widget = self.verticalScrollBar() - if old_widget: - old_widget.removeEventFilter(self) - - super(VerticallScrollArea, self).setVerticalScrollBar(widget) - if widget: - widget.installEventFilter(self) - - def setWidget(self, widget): - old_widget = self.widget() - if old_widget: - old_widget.removeEventFilter(self) - - super(VerticallScrollArea, self).setWidget(widget) - if widget: - widget.installEventFilter(self) - - def _on_timer_timeout(self): - width = self.widget().width() - if self.verticalScrollBar().isVisible(): - width += self.verticalScrollBar().width() - self.setMinimumWidth(width) - - def eventFilter(self, obj, event): - if ( - event.type() == QtCore.QEvent.Resize - and (obj is self.widget() or obj is self.verticalScrollBar()) - ): - self._size_changed_timer.start() - return super(VerticallScrollArea, self).eventFilter(obj, event) - - -class ValidationArtistMessage(QtWidgets.QWidget): - def __init__(self, message, parent): - super(ValidationArtistMessage, self).__init__(parent) - - artist_msg_label = QtWidgets.QLabel(message, self) - artist_msg_label.setAlignment(QtCore.Qt.AlignCenter) - - main_layout = QtWidgets.QHBoxLayout(self) - main_layout.setContentsMargins(0, 0, 0, 0) - main_layout.addWidget( - artist_msg_label, 1, QtCore.Qt.AlignCenter - ) - - -class ValidationsWidget(QtWidgets.QFrame): - """Widgets showing validation error. - - This widget is shown if validation error/s happened during validation part. - - Shows validation error titles with instances on which happened and - validation error detail with possible actions (repair). - - ┌──────┬────────────────┬───────┐ - │titles│ │actions│ - │ │ │ │ - │ │ Error detail │ │ - │ │ │ │ - │ │ │ │ - └──────┴────────────────┴───────┘ - """ - - def __init__(self, controller, parent): - super(ValidationsWidget, self).__init__(parent) - - # Before publishing - before_publish_widget = ValidationArtistMessage( - "Nothing to report until you run publish", self - ) - # After success publishing - publish_started_widget = ValidationArtistMessage( - "So far so good", self - ) - # After success publishing - publish_stop_ok_widget = ValidationArtistMessage( - "Publishing finished successfully", self - ) - # After failed publishing (not with validation error) - publish_stop_fail_widget = ValidationArtistMessage( - "This is not your fault...", self - ) - - # Validation errors - validations_widget = QtWidgets.QWidget(self) - - content_widget = QtWidgets.QWidget(validations_widget) - - errors_scroll = VerticallScrollArea(content_widget) - errors_scroll.setWidgetResizable(True) - - errors_widget = QtWidgets.QWidget(errors_scroll) - errors_widget.setAttribute(QtCore.Qt.WA_TranslucentBackground) - errors_layout = QtWidgets.QVBoxLayout(errors_widget) - errors_layout.setContentsMargins(0, 0, 0, 0) - - errors_scroll.setWidget(errors_widget) - - error_details_frame = QtWidgets.QFrame(content_widget) - error_details_input = QtWidgets.QTextEdit(error_details_frame) - error_details_input.setObjectName("InfoText") - error_details_input.setTextInteractionFlags( - QtCore.Qt.TextBrowserInteraction - ) - - actions_widget = ValidateActionsWidget(controller, content_widget) - actions_widget.setMinimumWidth(140) - - error_details_layout = QtWidgets.QHBoxLayout(error_details_frame) - error_details_layout.addWidget(error_details_input, 1) - error_details_layout.addWidget(actions_widget, 0) - - content_layout = QtWidgets.QHBoxLayout(content_widget) - content_layout.setSpacing(0) - content_layout.setContentsMargins(0, 0, 0, 0) - - content_layout.addWidget(errors_scroll, 0) - content_layout.addWidget(error_details_frame, 1) - - top_label = QtWidgets.QLabel( - "Publish validation report", content_widget - ) - top_label.setObjectName("PublishInfoMainLabel") - top_label.setAlignment(QtCore.Qt.AlignCenter) - - validation_layout = QtWidgets.QVBoxLayout(validations_widget) - validation_layout.setContentsMargins(0, 0, 0, 0) - validation_layout.addWidget(top_label, 0) - validation_layout.addWidget(content_widget, 1) - - main_layout = QtWidgets.QStackedLayout(self) - main_layout.addWidget(before_publish_widget) - main_layout.addWidget(publish_started_widget) - main_layout.addWidget(publish_stop_ok_widget) - main_layout.addWidget(publish_stop_fail_widget) - main_layout.addWidget(validations_widget) - - main_layout.setCurrentWidget(before_publish_widget) - - controller.event_system.add_callback( - "publish.process.started", self._on_publish_start - ) - controller.event_system.add_callback( - "publish.reset.finished", self._on_publish_reset - ) - controller.event_system.add_callback( - "publish.process.stopped", self._on_publish_stop - ) - - self._main_layout = main_layout - - self._before_publish_widget = before_publish_widget - self._publish_started_widget = publish_started_widget - self._publish_stop_ok_widget = publish_stop_ok_widget - self._publish_stop_fail_widget = publish_stop_fail_widget - self._validations_widget = validations_widget - - self._top_label = top_label - self._errors_widget = errors_widget - self._errors_layout = errors_layout - self._error_details_frame = error_details_frame - self._error_details_input = error_details_input - self._actions_widget = actions_widget - - self._title_widgets = {} - self._error_info = {} - self._previous_select = None - - self._controller = controller - - def clear(self): - """Delete all dynamic widgets and hide all wrappers.""" - self._title_widgets = {} - self._error_info = {} - self._previous_select = None - while self._errors_layout.count(): - item = self._errors_layout.takeAt(0) - widget = item.widget() - if widget: - widget.deleteLater() - - self._top_label.setVisible(False) - self._error_details_frame.setVisible(False) - self._errors_widget.setVisible(False) - self._actions_widget.setVisible(False) - - def _set_errors(self, validation_error_report): - """Set errors into context and created titles. - - Args: - validation_error_report (PublishValidationErrorsReport): Report - with information about validation errors and publish plugin - actions. - """ - - self.clear() - if not validation_error_report: - return - - self._top_label.setVisible(True) - self._error_details_frame.setVisible(True) - self._errors_widget.setVisible(True) - - grouped_error_items = validation_error_report.group_items_by_title() - for idx, error_info in enumerate(grouped_error_items): - widget = ValidationErrorTitleWidget(idx, error_info, self) - widget.selected.connect(self._on_select) - widget.instance_changed.connect(self._on_instance_change) - self._errors_layout.addWidget(widget) - self._title_widgets[idx] = widget - self._error_info[idx] = error_info - - self._errors_layout.addStretch(1) - - if self._title_widgets: - self._title_widgets[0].set_selected(True) - - self.updateGeometry() - - def _set_current_widget(self, widget): - self._main_layout.setCurrentWidget(widget) - - def _on_publish_start(self): - self._set_current_widget(self._publish_started_widget) - - def _on_publish_reset(self): - self._set_current_widget(self._before_publish_widget) - - def _on_publish_stop(self): - if self._controller.publish_has_crashed: - self._set_current_widget(self._publish_stop_fail_widget) - return - - if self._controller.publish_has_validation_errors: - validation_errors = self._controller.get_validation_errors() - self._set_current_widget(self._validations_widget) - self._set_errors(validation_errors) - return - - if self._controller.publish_has_finished: - self._set_current_widget(self._publish_stop_ok_widget) - return - - self._set_current_widget(self._publish_started_widget) - - def _on_select(self, index): - if self._previous_select: - if self._previous_select.index == index: - return - self._previous_select.set_selected(False) - - self._previous_select = self._title_widgets[index] - - error_item = self._error_info[index] - - self._actions_widget.set_error_item(error_item) - - self._update_description() - - def _on_instance_change(self, index): - if self._previous_select and self._previous_select.index != index: - self._title_widgets[index].set_selected(True) - else: - self._update_description() - - def _update_description(self): - description = self._previous_select.current_description_text() - if commonmark: - html = commonmark.commonmark(description) - self._error_details_input.setHtml(html) - elif hasattr(self._error_details_input, "setMarkdown"): - self._error_details_input.setMarkdown(description) - else: - self._error_details_input.setText(description) diff --git a/openpype/tools/publisher/widgets/widgets.py b/openpype/tools/publisher/widgets/widgets.py index cd1f1f5a96..0b13f26d57 100644 --- a/openpype/tools/publisher/widgets/widgets.py +++ b/openpype/tools/publisher/widgets/widgets.py @@ -40,6 +40,41 @@ from ..constants import ( INPUTS_LAYOUT_VSPACING, ) +FA_PREFIXES = ["", "fa.", "fa5.", "fa5b.", "fa5s.", "ei.", "mdi."] + + +def parse_icon_def( + icon_def, default_width=None, default_height=None, color=None +): + if not icon_def: + return None + + if isinstance(icon_def, QtGui.QPixmap): + return icon_def + + color = color or "white" + default_width = default_width or 512 + default_height = default_height or 512 + + if isinstance(icon_def, QtGui.QIcon): + return icon_def.pixmap(default_width, default_height) + + try: + if os.path.exists(icon_def): + return QtGui.QPixmap(icon_def) + except Exception: + # TODO logging + pass + + for prefix in FA_PREFIXES: + try: + icon_name = "{}{}".format(prefix, icon_def) + icon = qtawesome.icon(icon_name, color=color) + return icon.pixmap(default_width, default_height) + except Exception: + # TODO logging + continue + class PublishPixmapLabel(PixmapLabel): def _get_pix_size(self): @@ -54,7 +89,6 @@ class IconValuePixmapLabel(PublishPixmapLabel): Handle icon parsing from creators/instances. Using of QAwesome module of path to images. """ - fa_prefixes = ["", "fa."] default_size = 200 def __init__(self, icon_def, parent): @@ -77,31 +111,9 @@ class IconValuePixmapLabel(PublishPixmapLabel): return pix def _parse_icon_def(self, icon_def): - if not icon_def: - return self._default_pixmap() - - if isinstance(icon_def, QtGui.QPixmap): - return icon_def - - if isinstance(icon_def, QtGui.QIcon): - return icon_def.pixmap(self.default_size, self.default_size) - - try: - if os.path.exists(icon_def): - return QtGui.QPixmap(icon_def) - except Exception: - # TODO logging - pass - - for prefix in self.fa_prefixes: - try: - icon_name = "{}{}".format(prefix, icon_def) - icon = qtawesome.icon(icon_name, color="white") - return icon.pixmap(self.default_size, self.default_size) - except Exception: - # TODO logging - continue - + icon = parse_icon_def(icon_def, self.default_size, self.default_size) + if icon: + return icon return self._default_pixmap() @@ -692,6 +704,7 @@ class TasksCombobox(QtWidgets.QComboBox): style.drawControl( QtWidgets.QStyle.CE_ComboBoxLabel, opt, painter, self ) + painter.end() def is_valid(self): """Are all selected items valid.""" diff --git a/openpype/tools/publisher/window.py b/openpype/tools/publisher/window.py index b3471163ae..fc90e66f21 100644 --- a/openpype/tools/publisher/window.py +++ b/openpype/tools/publisher/window.py @@ -1,3 +1,6 @@ +import os +import json +import time import collections import copy from qtpy import QtWidgets, QtCore, QtGui @@ -15,10 +18,11 @@ from openpype.tools.utils import ( from .constants import ResetKeySequence from .publish_report_viewer import PublishReportViewerWidget +from .control import CardMessageTypes from .control_qt import QtPublisherController from .widgets import ( OverviewWidget, - ValidationsWidget, + ReportPageWidget, PublishFrame, PublisherTabsWidget, @@ -182,7 +186,7 @@ class PublisherWindow(QtWidgets.QDialog): controller, content_stacked_widget ) - report_widget = ValidationsWidget(controller, parent) + report_widget = ReportPageWidget(controller, parent) # Details - Publish details publish_details_widget = PublishReportViewerWidget( @@ -313,6 +317,13 @@ class PublisherWindow(QtWidgets.QDialog): controller.event_system.add_callback( "convertors.find.failed", self._on_convertor_error ) + controller.event_system.add_callback( + "export_report.request", self._export_report + ) + controller.event_system.add_callback( + "copy_report.request", self._copy_report + ) + # Store extra header widget for TrayPublisher # - can be used to add additional widgets to header between context @@ -825,6 +836,9 @@ class PublisherWindow(QtWidgets.QDialog): self._validate_btn.setEnabled(validate_enabled) self._publish_btn.setEnabled(publish_enabled) + if not publish_enabled: + self._publish_frame.set_shrunk_state(True) + self._update_publish_details_widget() def _validate_create_instances(self): @@ -941,6 +955,46 @@ class PublisherWindow(QtWidgets.QDialog): under_mouse = widget_x < global_pos.x() self._create_overlay_button.set_under_mouse(under_mouse) + def _copy_report(self): + logs = self._controller.get_publish_report() + logs_string = json.dumps(logs, indent=4) + + mime_data = QtCore.QMimeData() + mime_data.setText(logs_string) + QtWidgets.QApplication.instance().clipboard().setMimeData( + mime_data + ) + self._controller.emit_card_message( + "Report added to clipboard", + CardMessageTypes.info) + + def _export_report(self): + default_filename = "publish-report-{}".format( + time.strftime("%y%m%d-%H-%M") + ) + default_filepath = os.path.join( + os.path.expanduser("~"), + default_filename + ) + new_filepath, ext = QtWidgets.QFileDialog.getSaveFileName( + self, "Save report", default_filepath, ".json" + ) + if not ext or not new_filepath: + return + + logs = self._controller.get_publish_report() + full_path = new_filepath + ext + dir_path = os.path.dirname(full_path) + if not os.path.exists(dir_path): + os.makedirs(dir_path) + + with open(full_path, "w") as file_stream: + json.dump(logs, file_stream) + + self._controller.emit_card_message( + "Report saved", + CardMessageTypes.info) + class ErrorsMessageBox(ErrorMessageBox): def __init__(self, error_title, failed_info, message_start, parent): diff --git a/openpype/tools/utils/__init__.py b/openpype/tools/utils/__init__.py index 4149763f80..10bd527692 100644 --- a/openpype/tools/utils/__init__.py +++ b/openpype/tools/utils/__init__.py @@ -1,13 +1,16 @@ +from .layouts import FlowLayout from .widgets import ( FocusSpinBox, FocusDoubleSpinBox, ComboBox, CustomTextComboBox, PlaceholderLineEdit, + ExpandingTextEdit, BaseClickableFrame, ClickableFrame, ClickableLabel, ExpandBtn, + ClassicExpandBtn, PixmapLabel, IconButton, PixmapButton, @@ -37,15 +40,19 @@ from .overlay_messages import ( __all__ = ( + "FlowLayout", + "FocusSpinBox", "FocusDoubleSpinBox", "ComboBox", "CustomTextComboBox", "PlaceholderLineEdit", + "ExpandingTextEdit", "BaseClickableFrame", "ClickableFrame", "ClickableLabel", "ExpandBtn", + "ClassicExpandBtn", "PixmapLabel", "IconButton", "PixmapButton", diff --git a/openpype/tools/utils/layouts.py b/openpype/tools/utils/layouts.py new file mode 100644 index 0000000000..65ea087c27 --- /dev/null +++ b/openpype/tools/utils/layouts.py @@ -0,0 +1,150 @@ +from qtpy import QtWidgets, QtCore + + +class FlowLayout(QtWidgets.QLayout): + """Layout that organize widgets by minimum size into a flow layout. + + Layout is putting widget from left to right and top to bottom. When widget + can't fit a row it is added to next line. Minimum size matches widget with + biggest 'sizeHint' width and height using calculated geometry. + + Content margins are part of calculations. It is possible to define + horizontal and vertical spacing. + + Layout does not support stretch and spacing items. + + Todos: + Unified width concept -> use width of largest item so all of them are + same. This could allow to have minimum columns option too. + """ + + def __init__(self, parent=None): + super(FlowLayout, self).__init__(parent) + + # spaces between each item + self._horizontal_spacing = 5 + self._vertical_spacing = 5 + + self._items = [] + + def __del__(self): + while self.count(): + self.takeAt(0, False) + + def isEmpty(self): + for item in self._items: + if not item.isEmpty(): + return False + return True + + def setSpacing(self, spacing): + self._horizontal_spacing = spacing + self._vertical_spacing = spacing + self.invalidate() + + def setHorizontalSpacing(self, spacing): + self._horizontal_spacing = spacing + self.invalidate() + + def setVerticalSpacing(self, spacing): + self._vertical_spacing = spacing + self.invalidate() + + def addItem(self, item): + self._items.append(item) + self.invalidate() + + def count(self): + return len(self._items) + + def itemAt(self, index): + if 0 <= index < len(self._items): + return self._items[index] + return None + + def takeAt(self, index, invalidate=True): + if 0 <= index < len(self._items): + item = self._items.pop(index) + if invalidate: + self.invalidate() + return item + return None + + def expandingDirections(self): + return QtCore.Qt.Orientations(QtCore.Qt.Vertical) + + def hasHeightForWidth(self): + return True + + def heightForWidth(self, width): + return self._setup_geometry(QtCore.QRect(0, 0, width, 0), True) + + def setGeometry(self, rect): + super(FlowLayout, self).setGeometry(rect) + self._setup_geometry(rect) + + def sizeHint(self): + return self.minimumSize() + + def minimumSize(self): + size = QtCore.QSize(0, 0) + for item in self._items: + widget = item.widget() + if widget is not None: + parent = widget.parent() + if not widget.isVisibleTo(parent): + continue + size = size.expandedTo(item.minimumSize()) + + if size.width() < 1 or size.height() < 1: + return size + l_margin, t_margin, r_margin, b_margin = self.getContentsMargins() + size += QtCore.QSize(l_margin + r_margin, t_margin + b_margin) + return size + + def _setup_geometry(self, rect, only_calculate=False): + h_spacing = self._horizontal_spacing + v_spacing = self._vertical_spacing + l_margin, t_margin, r_margin, b_margin = self.getContentsMargins() + + left_x = rect.x() + l_margin + top_y = rect.y() + t_margin + pos_x = left_x + pos_y = top_y + row_height = 0 + for item in self._items: + item_hint = item.sizeHint() + item_width = item_hint.width() + item_height = item_hint.height() + if item_width < 1 or item_height < 1: + continue + + end_x = pos_x + item_width + + wrap = ( + row_height > 0 + and ( + end_x > rect.right() + or (end_x + r_margin) > rect.right() + ) + ) + if not wrap: + next_pos_x = end_x + h_spacing + else: + pos_x = left_x + pos_y += row_height + v_spacing + next_pos_x = pos_x + item_width + h_spacing + row_height = 0 + + if not only_calculate: + item.setGeometry( + QtCore.QRect(pos_x, pos_y, item_width, item_height) + ) + + pos_x = next_pos_x + row_height = max(row_height, item_height) + + height = (pos_y - top_y) + row_height + if height > 0: + height += b_margin + return height diff --git a/openpype/tools/utils/widgets.py b/openpype/tools/utils/widgets.py index bae89aeb09..5a8104611b 100644 --- a/openpype/tools/utils/widgets.py +++ b/openpype/tools/utils/widgets.py @@ -101,6 +101,46 @@ class PlaceholderLineEdit(QtWidgets.QLineEdit): self.setPalette(filter_palette) +class ExpandingTextEdit(QtWidgets.QTextEdit): + """QTextEdit which does not have sroll area but expands height.""" + + def __init__(self, parent=None): + super(ExpandingTextEdit, self).__init__(parent) + + size_policy = self.sizePolicy() + size_policy.setHeightForWidth(True) + size_policy.setVerticalPolicy(QtWidgets.QSizePolicy.Preferred) + self.setSizePolicy(size_policy) + + self.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + self.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff) + + doc = self.document() + doc.contentsChanged.connect(self._on_doc_change) + + def _on_doc_change(self): + self.updateGeometry() + + def hasHeightForWidth(self): + return True + + def heightForWidth(self, width): + margins = self.contentsMargins() + + document_width = 0 + if width >= margins.left() + margins.right(): + document_width = width - margins.left() - margins.right() + + document = self.document().clone() + document.setTextWidth(document_width) + + return margins.top() + document.size().height() + margins.bottom() + + def sizeHint(self): + width = super(ExpandingTextEdit, self).sizeHint().width() + return QtCore.QSize(width, self.heightForWidth(width)) + + class BaseClickableFrame(QtWidgets.QFrame): """Widget that catch left mouse click and can trigger a callback. @@ -161,19 +201,34 @@ class ClickableLabel(QtWidgets.QLabel): class ExpandBtnLabel(QtWidgets.QLabel): """Label showing expand icon meant for ExpandBtn.""" + state_changed = QtCore.Signal() + + def __init__(self, parent): super(ExpandBtnLabel, self).__init__(parent) - self._source_collapsed_pix = QtGui.QPixmap( - get_style_image_path("branch_closed") - ) - self._source_expanded_pix = QtGui.QPixmap( - get_style_image_path("branch_open") - ) + self._source_collapsed_pix = self._create_collapsed_pixmap() + self._source_expanded_pix = self._create_expanded_pixmap() self._current_image = self._source_collapsed_pix self._collapsed = True - def set_collapsed(self, collapsed): + def _create_collapsed_pixmap(self): + return QtGui.QPixmap( + get_style_image_path("branch_closed") + ) + + def _create_expanded_pixmap(self): + return QtGui.QPixmap( + get_style_image_path("branch_open") + ) + + @property + def collapsed(self): + return self._collapsed + + def set_collapsed(self, collapsed=None): + if collapsed is None: + collapsed = not self._collapsed if self._collapsed == collapsed: return self._collapsed = collapsed @@ -182,6 +237,7 @@ class ExpandBtnLabel(QtWidgets.QLabel): else: self._current_image = self._source_expanded_pix self._set_resized_pix() + self.state_changed.emit() def resizeEvent(self, event): self._set_resized_pix() @@ -203,21 +259,55 @@ class ExpandBtnLabel(QtWidgets.QLabel): class ExpandBtn(ClickableFrame): + state_changed = QtCore.Signal() + def __init__(self, parent=None): super(ExpandBtn, self).__init__(parent) - pixmap_label = ExpandBtnLabel(self) + pixmap_label = self._create_pix_widget(self) layout = QtWidgets.QHBoxLayout(self) layout.setContentsMargins(0, 0, 0, 0) layout.addWidget(pixmap_label) + pixmap_label.state_changed.connect(self.state_changed) + self._pixmap_label = pixmap_label - def set_collapsed(self, collapsed): + def _create_pix_widget(self, parent=None): + if parent is None: + parent = self + return ExpandBtnLabel(parent) + + @property + def collapsed(self): + return self._pixmap_label.collapsed + + def set_collapsed(self, collapsed=None): self._pixmap_label.set_collapsed(collapsed) +class ClassicExpandBtnLabel(ExpandBtnLabel): + def _create_collapsed_pixmap(self): + return QtGui.QPixmap( + get_style_image_path("right_arrow") + ) + + def _create_expanded_pixmap(self): + return QtGui.QPixmap( + get_style_image_path("down_arrow") + ) + + +class ClassicExpandBtn(ExpandBtn): + """Same as 'ExpandBtn' but with arrow images.""" + + def _create_pix_widget(self, parent=None): + if parent is None: + parent = self + return ClassicExpandBtnLabel(parent) + + class ImageButton(QtWidgets.QPushButton): """PushButton with icon and size of font.