From 1c78452d398581b92f45d37f5cb2d1e3f6c3d749 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Fri, 1 Oct 2021 17:54:01 +0200 Subject: [PATCH 01/89] Add basis for a new material sync wizard Just the set-up with a basic page to test the window with. Contributes to issue CURA-8609. --- .../Preferences/Materials/MaterialsPage.qml | 8 +++- .../Materials/MaterialsSyncDialog.qml | 44 +++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) create mode 100644 resources/qml/Preferences/Materials/MaterialsSyncDialog.qml diff --git a/resources/qml/Preferences/Materials/MaterialsPage.qml b/resources/qml/Preferences/Materials/MaterialsPage.qml index 4de3ad918b..8f15838e0d 100644 --- a/resources/qml/Preferences/Materials/MaterialsPage.qml +++ b/resources/qml/Preferences/Materials/MaterialsPage.qml @@ -201,8 +201,7 @@ Item onClicked: { forceActiveFocus(); - exportAllMaterialsDialog.folder = base.materialManagementModel.getPreferredExportAllPath(); - exportAllMaterialsDialog.open(); + materialsSyncDialog.show(); } visible: Cura.MachineManager.activeMachine.supportsMaterialExport } @@ -400,4 +399,9 @@ Item { id: messageDialog } + + MaterialsSyncDialog + { + id: materialsSyncDialog + } } diff --git a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml new file mode 100644 index 0000000000..55a44f3ad5 --- /dev/null +++ b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml @@ -0,0 +1,44 @@ +//Copyright (c) 2021 Ultimaker B.V. +//Cura is released under the terms of the LGPLv3 or higher. + +import QtQuick 2.1 +import QtQuick.Controls 2.1 +import QtQuick.Window 2.1 +import UM 1.2 as UM + +Window +{ + id: materialsSyncDialog + title: catalog.i18nc("@title:window", "Sync materials with printers") + minimumWidth: UM.Theme.getSize("modal_window_minimum").width + minimumHeight: UM.Theme.getSize("modal_window_minimum").height + width: minimumWidth + height: minimumHeight + + SwipeView + { + id: swipeView + anchors.fill: parent + + Rectangle + { + id: introPage + color: UM.Theme.getColor("main_background") + Column + { + Label + { + text: catalog.i18nc("@title:header", "Sync materials with printers") + font: UM.Theme.getFont("large_bold") + color: UM.Theme.getColor("text") + } + Label + { + text: catalog.i18nc("@text", "Following a few simple steps, you will be able to synchronize all your material profiles with your printers.") + font: UM.Theme.getFont("medium") + color: UM.Theme.getColor("text") + } + } + } + } +} \ No newline at end of file From 9ec731eaf62720a3a0fa25804db58c8f3fc7fe63 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Mon, 4 Oct 2021 16:23:28 +0200 Subject: [PATCH 02/89] Create MaterialsSyncDialog from a Python function Rather than from the QML. This allows creating this dialogue from a message button without needing to put it in the base application. Contributes to issue CURA-8609. --- cura/Machines/Models/MaterialManagementModel.py | 17 +++++++++++++++++ .../qml/Preferences/Materials/MaterialsPage.qml | 7 +------ .../Materials/MaterialsSyncDialog.qml | 2 ++ 3 files changed, 20 insertions(+), 6 deletions(-) diff --git a/cura/Machines/Models/MaterialManagementModel.py b/cura/Machines/Models/MaterialManagementModel.py index c75e16cd63..31f9bfbc92 100644 --- a/cura/Machines/Models/MaterialManagementModel.py +++ b/cura/Machines/Models/MaterialManagementModel.py @@ -10,6 +10,7 @@ import zipfile # To export all materials in a .zip archive. from UM.i18n import i18nCatalog from UM.Logger import Logger from UM.Message import Message +from UM.Resources import Resources # To find QML files. from UM.Signal import postponeSignals, CompressTechnique import cura.CuraApplication # Imported like this to prevent circular imports. @@ -28,6 +29,10 @@ class MaterialManagementModel(QObject): :param The base file of the material is provided as parameter when this emits """ + def __init__(self, parent: QObject = None): + super().__init__(parent) + self._sync_all_dialog = None # type: Optional[QObject] + @pyqtSlot("QVariant", result = bool) def canMaterialBeRemoved(self, material_node: "MaterialNode") -> bool: """Can a certain material be deleted, or is it still in use in one of the container stacks anywhere? @@ -262,6 +267,18 @@ class MaterialManagementModel(QObject): except ValueError: # Material was not in the favorites list. Logger.log("w", "Material {material_base_file} was already not a favorite material.".format(material_base_file = material_base_file)) + @pyqtSlot() + def openSyncAllWindow(self) -> None: + """ + Opens the window to sync all materials. + """ + if self._sync_all_dialog is None: + qml_path = Resources.getPath(cura.CuraApplication.CuraApplication.ResourceTypes.QmlFiles, "Preferences", "Materials", "MaterialsSyncDialog.qml") + self._sync_all_dialog = cura.CuraApplication.CuraApplication.getInstance().createQmlComponent(qml_path, {}) + if self._sync_all_dialog is None: # Failed to load QML file. + return + self._sync_all_dialog.show() + @pyqtSlot(result = QUrl) def getPreferredExportAllPath(self) -> QUrl: """ diff --git a/resources/qml/Preferences/Materials/MaterialsPage.qml b/resources/qml/Preferences/Materials/MaterialsPage.qml index 8f15838e0d..1d3519624d 100644 --- a/resources/qml/Preferences/Materials/MaterialsPage.qml +++ b/resources/qml/Preferences/Materials/MaterialsPage.qml @@ -201,7 +201,7 @@ Item onClicked: { forceActiveFocus(); - materialsSyncDialog.show(); + base.materialManagementModel.openSyncAllWindow(); } visible: Cura.MachineManager.activeMachine.supportsMaterialExport } @@ -399,9 +399,4 @@ Item { id: messageDialog } - - MaterialsSyncDialog - { - id: materialsSyncDialog - } } diff --git a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml index 55a44f3ad5..d7d92e1d5e 100644 --- a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml +++ b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml @@ -9,6 +9,8 @@ import UM 1.2 as UM Window { id: materialsSyncDialog + property variant catalog: UM.I18nCatalog { name: "cura" } + title: catalog.i18nc("@title:window", "Sync materials with printers") minimumWidth: UM.Theme.getSize("modal_window_minimum").width minimumHeight: UM.Theme.getSize("modal_window_minimum").height From b5edd5fa9c2427566ba80475b4493242663e1464 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Mon, 4 Oct 2021 16:27:36 +0200 Subject: [PATCH 03/89] Make Sync dialogue modal Otherwise it appears behind other modal windows, such as the material manager. Contributes to issue CURA-8609. --- resources/qml/Preferences/Materials/MaterialsSyncDialog.qml | 1 + 1 file changed, 1 insertion(+) diff --git a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml index d7d92e1d5e..038384eb0c 100644 --- a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml +++ b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml @@ -16,6 +16,7 @@ Window minimumHeight: UM.Theme.getSize("modal_window_minimum").height width: minimumWidth height: minimumHeight + modality: Qt.ApplicationModal SwipeView { From 1e9ae92d2935cda3048ff7ef5e43f4b82fca0874 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Mon, 4 Oct 2021 16:39:56 +0200 Subject: [PATCH 04/89] Add spacing and padding to first page Contributes to issue CURA-8609. --- resources/qml/Preferences/Materials/MaterialsSyncDialog.qml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml index 038384eb0c..d571050799 100644 --- a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml +++ b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml @@ -29,6 +29,10 @@ Window color: UM.Theme.getColor("main_background") Column { + spacing: UM.Theme.getSize("default_margin").height + anchors.fill: parent + anchors.margins: UM.Theme.getSize("default_margin").width + Label { text: catalog.i18nc("@title:header", "Sync materials with printers") From 346d9f1db29f1083658cf11a006c649650bf2115 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Mon, 4 Oct 2021 17:59:16 +0200 Subject: [PATCH 05/89] Add images describing the workflow to first page Contributes to issue CURA-8609. --- .../Materials/MaterialsSyncDialog.qml | 62 +++++++++++++++++++ .../themes/cura-light/images/3d_printer.svg | 27 ++++++++ .../cura-light/images/connected_cura.svg | 15 +++++ .../cura-light/images/material_spool.svg | 28 +++++++++ 4 files changed, 132 insertions(+) create mode 100644 resources/themes/cura-light/images/3d_printer.svg create mode 100644 resources/themes/cura-light/images/connected_cura.svg create mode 100644 resources/themes/cura-light/images/material_spool.svg diff --git a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml index d571050799..303b9cfbf5 100644 --- a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml +++ b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml @@ -45,6 +45,68 @@ Window font: UM.Theme.getFont("medium") color: UM.Theme.getColor("text") } + Row + { + width: parent.width + height: parent.height * 2 / 3 + Image + { + id: spool_image + source: UM.Theme.getImage("material_spool") + width: Math.round(parent.width / 6) + anchors.bottom: parent.bottom + fillMode: Image.PreserveAspectFit + sourceSize.width: width + } + Canvas + { + width: Math.round(parent.width / 12) + height: UM.Theme.getSize("thick_lining").width + onPaint: { + var ctx = getContext("2d"); + ctx.setLineDash([2, 2]); + ctx.lineWidth = UM.Theme.getSize("thick_lining").width; + ctx.beginPath(); + ctx.moveTo(0, height / 2); + ctx.lineTo(width, height / 2); + ctx.stroke(); + } + anchors.bottom: parent.bottom + anchors.bottomMargin: spool_image.paintedHeight / 2 - height / 2 //Align to the vertical center of spool_image's real size. + } + Image + { + source: UM.Theme.getImage("connected_cura") + width: Math.round(parent.width / 3) + anchors.bottom: parent.bottom + fillMode: Image.PreserveAspectFit + sourceSize.width: width + } + Canvas + { + width: Math.round(parent.width / 12) + height: UM.Theme.getSize("thick_lining").width + onPaint: { + var ctx = getContext("2d"); + ctx.setLineDash([2, 2]); + ctx.lineWidth = UM.Theme.getSize("thick_lining").width; + ctx.beginPath(); + ctx.moveTo(0, height / 2); + ctx.lineTo(width, height / 2); + ctx.stroke(); + } + anchors.bottom: parent.bottom + anchors.bottomMargin: spool_image.paintedHeight / 2 - height / 2 //Align to the vertical center of spool_image's real size. + } + Image + { + source: UM.Theme.getImage("3d_printer") + width: Math.round(parent.width / 3) + anchors.bottom: parent.bottom + fillMode: Image.PreserveAspectFit + sourceSize.width: width + } + } } } } diff --git a/resources/themes/cura-light/images/3d_printer.svg b/resources/themes/cura-light/images/3d_printer.svg new file mode 100644 index 0000000000..76651472ec --- /dev/null +++ b/resources/themes/cura-light/images/3d_printer.svg @@ -0,0 +1,27 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/resources/themes/cura-light/images/connected_cura.svg b/resources/themes/cura-light/images/connected_cura.svg new file mode 100644 index 0000000000..ea6f0935c9 --- /dev/null +++ b/resources/themes/cura-light/images/connected_cura.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/resources/themes/cura-light/images/material_spool.svg b/resources/themes/cura-light/images/material_spool.svg new file mode 100644 index 0000000000..80abb2c75c --- /dev/null +++ b/resources/themes/cura-light/images/material_spool.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + From e56076af08a55905eb22b1fea1bf8d0cd2151ea2 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Mon, 4 Oct 2021 18:09:47 +0200 Subject: [PATCH 06/89] Render material spool somewhat bigger and give it a margin The image of the spool doesn't have a margin by itself, contrary to the other images which have a visual margin given by the size of the table. We have to give it a margin manually. This is one of the consequences of encoding margins inside of images that may be used all over the place. We can't just remove the margins inside of the image because the laptop image will be used in other places too. Just adding a margin would subtract from the size of the spool image making it very small. To compensate I'm making the spool image slightly bigger. Contributes to issue CURA-8609. --- .../Materials/MaterialsSyncDialog.qml | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml index 303b9cfbf5..1b6be74f84 100644 --- a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml +++ b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml @@ -47,20 +47,32 @@ Window } Row { + /* + This is a row with 3 images, and a dashed line between each of them. + The images have various sizes, scaled to the window size: + - The computer screen and 3D printer have 1/3rd of the window size each. + - The remaining space is 2/3rds filled with the material spool image (so 2/9th in total). + - The remaining remaining space is divided equally over the two dashed lines (so 1/9th in total, or 1/18th per line). + */ width: parent.width height: parent.height * 2 / 3 - Image + Item { - id: spool_image - source: UM.Theme.getImage("material_spool") - width: Math.round(parent.width / 6) - anchors.bottom: parent.bottom - fillMode: Image.PreserveAspectFit - sourceSize.width: width + width: Math.round(parent.width * 2 / 9) + height: parent.height + Image + { + id: spool_image + source: UM.Theme.getImage("material_spool") + width: parent.width - UM.Theme.getSize("default_margin").width + anchors.bottom: parent.bottom + fillMode: Image.PreserveAspectFit + sourceSize.width: width + } } Canvas { - width: Math.round(parent.width / 12) + width: Math.round(parent.width / 18) height: UM.Theme.getSize("thick_lining").width onPaint: { var ctx = getContext("2d"); @@ -84,7 +96,7 @@ Window } Canvas { - width: Math.round(parent.width / 12) + width: Math.round(parent.width / 18) height: UM.Theme.getSize("thick_lining").width onPaint: { var ctx = getContext("2d"); From 6b0e221b73acf6975e0a1eb06193c2db347b0c71 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Mon, 4 Oct 2021 18:13:12 +0200 Subject: [PATCH 07/89] Implement word wrapping for introductory text Contributes to issue CURA-8609. --- resources/qml/Preferences/Materials/MaterialsSyncDialog.qml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml index 1b6be74f84..606286ab1c 100644 --- a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml +++ b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml @@ -44,6 +44,8 @@ Window text: catalog.i18nc("@text", "Following a few simple steps, you will be able to synchronize all your material profiles with your printers.") font: UM.Theme.getFont("medium") color: UM.Theme.getColor("text") + wrapMode: Text.WordWrap + width: parent.width - parent.anchors.margins * 2 } Row { From d8dcd0affc6f81f5e04342ed39a3ddbad5132318 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Tue, 5 Oct 2021 11:44:06 +0200 Subject: [PATCH 08/89] Replace three image layout with a single unified image This new image positions all the images correctly and makes sure it all uses the correct line width even though they are scaled relatively to each other. Contributes to issue CURA-8609. --- .../Materials/MaterialsSyncDialog.qml | 80 ++--------------- .../themes/cura-light/images/3d_printer.svg | 27 ------ .../cura-light/images/connected_cura.svg | 15 ---- .../cura-light/images/material_ecosystem.svg | 89 +++++++++++++++++++ .../cura-light/images/material_spool.svg | 28 ------ 5 files changed, 95 insertions(+), 144 deletions(-) delete mode 100644 resources/themes/cura-light/images/3d_printer.svg delete mode 100644 resources/themes/cura-light/images/connected_cura.svg create mode 100644 resources/themes/cura-light/images/material_ecosystem.svg delete mode 100644 resources/themes/cura-light/images/material_spool.svg diff --git a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml index 606286ab1c..be95efa009 100644 --- a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml +++ b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml @@ -45,81 +45,13 @@ Window font: UM.Theme.getFont("medium") color: UM.Theme.getColor("text") wrapMode: Text.WordWrap - width: parent.width - parent.anchors.margins * 2 - } - Row - { - /* - This is a row with 3 images, and a dashed line between each of them. - The images have various sizes, scaled to the window size: - - The computer screen and 3D printer have 1/3rd of the window size each. - - The remaining space is 2/3rds filled with the material spool image (so 2/9th in total). - - The remaining remaining space is divided equally over the two dashed lines (so 1/9th in total, or 1/18th per line). - */ width: parent.width - height: parent.height * 2 / 3 - Item - { - width: Math.round(parent.width * 2 / 9) - height: parent.height - Image - { - id: spool_image - source: UM.Theme.getImage("material_spool") - width: parent.width - UM.Theme.getSize("default_margin").width - anchors.bottom: parent.bottom - fillMode: Image.PreserveAspectFit - sourceSize.width: width - } - } - Canvas - { - width: Math.round(parent.width / 18) - height: UM.Theme.getSize("thick_lining").width - onPaint: { - var ctx = getContext("2d"); - ctx.setLineDash([2, 2]); - ctx.lineWidth = UM.Theme.getSize("thick_lining").width; - ctx.beginPath(); - ctx.moveTo(0, height / 2); - ctx.lineTo(width, height / 2); - ctx.stroke(); - } - anchors.bottom: parent.bottom - anchors.bottomMargin: spool_image.paintedHeight / 2 - height / 2 //Align to the vertical center of spool_image's real size. - } - Image - { - source: UM.Theme.getImage("connected_cura") - width: Math.round(parent.width / 3) - anchors.bottom: parent.bottom - fillMode: Image.PreserveAspectFit - sourceSize.width: width - } - Canvas - { - width: Math.round(parent.width / 18) - height: UM.Theme.getSize("thick_lining").width - onPaint: { - var ctx = getContext("2d"); - ctx.setLineDash([2, 2]); - ctx.lineWidth = UM.Theme.getSize("thick_lining").width; - ctx.beginPath(); - ctx.moveTo(0, height / 2); - ctx.lineTo(width, height / 2); - ctx.stroke(); - } - anchors.bottom: parent.bottom - anchors.bottomMargin: spool_image.paintedHeight / 2 - height / 2 //Align to the vertical center of spool_image's real size. - } - Image - { - source: UM.Theme.getImage("3d_printer") - width: Math.round(parent.width / 3) - anchors.bottom: parent.bottom - fillMode: Image.PreserveAspectFit - sourceSize.width: width - } + } + Image + { + source: UM.Theme.getImage("material_ecosystem") + width: parent.width + sourceSize.width: width } } } diff --git a/resources/themes/cura-light/images/3d_printer.svg b/resources/themes/cura-light/images/3d_printer.svg deleted file mode 100644 index 76651472ec..0000000000 --- a/resources/themes/cura-light/images/3d_printer.svg +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/resources/themes/cura-light/images/connected_cura.svg b/resources/themes/cura-light/images/connected_cura.svg deleted file mode 100644 index ea6f0935c9..0000000000 --- a/resources/themes/cura-light/images/connected_cura.svg +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - - - - - - - - - diff --git a/resources/themes/cura-light/images/material_ecosystem.svg b/resources/themes/cura-light/images/material_ecosystem.svg new file mode 100644 index 0000000000..30cf7a6473 --- /dev/null +++ b/resources/themes/cura-light/images/material_ecosystem.svg @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/resources/themes/cura-light/images/material_spool.svg b/resources/themes/cura-light/images/material_spool.svg deleted file mode 100644 index 80abb2c75c..0000000000 --- a/resources/themes/cura-light/images/material_spool.svg +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - From cef5c5da2a6051e578f90c0ceb936117f2c6b62b Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Tue, 5 Oct 2021 12:05:26 +0200 Subject: [PATCH 09/89] Add link to help and button to continue These two are aligned vertically. Contributes to issue CURA-8609. --- .../Materials/MaterialsSyncDialog.qml | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml index be95efa009..2a8828ca82 100644 --- a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml +++ b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml @@ -4,6 +4,7 @@ import QtQuick 2.1 import QtQuick.Controls 2.1 import QtQuick.Window 2.1 +import Cura 1.1 as Cura import UM 1.2 as UM Window @@ -54,6 +55,33 @@ Window sourceSize.width: width } } + + Cura.PrimaryButton + { + id: startButton + anchors + { + right: parent.right + rightMargin: UM.Theme.getSize("default_margin").width + bottom: parent.bottom + bottomMargin: UM.Theme.getSize("default_margin").height + } + text: catalog.i18nc("@button", "Start") + onClicked: swipeView.currentIndex += 1 + } + Cura.TertiaryButton + { + anchors + { + left: parent.left + leftMargin: UM.Theme.getSize("default_margin").width + verticalCenter: startButton.verticalCenter + } + text: catalog.i18nc("@button", "Why do I need to sync material profiles?") + iconSource: UM.Theme.getIcon("LinkExternal") + isIconOnRightSide: true + onClicked: Qt.openUrlExternally("https://ultimaker.com") + } } } } \ No newline at end of file From 3b62759ff299611b6e02268231f88a4a9d4c5400 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Tue, 5 Oct 2021 13:15:40 +0200 Subject: [PATCH 10/89] Design for sign in page of material sync dialog It's not functional yet at the moment, but this is a nice layout. Contributes to issue CURA-8609. --- .../Materials/MaterialsSyncDialog.qml | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml index 2a8828ca82..64c52fb7e0 100644 --- a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml +++ b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml @@ -3,6 +3,7 @@ import QtQuick 2.1 import QtQuick.Controls 2.1 +import QtQuick.Layouts 1.15 import QtQuick.Window 2.1 import Cura 1.1 as Cura import UM 1.2 as UM @@ -23,6 +24,7 @@ Window { id: swipeView anchors.fill: parent + interactive: false Rectangle { @@ -83,5 +85,65 @@ Window onClicked: Qt.openUrlExternally("https://ultimaker.com") } } + + Rectangle + { + id: signinPage + color: UM.Theme.getColor("main_background") + + ColumnLayout + { + spacing: UM.Theme.getSize("default_margin").height + anchors.fill: parent + anchors.margins: UM.Theme.getSize("default_margin").width + + Label + { + text: catalog.i18nc("@title:header", "Sign in") + font: UM.Theme.getFont("large_bold") + color: UM.Theme.getColor("text") + Layout.preferredHeight: height + } + Label + { + text: catalog.i18nc("@text", "To automatically sync the material profiles with all your printers connected to Digital Factory you need to be signed in in Cura.") + font: UM.Theme.getFont("medium") + color: UM.Theme.getColor("text") + wrapMode: Text.WordWrap + width: parent.width + Layout.maximumWidth: width + Layout.preferredHeight: height + } + Item + { + Layout.preferredWidth: parent.width + Layout.fillHeight: true + Image + { + source: UM.Theme.getImage("first_run_ultimaker_cloud") + width: parent.width / 2 + sourceSize.width: width + anchors.verticalCenter: parent.verticalCenter + anchors.horizontalCenter: parent.horizontalCenter + } + } + Item + { + width: parent.width + height: childrenRect.height + Layout.preferredHeight: height + Cura.SecondaryButton + { + anchors.left: parent.left + text: catalog.i18nc("@button", "Sync materials with USB") + } + Cura.PrimaryButton + { + anchors.right: parent.right + text: catalog.i18nc("@button", "Sign in") + } + } + } + } } } \ No newline at end of file From c761820d520e3b38435e513ac2617efa2821516c Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Tue, 5 Oct 2021 13:22:33 +0200 Subject: [PATCH 11/89] Add behaviour to sign in page buttons It will now sign you in when you press that button. It doesn't automatically proceed to the next page when signed in though. For that I reckon I'll need to build that next page first, or at least the beginnings of it. Contributes to issue CURA-8609. --- resources/qml/Preferences/Materials/MaterialsSyncDialog.qml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml index 64c52fb7e0..ee34df3194 100644 --- a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml +++ b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml @@ -136,11 +136,13 @@ Window { anchors.left: parent.left text: catalog.i18nc("@button", "Sync materials with USB") + onClicked: swipeView.currentIndex = swipeView.count - 1 //Go to the last page, which is USB. } Cura.PrimaryButton { anchors.right: parent.right text: catalog.i18nc("@button", "Sign in") + onClicked: Cura.API.account.login() } } } From 9b1c8d1c7aad98182d4d666a0f44d027d62e5a48 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Tue, 5 Oct 2021 13:26:25 +0200 Subject: [PATCH 12/89] Add beginnings of printer list page We just need this to have something to navigate to from the other pages as I implement that it automatically skips the sign in page when already signed in, and that it continues after being signed in from the sign in page. Contributes to issue CURA-8609. --- .../Materials/MaterialsSyncDialog.qml | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml index ee34df3194..8d7be1e119 100644 --- a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml +++ b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml @@ -147,5 +147,27 @@ Window } } } + + Rectangle + { + id: printerListPage + color: UM.Theme.getColor("main_background") + + ColumnLayout + { + spacing: UM.Theme.getSize("default_margin").height + anchors.fill: parent + anchors.margins: UM.Theme.getSize("default_margin").width + + Label + { + text: catalog.i18nc("@title:header", "The following printers will receive the new material profiles") + font: UM.Theme.getFont("large_bold") + color: UM.Theme.getColor("text") + Layout.preferredHeight: height + } + //TODO: Add contents. + } + } } } \ No newline at end of file From f2aba01eff3290dca8bb40215c63c67928eab5d6 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Tue, 5 Oct 2021 13:32:37 +0200 Subject: [PATCH 13/89] Reset page index to 0 when re-opening sync dialogue When the user previously opened the dialog and advanced through the pages, but closes the window, then they'll probably have to restart when they want to try it again. Contributes to issue CURA-8609. --- cura/Machines/Models/MaterialManagementModel.py | 1 + resources/qml/Preferences/Materials/MaterialsSyncDialog.qml | 2 ++ 2 files changed, 3 insertions(+) diff --git a/cura/Machines/Models/MaterialManagementModel.py b/cura/Machines/Models/MaterialManagementModel.py index 31f9bfbc92..8f1d421ffb 100644 --- a/cura/Machines/Models/MaterialManagementModel.py +++ b/cura/Machines/Models/MaterialManagementModel.py @@ -277,6 +277,7 @@ class MaterialManagementModel(QObject): self._sync_all_dialog = cura.CuraApplication.CuraApplication.getInstance().createQmlComponent(qml_path, {}) if self._sync_all_dialog is None: # Failed to load QML file. return + self._sync_all_dialog.setProperty("pageIndex", 0) self._sync_all_dialog.show() @pyqtSlot(result = QUrl) diff --git a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml index 8d7be1e119..88532d20e4 100644 --- a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml +++ b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml @@ -20,6 +20,8 @@ Window height: minimumHeight modality: Qt.ApplicationModal + property alias pageIndex: swipeView.currentIndex + SwipeView { id: swipeView From 0bb0c672c5fd8a2976aa6d7c6bc55a49947e8fe3 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Tue, 5 Oct 2021 14:05:08 +0200 Subject: [PATCH 14/89] Skip sign in page if already signed in Contributes to issue CURA-8609. --- .../qml/Preferences/Materials/MaterialsSyncDialog.qml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml index 88532d20e4..a852095357 100644 --- a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml +++ b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml @@ -71,7 +71,16 @@ Window bottomMargin: UM.Theme.getSize("default_margin").height } text: catalog.i18nc("@button", "Start") - onClicked: swipeView.currentIndex += 1 + onClicked: { + if(Cura.API.account.isLoggedIn) + { + swipeView.currentIndex += 2; //Skip sign in page. + } + else + { + swipeView.currentIndex += 1; + } + } } Cura.TertiaryButton { From ec79961153b3d25417f8639df69fbc08b3c109d6 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Tue, 5 Oct 2021 14:20:50 +0200 Subject: [PATCH 15/89] Skip to next page when login is successful Contributes to issue CURA-8609. --- .../Preferences/Materials/MaterialsSyncDialog.qml | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml index a852095357..b4162b63e9 100644 --- a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml +++ b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml @@ -102,6 +102,18 @@ Window id: signinPage color: UM.Theme.getColor("main_background") + Connections //While this page is active, continue to the next page if the user logs in. + { + target: Cura.API.account + function onLoginStateChanged(is_logged_in) + { + if(is_logged_in && signinPage.SwipeView.isCurrentItem) + { + swipeView.currentIndex += 1; + } + } + } + ColumnLayout { spacing: UM.Theme.getSize("default_margin").height From c95de3e37943252f972267cea98f7995dcee0839 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Tue, 5 Oct 2021 14:30:42 +0200 Subject: [PATCH 16/89] Add main layout for printer list page The actual printer list is not implemented yet and some of these elements may need to be swapped out if there are no printers available. But this is the basis of it. Contributes to issue CURA-8609. --- .../Materials/MaterialsSyncDialog.qml | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml index b4162b63e9..caa693c685 100644 --- a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml +++ b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml @@ -189,7 +189,38 @@ Window color: UM.Theme.getColor("text") Layout.preferredHeight: height } - //TODO: Add contents. + Rectangle + { + color: "pink" + width: parent.width + Layout.preferredWidth: width + Layout.fillHeight: true + //TODO: Add printer list. + } + Cura.TertiaryButton + { + text: catalog.i18nc("@button", "Troubleshooting") + iconSource: UM.Theme.getIcon("LinkExternal") + Layout.preferredHeight: height + } + Item + { + width: parent.width + height: childrenRect.height + Layout.preferredWidth: width + Layout.preferredHeight: height + Cura.SecondaryButton + { + anchors.left: parent.left + text: catalog.i18nc("@button", "Sync materials with USB") + onClicked: swipeView.currentIndex = swipeView.count - 1 //Go to the last page, which is USB. + } + Cura.PrimaryButton + { + anchors.right: parent.right + text: catalog.i18nc("@button", "Sync") + } + } } } } From 7a47ffb1b7ea4b65d0ff528e4a9c72b155910a50 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Tue, 5 Oct 2021 16:34:45 +0200 Subject: [PATCH 17/89] Add design for cards with printers I'm adding the theme entry 'card' once and for all to hopefully unify the design we're using for cards in the toolbox, in the digital library, in the LAN connection interface, etc. Contributes to issue CURA-8609. --- .../Materials/MaterialsSyncDialog.qml | 74 +++++++++++++++++-- resources/themes/cura-light/theme.json | 1 + 2 files changed, 69 insertions(+), 6 deletions(-) diff --git a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml index caa693c685..e7e8b1f2aa 100644 --- a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml +++ b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml @@ -2,7 +2,7 @@ //Cura is released under the terms of the LGPLv3 or higher. import QtQuick 2.1 -import QtQuick.Controls 2.1 +import QtQuick.Controls 2.15 import QtQuick.Layouts 1.15 import QtQuick.Window 2.1 import Cura 1.1 as Cura @@ -146,8 +146,7 @@ Window source: UM.Theme.getImage("first_run_ultimaker_cloud") width: parent.width / 2 sourceSize.width: width - anchors.verticalCenter: parent.verticalCenter - anchors.horizontalCenter: parent.horizontalCenter + anchors.centerIn: parent } } Item @@ -189,13 +188,76 @@ Window color: UM.Theme.getColor("text") Layout.preferredHeight: height } - Rectangle + ScrollView { - color: "pink" + id: printerListScrollView width: parent.width Layout.preferredWidth: width Layout.fillHeight: true - //TODO: Add printer list. + clip: true + ScrollBar.horizontal.policy: ScrollBar.AlwaysOff + + ListView + { + width: parent.width + spacing: UM.Theme.getSize("default_margin").height + + model: Cura.GlobalStacksModel {} + delegate: Rectangle + { + border.color: UM.Theme.getColor("lining") + border.width: UM.Theme.getSize("default_lining").width + width: printerListScrollView.width + height: UM.Theme.getSize("card").height + + Cura.IconWithText + { + anchors + { + verticalCenter: parent.verticalCenter + left: parent.left + leftMargin: Math.round(parent.height - height) / 2 //Equal margin on the left as above and below. + right: parent.right + rightMargin: Math.round(parent.height - height) / 2 + } + + text: model.name + font: UM.Theme.getFont("medium") + + source: UM.Theme.getIcon("Printer", "medium") + iconColor: UM.Theme.getColor("machine_selector_printer_icon") + iconSize: UM.Theme.getSize("machine_selector_icon").width + + //Printer status badge (always cloud, but whether it's online or offline). + UM.RecolorImage + { + width: UM.Theme.getSize("printer_status_icon").width + height: UM.Theme.getSize("printer_status_icon").height + anchors + { + bottom: parent.bottom + bottomMargin: -Math.round(height / 6) + left: parent.left + leftMargin: parent.iconSize - Math.round(width * 5 / 6) + } + + source: UM.Theme.getIcon("CloudBadge", "low") + color: "red" //TODO: connectionStatus == "printer_cloud_not_available" ? UM.Theme.getColor("cloud_unavailable") : UM.Theme.getColor("primary") + + //Make a themeable circle in the background so we can change it in other themes. + Rectangle + { + anchors.centerIn: parent + width: parent.width - 1.5 //1.5 pixels smaller (at least sqrt(2), regardless of pixel scale) so that the circle doesn't show up behind the icon due to anti-aliasing. + height: parent.height - 1.5 + radius: width / 2 + color: UM.Theme.getColor("connection_badge_background") + z: parent.z - 1 + } + } + } + } + } } Cura.TertiaryButton { diff --git a/resources/themes/cura-light/theme.json b/resources/themes/cura-light/theme.json index daa12b3390..148d020f60 100644 --- a/resources/themes/cura-light/theme.json +++ b/resources/themes/cura-light/theme.json @@ -540,6 +540,7 @@ "section_icon": [2, 2], "section_icon_column": [2.5, 2.5], "rating_star": [1.0, 1.0], + "card": [25.0, 6.0], "setting": [25.0, 1.8], "setting_control": [11.0, 2.0], From 554f580f131b5db521c1a03eaaf1c2209202f564 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Tue, 5 Oct 2021 17:28:26 +0200 Subject: [PATCH 18/89] Add extra card to refresh printer list Contributes to issue CURA-8609. --- .../Materials/MaterialsSyncDialog.qml | 65 +++++++++++++++++++ .../cura-light/icons/default/EmptyInfo.svg | 3 + 2 files changed, 68 insertions(+) create mode 100644 resources/themes/cura-light/icons/default/EmptyInfo.svg diff --git a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml index e7e8b1f2aa..06a26b2ad4 100644 --- a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml +++ b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml @@ -199,6 +199,7 @@ Window ListView { + id: printerList width: parent.width spacing: UM.Theme.getSize("default_margin").height @@ -257,6 +258,70 @@ Window } } } + + footer: Item + { + width: printerListScrollView.width + height: UM.Theme.getSize("card").height + UM.Theme.getSize("default_margin").height + Rectangle + { + border.color: UM.Theme.getColor("lining") + border.width: UM.Theme.getSize("default_lining").width + anchors.fill: parent + anchors.topMargin: UM.Theme.getSize("default_margin").height + + RowLayout + { + anchors + { + fill: parent + leftMargin: (parent.height - infoIcon.height) / 2 //Same margin on the left as top and bottom. + rightMargin: (parent.height - infoIcon.height) / 2 + } + spacing: UM.Theme.getSize("default_margin").width + + Rectangle //Info icon with a themeable color and background. + { + id: infoIcon + width: UM.Theme.getSize("machine_selector_icon").width + height: width + Layout.preferredWidth: width + Layout.alignment: Qt.AlignVCenter + radius: height / 2 + color: UM.Theme.getColor("warning") + + UM.RecolorImage + { + source: UM.Theme.getIcon("EmptyInfo") + anchors.fill: parent + color: UM.Theme.getColor("machine_selector_printer_icon") + } + } + + Label + { + text: catalog.i18nc("@text Asking the user whether printers are missing in a list.", "Printers missing?") + + "\n" + + catalog.i18nc("@text", "Make sure all your printers are turned ON and connected to Digital Factory.") + font: UM.Theme.getFont("medium") + elide: Text.ElideRight + + Layout.alignment: Qt.AlignVCenter + Layout.fillWidth: true + } + + Cura.SecondaryButton + { + id: refreshListButton + text: catalog.i18nc("@button", "Refresh List") + iconSource: UM.Theme.getIcon("ArrowDoubleCircleRight") + + Layout.alignment: Qt.AlignVCenter + Layout.preferredWidth: width + } + } + } + } } } Cura.TertiaryButton diff --git a/resources/themes/cura-light/icons/default/EmptyInfo.svg b/resources/themes/cura-light/icons/default/EmptyInfo.svg new file mode 100644 index 0000000000..49d67746d1 --- /dev/null +++ b/resources/themes/cura-light/icons/default/EmptyInfo.svg @@ -0,0 +1,3 @@ + + + From c8931d4c23356470f416c75d65858f82039a0495 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Tue, 5 Oct 2021 17:31:27 +0200 Subject: [PATCH 19/89] Implement sync function for refresh button Quite simple. There's not a lot of feedback though when you press that button. Maybe we need to look at that. Contributes to issue CURA-8609. --- resources/qml/Preferences/Materials/MaterialsSyncDialog.qml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml index 06a26b2ad4..0c410c996a 100644 --- a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml +++ b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml @@ -318,6 +318,8 @@ Window Layout.alignment: Qt.AlignVCenter Layout.preferredWidth: width + + onClicked: Cura.API.account.sync(true) } } } From d16217c674cb7ea379037f9b4487678bf4a20586 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Tue, 5 Oct 2021 17:44:15 +0200 Subject: [PATCH 20/89] Implement filter for printer list to only show printers with certain configured connections Because here we only want to show cloud-connected printers. Contributes to issue CURA-8609. --- cura/Machines/Models/GlobalStacksModel.py | 25 +++++++++++++++++-- .../Materials/MaterialsSyncDialog.qml | 5 +++- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/cura/Machines/Models/GlobalStacksModel.py b/cura/Machines/Models/GlobalStacksModel.py index 712597c2e7..bcc6a81584 100644 --- a/cura/Machines/Models/GlobalStacksModel.py +++ b/cura/Machines/Models/GlobalStacksModel.py @@ -1,7 +1,7 @@ -# Copyright (c) 2018 Ultimaker B.V. +# Copyright (c) 2021 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from PyQt5.QtCore import Qt, QTimer +from PyQt5.QtCore import Qt, QTimer, pyqtProperty, pyqtSignal from UM.Qt.ListModel import ListModel from UM.i18n import i18nCatalog @@ -37,12 +37,29 @@ class GlobalStacksModel(ListModel): self._change_timer.setSingleShot(True) self._change_timer.timeout.connect(self._update) + self._filter_connection_type = -1 + # Listen to changes CuraContainerRegistry.getInstance().containerAdded.connect(self._onContainerChanged) CuraContainerRegistry.getInstance().containerMetaDataChanged.connect(self._onContainerChanged) CuraContainerRegistry.getInstance().containerRemoved.connect(self._onContainerChanged) self._updateDelayed() + filterConnectionTypeChanged = pyqtSignal() + + def setFilterConnectionType(self, new_filter: int) -> None: + self._filter_connection_type = new_filter + + @pyqtProperty(int, fset = setFilterConnectionType, notify = filterConnectionTypeChanged) + def filterConnectionType(self) -> int: + """ + The connection type to filter the list of printers by. + + Only printers that match this connection type will be listed in the + model. + """ + return self._filter_connection_type + def _onContainerChanged(self, container) -> None: """Handler for container added/removed events from registry""" @@ -58,6 +75,10 @@ class GlobalStacksModel(ListModel): container_stacks = CuraContainerRegistry.getInstance().findContainerStacks(type = "machine") for container_stack in container_stacks: + if self._filter_connection_type != -1: # We want to filter on connection types. + if not any((connection_type == self._filter_connection_type for connection_type in container_stack.configuredConnectionTypes)): + continue # No connection type on this printer matches the filter. + has_remote_connection = False for connection_type in container_stack.configuredConnectionTypes: diff --git a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml index 0c410c996a..34c263bf6b 100644 --- a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml +++ b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml @@ -203,7 +203,10 @@ Window width: parent.width spacing: UM.Theme.getSize("default_margin").height - model: Cura.GlobalStacksModel {} + model: Cura.GlobalStacksModel + { + filterConnectionType: 3 //Only show cloud connections. + } delegate: Rectangle { border.color: UM.Theme.getColor("lining") From 5db964aed10b37aa77f22281068d44333aa32bcb Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Wed, 6 Oct 2021 14:25:12 +0200 Subject: [PATCH 21/89] Link opening sync all window to the button that should open it This is a new button just merged in from the Master branch. With the window implemented, we can now properly open it. Contributes to issue CURA-8609. --- cura/Machines/Models/MaterialManagementModel.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cura/Machines/Models/MaterialManagementModel.py b/cura/Machines/Models/MaterialManagementModel.py index 35b3f077ab..fda687c3ef 100644 --- a/cura/Machines/Models/MaterialManagementModel.py +++ b/cura/Machines/Models/MaterialManagementModel.py @@ -83,8 +83,7 @@ class MaterialManagementModel(QObject): def _onSyncMaterialsMessageActionTriggered(self, sync_message: Message, sync_message_action: str): if sync_message_action == "sync": - QDesktopServices.openUrl(QUrl("https://example.com/openSyncAllWindow")) - # self.openSyncAllWindow() + self.openSyncAllWindow() sync_message.hide() elif sync_message_action == "learn_more": QDesktopServices.openUrl(QUrl("https://support.ultimaker.com/hc/en-us/articles/360013137919?utm_source=cura&utm_medium=software&utm_campaign=sync-material-printer-message")) From e5dc90a51917818dc57d0efd944af804ebd395be Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Wed, 6 Oct 2021 15:11:14 +0200 Subject: [PATCH 22/89] Add USB syncing page to swipe view Looks like a lot of blank space. May need to adjust sizes. Contributes to issue CURA-8609. --- .../Materials/MaterialsSyncDialog.qml | 79 +++++++++++++++++++ .../themes/cura-light/images/insert_usb.svg | 33 ++++++++ 2 files changed, 112 insertions(+) create mode 100644 resources/themes/cura-light/images/insert_usb.svg diff --git a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml index 34c263bf6b..215ac646ff 100644 --- a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml +++ b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml @@ -341,6 +341,7 @@ Window height: childrenRect.height Layout.preferredWidth: width Layout.preferredHeight: height + Cura.SecondaryButton { anchors.left: parent.left @@ -355,5 +356,83 @@ Window } } } + + Rectangle + { + id: removableDriveSyncPage + color: UM.Theme.getColor("main_background") + + ColumnLayout + { + spacing: UM.Theme.getSize("default_margin").height + anchors.fill: parent + anchors.margins: UM.Theme.getSize("default_margin").width + + Label + { + text: catalog.i18nc("@title:header", "Sync material profiles via USB") + font: UM.Theme.getFont("large_bold") + color: UM.Theme.getColor("text") + Layout.preferredHeight: height + } + Label + { + text: catalog.i18nc("@text In the UI this is followed by a list of steps the user needs to take.", "Follow the following steps to load the new material profiles to your printer.") + font: UM.Theme.getFont("medium") + color: UM.Theme.getColor("text") + wrapMode: Text.WordWrap + width: parent.width + Layout.maximumWidth: width + Layout.preferredHeight: height + } + Row + { + width: parent.width + Layout.preferredWidth: width + Layout.fillHeight: true + spacing: UM.Theme.getSize("default_margin").width + + Image + { + source: UM.Theme.getImage("insert_usb") + width: parent.width / 4 + height: width + anchors.verticalCenter: parent.verticalCenter + sourceSize.width: width + } + Label + { + text: "1. " + catalog.i18nc("@text 'hit' as in pressing the button", "Hit the export material archive button.") + + "\n2. " + catalog.i18nc("@text", "Save the .umm file on a USB stick.") + + "\n3. " + catalog.i18nc("@text", "Insert the USB stick into your printer and launch the procedure to load new material profiles.") + font: UM.Theme.getFont("medium") + color: UM.Theme.getColor("text") + wrapMode: Text.WordWrap + width: parent.width * 3 / 4 - UM.Theme.getSize("default_margin").width + anchors.verticalCenter: parent.verticalCenter + } + } + Item + { + width: parent.width + height: childrenRect.height + Layout.preferredWidth: width + Layout.preferredHeight: height + + Cura.TertiaryButton + { + anchors.left: parent.left + text: catalog.i18nc("@button", "How to load new material profiles to my printer") + iconSource: UM.Theme.getIcon("LinkExternal") + onClicked: Qt.openUrlExternally("https://www.ultimaker.com") + } + Cura.PrimaryButton + { + anchors.right: parent.right + text: catalog.i18nc("@button", "Export material archive") + } + } + } + } } } \ No newline at end of file diff --git a/resources/themes/cura-light/images/insert_usb.svg b/resources/themes/cura-light/images/insert_usb.svg new file mode 100644 index 0000000000..4a343e1477 --- /dev/null +++ b/resources/themes/cura-light/images/insert_usb.svg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file From 038db1fc4f9522dd79da4b7073db3b615e6a7f19 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Wed, 6 Oct 2021 15:20:26 +0200 Subject: [PATCH 23/89] Implement functionality of export to USB button It now creates a similar dialogue of what the old button did. The dialogue is no longer necessary in the materials page, so I've moved it to here specifically. Contributes to issue CURA-8609. --- .../Models/MaterialManagementModel.py | 1 + .../Preferences/Materials/MaterialsPage.qml | 13 ------------ .../Materials/MaterialsSyncDialog.qml | 20 +++++++++++++++++++ 3 files changed, 21 insertions(+), 13 deletions(-) diff --git a/cura/Machines/Models/MaterialManagementModel.py b/cura/Machines/Models/MaterialManagementModel.py index fda687c3ef..d5191988fa 100644 --- a/cura/Machines/Models/MaterialManagementModel.py +++ b/cura/Machines/Models/MaterialManagementModel.py @@ -332,6 +332,7 @@ class MaterialManagementModel(QObject): self._sync_all_dialog = cura.CuraApplication.CuraApplication.getInstance().createQmlComponent(qml_path, {}) if self._sync_all_dialog is None: # Failed to load QML file. return + self._sync_all_dialog.setProperty("materialManagementModel", self) self._sync_all_dialog.setProperty("pageIndex", 0) self._sync_all_dialog.show() diff --git a/resources/qml/Preferences/Materials/MaterialsPage.qml b/resources/qml/Preferences/Materials/MaterialsPage.qml index 1d3519624d..6ec23f001f 100644 --- a/resources/qml/Preferences/Materials/MaterialsPage.qml +++ b/resources/qml/Preferences/Materials/MaterialsPage.qml @@ -382,19 +382,6 @@ Item } } - FileDialog - { - id: exportAllMaterialsDialog - title: catalog.i18nc("@title:window", "Export All Materials") - selectExisting: false - nameFilters: ["Material archives (*.umm)", "All files (*)"] - onAccepted: - { - base.materialManagementModel.exportAll(fileUrl); - CuraApplication.setDefaultPath("dialog_material_path", folder); - } - } - MessageDialog { id: messageDialog diff --git a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml index 215ac646ff..2e9522e445 100644 --- a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml +++ b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml @@ -3,6 +3,7 @@ import QtQuick 2.1 import QtQuick.Controls 2.15 +import QtQuick.Dialogs 1.2 import QtQuick.Layouts 1.15 import QtQuick.Window 2.1 import Cura 1.1 as Cura @@ -20,6 +21,7 @@ Window height: minimumHeight modality: Qt.ApplicationModal + property variant materialManagementModel property alias pageIndex: swipeView.currentIndex SwipeView @@ -430,9 +432,27 @@ Window { anchors.right: parent.right text: catalog.i18nc("@button", "Export material archive") + onClicked: + { + exportUsbDialog.folder = materialManagementModel.getPreferredExportAllPath(); + exportUsbDialog.open(); + } } } } } } + + FileDialog + { + id: exportUsbDialog + title: catalog.i18nc("@title:window", "Export All Materials") + selectExisting: false + nameFilters: ["Material archives (*.umm)", "All files (*)"] + onAccepted: + { + materialManagementModel.exportAll(fileUrl); + CuraApplication.setDefaultPath("dialog_material_path", folder); + } + } } \ No newline at end of file From 52373c6850763f99386579b82955be39122c19c1 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Wed, 6 Oct 2021 15:46:43 +0200 Subject: [PATCH 24/89] Add correct links for troubleshooting buttons Contributes to issue CURA-8609. --- resources/qml/Preferences/Materials/MaterialsSyncDialog.qml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml index 2e9522e445..0eb3b30c63 100644 --- a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml +++ b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml @@ -95,7 +95,7 @@ Window text: catalog.i18nc("@button", "Why do I need to sync material profiles?") iconSource: UM.Theme.getIcon("LinkExternal") isIconOnRightSide: true - onClicked: Qt.openUrlExternally("https://ultimaker.com") + onClicked: Qt.openUrlExternally("https://support.ultimaker.com/hc/en-us/articles/360013137919?utm_source=cura&utm_medium=software&utm_campaign=sync-material-printer-why") } } @@ -336,6 +336,7 @@ Window text: catalog.i18nc("@button", "Troubleshooting") iconSource: UM.Theme.getIcon("LinkExternal") Layout.preferredHeight: height + onClicked: Qt.openUrlExternally("https://support.ultimaker.com/hc/en-us/articles/360012019239?utm_source=cura&utm_medium=software&utm_campaign=sync-material-wizard-troubleshoot-cloud-printer") } Item { @@ -426,7 +427,7 @@ Window anchors.left: parent.left text: catalog.i18nc("@button", "How to load new material profiles to my printer") iconSource: UM.Theme.getIcon("LinkExternal") - onClicked: Qt.openUrlExternally("https://www.ultimaker.com") + onClicked: Qt.openUrlExternally("https://support.ultimaker.com/hc/en-us/articles/360013137919?utm_source=cura&utm_medium=software&utm_campaign=sync-material-wizard-how-usb") } Cura.PrimaryButton { From d9d83b4334a3643039ed4a23296713375ec13877 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Wed, 6 Oct 2021 16:42:56 +0200 Subject: [PATCH 25/89] Add page for when the user has no cloud printers It should show this image, instructions, and a refresh button. If the refresh button is pressed and the user now has more printers, it should automatically swap to the printer list. Or vice-versa if printers get deleted on the website. Contributes to issue CURA-8609. --- .../Materials/MaterialsSyncDialog.qml | 99 +++++++++++++++++-- .../cura-light/images/3d_printer_faded.svg | 29 ++++++ 2 files changed, 119 insertions(+), 9 deletions(-) create mode 100644 resources/themes/cura-light/images/3d_printer_faded.svg diff --git a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml index 0eb3b30c63..7a426b836c 100644 --- a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml +++ b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml @@ -51,7 +51,7 @@ Window text: catalog.i18nc("@text", "Following a few simple steps, you will be able to synchronize all your material profiles with your printers.") font: UM.Theme.getFont("medium") color: UM.Theme.getColor("text") - wrapMode: Text.WordWrap + wrapMode: Text.Wrap width: parent.width } Image @@ -73,7 +73,8 @@ Window bottomMargin: UM.Theme.getSize("default_margin").height } text: catalog.i18nc("@button", "Start") - onClicked: { + onClicked: + { if(Cura.API.account.isLoggedIn) { swipeView.currentIndex += 2; //Skip sign in page. @@ -134,7 +135,7 @@ Window text: catalog.i18nc("@text", "To automatically sync the material profiles with all your printers connected to Digital Factory you need to be signed in in Cura.") font: UM.Theme.getFont("medium") color: UM.Theme.getColor("text") - wrapMode: Text.WordWrap + wrapMode: Text.Wrap width: parent.width Layout.maximumWidth: width Layout.preferredHeight: height @@ -182,6 +183,7 @@ Window spacing: UM.Theme.getSize("default_margin").height anchors.fill: parent anchors.margins: UM.Theme.getSize("default_margin").width + visible: cloudPrinterList.count > 0 Label { @@ -205,10 +207,7 @@ Window width: parent.width spacing: UM.Theme.getSize("default_margin").height - model: Cura.GlobalStacksModel - { - filterConnectionType: 3 //Only show cloud connections. - } + model: cloudPrinterList delegate: Rectangle { border.color: UM.Theme.getColor("lining") @@ -358,6 +357,82 @@ Window } } } + + ColumnLayout //Placeholder for when the user has no cloud printers. + { + spacing: UM.Theme.getSize("default_margin").height + anchors.fill: parent + anchors.margins: UM.Theme.getSize("default_margin").width + visible: cloudPrinterList.count == 0 + + Label + { + text: catalog.i18nc("@title:header", "No printers found") + font: UM.Theme.getFont("large_bold") + color: UM.Theme.getColor("text") + Layout.preferredWidth: width + Layout.preferredHeight: height + } + Image + { + source: UM.Theme.getImage("3d_printer_faded") + sourceSize.width: width + fillMode: Image.PreserveAspectFit + Layout.alignment: Qt.AlignHCenter + Layout.preferredWidth: parent.width / 3 + } + Label + { + text: catalog.i18nc("@text", "It seems like you don't have access to any printers connected to Digital Factory.") + width: parent.width + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.Wrap + Layout.preferredWidth: width + Layout.preferredHeight: height + } + Item + { + Layout.preferredWidth: parent.width + Layout.fillHeight: true + Cura.TertiaryButton + { + text: catalog.i18nc("@button", "Learn how to connect your printer to Digital Factory") + iconSource: UM.Theme.getIcon("LinkExternal") + onClicked: Qt.openUrlExternally("https://support.ultimaker.com/hc/en-us/articles/360012019239?utm_source=cura&utm_medium=software&utm_campaign=sync-material-wizard-add-cloud-printer") + anchors.horizontalCenter: parent.horizontalCenter + } + } + Item + { + width: parent.width + height: childrenRect.height + Layout.preferredWidth: width + Layout.preferredHeight: height + + Cura.SecondaryButton + { + anchors.left: parent.left + text: catalog.i18nc("@button", "Sync materials with USB") + onClicked: swipeView.currentIndex = swipeView.count - 1 //Go to the last page, which is USB. + } + Cura.PrimaryButton + { + id: disabledSyncButton + anchors.right: parent.right + text: catalog.i18nc("@button", "Sync") + enabled: false //If there are no printers, always disable this button. + } + Cura.SecondaryButton + { + anchors.right: disabledSyncButton.left + anchors.rightMargin: UM.Theme.getSize("default_margin").width + text: catalog.i18nc("@button", "Refresh") + iconSource: UM.Theme.getIcon("ArrowDoubleCircleRight") + outlineColor: "transparent" + onClicked: Cura.API.account.sync(true) + } + } + } } Rectangle @@ -383,7 +458,7 @@ Window text: catalog.i18nc("@text In the UI this is followed by a list of steps the user needs to take.", "Follow the following steps to load the new material profiles to your printer.") font: UM.Theme.getFont("medium") color: UM.Theme.getColor("text") - wrapMode: Text.WordWrap + wrapMode: Text.Wrap width: parent.width Layout.maximumWidth: width Layout.preferredHeight: height @@ -410,7 +485,7 @@ Window + "\n3. " + catalog.i18nc("@text", "Insert the USB stick into your printer and launch the procedure to load new material profiles.") font: UM.Theme.getFont("medium") color: UM.Theme.getColor("text") - wrapMode: Text.WordWrap + wrapMode: Text.Wrap width: parent.width * 3 / 4 - UM.Theme.getSize("default_margin").width anchors.verticalCenter: parent.verticalCenter } @@ -444,6 +519,12 @@ Window } } + Cura.GlobalStacksModel + { + id: cloudPrinterList + filterConnectionType: 3 //Only show cloud connections. + } + FileDialog { id: exportUsbDialog diff --git a/resources/themes/cura-light/images/3d_printer_faded.svg b/resources/themes/cura-light/images/3d_printer_faded.svg new file mode 100644 index 0000000000..001b12e266 --- /dev/null +++ b/resources/themes/cura-light/images/3d_printer_faded.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file From 75c7d5c4f5271d62a511b9515c6e352f580b6803 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Wed, 6 Oct 2021 16:48:52 +0200 Subject: [PATCH 26/89] Link to USB page without depending on actual order of pages This is more future-proof. If someone were to add a page to the end, it wouldn't immediately break these buttons any more. An example of why self-documenting code can be good. Contributes to issue CURA-8609. --- resources/qml/Preferences/Materials/MaterialsSyncDialog.qml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml index 7a426b836c..e038ac670d 100644 --- a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml +++ b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml @@ -161,7 +161,7 @@ Window { anchors.left: parent.left text: catalog.i18nc("@button", "Sync materials with USB") - onClicked: swipeView.currentIndex = swipeView.count - 1 //Go to the last page, which is USB. + onClicked: swipeView.currentIndex = removableDriveSyncPage.SwipeView.index } Cura.PrimaryButton { @@ -348,7 +348,7 @@ Window { anchors.left: parent.left text: catalog.i18nc("@button", "Sync materials with USB") - onClicked: swipeView.currentIndex = swipeView.count - 1 //Go to the last page, which is USB. + onClicked: swipeView.currentIndex = removableDriveSyncPage.SwipeView.index } Cura.PrimaryButton { @@ -413,7 +413,7 @@ Window { anchors.left: parent.left text: catalog.i18nc("@button", "Sync materials with USB") - onClicked: swipeView.currentIndex = swipeView.count - 1 //Go to the last page, which is USB. + onClicked: swipeView.currentIndex = removableDriveSyncPage.SwipeView.index } Cura.PrimaryButton { From 0bf4a3d944c20df0a935c8275513dcb4faafae80 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Fri, 8 Oct 2021 13:49:20 +0200 Subject: [PATCH 27/89] Store online status of printer in the printer's metadata It's a little bit weird with the hidden global stack system when there's a cluster of various types of printers. But it should behave the same way. Contributes to issue CURA-8609. --- .../NetworkedPrinterOutputDevice.py | 13 +++++++---- cura/PrinterOutput/PrinterOutputDevice.py | 23 ++++++++++++++++--- 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py index 9979354dba..42c1cd78aa 100644 --- a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py +++ b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py @@ -1,4 +1,4 @@ -# Copyright (c) 2018 Ultimaker B.V. +# Copyright (c) 2021 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. from UM.FileHandler.FileHandler import FileHandler #For typing. @@ -114,6 +114,11 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice): return b"".join(file_data_bytes_list) def _update(self) -> None: + """ + Update the connection state of this device. + + This is called on regular intervals. + """ if self._last_response_time: time_since_last_response = time() - self._last_response_time else: @@ -127,11 +132,11 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice): if time_since_last_response > self._timeout_time >= time_since_last_request: # Go (or stay) into timeout. if self._connection_state_before_timeout is None: - self._connection_state_before_timeout = self._connection_state + self._connection_state_before_timeout = self.connectionState self.setConnectionState(ConnectionState.Closed) - elif self._connection_state == ConnectionState.Closed: + elif self.connectionState == ConnectionState.Closed: # Go out of timeout. if self._connection_state_before_timeout is not None: # sanity check, but it should never be None here self.setConnectionState(self._connection_state_before_timeout) @@ -361,7 +366,7 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice): self._last_response_time = time() - if self._connection_state == ConnectionState.Connecting: + if self.connectionState == ConnectionState.Connecting: self.setConnectionState(ConnectionState.Connected) callback_key = reply.url().toString() + str(reply.operation()) diff --git a/cura/PrinterOutput/PrinterOutputDevice.py b/cura/PrinterOutput/PrinterOutputDevice.py index 526d713748..2939076a9a 100644 --- a/cura/PrinterOutput/PrinterOutputDevice.py +++ b/cura/PrinterOutput/PrinterOutputDevice.py @@ -1,11 +1,13 @@ -# Copyright (c) 2018 Ultimaker B.V. +# Copyright (c) 2021 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. + from enum import IntEnum from typing import Callable, List, Optional, Union from PyQt5.QtCore import pyqtProperty, pyqtSignal, QObject, QTimer, QUrl from PyQt5.QtWidgets import QMessageBox +import cura.CuraApplication # Imported like this to prevent circular imports. from UM.Logger import Logger from UM.Signal import signalemitter from UM.Qt.QtApplication import QtApplication @@ -120,11 +122,22 @@ class PrinterOutputDevice(QObject, OutputDevice): callback(QMessageBox.Yes) def isConnected(self) -> bool: - return self._connection_state != ConnectionState.Closed and self._connection_state != ConnectionState.Error + """ + Returns whether we could theoretically send commands to this printer. + :return: `True` if we are connected, or `False` if not. + """ + return self.connectionState != ConnectionState.Closed and self.connectionState != ConnectionState.Error def setConnectionState(self, connection_state: "ConnectionState") -> None: - if self._connection_state != connection_state: + """ + Store the connection state of the printer. + + Causes everything that displays the connection state to update its QML models. + :param connection_state: The new connection state to store. + """ + if self.connectionState != connection_state: self._connection_state = connection_state + cura.CuraApplication.CuraApplication.getInstance().getGlobalContainerStack().setMetaDataEntry("is_online", self.isConnected()) self.connectionStateChanged.emit(self._id) @pyqtProperty(int, constant = True) @@ -133,6 +146,10 @@ class PrinterOutputDevice(QObject, OutputDevice): @pyqtProperty(int, notify = connectionStateChanged) def connectionState(self) -> "ConnectionState": + """ + Get the connection state of the printer, e.g. whether it is connected, still connecting, error state, etc. + :return: The current connection state of this output device. + """ return self._connection_state def _update(self) -> None: From 93953630ec8347ae1f3db720a47d715c9fb48f3b Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Fri, 8 Oct 2021 14:08:02 +0200 Subject: [PATCH 28/89] Update online status when updating account printer status This updates the metadata entries. Naturally this updates every 60 seconds in Cura, but it could also manually be triggered by the refresh button. Contributes to issue CURA-8609. --- .../src/Cloud/CloudOutputDeviceManager.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index b35cd5b5f5..dfc1d3e39d 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -1,4 +1,4 @@ -# Copyright (c) 2020 Ultimaker B.V. +# Copyright (c) 2021 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. import os @@ -16,6 +16,7 @@ from UM.Util import parseBool from cura.API import Account from cura.API.Account import SyncState from cura.CuraApplication import CuraApplication +from cura.Settings.CuraContainerRegistry import CuraContainerRegistry # To update printer metadata with information received about cloud printers. from cura.Settings.CuraStackBuilder import CuraStackBuilder from cura.Settings.GlobalStack import GlobalStack from cura.UltimakerCloud.UltimakerCloudConstants import META_UM_LINKED_TO_ACCOUNT @@ -129,6 +130,8 @@ class CloudOutputDeviceManager: self._um_cloud_printers[device_id].setMetaDataEntry(META_UM_LINKED_TO_ACCOUNT, True) self._onDevicesDiscovered(new_clusters) + self._updateOnlinePrinters(all_clusters) + # Hide the current removed_printers_message, if there is any if self._removed_printers_message: self._removed_printers_message.actionTriggered.disconnect(self._onRemovedPrintersMessageActionTriggered) @@ -255,6 +258,16 @@ class CloudOutputDeviceManager: message_text = self.i18n_catalog.i18nc("info:status", "Printers added from Digital Factory:") + "
    " + device_names + "
" message.setText(message_text) + def _updateOnlinePrinters(self, printer_responses: Dict[str, CloudClusterResponse]) -> None: + """ + Update the metadata of the printers to store whether they are online or not. + :param printer_responses: The responses received from the API about the printer statuses. + """ + for container_stack in CuraContainerRegistry.getInstance().findContainerStacks(type = "machine"): + cluster_id = container_stack.getMetaDataEntry("um_cloud_cluster_id", "") + if cluster_id in printer_responses: + container_stack.setMetaDataEntry("is_online", printer_responses[cluster_id].is_online) + def _updateOutdatedMachine(self, outdated_machine: GlobalStack, new_cloud_output_device: CloudOutputDevice) -> None: """ Update the cloud metadata of a pre-existing machine that is rediscovered (e.g. if the printer was removed and From 32c63c2757f5ed6c654486ff28b41dae519b0618 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Fri, 8 Oct 2021 14:09:12 +0200 Subject: [PATCH 29/89] Add isOnline role to GlobalStacksModel This way we can filter for only online printers or display whether printers are online or not in the future. Contributes to issue CURA-8609. --- cura/Machines/Models/GlobalStacksModel.py | 6 +++++- resources/qml/Preferences/Materials/MaterialsSyncDialog.qml | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/cura/Machines/Models/GlobalStacksModel.py b/cura/Machines/Models/GlobalStacksModel.py index bcc6a81584..82ed03e935 100644 --- a/cura/Machines/Models/GlobalStacksModel.py +++ b/cura/Machines/Models/GlobalStacksModel.py @@ -20,6 +20,7 @@ class GlobalStacksModel(ListModel): MetaDataRole = Qt.UserRole + 5 DiscoverySourceRole = Qt.UserRole + 6 # For separating local and remote printers in the machine management page RemovalWarningRole = Qt.UserRole + 7 + IsOnlineRole = Qt.UserRole + 8 def __init__(self, parent = None) -> None: super().__init__(parent) @@ -31,6 +32,7 @@ class GlobalStacksModel(ListModel): self.addRoleName(self.HasRemoteConnectionRole, "hasRemoteConnection") self.addRoleName(self.MetaDataRole, "metadata") self.addRoleName(self.DiscoverySourceRole, "discoverySource") + self.addRoleName(self.IsOnlineRole, "isOnline") self._change_timer = QTimer() self._change_timer.setInterval(200) @@ -91,6 +93,7 @@ class GlobalStacksModel(ListModel): device_name = container_stack.getMetaDataEntry("group_name", container_stack.getName()) section_name = "Connected printers" if has_remote_connection else "Preset printers" section_name = self._catalog.i18nc("@info:title", section_name) + is_online = container_stack.getMetaDataEntry("is_online", False) default_removal_warning = self._catalog.i18nc( "@label {0} is the name of a printer that's about to be deleted.", @@ -103,6 +106,7 @@ class GlobalStacksModel(ListModel): "hasRemoteConnection": has_remote_connection, "metadata": container_stack.getMetaData().copy(), "discoverySource": section_name, - "removalWarning": removal_warning}) + "removalWarning": removal_warning, + "isOnline": is_online}) items.sort(key=lambda i: (not i["hasRemoteConnection"], i["name"])) self.setItems(items) diff --git a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml index e038ac670d..651963fa86 100644 --- a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml +++ b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml @@ -247,7 +247,7 @@ Window } source: UM.Theme.getIcon("CloudBadge", "low") - color: "red" //TODO: connectionStatus == "printer_cloud_not_available" ? UM.Theme.getColor("cloud_unavailable") : UM.Theme.getColor("primary") + color: model.isOnline ? UM.Theme.getColor("primary") : UM.Theme.getColor("cloud_unavailable") //Make a themeable circle in the background so we can change it in other themes. Rectangle From 82342762049fadc812dfb89331055af219cabc76 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Fri, 8 Oct 2021 14:13:54 +0200 Subject: [PATCH 30/89] Add log entry when syncing with cloud printers Not only do I now have an interface where the user doesn't get any feedback when syncing happens, but even in the log there was nothing shown when it happens. Contributes to issue CURA-8609. --- .../UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index dfc1d3e39d..5b1844e7cb 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -157,6 +157,8 @@ class CloudOutputDeviceManager: self._syncing = False self._account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.SUCCESS) + Logger.debug("Synced cloud printers with account.") + def _onGetRemoteClusterFailed(self, reply: QNetworkReply, error: QNetworkReply.NetworkError) -> None: self._syncing = False self._account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.ERROR) From 07b2c1b7775d9147703b709f48c3ba274f9fc18e Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Fri, 8 Oct 2021 14:21:03 +0200 Subject: [PATCH 31/89] Add option to only show printers that are online I have a feeling this will be abused later. But fine. We currently need it for the list of printers that we can sync materials to via the cloud. Contributes to issue CURA-8609. --- cura/Machines/Models/GlobalStacksModel.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/cura/Machines/Models/GlobalStacksModel.py b/cura/Machines/Models/GlobalStacksModel.py index 82ed03e935..0f01df6b28 100644 --- a/cura/Machines/Models/GlobalStacksModel.py +++ b/cura/Machines/Models/GlobalStacksModel.py @@ -40,6 +40,7 @@ class GlobalStacksModel(ListModel): self._change_timer.timeout.connect(self._update) self._filter_connection_type = -1 + self._filter_online_only = False # Listen to changes CuraContainerRegistry.getInstance().containerAdded.connect(self._onContainerChanged) @@ -48,7 +49,6 @@ class GlobalStacksModel(ListModel): self._updateDelayed() filterConnectionTypeChanged = pyqtSignal() - def setFilterConnectionType(self, new_filter: int) -> None: self._filter_connection_type = new_filter @@ -62,6 +62,17 @@ class GlobalStacksModel(ListModel): """ return self._filter_connection_type + filterOnlineOnlyChanged = pyqtSignal() + def setFilterOnlineOnly(self, new_filter: bool) -> None: + self._filter_online_only = new_filter + + @pyqtProperty(bool, fset = setFilterOnlineOnly, notify = filterOnlineOnlyChanged) + def filterOnlineOnly(self) -> bool: + """ + Whether to filter the global stacks to show only printers that are online. + """ + return self._filter_online_only + def _onContainerChanged(self, container) -> None: """Handler for container added/removed events from registry""" @@ -90,10 +101,13 @@ class GlobalStacksModel(ListModel): if parseBool(container_stack.getMetaDataEntry("hidden", False)): continue + is_online = container_stack.getMetaDataEntry("is_online", False) + if self._filter_online_only and not is_online: + continue + device_name = container_stack.getMetaDataEntry("group_name", container_stack.getName()) section_name = "Connected printers" if has_remote_connection else "Preset printers" section_name = self._catalog.i18nc("@info:title", section_name) - is_online = container_stack.getMetaDataEntry("is_online", False) default_removal_warning = self._catalog.i18nc( "@label {0} is the name of a printer that's about to be deleted.", From a7f172d8360b0003c6551e1dc31fe854d5e873f4 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Fri, 8 Oct 2021 14:22:46 +0200 Subject: [PATCH 32/89] Only show online printers in list of printers to sync to We can only sync with printers that are currently online. We'll just send it to the account to sync with everything. But these are the ones it should arrive with. Contributes to issue CURA-8609. --- resources/qml/Preferences/Materials/MaterialsSyncDialog.qml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml index 651963fa86..00e19ded9f 100644 --- a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml +++ b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml @@ -247,7 +247,7 @@ Window } source: UM.Theme.getIcon("CloudBadge", "low") - color: model.isOnline ? UM.Theme.getColor("primary") : UM.Theme.getColor("cloud_unavailable") + color: UM.Theme.getColor("primary") //Make a themeable circle in the background so we can change it in other themes. Rectangle @@ -523,6 +523,7 @@ Window { id: cloudPrinterList filterConnectionType: 3 //Only show cloud connections. + filterOnlineOnly: true //Only show printers that are online. } FileDialog From 599c59bd3a92ec9c7df6402110ec1cbfac6c746d Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Fri, 8 Oct 2021 15:15:57 +0200 Subject: [PATCH 33/89] Add a background job to upload material archives It creates the archive now. It doesn't yet upload it. Contributes to issue CURA-8609. --- .../Models/MaterialManagementModel.py | 9 +++++++ cura/PrinterOutput/UploadMaterialsJob.py | 25 +++++++++++++++++++ .../Materials/MaterialsSyncDialog.qml | 1 + 3 files changed, 35 insertions(+) create mode 100644 cura/PrinterOutput/UploadMaterialsJob.py diff --git a/cura/Machines/Models/MaterialManagementModel.py b/cura/Machines/Models/MaterialManagementModel.py index d5191988fa..617fc5be9e 100644 --- a/cura/Machines/Models/MaterialManagementModel.py +++ b/cura/Machines/Models/MaterialManagementModel.py @@ -16,6 +16,7 @@ from UM.Signal import postponeSignals, CompressTechnique import cura.CuraApplication # Imported like this to prevent circular imports. from cura.Machines.ContainerTree import ContainerTree +from cura.PrinterOutput.UploadMaterialsJob import UploadMaterialsJob # To export materials to the output printer. from cura.Settings.CuraContainerRegistry import CuraContainerRegistry # To find the sets of materials belonging to each other, and currently loaded extruder stacks. if TYPE_CHECKING: @@ -385,3 +386,11 @@ class MaterialManagementModel(QObject): archive.writestr(filename, material.serialize()) except OSError as e: Logger.log("e", f"An error has occurred while writing the material \'{metadata['id']}\' in the file \'{filename}\': {e}.") + + @pyqtSlot() + def exportUpload(self) -> None: + """ + Export all materials and upload them to the user's account. + """ + job = UploadMaterialsJob() + job.start() diff --git a/cura/PrinterOutput/UploadMaterialsJob.py b/cura/PrinterOutput/UploadMaterialsJob.py new file mode 100644 index 0000000000..42254d2475 --- /dev/null +++ b/cura/PrinterOutput/UploadMaterialsJob.py @@ -0,0 +1,25 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. + +from PyQt5.QtCore import QUrl +import os # To delete the archive when we're done. +import tempfile # To create an archive before we upload it. + +import cura.CuraApplication # Imported like this to prevent circular imports. +from UM.Job import Job + + +class UploadMaterialsJob(Job): + """ + Job that uploads a set of materials to the Digital Factory. + """ + + def run(self): + archive_file = tempfile.NamedTemporaryFile("wb", delete = False) + archive_file.close() + + cura.CuraApplication.CuraApplication.getInstance().getMaterialManagementModel().exportAll(QUrl.fromLocalFile(archive_file.name)) + + print("Creating archive completed. Now we need to upload it.") # TODO: Upload that file. + + os.remove(archive_file.name) # Clean up. \ No newline at end of file diff --git a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml index 00e19ded9f..0bcafba8d2 100644 --- a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml +++ b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml @@ -354,6 +354,7 @@ Window { anchors.right: parent.right text: catalog.i18nc("@button", "Sync") + onClicked: materialManagementModel.exportUpload() } } } From 042bd46fba85bcdb77d3f325b1ccdc7f654070c8 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Fri, 8 Oct 2021 15:59:42 +0200 Subject: [PATCH 34/89] Don't import CuraApplication if not type checking Otherwise we'll get unnecessary import loops here. Contributes to issue CURA-8609. --- cura/UltimakerCloud/UltimakerCloudScope.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/cura/UltimakerCloud/UltimakerCloudScope.py b/cura/UltimakerCloud/UltimakerCloudScope.py index 5477423099..c8ac35f893 100644 --- a/cura/UltimakerCloud/UltimakerCloudScope.py +++ b/cura/UltimakerCloud/UltimakerCloudScope.py @@ -1,9 +1,15 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. + from PyQt5.QtNetwork import QNetworkRequest from UM.Logger import Logger from UM.TaskManagement.HttpRequestScope import DefaultUserAgentScope from cura.API import Account -from cura.CuraApplication import CuraApplication + +from typing import TYPE_CHECKING +if TYPE_CHECKING: + from cura.CuraApplication import CuraApplication class UltimakerCloudScope(DefaultUserAgentScope): @@ -12,7 +18,7 @@ class UltimakerCloudScope(DefaultUserAgentScope): Also add the user agent headers (see DefaultUserAgentScope). """ - def __init__(self, application: CuraApplication): + def __init__(self, application: "CuraApplication"): super().__init__(application) api = application.getCuraAPI() self._account = api.account # type: Account From 2b785343b59288e738e09ef49f771223db8d0212 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Fri, 8 Oct 2021 16:02:31 +0200 Subject: [PATCH 35/89] Implement basic uploading of material Steps involved are: - Create an archive of all materials. - Request the cloud API to provide a URL to upload the archive to. - Upload the archive to that API. Currently the two internet requests are asynchronous, meaning that the job will 'end' before the upload is complete. Most likely the job instance will even be deleted before we get a response from the server. So this won't work, really. Need to structure that a bit differently. But I want to save this progress because it embodies the happy path well. Contributes to issue CURA-8609. --- cura/PrinterOutput/UploadMaterialsJob.py | 62 +++++++++++++++++++++++- 1 file changed, 60 insertions(+), 2 deletions(-) diff --git a/cura/PrinterOutput/UploadMaterialsJob.py b/cura/PrinterOutput/UploadMaterialsJob.py index 42254d2475..e675524ec4 100644 --- a/cura/PrinterOutput/UploadMaterialsJob.py +++ b/cura/PrinterOutput/UploadMaterialsJob.py @@ -4,22 +4,80 @@ from PyQt5.QtCore import QUrl import os # To delete the archive when we're done. import tempfile # To create an archive before we upload it. +import enum import cura.CuraApplication # Imported like this to prevent circular imports. +from cura.UltimakerCloud import UltimakerCloudConstants # To know where the API is. +from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope # To know how to communicate with this server. from UM.Job import Job +from UM.Logger import Logger +from UM.TaskManagement.HttpRequestManager import HttpRequestManager # To call the API. +from UM.TaskManagement.HttpRequestScope import JsonDecoratorScope +from typing import Optional, TYPE_CHECKING +if TYPE_CHECKING: + from PyQt5.QtNetwork import QNetworkReply class UploadMaterialsJob(Job): """ Job that uploads a set of materials to the Digital Factory. """ + UPLOAD_REQUEST_URL = f"{UltimakerCloudConstants.CuraDigitalFactoryURL}/materials/profile_upload" + + class Result(enum.IntEnum): + SUCCCESS = 0 + FAILED = 1 + + def __init__(self): + super().__init__() + self._scope = JsonDecoratorScope(UltimakerCloudScope(cura.CuraApplication.CuraApplication.getInstance())) # type: JsonDecoratorScope + def run(self): archive_file = tempfile.NamedTemporaryFile("wb", delete = False) archive_file.close() cura.CuraApplication.CuraApplication.getInstance().getMaterialManagementModel().exportAll(QUrl.fromLocalFile(archive_file.name)) - print("Creating archive completed. Now we need to upload it.") # TODO: Upload that file. + http = HttpRequestManager.getInstance() + http.get( + url = self.UPLOAD_REQUEST_URL, + callback = self.onUploadRequestCompleted, + error_callback = self.onError, + scope = self._scope + ) - os.remove(archive_file.name) # Clean up. \ No newline at end of file + def onUploadRequestCompleted(self, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"]): + if error is not None: + Logger.error(f"Could not request URL to upload material archive to: {error}") + self.setResult(self.Result.FAILED) + return + + response_data = HttpRequestManager.readJSON(reply) + if response_data is None: + Logger.error(f"Invalid response to material upload request. Could not parse JSON data.") + self.setResult(self.Result.FAILED) + return + if "upload_url" not in response_data: + Logger.error(f"Invalid response to material upload request: Missing 'upload_url' field to upload archive to.") + self.setResult(self.Result.Failed) + return + + upload_url = response_data["upload_url"] + http = HttpRequestManager.getInstance() + http.put( + url = upload_url, + callback = self.onUploadCompleted, + error_callback = self.onError, + scope = self._scope + ) + + def onUploadCompleted(self, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"]): + if error is not None: + Logger.error(f"Failed to upload material archive: {error}") + self.setResult(self.Result.FAILED) + return + self.setResult(self.Result.SUCCESS) + + def onError(self, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"]): + pass # TODO: Handle errors. \ No newline at end of file From fb7e67b8ad575ca8227cee0fb56c3a19303a2f54 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Fri, 8 Oct 2021 16:51:04 +0200 Subject: [PATCH 36/89] Change 'hit' to 'click' This is more consistent with other texts. Contributes to issue CURA-8609. --- resources/qml/Preferences/Materials/MaterialsSyncDialog.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml index 0bcafba8d2..489bfc237a 100644 --- a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml +++ b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml @@ -481,7 +481,7 @@ Window } Label { - text: "1. " + catalog.i18nc("@text 'hit' as in pressing the button", "Hit the export material archive button.") + text: "1. " + catalog.i18nc("@text", "Click the export material archive button.") + "\n2. " + catalog.i18nc("@text", "Save the .umm file on a USB stick.") + "\n3. " + catalog.i18nc("@text", "Insert the USB stick into your printer and launch the procedure to load new material profiles.") font: UM.Theme.getFont("medium") From 9729f4f3d2320a23181b544f4188089b35a382ef Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Fri, 8 Oct 2021 17:46:10 +0200 Subject: [PATCH 37/89] Set properties immediately upon constructing Instead of afterwards. A bit more efficient. Contributes to issue CURA-8609. --- cura/Machines/Models/MaterialManagementModel.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/cura/Machines/Models/MaterialManagementModel.py b/cura/Machines/Models/MaterialManagementModel.py index 617fc5be9e..2994b48918 100644 --- a/cura/Machines/Models/MaterialManagementModel.py +++ b/cura/Machines/Models/MaterialManagementModel.py @@ -330,11 +330,12 @@ class MaterialManagementModel(QObject): """ if self._sync_all_dialog is None: qml_path = Resources.getPath(cura.CuraApplication.CuraApplication.ResourceTypes.QmlFiles, "Preferences", "Materials", "MaterialsSyncDialog.qml") - self._sync_all_dialog = cura.CuraApplication.CuraApplication.getInstance().createQmlComponent(qml_path, {}) + self._sync_all_dialog = cura.CuraApplication.CuraApplication.getInstance().createQmlComponent(qml_path, { + "materialManagementModel": self, + "pageIndex": 0 + }) if self._sync_all_dialog is None: # Failed to load QML file. return - self._sync_all_dialog.setProperty("materialManagementModel", self) - self._sync_all_dialog.setProperty("pageIndex", 0) self._sync_all_dialog.show() @pyqtSlot(result = QUrl) From e7b49ee551590ce5652d18b782350772e33e8abb Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Mon, 11 Oct 2021 13:11:54 +0200 Subject: [PATCH 38/89] Disable sync button while in progress Need to show a bit more feedback I think. Let's see what the design said... Contributes to issue CURA-8609. --- .../Models/MaterialManagementModel.py | 23 +++++++++++++++---- cura/PrinterOutput/UploadMaterialsJob.py | 4 ++++ .../Materials/MaterialsSyncDialog.qml | 8 +++++++ 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/cura/Machines/Models/MaterialManagementModel.py b/cura/Machines/Models/MaterialManagementModel.py index 2994b48918..ecd809fef0 100644 --- a/cura/Machines/Models/MaterialManagementModel.py +++ b/cura/Machines/Models/MaterialManagementModel.py @@ -2,7 +2,7 @@ # Cura is released under the terms of the LGPLv3 or higher. import copy # To duplicate materials. -from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject, QUrl +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl from PyQt5.QtGui import QDesktopServices from typing import Any, Dict, Optional, TYPE_CHECKING import uuid # To generate new GUIDs for new materials. @@ -35,6 +35,7 @@ class MaterialManagementModel(QObject): def __init__(self, parent: QObject = None): super().__init__(parent) self._sync_all_dialog = None # type: Optional[QObject] + self._export_upload_status = "idle" self._checkIfNewMaterialsWereInstalled() def _checkIfNewMaterialsWereInstalled(self) -> None: @@ -330,12 +331,11 @@ class MaterialManagementModel(QObject): """ if self._sync_all_dialog is None: qml_path = Resources.getPath(cura.CuraApplication.CuraApplication.ResourceTypes.QmlFiles, "Preferences", "Materials", "MaterialsSyncDialog.qml") - self._sync_all_dialog = cura.CuraApplication.CuraApplication.getInstance().createQmlComponent(qml_path, { - "materialManagementModel": self, - "pageIndex": 0 - }) + self._sync_all_dialog = cura.CuraApplication.CuraApplication.getInstance().createQmlComponent(qml_path, {}) if self._sync_all_dialog is None: # Failed to load QML file. return + self._sync_all_dialog.setProperty("materialManagementModel", self) + self._sync_all_dialog.setProperty("pageIndex", 0) # Return to first page. self._sync_all_dialog.show() @pyqtSlot(result = QUrl) @@ -388,10 +388,23 @@ class MaterialManagementModel(QObject): except OSError as e: Logger.log("e", f"An error has occurred while writing the material \'{metadata['id']}\' in the file \'{filename}\': {e}.") + exportUploadStatusChanged = pyqtSignal() + + @pyqtProperty(str, notify = exportUploadStatusChanged) + def exportUploadStatus(self): + return self._export_upload_status + @pyqtSlot() def exportUpload(self) -> None: """ Export all materials and upload them to the user's account. """ + self._export_upload_status = "uploading" + self.exportUploadStatusChanged.emit() job = UploadMaterialsJob() + job.uploadCompleted.connect(self.exportUploadCompleted) job.start() + + def exportUploadCompleted(self): + self._export_upload_status = "idle" + self.exportUploadStatusChanged.emit() \ No newline at end of file diff --git a/cura/PrinterOutput/UploadMaterialsJob.py b/cura/PrinterOutput/UploadMaterialsJob.py index e675524ec4..13e97a5093 100644 --- a/cura/PrinterOutput/UploadMaterialsJob.py +++ b/cura/PrinterOutput/UploadMaterialsJob.py @@ -11,6 +11,7 @@ from cura.UltimakerCloud import UltimakerCloudConstants # To know where the API from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope # To know how to communicate with this server. from UM.Job import Job from UM.Logger import Logger +from UM.Signal import Signal from UM.TaskManagement.HttpRequestManager import HttpRequestManager # To call the API. from UM.TaskManagement.HttpRequestScope import JsonDecoratorScope @@ -33,6 +34,8 @@ class UploadMaterialsJob(Job): super().__init__() self._scope = JsonDecoratorScope(UltimakerCloudScope(cura.CuraApplication.CuraApplication.getInstance())) # type: JsonDecoratorScope + uploadCompleted = Signal() + def run(self): archive_file = tempfile.NamedTemporaryFile("wb", delete = False) archive_file.close() @@ -78,6 +81,7 @@ class UploadMaterialsJob(Job): self.setResult(self.Result.FAILED) return self.setResult(self.Result.SUCCESS) + self.uploadCompleted.emit() def onError(self, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"]): pass # TODO: Handle errors. \ No newline at end of file diff --git a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml index 489bfc237a..d3d794287d 100644 --- a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml +++ b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml @@ -355,6 +355,14 @@ Window anchors.right: parent.right text: catalog.i18nc("@button", "Sync") onClicked: materialManagementModel.exportUpload() + enabled: + { + if(!materialManagementModel) + { + return false; + } + return materialManagementModel.exportUploadStatus != "uploading"; + } } } } From da76280359794d044790c9e3e005d3fef6d5e126 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Mon, 11 Oct 2021 13:38:41 +0200 Subject: [PATCH 39/89] Make USB image a bit larger This is more in line with the design. Contributes to issue CURA-8609. --- resources/qml/Preferences/Materials/MaterialsSyncDialog.qml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml index d3d794287d..0c6583ede5 100644 --- a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml +++ b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml @@ -357,7 +357,7 @@ Window onClicked: materialManagementModel.exportUpload() enabled: { - if(!materialManagementModel) + if(!materialManagementModel) //When the dialog is created, this is not set yet. { return false; } @@ -482,7 +482,7 @@ Window Image { source: UM.Theme.getImage("insert_usb") - width: parent.width / 4 + width: parent.width / 3 height: width anchors.verticalCenter: parent.verticalCenter sourceSize.width: width @@ -495,7 +495,7 @@ Window font: UM.Theme.getFont("medium") color: UM.Theme.getColor("text") wrapMode: Text.Wrap - width: parent.width * 3 / 4 - UM.Theme.getSize("default_margin").width + width: parent.width * 2 / 3 - UM.Theme.getSize("default_margin").width anchors.verticalCenter: parent.verticalCenter } } From 43bcd2b56a03a5d3f17d37a180934fc5bfd9972b Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Mon, 11 Oct 2021 13:49:45 +0200 Subject: [PATCH 40/89] Implement info icon with UM.StatusIcon This way the sizes are consistent with the other similar icons in the UI. Contributes to issue CURA-8609. --- .../Materials/MaterialsSyncDialog.qml | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml index 0c6583ede5..7df25d3253 100644 --- a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml +++ b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml @@ -7,7 +7,7 @@ import QtQuick.Dialogs 1.2 import QtQuick.Layouts 1.15 import QtQuick.Window 2.1 import Cura 1.1 as Cura -import UM 1.2 as UM +import UM 1.4 as UM Window { @@ -284,22 +284,14 @@ Window } spacing: UM.Theme.getSize("default_margin").width - Rectangle //Info icon with a themeable color and background. + UM.StatusIcon { id: infoIcon - width: UM.Theme.getSize("machine_selector_icon").width + width: UM.Theme.getSize("section_icon").width height: width - Layout.preferredWidth: width Layout.alignment: Qt.AlignVCenter - radius: height / 2 - color: UM.Theme.getColor("warning") - UM.RecolorImage - { - source: UM.Theme.getIcon("EmptyInfo") - anchors.fill: parent - color: UM.Theme.getColor("machine_selector_printer_icon") - } + status: UM.StatusIcon.Status.WARNING } Label From 9ffbaa772f55d1655469be8caf33bf8bd09a7997 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Mon, 11 Oct 2021 13:58:05 +0200 Subject: [PATCH 41/89] Add back button to return from USB workflow to welcome screen This allows the user to see the welcome screen instructions again. Or to switch back to internet-syncing if they accidentally pressed the USB button (or were just curious). Contributes to issue CURA-8609. --- .../Preferences/Materials/MaterialsSyncDialog.qml | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml index 7df25d3253..3b57f5c28d 100644 --- a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml +++ b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml @@ -491,6 +491,14 @@ Window anchors.verticalCenter: parent.verticalCenter } } + + Cura.TertiaryButton + { + text: catalog.i18nc("@button", "How to load new material profiles to my printer") + iconSource: UM.Theme.getIcon("LinkExternal") + onClicked: Qt.openUrlExternally("https://support.ultimaker.com/hc/en-us/articles/360013137919?utm_source=cura&utm_medium=software&utm_campaign=sync-material-wizard-how-usb") + } + Item { width: parent.width @@ -498,12 +506,11 @@ Window Layout.preferredWidth: width Layout.preferredHeight: height - Cura.TertiaryButton + Cura.SecondaryButton { anchors.left: parent.left - text: catalog.i18nc("@button", "How to load new material profiles to my printer") - iconSource: UM.Theme.getIcon("LinkExternal") - onClicked: Qt.openUrlExternally("https://support.ultimaker.com/hc/en-us/articles/360013137919?utm_source=cura&utm_medium=software&utm_campaign=sync-material-wizard-how-usb") + text: catalog.i18nc("@button", "Back") + onClicked: swipeView.currentIndex = 0 //Reset to first page. } Cura.PrimaryButton { From 56eb694745e019cf13766491b2c1c65bd338b3d6 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Mon, 11 Oct 2021 14:11:28 +0200 Subject: [PATCH 42/89] Hide refresh button if there are no offline printers It's not considered a big use case when the user has already added some printers but would want to add more while syncing materials to those printers. Contributes to issue CURA-8609. --- .../qml/Preferences/Materials/MaterialsSyncDialog.qml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml index 3b57f5c28d..3bbcf0fa94 100644 --- a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml +++ b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml @@ -266,7 +266,8 @@ Window footer: Item { width: printerListScrollView.width - height: UM.Theme.getSize("card").height + UM.Theme.getSize("default_margin").height + height: visible ? UM.Theme.getSize("card").height + UM.Theme.getSize("default_margin").height : 0 + visible: includeOfflinePrinterList.count - cloudPrinterList.count > 0 Rectangle { border.color: UM.Theme.getColor("lining") @@ -533,6 +534,13 @@ Window filterConnectionType: 3 //Only show cloud connections. filterOnlineOnly: true //Only show printers that are online. } + Cura.GlobalStacksModel + { + //In order to show a refresh button only when there are offline cloud printers, we need to know if there are any offline printers. + //A global stacks model without the filter for online-only printers allows this. + id: includeOfflinePrinterList + filterConnectionType: 3 //Still only show cloud connections. + } FileDialog { From ffee4a2443d09b66fb168474523f050ce3621ded Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Mon, 11 Oct 2021 14:30:10 +0200 Subject: [PATCH 43/89] Show syncing spinner while syncing Rather than disabling the sync button, hide it completely and show this spinner instead. Contributes to issue CURA-8609. --- .../Materials/MaterialsSyncDialog.qml | 48 +++++++++++++++++-- 1 file changed, 45 insertions(+), 3 deletions(-) diff --git a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml index 3bbcf0fa94..be9f2eb06e 100644 --- a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml +++ b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml @@ -1,7 +1,7 @@ //Copyright (c) 2021 Ultimaker B.V. //Cura is released under the terms of the LGPLv3 or higher. -import QtQuick 2.1 +import QtQuick 2.15 import QtQuick.Controls 2.15 import QtQuick.Dialogs 1.2 import QtQuick.Layouts 1.15 @@ -345,18 +345,60 @@ Window } Cura.PrimaryButton { + id: syncButton anchors.right: parent.right text: catalog.i18nc("@button", "Sync") onClicked: materialManagementModel.exportUpload() - enabled: + visible: { if(!materialManagementModel) //When the dialog is created, this is not set yet. { - return false; + return true; } return materialManagementModel.exportUploadStatus != "uploading"; } } + Item + { + anchors.right: parent.right + width: childrenRect.width + height: syncButton.height + + visible: !syncButton.visible + + UM.RecolorImage + { + id: syncingIcon + height: UM.Theme.getSize("action_button_icon").height + width: height + anchors.right: syncingLabel.left + anchors.rightMargin: UM.Theme.getSize("narrow_margin").width + anchors.verticalCenter: parent.verticalCenter + + source: UM.Theme.getIcon("ArrowDoubleCircleRight") + color: UM.Theme.getColor("primary") + + RotationAnimator + { + target: syncingIcon + from: 0 + to: 360 + duration: 1000 + loops: Animation.Infinite + running: !syncButton.visible //Don't run while invisible. Would be a waste of render updates. + } + } + Label + { + id: syncingLabel + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + + text: catalog.i18nc("@button", "Syncing") + color: UM.Theme.getColor("primary") + font: UM.Theme.getFont("medium") + } + } } } From ffd3277854969b0b06c1cc0f6dc93578443373af Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Mon, 11 Oct 2021 14:57:21 +0200 Subject: [PATCH 44/89] Show error if the upload failed Contributes to issue CURA-8609. --- cura/Machines/Models/MaterialManagementModel.py | 4 +++- cura/PrinterOutput/UploadMaterialsJob.py | 10 ++++++---- .../Preferences/Materials/MaterialsSyncDialog.qml | 14 ++++++++++++++ 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/cura/Machines/Models/MaterialManagementModel.py b/cura/Machines/Models/MaterialManagementModel.py index ecd809fef0..f18a5c302c 100644 --- a/cura/Machines/Models/MaterialManagementModel.py +++ b/cura/Machines/Models/MaterialManagementModel.py @@ -405,6 +405,8 @@ class MaterialManagementModel(QObject): job.uploadCompleted.connect(self.exportUploadCompleted) job.start() - def exportUploadCompleted(self): + def exportUploadCompleted(self, job_result: UploadMaterialsJob.Result): + if job_result == UploadMaterialsJob.Result.FAILED: + self._sync_all_dialog.setProperty("syncStatusText", catalog.i18nc("@text", "Something went wrong when sending the materials to the printers.")) self._export_upload_status = "idle" self.exportUploadStatusChanged.emit() \ No newline at end of file diff --git a/cura/PrinterOutput/UploadMaterialsJob.py b/cura/PrinterOutput/UploadMaterialsJob.py index 13e97a5093..45ac653337 100644 --- a/cura/PrinterOutput/UploadMaterialsJob.py +++ b/cura/PrinterOutput/UploadMaterialsJob.py @@ -79,9 +79,11 @@ class UploadMaterialsJob(Job): if error is not None: Logger.error(f"Failed to upload material archive: {error}") self.setResult(self.Result.FAILED) - return - self.setResult(self.Result.SUCCESS) - self.uploadCompleted.emit() + else: + self.setResult(self.Result.SUCCESS) + self.uploadCompleted.emit(self.getResult()) def onError(self, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"]): - pass # TODO: Handle errors. \ No newline at end of file + Logger.error(f"Failed to upload material archive: {error}") + self.setResult(self.Result.FAILED) + self.uploadCompleted.emit(self.getResult()) \ No newline at end of file diff --git a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml index be9f2eb06e..69d30167c2 100644 --- a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml +++ b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml @@ -23,6 +23,7 @@ Window property variant materialManagementModel property alias pageIndex: swipeView.currentIndex + property alias syncStatusText: syncStatusLabel.text SwipeView { @@ -358,6 +359,19 @@ Window return materialManagementModel.exportUploadStatus != "uploading"; } } + Label + { + id: syncStatusLabel + + anchors.right: syncButton.left + anchors.rightMargin: UM.Theme.getSize("default_margin").width + anchors.verticalCenter: parent.verticalCenter + + visible: syncButton.visible + text: "" + color: UM.Theme.getColor("text") + font: UM.Theme.getFont("default") + } Item { anchors.right: parent.right From 8ae93c6bc12b1a1cf66dcdcabfe4d43f43c25c6d Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Mon, 11 Oct 2021 15:19:06 +0200 Subject: [PATCH 45/89] Resolve binding loops in sync spinner Contributes to issue CURA-8609. --- .../qml/Preferences/Materials/MaterialsSyncDialog.qml | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml index 69d30167c2..07a225054b 100644 --- a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml +++ b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml @@ -365,7 +365,6 @@ Window anchors.right: syncButton.left anchors.rightMargin: UM.Theme.getSize("default_margin").width - anchors.verticalCenter: parent.verticalCenter visible: syncButton.visible text: "" @@ -385,9 +384,7 @@ Window id: syncingIcon height: UM.Theme.getSize("action_button_icon").height width: height - anchors.right: syncingLabel.left - anchors.rightMargin: UM.Theme.getSize("narrow_margin").width - anchors.verticalCenter: parent.verticalCenter + anchors.verticalCenter: syncingLabel.verticalCenter source: UM.Theme.getIcon("ArrowDoubleCircleRight") color: UM.Theme.getColor("primary") @@ -405,8 +402,8 @@ Window Label { id: syncingLabel - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter + anchors.left: syncingIcon.right + anchors.leftMargin: UM.Theme.getSize("narrow_margin").width text: catalog.i18nc("@button", "Syncing") color: UM.Theme.getColor("primary") From c3d392c5cf77eea2086d1776b764e7678dd52d56 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Mon, 11 Oct 2021 15:27:54 +0200 Subject: [PATCH 46/89] Show upload error or success in header once completed According to the brand new design. Contributes to issue CURA-8609. --- .../Models/MaterialManagementModel.py | 4 +- .../Materials/MaterialsSyncDialog.qml | 68 +++++++++++++++---- 2 files changed, 56 insertions(+), 16 deletions(-) diff --git a/cura/Machines/Models/MaterialManagementModel.py b/cura/Machines/Models/MaterialManagementModel.py index f18a5c302c..55bf0e65e6 100644 --- a/cura/Machines/Models/MaterialManagementModel.py +++ b/cura/Machines/Models/MaterialManagementModel.py @@ -408,5 +408,7 @@ class MaterialManagementModel(QObject): def exportUploadCompleted(self, job_result: UploadMaterialsJob.Result): if job_result == UploadMaterialsJob.Result.FAILED: self._sync_all_dialog.setProperty("syncStatusText", catalog.i18nc("@text", "Something went wrong when sending the materials to the printers.")) - self._export_upload_status = "idle" + self._export_upload_status = "error" + else: + self._export_upload_status = "success" self.exportUploadStatusChanged.emit() \ No newline at end of file diff --git a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml index 07a225054b..39d0dfe457 100644 --- a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml +++ b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml @@ -186,12 +186,62 @@ Window anchors.margins: UM.Theme.getSize("default_margin").width visible: cloudPrinterList.count > 0 + Row + { + Layout.preferredHeight: childrenRect.height + spacing: UM.Theme.getSize("default_margin").width + + states: [ + State + { + name: "idle" + when: typeof materialManagementModel === "undefined" || materialManagementModel.exportUploadStatus == "idle" || materialManagementModel.exportUploadStatus == "uploading" + PropertyChanges { target: printerListHeader; text: catalog.i18nc("@title:header", "The following printers will receive the new material profiles:") } + PropertyChanges { target: printerListHeaderIcon; status: UM.StatusIcon.Status.NEUTRAL } + }, + State + { + name: "error" + when: typeof materialManagementModel !== "undefined" && materialManagementModel.exportUploadStatus == "error" + PropertyChanges { target: printerListHeader; text: catalog.i18nc("@title:header", "Something went wrong when sending the materials to the printers.") } + PropertyChanges { target: printerListHeaderIcon; status: UM.StatusIcon.Status.ERROR } + }, + State + { + name: "success" + when: typeof materialManagementModel !== "undefined" && materialManagementModel.exportUploadStatus == "success" + PropertyChanges { target: printerListHeader; text: catalog.i18nc("@title:header", "Material profiles successfully synced with the following printers:") } + PropertyChanges { target: printerListHeaderIcon; status: UM.StatusIcon.Status.POSITIVE } + } + ] + + UM.StatusIcon + { + id: printerListHeaderIcon + width: UM.Theme.getSize("section_icon").width + height: width + anchors.verticalCenter: parent.verticalCenter + } + Label + { + id: printerListHeader + anchors.verticalCenter: parent.verticalCenter + //Text is always defined by the states above. + font: UM.Theme.getFont("large_bold") + color: UM.Theme.getColor("text") + } + } Label { - text: catalog.i18nc("@title:header", "The following printers will receive the new material profiles") - font: UM.Theme.getFont("large_bold") + id: syncStatusLabel + + anchors.right: syncButton.left + anchors.rightMargin: UM.Theme.getSize("default_margin").width + + visible: text !== "" + text: "" color: UM.Theme.getColor("text") - Layout.preferredHeight: height + font: UM.Theme.getFont("medium") } ScrollView { @@ -359,18 +409,6 @@ Window return materialManagementModel.exportUploadStatus != "uploading"; } } - Label - { - id: syncStatusLabel - - anchors.right: syncButton.left - anchors.rightMargin: UM.Theme.getSize("default_margin").width - - visible: syncButton.visible - text: "" - color: UM.Theme.getColor("text") - font: UM.Theme.getFont("default") - } Item { anchors.right: parent.right From 4661b02e4c42a0d47a3078bfe79aaac4b19bb1b9 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Tue, 12 Oct 2021 09:43:21 +0200 Subject: [PATCH 47/89] Move code and status related to uploading materials to separate class There's quite a lot of status to track, errors and progress. It's better kept separate. Contributes to issue CURA-8609. --- .../Models/MaterialManagementModel.py | 150 +---------------- cura/PrinterOutput/UploadMaterialsJob.py | 6 +- cura/UltimakerCloud/CloudMaterialSync.py | 154 ++++++++++++++++++ .../Materials/MaterialsSyncDialog.qml | 18 +- 4 files changed, 176 insertions(+), 152 deletions(-) create mode 100644 cura/UltimakerCloud/CloudMaterialSync.py diff --git a/cura/Machines/Models/MaterialManagementModel.py b/cura/Machines/Models/MaterialManagementModel.py index 55bf0e65e6..61d8e23acd 100644 --- a/cura/Machines/Models/MaterialManagementModel.py +++ b/cura/Machines/Models/MaterialManagementModel.py @@ -2,22 +2,19 @@ # Cura is released under the terms of the LGPLv3 or higher. import copy # To duplicate materials. -from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl -from PyQt5.QtGui import QDesktopServices +from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject from typing import Any, Dict, Optional, TYPE_CHECKING import uuid # To generate new GUIDs for new materials. -import zipfile # To export all materials in a .zip archive. from UM.i18n import i18nCatalog from UM.Logger import Logger -from UM.Message import Message from UM.Resources import Resources # To find QML files. from UM.Signal import postponeSignals, CompressTechnique import cura.CuraApplication # Imported like this to prevent circular imports. from cura.Machines.ContainerTree import ContainerTree -from cura.PrinterOutput.UploadMaterialsJob import UploadMaterialsJob # To export materials to the output printer. from cura.Settings.CuraContainerRegistry import CuraContainerRegistry # To find the sets of materials belonging to each other, and currently loaded extruder stacks. +from cura.UltimakerCloud.CloudMaterialSync import CloudMaterialSync if TYPE_CHECKING: from cura.Machines.MaterialNode import MaterialNode @@ -34,61 +31,7 @@ class MaterialManagementModel(QObject): def __init__(self, parent: QObject = None): super().__init__(parent) - self._sync_all_dialog = None # type: Optional[QObject] - self._export_upload_status = "idle" - self._checkIfNewMaterialsWereInstalled() - - def _checkIfNewMaterialsWereInstalled(self) -> None: - """ - Checks whether new material packages were installed in the latest startup. If there were, then it shows - a message prompting the user to sync the materials with their printers. - """ - application = cura.CuraApplication.CuraApplication.getInstance() - for package_id, package_data in application.getPackageManager().getPackagesInstalledOnStartup().items(): - if package_data["package_info"]["package_type"] == "material": - # At least one new material was installed - self._showSyncNewMaterialsMessage() - break - - def _showSyncNewMaterialsMessage(self) -> None: - sync_materials_message = Message( - text = catalog.i18nc("@action:button", - "Please sync the material profiles with your printers before starting to print."), - title = catalog.i18nc("@action:button", "New materials installed"), - message_type = Message.MessageType.WARNING, - lifetime = 0 - ) - - sync_materials_message.addAction( - "sync", - name = catalog.i18nc("@action:button", "Sync materials with printers"), - icon = "", - description = "Sync your newly installed materials with your printers.", - button_align = Message.ActionButtonAlignment.ALIGN_RIGHT - ) - - sync_materials_message.addAction( - "learn_more", - name = catalog.i18nc("@action:button", "Learn more"), - icon = "", - description = "Learn more about syncing your newly installed materials with your printers.", - button_align = Message.ActionButtonAlignment.ALIGN_LEFT, - button_style = Message.ActionButtonStyle.LINK - ) - sync_materials_message.actionTriggered.connect(self._onSyncMaterialsMessageActionTriggered) - - # Show the message only if there are printers that support material export - container_registry = cura.CuraApplication.CuraApplication.getInstance().getContainerRegistry() - global_stacks = container_registry.findContainerStacks(type = "machine") - if any([stack.supportsMaterialExport for stack in global_stacks]): - sync_materials_message.show() - - def _onSyncMaterialsMessageActionTriggered(self, sync_message: Message, sync_message_action: str): - if sync_message_action == "sync": - self.openSyncAllWindow() - sync_message.hide() - elif sync_message_action == "learn_more": - QDesktopServices.openUrl(QUrl("https://support.ultimaker.com/hc/en-us/articles/360013137919?utm_source=cura&utm_medium=software&utm_campaign=sync-material-printer-message")) + self._material_sync = CloudMaterialSync(parent = self) @pyqtSlot("QVariant", result = bool) def canMaterialBeRemoved(self, material_node: "MaterialNode") -> bool: @@ -329,86 +272,11 @@ class MaterialManagementModel(QObject): """ Opens the window to sync all materials. """ - if self._sync_all_dialog is None: + if self._material_sync.sync_all_dialog is None: qml_path = Resources.getPath(cura.CuraApplication.CuraApplication.ResourceTypes.QmlFiles, "Preferences", "Materials", "MaterialsSyncDialog.qml") - self._sync_all_dialog = cura.CuraApplication.CuraApplication.getInstance().createQmlComponent(qml_path, {}) - if self._sync_all_dialog is None: # Failed to load QML file. + self._material_sync.sync_all_dialog = cura.CuraApplication.CuraApplication.getInstance().createQmlComponent(qml_path, {}) + if self._material_sync.sync_all_dialog is None: # Failed to load QML file. return - self._sync_all_dialog.setProperty("materialManagementModel", self) - self._sync_all_dialog.setProperty("pageIndex", 0) # Return to first page. - self._sync_all_dialog.show() - - @pyqtSlot(result = QUrl) - def getPreferredExportAllPath(self) -> QUrl: - """ - Get the preferred path to export materials to. - - If there is a removable drive, that should be the preferred path. Otherwise it should be the most recent local - file path. - :return: The preferred path to export all materials to. - """ - cura_application = cura.CuraApplication.CuraApplication.getInstance() - device_manager = cura_application.getOutputDeviceManager() - devices = device_manager.getOutputDevices() - for device in devices: - if device.__class__.__name__ == "RemovableDriveOutputDevice": - return QUrl.fromLocalFile(device.getId()) - else: # No removable drives? Use local path. - return cura_application.getDefaultPath("dialog_material_path") - - @pyqtSlot(QUrl) - def exportAll(self, file_path: QUrl) -> None: - """ - Export all materials to a certain file path. - :param file_path: The path to export the materials to. - """ - registry = CuraContainerRegistry.getInstance() - - try: - archive = zipfile.ZipFile(file_path.toLocalFile(), "w", compression = zipfile.ZIP_DEFLATED) - except OSError as e: - Logger.log("e", f"Can't write to destination {file_path.toLocalFile()}: {type(e)} - {str(e)}") - error_message = Message( - text = catalog.i18nc("@message:text", "Could not save material archive to {}:").format(file_path.toLocalFile()) + " " + str(e), - title = catalog.i18nc("@message:title", "Failed to save material archive"), - message_type = Message.MessageType.ERROR - ) - error_message.show() - return - for metadata in registry.findInstanceContainersMetadata(type = "material"): - if metadata["base_file"] != metadata["id"]: # Only process base files. - continue - if metadata["id"] == "empty_material": # Don't export the empty material. - continue - material = registry.findContainers(id = metadata["id"])[0] - suffix = registry.getMimeTypeForContainer(type(material)).preferredSuffix - filename = metadata["id"] + "." + suffix - try: - archive.writestr(filename, material.serialize()) - except OSError as e: - Logger.log("e", f"An error has occurred while writing the material \'{metadata['id']}\' in the file \'{filename}\': {e}.") - - exportUploadStatusChanged = pyqtSignal() - - @pyqtProperty(str, notify = exportUploadStatusChanged) - def exportUploadStatus(self): - return self._export_upload_status - - @pyqtSlot() - def exportUpload(self) -> None: - """ - Export all materials and upload them to the user's account. - """ - self._export_upload_status = "uploading" - self.exportUploadStatusChanged.emit() - job = UploadMaterialsJob() - job.uploadCompleted.connect(self.exportUploadCompleted) - job.start() - - def exportUploadCompleted(self, job_result: UploadMaterialsJob.Result): - if job_result == UploadMaterialsJob.Result.FAILED: - self._sync_all_dialog.setProperty("syncStatusText", catalog.i18nc("@text", "Something went wrong when sending the materials to the printers.")) - self._export_upload_status = "error" - else: - self._export_upload_status = "success" - self.exportUploadStatusChanged.emit() \ No newline at end of file + self._material_sync.sync_all_dialog.setProperty("syncModel", self._material_sync) + self._material_sync.sync_all_dialog.setProperty("pageIndex", 0) # Return to first page. + self._material_sync.sync_all_dialog.show() \ No newline at end of file diff --git a/cura/PrinterOutput/UploadMaterialsJob.py b/cura/PrinterOutput/UploadMaterialsJob.py index 45ac653337..2e7a2c1575 100644 --- a/cura/PrinterOutput/UploadMaterialsJob.py +++ b/cura/PrinterOutput/UploadMaterialsJob.py @@ -18,6 +18,7 @@ from UM.TaskManagement.HttpRequestScope import JsonDecoratorScope from typing import Optional, TYPE_CHECKING if TYPE_CHECKING: from PyQt5.QtNetwork import QNetworkReply + from cura.UltimakerCloud.CloudMaterialSync import CloudMaterialSync class UploadMaterialsJob(Job): """ @@ -30,8 +31,9 @@ class UploadMaterialsJob(Job): SUCCCESS = 0 FAILED = 1 - def __init__(self): + def __init__(self, material_sync: "CloudMaterialSync"): super().__init__() + self._material_sync = material_sync self._scope = JsonDecoratorScope(UltimakerCloudScope(cura.CuraApplication.CuraApplication.getInstance())) # type: JsonDecoratorScope uploadCompleted = Signal() @@ -40,7 +42,7 @@ class UploadMaterialsJob(Job): archive_file = tempfile.NamedTemporaryFile("wb", delete = False) archive_file.close() - cura.CuraApplication.CuraApplication.getInstance().getMaterialManagementModel().exportAll(QUrl.fromLocalFile(archive_file.name)) + self._material_sync.exportAll(QUrl.fromLocalFile(archive_file.name)) http = HttpRequestManager.getInstance() http.get( diff --git a/cura/UltimakerCloud/CloudMaterialSync.py b/cura/UltimakerCloud/CloudMaterialSync.py new file mode 100644 index 0000000000..60dfd963e7 --- /dev/null +++ b/cura/UltimakerCloud/CloudMaterialSync.py @@ -0,0 +1,154 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. + +from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl +from PyQt5.QtGui import QDesktopServices +from typing import Optional +import zipfile # To export all materials in a .zip archive. + +import cura.CuraApplication # Imported like this to prevent circular imports. +from cura.PrinterOutput.UploadMaterialsJob import UploadMaterialsJob # To export materials to the output printer. +from cura.Settings.CuraContainerRegistry import CuraContainerRegistry +from UM.i18n import i18nCatalog +from UM.Logger import Logger +from UM.Message import Message + +catalog = i18nCatalog("cura") + +class CloudMaterialSync(QObject): + """ + Handles the synchronisation of material profiles with cloud accounts. + """ + + def __init__(self, parent: QObject = None): + super().__init__(parent) + self.sync_all_dialog = None # type: Optional[QObject] + self._export_upload_status = "idle" + self._checkIfNewMaterialsWereInstalled() + + def _checkIfNewMaterialsWereInstalled(self) -> None: + """ + Checks whether new material packages were installed in the latest startup. If there were, then it shows + a message prompting the user to sync the materials with their printers. + """ + application = cura.CuraApplication.CuraApplication.getInstance() + for package_id, package_data in application.getPackageManager().getPackagesInstalledOnStartup().items(): + if package_data["package_info"]["package_type"] == "material": + # At least one new material was installed + self._showSyncNewMaterialsMessage() + break + + def _showSyncNewMaterialsMessage(self) -> None: + sync_materials_message = Message( + text = catalog.i18nc("@action:button", + "Please sync the material profiles with your printers before starting to print."), + title = catalog.i18nc("@action:button", "New materials installed"), + message_type = Message.MessageType.WARNING, + lifetime = 0 + ) + + sync_materials_message.addAction( + "sync", + name = catalog.i18nc("@action:button", "Sync materials with printers"), + icon = "", + description = "Sync your newly installed materials with your printers.", + button_align = Message.ActionButtonAlignment.ALIGN_RIGHT + ) + + sync_materials_message.addAction( + "learn_more", + name = catalog.i18nc("@action:button", "Learn more"), + icon = "", + description = "Learn more about syncing your newly installed materials with your printers.", + button_align = Message.ActionButtonAlignment.ALIGN_LEFT, + button_style = Message.ActionButtonStyle.LINK + ) + sync_materials_message.actionTriggered.connect(self._onSyncMaterialsMessageActionTriggered) + + # Show the message only if there are printers that support material export + container_registry = cura.CuraApplication.CuraApplication.getInstance().getContainerRegistry() + global_stacks = container_registry.findContainerStacks(type = "machine") + if any([stack.supportsMaterialExport for stack in global_stacks]): + sync_materials_message.show() + + def _onSyncMaterialsMessageActionTriggered(self, sync_message: Message, sync_message_action: str): + if sync_message_action == "sync": + self.openSyncAllWindow() + sync_message.hide() + elif sync_message_action == "learn_more": + QDesktopServices.openUrl(QUrl("https://support.ultimaker.com/hc/en-us/articles/360013137919?utm_source=cura&utm_medium=software&utm_campaign=sync-material-printer-message")) + + @pyqtSlot(result = QUrl) + def getPreferredExportAllPath(self) -> QUrl: + """ + Get the preferred path to export materials to. + + If there is a removable drive, that should be the preferred path. Otherwise it should be the most recent local + file path. + :return: The preferred path to export all materials to. + """ + cura_application = cura.CuraApplication.CuraApplication.getInstance() + device_manager = cura_application.getOutputDeviceManager() + devices = device_manager.getOutputDevices() + for device in devices: + if device.__class__.__name__ == "RemovableDriveOutputDevice": + return QUrl.fromLocalFile(device.getId()) + else: # No removable drives? Use local path. + return cura_application.getDefaultPath("dialog_material_path") + + @pyqtSlot(QUrl) + def exportAll(self, file_path: QUrl) -> None: + """ + Export all materials to a certain file path. + :param file_path: The path to export the materials to. + """ + registry = CuraContainerRegistry.getInstance() + + try: + archive = zipfile.ZipFile(file_path.toLocalFile(), "w", compression = zipfile.ZIP_DEFLATED) + except OSError as e: + Logger.log("e", f"Can't write to destination {file_path.toLocalFile()}: {type(e)} - {str(e)}") + error_message = Message( + text = catalog.i18nc("@message:text", "Could not save material archive to {}:").format(file_path.toLocalFile()) + " " + str(e), + title = catalog.i18nc("@message:title", "Failed to save material archive"), + message_type = Message.MessageType.ERROR + ) + error_message.show() + return + for metadata in registry.findInstanceContainersMetadata(type = "material"): + if metadata["base_file"] != metadata["id"]: # Only process base files. + continue + if metadata["id"] == "empty_material": # Don't export the empty material. + continue + material = registry.findContainers(id = metadata["id"])[0] + suffix = registry.getMimeTypeForContainer(type(material)).preferredSuffix + filename = metadata["id"] + "." + suffix + try: + archive.writestr(filename, material.serialize()) + except OSError as e: + Logger.log("e", f"An error has occurred while writing the material \'{metadata['id']}\' in the file \'{filename}\': {e}.") + + exportUploadStatusChanged = pyqtSignal() + + @pyqtProperty(str, notify = exportUploadStatusChanged) + def exportUploadStatus(self): + return self._export_upload_status + + @pyqtSlot() + def exportUpload(self) -> None: + """ + Export all materials and upload them to the user's account. + """ + self._export_upload_status = "uploading" + self.exportUploadStatusChanged.emit() + job = UploadMaterialsJob(self) + job.uploadCompleted.connect(self.exportUploadCompleted) + job.start() + + def exportUploadCompleted(self, job_result: UploadMaterialsJob.Result): + if job_result == UploadMaterialsJob.Result.FAILED: + self.sync_all_dialog.setProperty("syncStatusText", catalog.i18nc("@text", "Something went wrong when sending the materials to the printers.")) + self._export_upload_status = "error" + else: + self._export_upload_status = "success" + self.exportUploadStatusChanged.emit() \ No newline at end of file diff --git a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml index 39d0dfe457..44cf047bc9 100644 --- a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml +++ b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml @@ -21,7 +21,7 @@ Window height: minimumHeight modality: Qt.ApplicationModal - property variant materialManagementModel + property variant syncModel property alias pageIndex: swipeView.currentIndex property alias syncStatusText: syncStatusLabel.text @@ -195,21 +195,21 @@ Window State { name: "idle" - when: typeof materialManagementModel === "undefined" || materialManagementModel.exportUploadStatus == "idle" || materialManagementModel.exportUploadStatus == "uploading" + when: typeof syncModel === "undefined" || syncModel.exportUploadStatus == "idle" || syncModel.exportUploadStatus == "uploading" PropertyChanges { target: printerListHeader; text: catalog.i18nc("@title:header", "The following printers will receive the new material profiles:") } PropertyChanges { target: printerListHeaderIcon; status: UM.StatusIcon.Status.NEUTRAL } }, State { name: "error" - when: typeof materialManagementModel !== "undefined" && materialManagementModel.exportUploadStatus == "error" + when: typeof syncModel !== "undefined" && syncModel.exportUploadStatus == "error" PropertyChanges { target: printerListHeader; text: catalog.i18nc("@title:header", "Something went wrong when sending the materials to the printers.") } PropertyChanges { target: printerListHeaderIcon; status: UM.StatusIcon.Status.ERROR } }, State { name: "success" - when: typeof materialManagementModel !== "undefined" && materialManagementModel.exportUploadStatus == "success" + when: typeof syncModel !== "undefined" && syncModel.exportUploadStatus == "success" PropertyChanges { target: printerListHeader; text: catalog.i18nc("@title:header", "Material profiles successfully synced with the following printers:") } PropertyChanges { target: printerListHeaderIcon; status: UM.StatusIcon.Status.POSITIVE } } @@ -399,14 +399,14 @@ Window id: syncButton anchors.right: parent.right text: catalog.i18nc("@button", "Sync") - onClicked: materialManagementModel.exportUpload() + onClicked: syncModel.exportUpload() visible: { - if(!materialManagementModel) //When the dialog is created, this is not set yet. + if(!syncModel) //When the dialog is created, this is not set yet. { return true; } - return materialManagementModel.exportUploadStatus != "uploading"; + return syncModel.exportUploadStatus != "uploading"; } } Item @@ -610,7 +610,7 @@ Window text: catalog.i18nc("@button", "Export material archive") onClicked: { - exportUsbDialog.folder = materialManagementModel.getPreferredExportAllPath(); + exportUsbDialog.folder = syncModel.getPreferredExportAllPath(); exportUsbDialog.open(); } } @@ -641,7 +641,7 @@ Window nameFilters: ["Material archives (*.umm)", "All files (*)"] onAccepted: { - materialManagementModel.exportAll(fileUrl); + syncModel.exportAll(fileUrl); CuraApplication.setDefaultPath("dialog_material_path", folder); } } From 052e33e66b2c741502f37ada34e1480fa973277a Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Tue, 12 Oct 2021 10:17:03 +0200 Subject: [PATCH 48/89] Don't anchor status label to button any more It's now in a completely different place. Contributes to issue CURA-8609. --- resources/qml/Preferences/Materials/MaterialsSyncDialog.qml | 3 --- 1 file changed, 3 deletions(-) diff --git a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml index 44cf047bc9..04f496d29a 100644 --- a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml +++ b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml @@ -235,9 +235,6 @@ Window { id: syncStatusLabel - anchors.right: syncButton.left - anchors.rightMargin: UM.Theme.getSize("default_margin").width - visible: text !== "" text: "" color: UM.Theme.getColor("text") From 025ef743ee06d81b43ac3d000ae3b64eb06b119d Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Tue, 12 Oct 2021 10:24:07 +0200 Subject: [PATCH 49/89] Track progress from export job This way we can show a progress bar. Contributes to issue CURA-8609. --- cura/PrinterOutput/UploadMaterialsJob.py | 3 ++- cura/UltimakerCloud/CloudMaterialSync.py | 30 ++++++++++++++++++++---- 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/cura/PrinterOutput/UploadMaterialsJob.py b/cura/PrinterOutput/UploadMaterialsJob.py index 2e7a2c1575..3560b32a70 100644 --- a/cura/PrinterOutput/UploadMaterialsJob.py +++ b/cura/PrinterOutput/UploadMaterialsJob.py @@ -37,12 +37,13 @@ class UploadMaterialsJob(Job): self._scope = JsonDecoratorScope(UltimakerCloudScope(cura.CuraApplication.CuraApplication.getInstance())) # type: JsonDecoratorScope uploadCompleted = Signal() + uploadProgressChanged = Signal() def run(self): archive_file = tempfile.NamedTemporaryFile("wb", delete = False) archive_file.close() - self._material_sync.exportAll(QUrl.fromLocalFile(archive_file.name)) + self._material_sync.exportAll(QUrl.fromLocalFile(archive_file.name), notify_progress = self.uploadProgressChanged) http = HttpRequestManager.getInstance() http.get( diff --git a/cura/UltimakerCloud/CloudMaterialSync.py b/cura/UltimakerCloud/CloudMaterialSync.py index 60dfd963e7..f2bee1ac74 100644 --- a/cura/UltimakerCloud/CloudMaterialSync.py +++ b/cura/UltimakerCloud/CloudMaterialSync.py @@ -3,7 +3,7 @@ from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl from PyQt5.QtGui import QDesktopServices -from typing import Optional +from typing import Optional, TYPE_CHECKING import zipfile # To export all materials in a .zip archive. import cura.CuraApplication # Imported like this to prevent circular imports. @@ -13,6 +13,8 @@ from UM.i18n import i18nCatalog from UM.Logger import Logger from UM.Message import Message +if TYPE_CHECKING: + from UM.Signal import Signal catalog = i18nCatalog("cura") class CloudMaterialSync(QObject): @@ -25,6 +27,7 @@ class CloudMaterialSync(QObject): self.sync_all_dialog = None # type: Optional[QObject] self._export_upload_status = "idle" self._checkIfNewMaterialsWereInstalled() + self._export_progress = 0 def _checkIfNewMaterialsWereInstalled(self) -> None: """ @@ -97,13 +100,14 @@ class CloudMaterialSync(QObject): return cura_application.getDefaultPath("dialog_material_path") @pyqtSlot(QUrl) - def exportAll(self, file_path: QUrl) -> None: + def exportAll(self, file_path: QUrl, notify_progress: Optional["Signal"] = None) -> None: """ Export all materials to a certain file path. :param file_path: The path to export the materials to. """ registry = CuraContainerRegistry.getInstance() + # Create empty archive. try: archive = zipfile.ZipFile(file_path.toLocalFile(), "w", compression = zipfile.ZIP_DEFLATED) except OSError as e: @@ -115,7 +119,12 @@ class CloudMaterialSync(QObject): ) error_message.show() return - for metadata in registry.findInstanceContainersMetadata(type = "material"): + + materials_metadata = registry.findInstanceContainersMetadata(type = "material") + for index, metadata in enumerate(materials_metadata): + if notify_progress is not None: + progress = index / len(materials_metadata) + notify_progress.emit(progress) if metadata["base_file"] != metadata["id"]: # Only process base files. continue if metadata["id"] == "empty_material": # Don't export the empty material. @@ -131,7 +140,7 @@ class CloudMaterialSync(QObject): exportUploadStatusChanged = pyqtSignal() @pyqtProperty(str, notify = exportUploadStatusChanged) - def exportUploadStatus(self): + def exportUploadStatus(self) -> str: return self._export_upload_status @pyqtSlot() @@ -142,6 +151,7 @@ class CloudMaterialSync(QObject): self._export_upload_status = "uploading" self.exportUploadStatusChanged.emit() job = UploadMaterialsJob(self) + job.uploadProgressChanged.connect(self.setExportProgress) job.uploadCompleted.connect(self.exportUploadCompleted) job.start() @@ -151,4 +161,14 @@ class CloudMaterialSync(QObject): self._export_upload_status = "error" else: self._export_upload_status = "success" - self.exportUploadStatusChanged.emit() \ No newline at end of file + self.exportUploadStatusChanged.emit() + + exportProgressChanged = pyqtSignal(float) + + def setExportProgress(self, progress: float) -> None: + self._export_progress = progress + self.exportProgressChanged.emit(self._export_progress) + + @pyqtProperty(float, fset = setExportProgress, notify = exportProgressChanged) + def exportProgress(self) -> float: + return self._export_progress \ No newline at end of file From 125c80430b1231901ca7bb8105235fc9676a5d4b Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Tue, 12 Oct 2021 11:30:12 +0200 Subject: [PATCH 50/89] Show more information about errors we're getting Show the error code we received in the GUI, and allow expansion for different types of errors. Contributes to issue CURA-8609. --- cura/PrinterOutput/UploadMaterialsJob.py | 12 ++++++++++-- cura/UltimakerCloud/CloudMaterialSync.py | 9 ++++++--- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/cura/PrinterOutput/UploadMaterialsJob.py b/cura/PrinterOutput/UploadMaterialsJob.py index 3560b32a70..57f6332755 100644 --- a/cura/PrinterOutput/UploadMaterialsJob.py +++ b/cura/PrinterOutput/UploadMaterialsJob.py @@ -81,12 +81,20 @@ class UploadMaterialsJob(Job): def onUploadCompleted(self, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"]): if error is not None: Logger.error(f"Failed to upload material archive: {error}") + self.setError(UploadMaterialsError(error)) self.setResult(self.Result.FAILED) else: self.setResult(self.Result.SUCCESS) - self.uploadCompleted.emit(self.getResult()) + self.uploadCompleted.emit(self.getResult(), self.getError()) def onError(self, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"]): Logger.error(f"Failed to upload material archive: {error}") self.setResult(self.Result.FAILED) - self.uploadCompleted.emit(self.getResult()) \ No newline at end of file + self.setError(UploadMaterialsError(error)) + self.uploadCompleted.emit(self.getResult(), self.getError()) + +class UploadMaterialsError(Exception): + """ + Marker class to indicate something went wrong while uploading. + """ + pass \ No newline at end of file diff --git a/cura/UltimakerCloud/CloudMaterialSync.py b/cura/UltimakerCloud/CloudMaterialSync.py index f2bee1ac74..d3cc479a0a 100644 --- a/cura/UltimakerCloud/CloudMaterialSync.py +++ b/cura/UltimakerCloud/CloudMaterialSync.py @@ -7,7 +7,7 @@ from typing import Optional, TYPE_CHECKING import zipfile # To export all materials in a .zip archive. import cura.CuraApplication # Imported like this to prevent circular imports. -from cura.PrinterOutput.UploadMaterialsJob import UploadMaterialsJob # To export materials to the output printer. +from cura.PrinterOutput.UploadMaterialsJob import UploadMaterialsJob, UploadMaterialsError # To export materials to the output printer. from cura.Settings.CuraContainerRegistry import CuraContainerRegistry from UM.i18n import i18nCatalog from UM.Logger import Logger @@ -155,9 +155,12 @@ class CloudMaterialSync(QObject): job.uploadCompleted.connect(self.exportUploadCompleted) job.start() - def exportUploadCompleted(self, job_result: UploadMaterialsJob.Result): + def exportUploadCompleted(self, job_result: UploadMaterialsJob.Result, job_error: Optional[Exception]): if job_result == UploadMaterialsJob.Result.FAILED: - self.sync_all_dialog.setProperty("syncStatusText", catalog.i18nc("@text", "Something went wrong when sending the materials to the printers.")) + if isinstance(job_error, UploadMaterialsError): + self.sync_all_dialog.setProperty("syncStatusText", catalog.i18nc("@text", "Error sending materials to the Digital Factory:") + " " + str(job_error)) + else: # Could be "None" + self.sync_all_dialog.setProperty("syncStatusText", catalog.i18nc("@text", "Unknown error.")) self._export_upload_status = "error" else: self._export_upload_status = "success" From d5e3ed4c0e9d626fbf569a8d48a0a924fbc54571 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Tue, 12 Oct 2021 11:39:26 +0200 Subject: [PATCH 51/89] New material API endpoint URL This is the new URL they have settled on. Contributes to issue CURA-8609. --- cura/PrinterOutput/UploadMaterialsJob.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cura/PrinterOutput/UploadMaterialsJob.py b/cura/PrinterOutput/UploadMaterialsJob.py index 57f6332755..e500d8d407 100644 --- a/cura/PrinterOutput/UploadMaterialsJob.py +++ b/cura/PrinterOutput/UploadMaterialsJob.py @@ -25,7 +25,7 @@ class UploadMaterialsJob(Job): Job that uploads a set of materials to the Digital Factory. """ - UPLOAD_REQUEST_URL = f"{UltimakerCloudConstants.CuraDigitalFactoryURL}/materials/profile_upload" + UPLOAD_REQUEST_URL = f"{UltimakerCloudConstants.CuraCloudAPIRoot}/connect/v1/materials/upload" class Result(enum.IntEnum): SUCCCESS = 0 From f0d69cbef271c4ac31a37c5e027deb1f9780e2c7 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Tue, 12 Oct 2021 12:56:19 +0200 Subject: [PATCH 52/89] Add file data to PUT request The main point of the whole request, really. Contributes to issue CURA-8609. --- cura/PrinterOutput/UploadMaterialsJob.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cura/PrinterOutput/UploadMaterialsJob.py b/cura/PrinterOutput/UploadMaterialsJob.py index e500d8d407..6acac515fe 100644 --- a/cura/PrinterOutput/UploadMaterialsJob.py +++ b/cura/PrinterOutput/UploadMaterialsJob.py @@ -35,6 +35,7 @@ class UploadMaterialsJob(Job): super().__init__() self._material_sync = material_sync self._scope = JsonDecoratorScope(UltimakerCloudScope(cura.CuraApplication.CuraApplication.getInstance())) # type: JsonDecoratorScope + self._archive_filename = None # type: Optional[str] uploadCompleted = Signal() uploadProgressChanged = Signal() @@ -42,8 +43,9 @@ class UploadMaterialsJob(Job): def run(self): archive_file = tempfile.NamedTemporaryFile("wb", delete = False) archive_file.close() + self._archive_filename = archive_file.name - self._material_sync.exportAll(QUrl.fromLocalFile(archive_file.name), notify_progress = self.uploadProgressChanged) + self._material_sync.exportAll(QUrl.fromLocalFile(self._archive_filename), notify_progress = self.uploadProgressChanged) http = HttpRequestManager.getInstance() http.get( @@ -70,9 +72,11 @@ class UploadMaterialsJob(Job): return upload_url = response_data["upload_url"] + file_data = open(self._archive_filename, "rb").read() http = HttpRequestManager.getInstance() http.put( url = upload_url, + data = file_data, callback = self.onUploadCompleted, error_callback = self.onError, scope = self._scope From bdc269f8abe463349933e5cbc02456a5e65ee7b1 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Tue, 12 Oct 2021 13:00:08 +0200 Subject: [PATCH 53/89] Provide file size and an arbitrary name to the upload request Apparently the Cloud will need to know the file size before it gets uploaded. It is used as a redundancy code to verify that it's not corrupt there. Perhaps they should ask for a CRC instead, being more reliable against an upload containing only null bytes or whatever, but that is not up to me to decide. Contributes to issue CURA-8609. --- cura/PrinterOutput/UploadMaterialsJob.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cura/PrinterOutput/UploadMaterialsJob.py b/cura/PrinterOutput/UploadMaterialsJob.py index 6acac515fe..7cad115bb1 100644 --- a/cura/PrinterOutput/UploadMaterialsJob.py +++ b/cura/PrinterOutput/UploadMaterialsJob.py @@ -46,10 +46,11 @@ class UploadMaterialsJob(Job): self._archive_filename = archive_file.name self._material_sync.exportAll(QUrl.fromLocalFile(self._archive_filename), notify_progress = self.uploadProgressChanged) + file_size = os.path.getsize(self._archive_filename) http = HttpRequestManager.getInstance() http.get( - url = self.UPLOAD_REQUEST_URL, + url = self.UPLOAD_REQUEST_URL + f"?file_size={file_size}&file_name=cura.umm", # File name can be anything as long as it's .umm. It's not used by Cloud or firmware. callback = self.onUploadRequestCompleted, error_callback = self.onError, scope = self._scope From 4ccd4caaad92d1333f7f5288982ca5db5dbc46d6 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Tue, 12 Oct 2021 13:04:22 +0200 Subject: [PATCH 54/89] Store material profile ID received from the upload request We'll need this later to be able to tell the server which material archive it should send to certain printers. Contributes to issue CURA-8609. --- cura/PrinterOutput/UploadMaterialsJob.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/cura/PrinterOutput/UploadMaterialsJob.py b/cura/PrinterOutput/UploadMaterialsJob.py index 7cad115bb1..def7ff261f 100644 --- a/cura/PrinterOutput/UploadMaterialsJob.py +++ b/cura/PrinterOutput/UploadMaterialsJob.py @@ -36,6 +36,7 @@ class UploadMaterialsJob(Job): self._material_sync = material_sync self._scope = JsonDecoratorScope(UltimakerCloudScope(cura.CuraApplication.CuraApplication.getInstance())) # type: JsonDecoratorScope self._archive_filename = None # type: Optional[str] + self._archive_remote_id = None # type: Optional[str] # ID that the server gives to this archive. Used to communicate about the archive to the server. uploadCompleted = Signal() uploadProgressChanged = Signal() @@ -69,10 +70,15 @@ class UploadMaterialsJob(Job): return if "upload_url" not in response_data: Logger.error(f"Invalid response to material upload request: Missing 'upload_url' field to upload archive to.") - self.setResult(self.Result.Failed) + self.setResult(self.Result.FAILED) + return + if "material_profile_id" not in response_data: + Logger.error(f"Invalid response to material upload request: Missing 'material_profile_id' to communicate about the materials with the server.") + self.setResult(self.Result.FAILED) return upload_url = response_data["upload_url"] + self._archive_remote_id = response_data["material_profile_id"] file_data = open(self._archive_filename, "rb").read() http = HttpRequestManager.getInstance() http.put( From a6b6b075ea9911568819f503c742fbf8a0644893 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Tue, 12 Oct 2021 13:16:28 +0200 Subject: [PATCH 55/89] Always provide error message if upload failed Contributes to issue CURA-8609. --- cura/PrinterOutput/UploadMaterialsJob.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/cura/PrinterOutput/UploadMaterialsJob.py b/cura/PrinterOutput/UploadMaterialsJob.py index def7ff261f..2beb8057b7 100644 --- a/cura/PrinterOutput/UploadMaterialsJob.py +++ b/cura/PrinterOutput/UploadMaterialsJob.py @@ -9,12 +9,15 @@ import enum import cura.CuraApplication # Imported like this to prevent circular imports. from cura.UltimakerCloud import UltimakerCloudConstants # To know where the API is. from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope # To know how to communicate with this server. +from UM.i18n import i18nCatalog from UM.Job import Job from UM.Logger import Logger from UM.Signal import Signal from UM.TaskManagement.HttpRequestManager import HttpRequestManager # To call the API. from UM.TaskManagement.HttpRequestScope import JsonDecoratorScope +catalog = i18nCatalog("cura") + from typing import Optional, TYPE_CHECKING if TYPE_CHECKING: from PyQt5.QtNetwork import QNetworkReply @@ -66,14 +69,17 @@ class UploadMaterialsJob(Job): response_data = HttpRequestManager.readJSON(reply) if response_data is None: Logger.error(f"Invalid response to material upload request. Could not parse JSON data.") + self.setError(UploadMaterialsError(catalog.i18nc("@text:error", "The response from Digital Factory appears to be corrupted."))) self.setResult(self.Result.FAILED) return if "upload_url" not in response_data: Logger.error(f"Invalid response to material upload request: Missing 'upload_url' field to upload archive to.") + self.setError(UploadMaterialsError(catalog.i18nc("@text:error", "The response from Digital Factory is missing important information."))) self.setResult(self.Result.FAILED) return if "material_profile_id" not in response_data: Logger.error(f"Invalid response to material upload request: Missing 'material_profile_id' to communicate about the materials with the server.") + self.setError(UploadMaterialsError(catalog.i18nc("@text:error", "The response from Digital Factory is missing important information."))) self.setResult(self.Result.FAILED) return @@ -92,7 +98,7 @@ class UploadMaterialsJob(Job): def onUploadCompleted(self, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"]): if error is not None: Logger.error(f"Failed to upload material archive: {error}") - self.setError(UploadMaterialsError(error)) + self.setError(UploadMaterialsError(catalog.i18nc("@text:error", "Failed to connect to Digital Factory."))) self.setResult(self.Result.FAILED) else: self.setResult(self.Result.SUCCESS) @@ -101,7 +107,7 @@ class UploadMaterialsJob(Job): def onError(self, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"]): Logger.error(f"Failed to upload material archive: {error}") self.setResult(self.Result.FAILED) - self.setError(UploadMaterialsError(error)) + self.setError(UploadMaterialsError(catalog.i18nc("@text:error", "Failed to connect to Digital Factory."))) self.uploadCompleted.emit(self.getResult(), self.getError()) class UploadMaterialsError(Exception): From f677b338fdbfd316d1eb4d232ff5406c5fad7f1c Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Tue, 12 Oct 2021 13:19:09 +0200 Subject: [PATCH 56/89] Always provide error message if upload failed Contributes to issue CURA-8609. --- cura/PrinterOutput/UploadMaterialsJob.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/cura/PrinterOutput/UploadMaterialsJob.py b/cura/PrinterOutput/UploadMaterialsJob.py index 2beb8057b7..940a848a9e 100644 --- a/cura/PrinterOutput/UploadMaterialsJob.py +++ b/cura/PrinterOutput/UploadMaterialsJob.py @@ -63,7 +63,9 @@ class UploadMaterialsJob(Job): def onUploadRequestCompleted(self, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"]): if error is not None: Logger.error(f"Could not request URL to upload material archive to: {error}") + self.setError(UploadMaterialsError(catalog.i18nc("@text:error", "Failed to connect to Digital Factory."))) self.setResult(self.Result.FAILED) + self.uploadCompleted.emit(self.getResult(), self.getError()) return response_data = HttpRequestManager.readJSON(reply) @@ -71,16 +73,19 @@ class UploadMaterialsJob(Job): Logger.error(f"Invalid response to material upload request. Could not parse JSON data.") self.setError(UploadMaterialsError(catalog.i18nc("@text:error", "The response from Digital Factory appears to be corrupted."))) self.setResult(self.Result.FAILED) + self.uploadCompleted.emit(self.getResult(), self.getError()) return if "upload_url" not in response_data: Logger.error(f"Invalid response to material upload request: Missing 'upload_url' field to upload archive to.") self.setError(UploadMaterialsError(catalog.i18nc("@text:error", "The response from Digital Factory is missing important information."))) self.setResult(self.Result.FAILED) + self.uploadCompleted.emit(self.getResult(), self.getError()) return if "material_profile_id" not in response_data: Logger.error(f"Invalid response to material upload request: Missing 'material_profile_id' to communicate about the materials with the server.") self.setError(UploadMaterialsError(catalog.i18nc("@text:error", "The response from Digital Factory is missing important information."))) self.setResult(self.Result.FAILED) + self.uploadCompleted.emit(self.getResult(), self.getError()) return upload_url = response_data["upload_url"] @@ -100,6 +105,10 @@ class UploadMaterialsJob(Job): Logger.error(f"Failed to upload material archive: {error}") self.setError(UploadMaterialsError(catalog.i18nc("@text:error", "Failed to connect to Digital Factory."))) self.setResult(self.Result.FAILED) + return + + + else: self.setResult(self.Result.SUCCESS) self.uploadCompleted.emit(self.getResult(), self.getError()) From d4d17095bbdacb0017b2a2f7257d524c8cf4c418 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Tue, 12 Oct 2021 13:39:04 +0200 Subject: [PATCH 57/89] Implement confirming for all printers to send material sync We need to make this request for every printer. Contributes to issue CURA-8609. --- cura/PrinterOutput/UploadMaterialsJob.py | 31 ++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/cura/PrinterOutput/UploadMaterialsJob.py b/cura/PrinterOutput/UploadMaterialsJob.py index 940a848a9e..5591e3b8e8 100644 --- a/cura/PrinterOutput/UploadMaterialsJob.py +++ b/cura/PrinterOutput/UploadMaterialsJob.py @@ -5,8 +5,10 @@ from PyQt5.QtCore import QUrl import os # To delete the archive when we're done. import tempfile # To create an archive before we upload it. import enum +import functools import cura.CuraApplication # Imported like this to prevent circular imports. +from cura.Settings.CuraContainerRegistry import CuraContainerRegistry # To find all printers to upload to. from cura.UltimakerCloud import UltimakerCloudConstants # To know where the API is. from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope # To know how to communicate with this server. from UM.i18n import i18nCatalog @@ -29,6 +31,7 @@ class UploadMaterialsJob(Job): """ UPLOAD_REQUEST_URL = f"{UltimakerCloudConstants.CuraCloudAPIRoot}/connect/v1/materials/upload" + UPLOAD_CONFIRM_URL = UltimakerCloudConstants.CuraCloudAPIRoot + "/connect/v1/clusters/{cluster_id}/printers/{cluster_printer_id}/action/confirm_material_upload" class Result(enum.IntEnum): SUCCCESS = 0 @@ -105,12 +108,36 @@ class UploadMaterialsJob(Job): Logger.error(f"Failed to upload material archive: {error}") self.setError(UploadMaterialsError(catalog.i18nc("@text:error", "Failed to connect to Digital Factory."))) self.setResult(self.Result.FAILED) + self.uploadCompleted.emit(self.getResult(), self.getError()) return + for container_stack in CuraContainerRegistry.getInstance().findContainerStacksMetadata(type = "machine"): + if container_stack.get("connection_type", -1) != 3: # Not a cloud printer. + continue # Only upload to cloud printers. + if not container_stack.get("is_online", False): # Not online. + continue # Only upload to online printers. + if "host_guid" not in container_stack or "um_cloud_cluster_id" not in container_stack: + continue # Incomplete information about cloud printer. + cluster_id = container_stack["um_cloud_cluster_id"] + printer_id = container_stack["host_guid"] + http = HttpRequestManager.getInstance() + http.get( + url = self.UPLOAD_CONFIRM_URL.format(cluster_id = cluster_id, cluster_printer_id = printer_id), + callback = functools.partialmethod(self.onUploadConfirmed, printer_id), + error_callback = self.onError, + scope = self._scope + ) - else: - self.setResult(self.Result.SUCCESS) + def onUploadConfirmed(self, printer_id: str, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"]) -> None: + if error is not None: + Logger.error(f"Failed to confirm uploading material archive to printer {printer_id}: {error}") + self.setError(UploadMaterialsError(catalog.i18nc("@text:error", "Failed to connect to Digital Factory."))) + self.setResult(self.Result.FAILED) + self.uploadCompleted.emit(self.getResult(), self.getError()) + return + + self.setResult(self.Result.SUCCCESS) self.uploadCompleted.emit(self.getResult(), self.getError()) def onError(self, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"]): From 54d51536b0d60d0e3502691502d311297be233b9 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Tue, 12 Oct 2021 13:55:03 +0200 Subject: [PATCH 58/89] Improve error-handling per printer We can now know which printers failed to sync and which succeeded. Contributes to issue CURA-8609. --- cura/PrinterOutput/UploadMaterialsJob.py | 45 +++++++++++++++--------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/cura/PrinterOutput/UploadMaterialsJob.py b/cura/PrinterOutput/UploadMaterialsJob.py index 5591e3b8e8..9d4011b91e 100644 --- a/cura/PrinterOutput/UploadMaterialsJob.py +++ b/cura/PrinterOutput/UploadMaterialsJob.py @@ -18,13 +18,14 @@ from UM.Signal import Signal from UM.TaskManagement.HttpRequestManager import HttpRequestManager # To call the API. from UM.TaskManagement.HttpRequestScope import JsonDecoratorScope -catalog = i18nCatalog("cura") - from typing import Optional, TYPE_CHECKING if TYPE_CHECKING: from PyQt5.QtNetwork import QNetworkReply from cura.UltimakerCloud.CloudMaterialSync import CloudMaterialSync +catalog = i18nCatalog("cura") + + class UploadMaterialsJob(Job): """ Job that uploads a set of materials to the Digital Factory. @@ -43,6 +44,9 @@ class UploadMaterialsJob(Job): self._scope = JsonDecoratorScope(UltimakerCloudScope(cura.CuraApplication.CuraApplication.getInstance())) # type: JsonDecoratorScope self._archive_filename = None # type: Optional[str] self._archive_remote_id = None # type: Optional[str] # ID that the server gives to this archive. Used to communicate about the archive to the server. + self._num_synced_printers = 0 + self._completed_printers = set() # The printers that have successfully completed the upload. + self._failed_printers = set() uploadCompleted = Signal() uploadProgressChanged = Signal() @@ -111,13 +115,15 @@ class UploadMaterialsJob(Job): self.uploadCompleted.emit(self.getResult(), self.getError()) return - for container_stack in CuraContainerRegistry.getInstance().findContainerStacksMetadata(type = "machine"): - if container_stack.get("connection_type", -1) != 3: # Not a cloud printer. - continue # Only upload to cloud printers. - if not container_stack.get("is_online", False): # Not online. - continue # Only upload to online printers. - if "host_guid" not in container_stack or "um_cloud_cluster_id" not in container_stack: - continue # Incomplete information about cloud printer. + online_cloud_printers = CuraContainerRegistry.getInstance().findContainerStacksMetadata( + type = "machine", + connection_type = 3, # Only cloud printers. + is_online = True, # Only online printers. Otherwise the server gives an error. + host_guid = "*", # Required metadata field. Otherwise we get a KeyError. + um_cloud_cluster_id = "*" # Required metadata field. Otherwise we get a KeyError. + ) + self._num_synced_printers = len(online_cloud_printers) + for container_stack in online_cloud_printers: cluster_id = container_stack["um_cloud_cluster_id"] printer_id = container_stack["host_guid"] @@ -125,20 +131,24 @@ class UploadMaterialsJob(Job): http.get( url = self.UPLOAD_CONFIRM_URL.format(cluster_id = cluster_id, cluster_printer_id = printer_id), callback = functools.partialmethod(self.onUploadConfirmed, printer_id), - error_callback = self.onError, + error_callback = functools.partialmethod(self.onUploadConfirmed, printer_id), # Let this same function handle the error too. scope = self._scope ) def onUploadConfirmed(self, printer_id: str, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"]) -> None: if error is not None: Logger.error(f"Failed to confirm uploading material archive to printer {printer_id}: {error}") - self.setError(UploadMaterialsError(catalog.i18nc("@text:error", "Failed to connect to Digital Factory."))) - self.setResult(self.Result.FAILED) - self.uploadCompleted.emit(self.getResult(), self.getError()) - return + self._failed_printers.add(printer_id) + else: + self._completed_printers.add(printer_id) - self.setResult(self.Result.SUCCCESS) - self.uploadCompleted.emit(self.getResult(), self.getError()) + if len(self._completed_printers) + len(self._failed_printers) >= self._num_synced_printers: # This is the last response to be processed. + if self._failed_printers: + self.setResult(self.Result.FAILED) + self.setError(UploadMaterialsError(catalog.i18nc("@text:error", "Failed to connect to Digital Factory to sync materials with some of the printers."))) + else: + self.setResult(self.Result.SUCCESS) + self.uploadCompleted.emit(self.getResult(), self.getError()) def onError(self, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"]): Logger.error(f"Failed to upload material archive: {error}") @@ -146,8 +156,9 @@ class UploadMaterialsJob(Job): self.setError(UploadMaterialsError(catalog.i18nc("@text:error", "Failed to connect to Digital Factory."))) self.uploadCompleted.emit(self.getResult(), self.getError()) + class UploadMaterialsError(Exception): """ Marker class to indicate something went wrong while uploading. """ - pass \ No newline at end of file + pass From 8607eb5cffc7addf316e612b7548e9a4bf8163bd Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Tue, 12 Oct 2021 15:49:43 +0200 Subject: [PATCH 59/89] Store printer sync status flag per printer Because we'll need to display it per printer. Contributes to issue CURA-8609. --- cura/PrinterOutput/UploadMaterialsJob.py | 40 +++++++++++++----------- 1 file changed, 22 insertions(+), 18 deletions(-) diff --git a/cura/PrinterOutput/UploadMaterialsJob.py b/cura/PrinterOutput/UploadMaterialsJob.py index 9d4011b91e..f53476ccb2 100644 --- a/cura/PrinterOutput/UploadMaterialsJob.py +++ b/cura/PrinterOutput/UploadMaterialsJob.py @@ -18,7 +18,7 @@ from UM.Signal import Signal from UM.TaskManagement.HttpRequestManager import HttpRequestManager # To call the API. from UM.TaskManagement.HttpRequestScope import JsonDecoratorScope -from typing import Optional, TYPE_CHECKING +from typing import Dict, Optional, TYPE_CHECKING if TYPE_CHECKING: from PyQt5.QtNetwork import QNetworkReply from cura.UltimakerCloud.CloudMaterialSync import CloudMaterialSync @@ -44,14 +44,23 @@ class UploadMaterialsJob(Job): self._scope = JsonDecoratorScope(UltimakerCloudScope(cura.CuraApplication.CuraApplication.getInstance())) # type: JsonDecoratorScope self._archive_filename = None # type: Optional[str] self._archive_remote_id = None # type: Optional[str] # ID that the server gives to this archive. Used to communicate about the archive to the server. - self._num_synced_printers = 0 - self._completed_printers = set() # The printers that have successfully completed the upload. - self._failed_printers = set() + self._printer_sync_status = {} + self._printer_metadata = {} uploadCompleted = Signal() uploadProgressChanged = Signal() def run(self): + self._printer_metadata = CuraContainerRegistry.getInstance().findContainerStacksMetadata( + type = "machine", + connection_type = 3, # Only cloud printers. + is_online = True, # Only online printers. Otherwise the server gives an error. + host_guid = "*", # Required metadata field. Otherwise we get a KeyError. + um_cloud_cluster_id = "*" # Required metadata field. Otherwise we get a KeyError. + ) + for printer in self._printer_metadata: + self._printer_sync_status[printer["host_guid"]] = "uploading" + archive_file = tempfile.NamedTemporaryFile("wb", delete = False) archive_file.close() self._archive_filename = archive_file.name @@ -115,15 +124,7 @@ class UploadMaterialsJob(Job): self.uploadCompleted.emit(self.getResult(), self.getError()) return - online_cloud_printers = CuraContainerRegistry.getInstance().findContainerStacksMetadata( - type = "machine", - connection_type = 3, # Only cloud printers. - is_online = True, # Only online printers. Otherwise the server gives an error. - host_guid = "*", # Required metadata field. Otherwise we get a KeyError. - um_cloud_cluster_id = "*" # Required metadata field. Otherwise we get a KeyError. - ) - self._num_synced_printers = len(online_cloud_printers) - for container_stack in online_cloud_printers: + for container_stack in self._printer_metadata: cluster_id = container_stack["um_cloud_cluster_id"] printer_id = container_stack["host_guid"] @@ -138,12 +139,12 @@ class UploadMaterialsJob(Job): def onUploadConfirmed(self, printer_id: str, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"]) -> None: if error is not None: Logger.error(f"Failed to confirm uploading material archive to printer {printer_id}: {error}") - self._failed_printers.add(printer_id) + self._printer_sync_status[printer_id] = "failed" else: - self._completed_printers.add(printer_id) + self._printer_sync_status[printer_id] = "success" - if len(self._completed_printers) + len(self._failed_printers) >= self._num_synced_printers: # This is the last response to be processed. - if self._failed_printers: + if "uploading" not in self._printer_sync_status.values(): # This is the last response to be processed. + if "failed" in self._printer_sync_status.values(): self.setResult(self.Result.FAILED) self.setError(UploadMaterialsError(catalog.i18nc("@text:error", "Failed to connect to Digital Factory to sync materials with some of the printers."))) else: @@ -156,9 +157,12 @@ class UploadMaterialsJob(Job): self.setError(UploadMaterialsError(catalog.i18nc("@text:error", "Failed to connect to Digital Factory."))) self.uploadCompleted.emit(self.getResult(), self.getError()) + def getPrinterSyncStatus(self) -> Dict[str, str]: + return self._printer_sync_status + class UploadMaterialsError(Exception): """ - Marker class to indicate something went wrong while uploading. + Class to indicate something went wrong while uploading. """ pass From 0fa6f650f6b3b3f686b692d3ea79db87b8b7956c Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Tue, 12 Oct 2021 16:06:53 +0200 Subject: [PATCH 60/89] Expose printer status updates via progress update signal This way we can ask the printer status from QML even if it's updated via a job on a different thread and different class and all that. Contributes to issue CURA-8609. --- cura/PrinterOutput/UploadMaterialsJob.py | 12 ++++++++++-- cura/UltimakerCloud/CloudMaterialSync.py | 21 ++++++++++++++++++--- 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/cura/PrinterOutput/UploadMaterialsJob.py b/cura/PrinterOutput/UploadMaterialsJob.py index f53476ccb2..a65406aaf1 100644 --- a/cura/PrinterOutput/UploadMaterialsJob.py +++ b/cura/PrinterOutput/UploadMaterialsJob.py @@ -46,8 +46,10 @@ class UploadMaterialsJob(Job): self._archive_remote_id = None # type: Optional[str] # ID that the server gives to this archive. Used to communicate about the archive to the server. self._printer_sync_status = {} self._printer_metadata = {} + self.processProgressChanged.connect(self._onProcessProgressChanged) uploadCompleted = Signal() + processProgressChanged = Signal() uploadProgressChanged = Signal() def run(self): @@ -65,7 +67,7 @@ class UploadMaterialsJob(Job): archive_file.close() self._archive_filename = archive_file.name - self._material_sync.exportAll(QUrl.fromLocalFile(self._archive_filename), notify_progress = self.uploadProgressChanged) + self._material_sync.exportAll(QUrl.fromLocalFile(self._archive_filename), notify_progress = self.processProgressChanged) file_size = os.path.getsize(self._archive_filename) http = HttpRequestManager.getInstance() @@ -143,7 +145,10 @@ class UploadMaterialsJob(Job): else: self._printer_sync_status[printer_id] = "success" - if "uploading" not in self._printer_sync_status.values(): # This is the last response to be processed. + still_uploading = len([val for val in self._printer_sync_status.values() if val == "uploading"]) + self.uploadProgressChanged.emit(0.8 + (len(self._printer_sync_status) - still_uploading) / len(self._printer_sync_status), self.getPrinterSyncStatus()) + + if still_uploading == 0: # This is the last response to be processed. if "failed" in self._printer_sync_status.values(): self.setResult(self.Result.FAILED) self.setError(UploadMaterialsError(catalog.i18nc("@text:error", "Failed to connect to Digital Factory to sync materials with some of the printers."))) @@ -160,6 +165,9 @@ class UploadMaterialsJob(Job): def getPrinterSyncStatus(self) -> Dict[str, str]: return self._printer_sync_status + def _onProcessProgressChanged(self, progress: float) -> None: + self.uploadProgressChanged.emit(progress * 0.8, self.getPrinterSyncStatus()) # The processing is 80% of the progress bar. + class UploadMaterialsError(Exception): """ diff --git a/cura/UltimakerCloud/CloudMaterialSync.py b/cura/UltimakerCloud/CloudMaterialSync.py index d3cc479a0a..99282d7eb1 100644 --- a/cura/UltimakerCloud/CloudMaterialSync.py +++ b/cura/UltimakerCloud/CloudMaterialSync.py @@ -3,7 +3,7 @@ from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QUrl from PyQt5.QtGui import QDesktopServices -from typing import Optional, TYPE_CHECKING +from typing import Dict, Optional, TYPE_CHECKING import zipfile # To export all materials in a .zip archive. import cura.CuraApplication # Imported like this to prevent circular imports. @@ -28,6 +28,7 @@ class CloudMaterialSync(QObject): self._export_upload_status = "idle" self._checkIfNewMaterialsWereInstalled() self._export_progress = 0 + self._printer_status = {} def _checkIfNewMaterialsWereInstalled(self) -> None: """ @@ -151,10 +152,14 @@ class CloudMaterialSync(QObject): self._export_upload_status = "uploading" self.exportUploadStatusChanged.emit() job = UploadMaterialsJob(self) - job.uploadProgressChanged.connect(self.setExportProgress) + job.uploadProgressChanged.connect(self._onUploadProgressChanged) job.uploadCompleted.connect(self.exportUploadCompleted) job.start() + def _onUploadProgressChanged(self, progress: float, printers_status: Dict[str, str]): + self.setExportProgress(progress) + self.setPrinterStatus(printers_status) + def exportUploadCompleted(self, job_result: UploadMaterialsJob.Result, job_error: Optional[Exception]): if job_result == UploadMaterialsJob.Result.FAILED: if isinstance(job_error, UploadMaterialsError): @@ -174,4 +179,14 @@ class CloudMaterialSync(QObject): @pyqtProperty(float, fset = setExportProgress, notify = exportProgressChanged) def exportProgress(self) -> float: - return self._export_progress \ No newline at end of file + return self._export_progress + + printerStatusChanged = pyqtSignal() + + def setPrinterStatus(self, new_status: Dict[str, str]) -> None: + self._printer_status = new_status + self.printerStatusChanged.emit() + + @pyqtProperty("QVariantMap", fset = setPrinterStatus, notify = printerStatusChanged) + def printerStatus(self) -> Dict[str, str]: + return self._printer_status \ No newline at end of file From 3ffffad1ed4e5da7f68d1e9b8a6483e8badeeeb6 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Tue, 12 Oct 2021 16:15:58 +0200 Subject: [PATCH 61/89] Send progress update when failed And update all of the printer statuses to make them appear failed, if we have a general failure in an earlier stage. Contributes to issue CURA-8609. --- cura/PrinterOutput/UploadMaterialsJob.py | 53 +++++++++++++----------- 1 file changed, 28 insertions(+), 25 deletions(-) diff --git a/cura/PrinterOutput/UploadMaterialsJob.py b/cura/PrinterOutput/UploadMaterialsJob.py index a65406aaf1..6fb7dca583 100644 --- a/cura/PrinterOutput/UploadMaterialsJob.py +++ b/cura/PrinterOutput/UploadMaterialsJob.py @@ -26,6 +26,13 @@ if TYPE_CHECKING: catalog = i18nCatalog("cura") +class UploadMaterialsError(Exception): + """ + Class to indicate something went wrong while uploading. + """ + pass + + class UploadMaterialsJob(Job): """ Job that uploads a set of materials to the Digital Factory. @@ -81,29 +88,21 @@ class UploadMaterialsJob(Job): def onUploadRequestCompleted(self, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"]): if error is not None: Logger.error(f"Could not request URL to upload material archive to: {error}") - self.setError(UploadMaterialsError(catalog.i18nc("@text:error", "Failed to connect to Digital Factory."))) - self.setResult(self.Result.FAILED) - self.uploadCompleted.emit(self.getResult(), self.getError()) + self.failed(UploadMaterialsError(catalog.i18nc("@text:error", "Failed to connect to Digital Factory."))) return response_data = HttpRequestManager.readJSON(reply) if response_data is None: Logger.error(f"Invalid response to material upload request. Could not parse JSON data.") - self.setError(UploadMaterialsError(catalog.i18nc("@text:error", "The response from Digital Factory appears to be corrupted."))) - self.setResult(self.Result.FAILED) - self.uploadCompleted.emit(self.getResult(), self.getError()) + self.failed(UploadMaterialsError(catalog.i18nc("@text:error", "The response from Digital Factory appears to be corrupted."))) return if "upload_url" not in response_data: Logger.error(f"Invalid response to material upload request: Missing 'upload_url' field to upload archive to.") - self.setError(UploadMaterialsError(catalog.i18nc("@text:error", "The response from Digital Factory is missing important information."))) - self.setResult(self.Result.FAILED) - self.uploadCompleted.emit(self.getResult(), self.getError()) + self.failed(UploadMaterialsError(catalog.i18nc("@text:error", "The response from Digital Factory is missing important information."))) return if "material_profile_id" not in response_data: Logger.error(f"Invalid response to material upload request: Missing 'material_profile_id' to communicate about the materials with the server.") - self.setError(UploadMaterialsError(catalog.i18nc("@text:error", "The response from Digital Factory is missing important information."))) - self.setResult(self.Result.FAILED) - self.uploadCompleted.emit(self.getResult(), self.getError()) + self.failed(UploadMaterialsError(catalog.i18nc("@text:error", "The response from Digital Factory is missing important information."))) return upload_url = response_data["upload_url"] @@ -121,9 +120,7 @@ class UploadMaterialsJob(Job): def onUploadCompleted(self, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"]): if error is not None: Logger.error(f"Failed to upload material archive: {error}") - self.setError(UploadMaterialsError(catalog.i18nc("@text:error", "Failed to connect to Digital Factory."))) - self.setResult(self.Result.FAILED) - self.uploadCompleted.emit(self.getResult(), self.getError()) + self.failed(UploadMaterialsError(catalog.i18nc("@text:error", "Failed to connect to Digital Factory."))) return for container_stack in self._printer_metadata: @@ -158,19 +155,25 @@ class UploadMaterialsJob(Job): def onError(self, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"]): Logger.error(f"Failed to upload material archive: {error}") - self.setResult(self.Result.FAILED) - self.setError(UploadMaterialsError(catalog.i18nc("@text:error", "Failed to connect to Digital Factory."))) - self.uploadCompleted.emit(self.getResult(), self.getError()) + self.failed(UploadMaterialsError(catalog.i18nc("@text:error", "Failed to connect to Digital Factory."))) def getPrinterSyncStatus(self) -> Dict[str, str]: return self._printer_sync_status + def failed(self, error: UploadMaterialsError) -> None: + """ + Helper function for when we have a general failure. + + This sets the sync status for all printers to failed, sets the error on + the job and the result of the job to FAILED. + :param error: An error to show to the user. + """ + self.setResult(self.Result.FAILED) + self.setError(error) + for printer_id in self._printer_sync_status: + self._printer_sync_status[printer_id] = "failed" + self.uploadProgressChanged.emit(1.0, self.getPrinterSyncStatus()) + self.uploadCompleted.emit(self.getResult(), self.getError()) + def _onProcessProgressChanged(self, progress: float) -> None: self.uploadProgressChanged.emit(progress * 0.8, self.getPrinterSyncStatus()) # The processing is 80% of the progress bar. - - -class UploadMaterialsError(Exception): - """ - Class to indicate something went wrong while uploading. - """ - pass From 2b6a82ecf1ef8000cc49846d77cc2bd8f9b0ceec Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Tue, 12 Oct 2021 17:08:58 +0200 Subject: [PATCH 62/89] Match on strings for metadata It doesn't automatically cast these in the query. Contributes to issue CURA-8609. --- cura/PrinterOutput/UploadMaterialsJob.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cura/PrinterOutput/UploadMaterialsJob.py b/cura/PrinterOutput/UploadMaterialsJob.py index 6fb7dca583..994971c8d0 100644 --- a/cura/PrinterOutput/UploadMaterialsJob.py +++ b/cura/PrinterOutput/UploadMaterialsJob.py @@ -62,8 +62,8 @@ class UploadMaterialsJob(Job): def run(self): self._printer_metadata = CuraContainerRegistry.getInstance().findContainerStacksMetadata( type = "machine", - connection_type = 3, # Only cloud printers. - is_online = True, # Only online printers. Otherwise the server gives an error. + connection_type = "3", # Only cloud printers. + is_online = "True", # Only online printers. Otherwise the server gives an error. host_guid = "*", # Required metadata field. Otherwise we get a KeyError. um_cloud_cluster_id = "*" # Required metadata field. Otherwise we get a KeyError. ) From bfb39cf9890be9bbedac7b06148b566067b2d6a6 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Tue, 12 Oct 2021 17:09:54 +0200 Subject: [PATCH 63/89] Add spinners and status icons per printer These make use of the per-printer sync status to show either nothing, a spinner, a cross or a checkmark. Contributes to issue CURA-8609. --- .../Materials/MaterialsSyncDialog.qml | 47 ++++++++++++++++++- 1 file changed, 46 insertions(+), 1 deletion(-) diff --git a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml index 04f496d29a..a3a06f6242 100644 --- a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml +++ b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml @@ -258,11 +258,22 @@ Window model: cloudPrinterList delegate: Rectangle { + id: delegateContainer border.color: UM.Theme.getColor("lining") border.width: UM.Theme.getSize("default_lining").width width: printerListScrollView.width height: UM.Theme.getSize("card").height + property string syncStatus: + { + var printer_id = model.metadata["host_guid"] + if(syncModel.printerStatus[printer_id] === undefined) //No status information available. Could be added after we started syncing. + { + return "idle"; + } + return syncModel.printerStatus[printer_id]; + } + Cura.IconWithText { anchors @@ -309,6 +320,40 @@ Window } } } + + UM.RecolorImage + { + width: UM.Theme.getSize("section_icon").width + height: width + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + anchors.rightMargin: Math.round((parent.height - height) / 2) //Same margin on the right as above and below. + + visible: delegateContainer.syncStatus === "uploading" + source: UM.Theme.getIcon("ArrowDoubleCircleRight") + color: UM.Theme.getColor("primary") + + RotationAnimator + { + target: printerStatusSyncingIcon + from: 0 + to: 360 + duration: 1000 + loops: Animation.Infinite + running: true + } + } + UM.StatusIcon + { + width: UM.Theme.getSize("section_icon").width + height: width + anchors.verticalCenter: parent.verticalCenter + anchors.right: parent.right + anchors.rightMargin: Math.round((parent.height - height) / 2) //Same margin on the right as above and below. + + visible: delegateContainer.syncStatus === "failed" || delegateContainer.syncStatus === "success" + status: delegateContainer.syncStatus === "success" ? UM.StatusIcon.Status.POSITIVE : UM.StatusIcon.Status.ERROR + } } footer: Item @@ -431,7 +476,7 @@ Window to: 360 duration: 1000 loops: Animation.Infinite - running: !syncButton.visible //Don't run while invisible. Would be a waste of render updates. + running: true } } Label From cf860829c7a4d817757a3af921c21041ac302c35 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Tue, 12 Oct 2021 17:21:25 +0200 Subject: [PATCH 64/89] Provide upload request metadata as body of a PUT request Apparently the API is now a PUT request rather than a GET request. It needs a bit more metadata which can be hard-coded for our client. Contributes to issue CURA-8609. --- cura/PrinterOutput/UploadMaterialsJob.py | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/cura/PrinterOutput/UploadMaterialsJob.py b/cura/PrinterOutput/UploadMaterialsJob.py index 994971c8d0..8664e5b714 100644 --- a/cura/PrinterOutput/UploadMaterialsJob.py +++ b/cura/PrinterOutput/UploadMaterialsJob.py @@ -1,11 +1,12 @@ # Copyright (c) 2021 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from PyQt5.QtCore import QUrl -import os # To delete the archive when we're done. -import tempfile # To create an archive before we upload it. import enum import functools +import json # To serialise metadata for API calls. +import os # To delete the archive when we're done. +from PyQt5.QtCore import QUrl +import tempfile # To create an archive before we upload it. import cura.CuraApplication # Imported like this to prevent circular imports. from cura.Settings.CuraContainerRegistry import CuraContainerRegistry # To find all printers to upload to. @@ -77,9 +78,20 @@ class UploadMaterialsJob(Job): self._material_sync.exportAll(QUrl.fromLocalFile(self._archive_filename), notify_progress = self.processProgressChanged) file_size = os.path.getsize(self._archive_filename) + request_metadata = { + "data": { + "file_size": file_size, + "file_name": "cura.umm", # File name can be anything as long as it's .umm. It's not used by anyone. + "content_type": "application/zip", # This endpoint won't receive files of different MIME types. + "origin": "cura" # Some identifier against hackers intercepting this upload request, apparently. + } + } + request_payload = json.dumps(request_metadata).encode("UTF-8") + http = HttpRequestManager.getInstance() - http.get( - url = self.UPLOAD_REQUEST_URL + f"?file_size={file_size}&file_name=cura.umm", # File name can be anything as long as it's .umm. It's not used by Cloud or firmware. + http.put( + url = self.UPLOAD_REQUEST_URL, + data = request_payload, callback = self.onUploadRequestCompleted, error_callback = self.onError, scope = self._scope From c2057c94db469652235146a5a27602ca8c69ef0e Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Tue, 12 Oct 2021 17:28:09 +0200 Subject: [PATCH 65/89] Only show troubleshooting link if there is an error And show it next to the error. Contributes to issue CURA-8609. --- .../Materials/MaterialsSyncDialog.qml | 38 ++++++++++++------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml index a3a06f6242..49ac522b1f 100644 --- a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml +++ b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml @@ -231,14 +231,33 @@ Window color: UM.Theme.getColor("text") } } - Label + Row { - id: syncStatusLabel + Layout.preferredWidth: parent.width + Layout.preferredHeight: contentRect.height - visible: text !== "" - text: "" - color: UM.Theme.getColor("text") - font: UM.Theme.getFont("medium") + Label + { + id: syncStatusLabel + + width: parent.width - UM.Theme.getSize("default_margin").width - troubleshootingLink.width + anchors.verticalCenter: troubleshootingLink.verticalCenter + + elide: Text.ElideRight + visible: text !== "" + text: "" + color: UM.Theme.getColor("text") + font: UM.Theme.getFont("medium") + } + Cura.TertiaryButton + { + id: troubleshootingLink + text: catalog.i18nc("@button", "Troubleshooting") + visible: typeof syncModel !== "undefined" && syncModel.exportUploadStatus == "error" + iconSource: UM.Theme.getIcon("LinkExternal") + Layout.preferredHeight: height + onClicked: Qt.openUrlExternally("https://support.ultimaker.com/hc/en-us/articles/360012019239?utm_source=cura&utm_medium=software&utm_campaign=sync-material-wizard-troubleshoot-cloud-printer") + } } ScrollView { @@ -416,13 +435,6 @@ Window } } } - Cura.TertiaryButton - { - text: catalog.i18nc("@button", "Troubleshooting") - iconSource: UM.Theme.getIcon("LinkExternal") - Layout.preferredHeight: height - onClicked: Qt.openUrlExternally("https://support.ultimaker.com/hc/en-us/articles/360012019239?utm_source=cura&utm_medium=software&utm_campaign=sync-material-wizard-troubleshoot-cloud-printer") - } Item { width: parent.width From 5b14792c769d396bd97790e1942873514b5f42f0 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Tue, 12 Oct 2021 17:30:20 +0200 Subject: [PATCH 66/89] Use 'try again' as button text if the first attempt failed Contributes to issue CURA-8609. --- resources/qml/Preferences/Materials/MaterialsSyncDialog.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml index 49ac522b1f..83195611f2 100644 --- a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml +++ b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml @@ -452,7 +452,7 @@ Window { id: syncButton anchors.right: parent.right - text: catalog.i18nc("@button", "Sync") + text: (typeof syncModel !== "undefined" && syncModel.exportUploadStatus == "error") ? catalog.i18nc("@button", "Try again") : catalog.i18nc("@button", "Sync") onClicked: syncModel.exportUpload() visible: { From bfb8d9ddf177e94c02f4f8d523d29a7b78d429e7 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Tue, 12 Oct 2021 17:34:34 +0200 Subject: [PATCH 67/89] Show 'Done' button when sync was successful And make it close the window when pressed then. Contributes to issue CURA-8609. --- .../Materials/MaterialsSyncDialog.qml | 25 +++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml index 83195611f2..8e51cd2dd7 100644 --- a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml +++ b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml @@ -452,8 +452,29 @@ Window { id: syncButton anchors.right: parent.right - text: (typeof syncModel !== "undefined" && syncModel.exportUploadStatus == "error") ? catalog.i18nc("@button", "Try again") : catalog.i18nc("@button", "Sync") - onClicked: syncModel.exportUpload() + text: + { + if(typeof syncModel !== "undefined" && syncModel.exportUploadStatus == "error") + { + return catalog.i18nc("@button", "Try again"); + } + if(typeof syncModel !== "undefined" && syncModel.exportUploadStatus == "success") + { + return catalog.i18nc("@button", "Done"); + } + return catalog.i18nc("@button", "Sync"); + } + onClicked: + { + if(typeof syncModel !== "undefined" && syncModel.exportUploadStatus == "success") + { + materialsSyncDialog.close(); + } + else + { + syncModel.exportUpload(); + } + } visible: { if(!syncModel) //When the dialog is created, this is not set yet. From 2d53a548dca55af8e81bc49102004e230c09e03d Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Tue, 12 Oct 2021 17:38:47 +0200 Subject: [PATCH 68/89] Remove superfluous error handling If there is an error, it'll go into onError and handle the error there. Contributes to issue CURA-8609. --- cura/PrinterOutput/UploadMaterialsJob.py | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/cura/PrinterOutput/UploadMaterialsJob.py b/cura/PrinterOutput/UploadMaterialsJob.py index 8664e5b714..0c80ec94dc 100644 --- a/cura/PrinterOutput/UploadMaterialsJob.py +++ b/cura/PrinterOutput/UploadMaterialsJob.py @@ -98,11 +98,6 @@ class UploadMaterialsJob(Job): ) def onUploadRequestCompleted(self, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"]): - if error is not None: - Logger.error(f"Could not request URL to upload material archive to: {error}") - self.failed(UploadMaterialsError(catalog.i18nc("@text:error", "Failed to connect to Digital Factory."))) - return - response_data = HttpRequestManager.readJSON(reply) if response_data is None: Logger.error(f"Invalid response to material upload request. Could not parse JSON data.") @@ -130,11 +125,6 @@ class UploadMaterialsJob(Job): ) def onUploadCompleted(self, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"]): - if error is not None: - Logger.error(f"Failed to upload material archive: {error}") - self.failed(UploadMaterialsError(catalog.i18nc("@text:error", "Failed to connect to Digital Factory."))) - return - for container_stack in self._printer_metadata: cluster_id = container_stack["um_cloud_cluster_id"] printer_id = container_stack["host_guid"] From a703e6b882fffdf923cf21fd0f8625659b45dcd9 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Tue, 12 Oct 2021 17:47:05 +0200 Subject: [PATCH 69/89] Use lambdas instead of functools Partialmethod is not callable apparently. I think the problem is that it's calling the method outside of the scope of the class here. I'm probably not using it right. Lambas are easier since they automatically take their scope along with them. Contributes to issue CURA-8609. --- cura/PrinterOutput/UploadMaterialsJob.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/cura/PrinterOutput/UploadMaterialsJob.py b/cura/PrinterOutput/UploadMaterialsJob.py index 0c80ec94dc..d000d1ba2d 100644 --- a/cura/PrinterOutput/UploadMaterialsJob.py +++ b/cura/PrinterOutput/UploadMaterialsJob.py @@ -2,7 +2,6 @@ # Cura is released under the terms of the LGPLv3 or higher. import enum -import functools import json # To serialise metadata for API calls. import os # To delete the archive when we're done. from PyQt5.QtCore import QUrl @@ -132,8 +131,8 @@ class UploadMaterialsJob(Job): http = HttpRequestManager.getInstance() http.get( url = self.UPLOAD_CONFIRM_URL.format(cluster_id = cluster_id, cluster_printer_id = printer_id), - callback = functools.partialmethod(self.onUploadConfirmed, printer_id), - error_callback = functools.partialmethod(self.onUploadConfirmed, printer_id), # Let this same function handle the error too. + callback = lambda reply, error: self.onUploadConfirmed(printer_id, reply, error), + error_callback = lambda reply, error: self.onUploadConfirmed(printer_id, reply, error), # Let this same function handle the error too. scope = self._scope ) From af54316690eecba8ee09aabeaabd9dff9fed1694 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Wed, 13 Oct 2021 10:00:22 +0200 Subject: [PATCH 70/89] Typing fixes Some things the CI is complaining about. Contributes to issue CURA-8609. --- cura/PrinterOutput/UploadMaterialsJob.py | 11 ++++++----- cura/UltimakerCloud/CloudMaterialSync.py | 6 ++++-- cura/UltimakerCloud/UltimakerCloudScope.py | 2 +- 3 files changed, 11 insertions(+), 8 deletions(-) diff --git a/cura/PrinterOutput/UploadMaterialsJob.py b/cura/PrinterOutput/UploadMaterialsJob.py index d000d1ba2d..b082b47cbb 100644 --- a/cura/PrinterOutput/UploadMaterialsJob.py +++ b/cura/PrinterOutput/UploadMaterialsJob.py @@ -18,7 +18,7 @@ from UM.Signal import Signal from UM.TaskManagement.HttpRequestManager import HttpRequestManager # To call the API. from UM.TaskManagement.HttpRequestScope import JsonDecoratorScope -from typing import Dict, Optional, TYPE_CHECKING +from typing import Any, Dict, Optional, TYPE_CHECKING if TYPE_CHECKING: from PyQt5.QtNetwork import QNetworkReply from cura.UltimakerCloud.CloudMaterialSync import CloudMaterialSync @@ -42,7 +42,7 @@ class UploadMaterialsJob(Job): UPLOAD_CONFIRM_URL = UltimakerCloudConstants.CuraCloudAPIRoot + "/connect/v1/clusters/{cluster_id}/printers/{cluster_printer_id}/action/confirm_material_upload" class Result(enum.IntEnum): - SUCCCESS = 0 + SUCCESS = 0 FAILED = 1 def __init__(self, material_sync: "CloudMaterialSync"): @@ -51,8 +51,8 @@ class UploadMaterialsJob(Job): self._scope = JsonDecoratorScope(UltimakerCloudScope(cura.CuraApplication.CuraApplication.getInstance())) # type: JsonDecoratorScope self._archive_filename = None # type: Optional[str] self._archive_remote_id = None # type: Optional[str] # ID that the server gives to this archive. Used to communicate about the archive to the server. - self._printer_sync_status = {} - self._printer_metadata = {} + self._printer_sync_status = {} # type: Dict[str, str] + self._printer_metadata = {} # type: Dict[str, Any] self.processProgressChanged.connect(self._onProcessProgressChanged) uploadCompleted = Signal() @@ -113,7 +113,8 @@ class UploadMaterialsJob(Job): upload_url = response_data["upload_url"] self._archive_remote_id = response_data["material_profile_id"] - file_data = open(self._archive_filename, "rb").read() + with open(self._archive_filename, "rb") as f: + file_data = f.read() http = HttpRequestManager.getInstance() http.put( url = upload_url, diff --git a/cura/UltimakerCloud/CloudMaterialSync.py b/cura/UltimakerCloud/CloudMaterialSync.py index 99282d7eb1..3a9402c36e 100644 --- a/cura/UltimakerCloud/CloudMaterialSync.py +++ b/cura/UltimakerCloud/CloudMaterialSync.py @@ -27,8 +27,8 @@ class CloudMaterialSync(QObject): self.sync_all_dialog = None # type: Optional[QObject] self._export_upload_status = "idle" self._checkIfNewMaterialsWereInstalled() - self._export_progress = 0 - self._printer_status = {} + self._export_progress = 0.0 + self._printer_status = {} # type: Dict[str, str] def _checkIfNewMaterialsWereInstalled(self) -> None: """ @@ -161,6 +161,8 @@ class CloudMaterialSync(QObject): self.setPrinterStatus(printers_status) def exportUploadCompleted(self, job_result: UploadMaterialsJob.Result, job_error: Optional[Exception]): + if not self.sync_all_dialog: # Shouldn't get triggered before the dialog is open, but better to check anyway. + return if job_result == UploadMaterialsJob.Result.FAILED: if isinstance(job_error, UploadMaterialsError): self.sync_all_dialog.setProperty("syncStatusText", catalog.i18nc("@text", "Error sending materials to the Digital Factory:") + " " + str(job_error)) diff --git a/cura/UltimakerCloud/UltimakerCloudScope.py b/cura/UltimakerCloud/UltimakerCloudScope.py index c8ac35f893..bbcc8e2aa9 100644 --- a/cura/UltimakerCloud/UltimakerCloudScope.py +++ b/cura/UltimakerCloud/UltimakerCloudScope.py @@ -5,11 +5,11 @@ from PyQt5.QtNetwork import QNetworkRequest from UM.Logger import Logger from UM.TaskManagement.HttpRequestScope import DefaultUserAgentScope -from cura.API import Account from typing import TYPE_CHECKING if TYPE_CHECKING: from cura.CuraApplication import CuraApplication + from cura.API.Account import Account class UltimakerCloudScope(DefaultUserAgentScope): From 24cd2046f8e936fc1d30bea46df8a59e2859a125 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Wed, 13 Oct 2021 10:13:06 +0200 Subject: [PATCH 71/89] Initialise _printer_metadata as a list instead of a dict It should become a list later on, in any case. Contributes to issue CURA-8609. --- cura/PrinterOutput/UploadMaterialsJob.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cura/PrinterOutput/UploadMaterialsJob.py b/cura/PrinterOutput/UploadMaterialsJob.py index b082b47cbb..ff273c06c0 100644 --- a/cura/PrinterOutput/UploadMaterialsJob.py +++ b/cura/PrinterOutput/UploadMaterialsJob.py @@ -18,7 +18,7 @@ from UM.Signal import Signal from UM.TaskManagement.HttpRequestManager import HttpRequestManager # To call the API. from UM.TaskManagement.HttpRequestScope import JsonDecoratorScope -from typing import Any, Dict, Optional, TYPE_CHECKING +from typing import Any, cast, Dict, List, Optional, TYPE_CHECKING if TYPE_CHECKING: from PyQt5.QtNetwork import QNetworkReply from cura.UltimakerCloud.CloudMaterialSync import CloudMaterialSync @@ -52,7 +52,7 @@ class UploadMaterialsJob(Job): self._archive_filename = None # type: Optional[str] self._archive_remote_id = None # type: Optional[str] # ID that the server gives to this archive. Used to communicate about the archive to the server. self._printer_sync_status = {} # type: Dict[str, str] - self._printer_metadata = {} # type: Dict[str, Any] + self._printer_metadata = [] # type: List[Dict[str, Any]] self.processProgressChanged.connect(self._onProcessProgressChanged) uploadCompleted = Signal() @@ -113,7 +113,7 @@ class UploadMaterialsJob(Job): upload_url = response_data["upload_url"] self._archive_remote_id = response_data["material_profile_id"] - with open(self._archive_filename, "rb") as f: + with open(cast(str, self._archive_filename), "rb") as f: file_data = f.read() http = HttpRequestManager.getInstance() http.put( From a399bacab380ff016d4377e372b22eb92d9a92d9 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Wed, 13 Oct 2021 10:34:59 +0200 Subject: [PATCH 72/89] Patch CuraApplication away while running tests for output devices It needs CuraApplication because it wants to set metadata on the printer. But this is not relevant for the tests. Contributes to issue CURA-8609. --- .../TestNetworkedPrinterOutputDevice.py | 13 ++++++++----- tests/PrinterOutput/TestPrinterOutputDevice.py | 8 +++++--- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/tests/PrinterOutput/TestNetworkedPrinterOutputDevice.py b/tests/PrinterOutput/TestNetworkedPrinterOutputDevice.py index 84ad7f0473..2a5cc8a2d5 100644 --- a/tests/PrinterOutput/TestNetworkedPrinterOutputDevice.py +++ b/tests/PrinterOutput/TestNetworkedPrinterOutputDevice.py @@ -1,3 +1,6 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. + import time from unittest.mock import MagicMock, patch @@ -122,8 +125,9 @@ def test_put(): def test_timeout(): with patch("UM.Qt.QtApplication.QtApplication.getInstance"): - output_device = NetworkedPrinterOutputDevice(device_id="test", address="127.0.0.1", properties={}) - output_device.setConnectionState(ConnectionState.Connected) + output_device = NetworkedPrinterOutputDevice(device_id = "test", address = "127.0.0.1", properties = {}) + with patch("cura.CuraApplication.CuraApplication.getInstance"): + output_device.setConnectionState(ConnectionState.Connected) assert output_device.connectionState == ConnectionState.Connected output_device._update() @@ -131,9 +135,8 @@ def test_timeout(): output_device._last_response_time = time.time() - 15 # But we did recently ask for a response! output_device._last_request_time = time.time() - 5 - output_device._update() + with patch("cura.CuraApplication.CuraApplication.getInstance"): + output_device._update() # The connection should now be closed, since it went into timeout. assert output_device.connectionState == ConnectionState.Closed - - diff --git a/tests/PrinterOutput/TestPrinterOutputDevice.py b/tests/PrinterOutput/TestPrinterOutputDevice.py index 7a9e4e2cc5..7913e156b0 100644 --- a/tests/PrinterOutput/TestPrinterOutputDevice.py +++ b/tests/PrinterOutput/TestPrinterOutputDevice.py @@ -1,7 +1,8 @@ -from unittest.mock import MagicMock +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. import pytest -from unittest.mock import patch +from unittest.mock import MagicMock, patch from cura.PrinterOutput.Models.ExtruderConfigurationModel import ExtruderConfigurationModel from cura.PrinterOutput.Models.MaterialOutputModel import MaterialOutputModel @@ -33,7 +34,8 @@ def test_getAndSet(data, printer_output_device): setattr(model, data["attribute"] + "Changed", MagicMock()) # Attempt to set the value - getattr(model, "set" + attribute)(data["value"]) + with patch("cura.CuraApplication.CuraApplication.getInstance"): + getattr(model, "set" + attribute)(data["value"]) # Check if signal fired. signal = getattr(model, data["attribute"] + "Changed") From 2b4a31c9deba1fc59494f8372decad74bb57025b Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Fri, 15 Oct 2021 14:35:07 +0200 Subject: [PATCH 73/89] Change type of filter to Optional[ConnectionType] It's a bit more semantic this way. Contributes to issue CURA-8609. --- cura/Machines/Models/GlobalStacksModel.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/cura/Machines/Models/GlobalStacksModel.py b/cura/Machines/Models/GlobalStacksModel.py index 0f01df6b28..cfe8c7587a 100644 --- a/cura/Machines/Models/GlobalStacksModel.py +++ b/cura/Machines/Models/GlobalStacksModel.py @@ -2,6 +2,7 @@ # Cura is released under the terms of the LGPLv3 or higher. from PyQt5.QtCore import Qt, QTimer, pyqtProperty, pyqtSignal +from typing import Optional from UM.Qt.ListModel import ListModel from UM.i18n import i18nCatalog @@ -39,7 +40,7 @@ class GlobalStacksModel(ListModel): self._change_timer.setSingleShot(True) self._change_timer.timeout.connect(self._update) - self._filter_connection_type = -1 + self._filter_connection_type = None # type: Optional[ConnectionType] self._filter_online_only = False # Listen to changes @@ -49,7 +50,7 @@ class GlobalStacksModel(ListModel): self._updateDelayed() filterConnectionTypeChanged = pyqtSignal() - def setFilterConnectionType(self, new_filter: int) -> None: + def setFilterConnectionType(self, new_filter: Optional[ConnectionType]) -> None: self._filter_connection_type = new_filter @pyqtProperty(int, fset = setFilterConnectionType, notify = filterConnectionTypeChanged) @@ -60,7 +61,7 @@ class GlobalStacksModel(ListModel): Only printers that match this connection type will be listed in the model. """ - return self._filter_connection_type + return int(self._filter_connection_type) filterOnlineOnlyChanged = pyqtSignal() def setFilterOnlineOnly(self, new_filter: bool) -> None: @@ -88,7 +89,7 @@ class GlobalStacksModel(ListModel): container_stacks = CuraContainerRegistry.getInstance().findContainerStacks(type = "machine") for container_stack in container_stacks: - if self._filter_connection_type != -1: # We want to filter on connection types. + if self._filter_connection_type is not None: # We want to filter on connection types. if not any((connection_type == self._filter_connection_type for connection_type in container_stack.configuredConnectionTypes)): continue # No connection type on this printer matches the filter. From dfcefe11cc4cd6e76c07ae96c65a053a00579064 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Fri, 15 Oct 2021 14:51:07 +0200 Subject: [PATCH 74/89] Use enum for printer status constants This indicates how we're using it, and also allows for use of symbols in the code rather than strings, which integrate better with tooling. Contributes to issue CURA-8609. --- cura/PrinterOutput/UploadMaterialsJob.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/cura/PrinterOutput/UploadMaterialsJob.py b/cura/PrinterOutput/UploadMaterialsJob.py index ff273c06c0..7d2a98ff11 100644 --- a/cura/PrinterOutput/UploadMaterialsJob.py +++ b/cura/PrinterOutput/UploadMaterialsJob.py @@ -45,6 +45,11 @@ class UploadMaterialsJob(Job): SUCCESS = 0 FAILED = 1 + class PrinterStatus(enum.Enum): + UPLOADING = "uploading" + SUCCESS = "success" + FAILED = "failed" + def __init__(self, material_sync: "CloudMaterialSync"): super().__init__() self._material_sync = material_sync @@ -68,7 +73,7 @@ class UploadMaterialsJob(Job): um_cloud_cluster_id = "*" # Required metadata field. Otherwise we get a KeyError. ) for printer in self._printer_metadata: - self._printer_sync_status[printer["host_guid"]] = "uploading" + self._printer_sync_status[printer["host_guid"]] = self.PrinterStatus.UPLOADING.value archive_file = tempfile.NamedTemporaryFile("wb", delete = False) archive_file.close() @@ -140,15 +145,15 @@ class UploadMaterialsJob(Job): def onUploadConfirmed(self, printer_id: str, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"]) -> None: if error is not None: Logger.error(f"Failed to confirm uploading material archive to printer {printer_id}: {error}") - self._printer_sync_status[printer_id] = "failed" + self._printer_sync_status[printer_id] = self.PrinterStatus.FAILED.value else: - self._printer_sync_status[printer_id] = "success" + self._printer_sync_status[printer_id] = self.PrinterStatus.SUCCESS.value - still_uploading = len([val for val in self._printer_sync_status.values() if val == "uploading"]) + still_uploading = len([val for val in self._printer_sync_status.values() if val == self.PrinterStatus.UPLOADING.value]) self.uploadProgressChanged.emit(0.8 + (len(self._printer_sync_status) - still_uploading) / len(self._printer_sync_status), self.getPrinterSyncStatus()) if still_uploading == 0: # This is the last response to be processed. - if "failed" in self._printer_sync_status.values(): + if self.PrinterStatus.FAILED.value in self._printer_sync_status.values(): self.setResult(self.Result.FAILED) self.setError(UploadMaterialsError(catalog.i18nc("@text:error", "Failed to connect to Digital Factory to sync materials with some of the printers."))) else: @@ -173,7 +178,7 @@ class UploadMaterialsJob(Job): self.setResult(self.Result.FAILED) self.setError(error) for printer_id in self._printer_sync_status: - self._printer_sync_status[printer_id] = "failed" + self._printer_sync_status[printer_id] = self.PrinterStatus.FAILED.value self.uploadProgressChanged.emit(1.0, self.getPrinterSyncStatus()) self.uploadCompleted.emit(self.getResult(), self.getError()) From 4262dfaf5dcb1ece926285a3cd076203a3c612a9 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Fri, 15 Oct 2021 15:17:59 +0200 Subject: [PATCH 75/89] Better handle errors in local part of upload job It could be that the archive fails to save because the user doesn't have access to its own temporary folder, the firewall quarantines the archive, there's not enough disk space, whatever. These errors need to be handled and not crash Cura. Contributes to issue CURA-8609. --- cura/PrinterOutput/UploadMaterialsJob.py | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/cura/PrinterOutput/UploadMaterialsJob.py b/cura/PrinterOutput/UploadMaterialsJob.py index 7d2a98ff11..79affeabd3 100644 --- a/cura/PrinterOutput/UploadMaterialsJob.py +++ b/cura/PrinterOutput/UploadMaterialsJob.py @@ -75,12 +75,22 @@ class UploadMaterialsJob(Job): for printer in self._printer_metadata: self._printer_sync_status[printer["host_guid"]] = self.PrinterStatus.UPLOADING.value - archive_file = tempfile.NamedTemporaryFile("wb", delete = False) - archive_file.close() - self._archive_filename = archive_file.name + try: + archive_file = tempfile.NamedTemporaryFile("wb", delete = False) + archive_file.close() + self._archive_filename = archive_file.name + self._material_sync.exportAll(QUrl.fromLocalFile(self._archive_filename), notify_progress = self.processProgressChanged) + except OSError as e: + Logger.error(f"Failed to create archive of materials to sync with printers: {type(e)} - {e}") + self.failed(UploadMaterialsError(catalog.i18nc("@text:error", "Failed to create archive of materials to sync with printers."))) + return - self._material_sync.exportAll(QUrl.fromLocalFile(self._archive_filename), notify_progress = self.processProgressChanged) - file_size = os.path.getsize(self._archive_filename) + try: + file_size = os.path.getsize(self._archive_filename) + except OSError as e: + Logger.error(f"Failed to load the archive of materials to sync it with printers: {type(e)} - {e}") + self.failed(UploadMaterialsError(catalog.i18nc("@text:error", "Failed to load the archive of materials to sync it with printers."))) + return request_metadata = { "data": { From e05fa87b4885a809c8c2b1a0746de85fd3b4da74 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Fri, 15 Oct 2021 15:24:05 +0200 Subject: [PATCH 76/89] Handle errors reading material archive back in It could be that this archive is not accessible any more for whatever reason. Write-only file systems, quarantined files, etc. Whatever the reason, Cura shouldn't crash on this because it's not in Cura's control. Contributes to issue CURA-8609. --- cura/PrinterOutput/UploadMaterialsJob.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/cura/PrinterOutput/UploadMaterialsJob.py b/cura/PrinterOutput/UploadMaterialsJob.py index 79affeabd3..34e92ac8ef 100644 --- a/cura/PrinterOutput/UploadMaterialsJob.py +++ b/cura/PrinterOutput/UploadMaterialsJob.py @@ -128,8 +128,13 @@ class UploadMaterialsJob(Job): upload_url = response_data["upload_url"] self._archive_remote_id = response_data["material_profile_id"] - with open(cast(str, self._archive_filename), "rb") as f: - file_data = f.read() + try: + with open(cast(str, self._archive_filename), "rb") as f: + file_data = f.read() + except OSError as e: + Logger.error(f"Failed to load archive back in for sending to cloud: {type(e)} - {e}") + self.failed(UploadMaterialsError(catalog.i18nc("@text:error", "Failed to load the archive of materials to sync it with printers."))) + return http = HttpRequestManager.getInstance() http.put( url = upload_url, From 889000242d59c964bfbe18cb7bce37505e67ee72 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Fri, 15 Oct 2021 15:42:12 +0200 Subject: [PATCH 77/89] Document UploadMaterialsJob class better Including all of its signals and methods. Contributes to issue CURA-8609. --- cura/PrinterOutput/UploadMaterialsJob.py | 65 +++++++++++++++++++++--- 1 file changed, 58 insertions(+), 7 deletions(-) diff --git a/cura/PrinterOutput/UploadMaterialsJob.py b/cura/PrinterOutput/UploadMaterialsJob.py index 34e92ac8ef..0181a87c01 100644 --- a/cura/PrinterOutput/UploadMaterialsJob.py +++ b/cura/PrinterOutput/UploadMaterialsJob.py @@ -36,6 +36,13 @@ class UploadMaterialsError(Exception): class UploadMaterialsJob(Job): """ Job that uploads a set of materials to the Digital Factory. + + The job has a number of stages: + - First, it generates an archive of all materials. This typically takes a lot of processing power during which the + GIL remains locked. + - Then it requests the API to upload an archive. + - Then it uploads the archive to the URL given by the first request. + - Then it tells the API that the archive can be distributed to the printers. """ UPLOAD_REQUEST_URL = f"{UltimakerCloudConstants.CuraCloudAPIRoot}/connect/v1/materials/upload" @@ -60,11 +67,14 @@ class UploadMaterialsJob(Job): self._printer_metadata = [] # type: List[Dict[str, Any]] self.processProgressChanged.connect(self._onProcessProgressChanged) - uploadCompleted = Signal() - processProgressChanged = Signal() - uploadProgressChanged = Signal() + uploadCompleted = Signal() # Triggered when the job is really complete, including uploading to the cloud. + processProgressChanged = Signal() # Triggered when we've made progress creating the archive. + uploadProgressChanged = Signal() # Triggered when we've made progress with the complete job. This signal emits a progress fraction (0-1) as well as the status of every printer. - def run(self): + def run(self) -> None: + """ + Generates an archive of materials and starts uploading that archive to the cloud. + """ self._printer_metadata = CuraContainerRegistry.getInstance().findContainerStacksMetadata( type = "machine", connection_type = "3", # Only cloud printers. @@ -111,7 +121,14 @@ class UploadMaterialsJob(Job): scope = self._scope ) - def onUploadRequestCompleted(self, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"]): + def onUploadRequestCompleted(self, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"]) -> None: + """ + Triggered when we successfully requested to upload a material archive. + + We then need to start uploading the material archive to the URL that the request answered with. + :param reply: The reply from the server to our request to upload an archive. + :param error: An error code (Qt enum) if the request failed. Failure is handled by `onError` though. + """ response_data = HttpRequestManager.readJSON(reply) if response_data is None: Logger.error(f"Invalid response to material upload request. Could not parse JSON data.") @@ -144,7 +161,13 @@ class UploadMaterialsJob(Job): scope = self._scope ) - def onUploadCompleted(self, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"]): + def onUploadCompleted(self, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"]) -> None: + """ + When we've successfully uploaded the archive to the cloud, we need to notify the API to start syncing that + archive to every printer. + :param reply: The reply from the cloud storage when the upload succeeded. + :param error: An error message if the upload failed. Errors are handled by the `onError` function though. + """ for container_stack in self._printer_metadata: cluster_id = container_stack["um_cloud_cluster_id"] printer_id = container_stack["host_guid"] @@ -158,6 +181,16 @@ class UploadMaterialsJob(Job): ) def onUploadConfirmed(self, printer_id: str, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"]) -> None: + """ + Triggered when we've got a confirmation that the material is synced with the printer, or that syncing failed. + + If syncing succeeded we mark this printer as having the status "success". If it failed we mark the printer as + "failed". If this is the last upload that needed to be completed, we complete the job with either a success + state (every printer successfully synced) or a failed state (any printer failed). + :param printer_id: The printer host_guid that we completed syncing with. + :param reply: The reply that the server gave to confirm. + :param error: If the request failed, this error gives an indication what happened. + """ if error is not None: Logger.error(f"Failed to confirm uploading material archive to printer {printer_id}: {error}") self._printer_sync_status[printer_id] = self.PrinterStatus.FAILED.value @@ -175,11 +208,24 @@ class UploadMaterialsJob(Job): self.setResult(self.Result.SUCCESS) self.uploadCompleted.emit(self.getResult(), self.getError()) - def onError(self, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"]): + def onError(self, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"]) -> None: + """ + Used as callback from HTTP requests when the request failed. + + The given network error from the `HttpRequestManager` is logged, and the job is marked as failed. + :param reply: The main reply of the server. This reply will most likely not be valid. + :param error: The network error (Qt's enum) that occurred. + """ Logger.error(f"Failed to upload material archive: {error}") self.failed(UploadMaterialsError(catalog.i18nc("@text:error", "Failed to connect to Digital Factory."))) def getPrinterSyncStatus(self) -> Dict[str, str]: + """ + For each printer, identified by host_guid, this gives the current status of uploading the material archive. + + The possible states are given in the PrinterStatus enum. + :return: A dictionary with printer host_guids as keys, and their status as values. + """ return self._printer_sync_status def failed(self, error: UploadMaterialsError) -> None: @@ -198,4 +244,9 @@ class UploadMaterialsJob(Job): self.uploadCompleted.emit(self.getResult(), self.getError()) def _onProcessProgressChanged(self, progress: float) -> None: + """ + When we progress in the process of uploading materials, we not only signal the new progress (float from 0 to 1) + but we also signal the current status of every printer. These are emitted as the two parameters of the signal. + :param progress: The progress of this job, between 0 and 1. + """ self.uploadProgressChanged.emit(progress * 0.8, self.getPrinterSyncStatus()) # The processing is 80% of the progress bar. From f56e43874761b5d4eaa206604e2f4f3021b2450d Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Fri, 15 Oct 2021 15:52:54 +0200 Subject: [PATCH 78/89] Fix broken references Just some things that the QML is complaining about. This actually did fix the spinning of the spinners. Contributes to issue CURA-8609. --- resources/qml/Preferences/Materials/MaterialsSyncDialog.qml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml index 8e51cd2dd7..5d0f0dd922 100644 --- a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml +++ b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml @@ -234,7 +234,7 @@ Window Row { Layout.preferredWidth: parent.width - Layout.preferredHeight: contentRect.height + Layout.preferredHeight: childrenRect.height Label { @@ -342,6 +342,7 @@ Window UM.RecolorImage { + id: printerSpinner width: UM.Theme.getSize("section_icon").width height: width anchors.verticalCenter: parent.verticalCenter @@ -354,7 +355,7 @@ Window RotationAnimator { - target: printerStatusSyncingIcon + target: printerSpinner from: 0 to: 360 duration: 1000 From 0583814dfa053b62ea51d642a0704cb74029ca94 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Fri, 15 Oct 2021 16:00:54 +0200 Subject: [PATCH 79/89] Fix HttpNetworkManager not providing error parameter if there is no error Contributes to issue CURA-8609. --- cura/PrinterOutput/UploadMaterialsJob.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/cura/PrinterOutput/UploadMaterialsJob.py b/cura/PrinterOutput/UploadMaterialsJob.py index 0181a87c01..9ef05bd3d7 100644 --- a/cura/PrinterOutput/UploadMaterialsJob.py +++ b/cura/PrinterOutput/UploadMaterialsJob.py @@ -121,13 +121,12 @@ class UploadMaterialsJob(Job): scope = self._scope ) - def onUploadRequestCompleted(self, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"]) -> None: + def onUploadRequestCompleted(self, reply: "QNetworkReply") -> None: """ Triggered when we successfully requested to upload a material archive. We then need to start uploading the material archive to the URL that the request answered with. :param reply: The reply from the server to our request to upload an archive. - :param error: An error code (Qt enum) if the request failed. Failure is handled by `onError` though. """ response_data = HttpRequestManager.readJSON(reply) if response_data is None: @@ -161,12 +160,11 @@ class UploadMaterialsJob(Job): scope = self._scope ) - def onUploadCompleted(self, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"]) -> None: + def onUploadCompleted(self, reply: "QNetworkReply") -> None: """ When we've successfully uploaded the archive to the cloud, we need to notify the API to start syncing that archive to every printer. :param reply: The reply from the cloud storage when the upload succeeded. - :param error: An error message if the upload failed. Errors are handled by the `onError` function though. """ for container_stack in self._printer_metadata: cluster_id = container_stack["um_cloud_cluster_id"] @@ -180,7 +178,7 @@ class UploadMaterialsJob(Job): scope = self._scope ) - def onUploadConfirmed(self, printer_id: str, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"]) -> None: + def onUploadConfirmed(self, printer_id: str, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"] = None) -> None: """ Triggered when we've got a confirmation that the material is synced with the printer, or that syncing failed. From 0378531f13b989c6fe4bc22e2fbc14cce55c1dab Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Fri, 15 Oct 2021 16:24:43 +0200 Subject: [PATCH 80/89] Handle QML exposing of filterConnectionType if not filtering It should expose it as -1 then. Contributes to issue CURA-8609. --- cura/Machines/Models/GlobalStacksModel.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/cura/Machines/Models/GlobalStacksModel.py b/cura/Machines/Models/GlobalStacksModel.py index cfe8c7587a..586bd11819 100644 --- a/cura/Machines/Models/GlobalStacksModel.py +++ b/cura/Machines/Models/GlobalStacksModel.py @@ -61,7 +61,9 @@ class GlobalStacksModel(ListModel): Only printers that match this connection type will be listed in the model. """ - return int(self._filter_connection_type) + if self._filter_connection_type is None: + return -1 + return self._filter_connection_type.value filterOnlineOnlyChanged = pyqtSignal() def setFilterOnlineOnly(self, new_filter: bool) -> None: From bf6dd443b2daeb9b916b485539e099bcd861a397 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Mon, 18 Oct 2021 12:59:32 +0200 Subject: [PATCH 81/89] Add material profile writing permission The API needs this new scope name. Contributes to issue CURA-8609. --- cura/API/Account.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cura/API/Account.py b/cura/API/Account.py index a85e2c64c5..86cd094f11 100644 --- a/cura/API/Account.py +++ b/cura/API/Account.py @@ -61,7 +61,7 @@ class Account(QObject): CLIENT_SCOPES = "account.user.read drive.backup.read drive.backup.write packages.download " \ "packages.rating.read packages.rating.write connect.cluster.read connect.cluster.write " \ "library.project.read library.project.write cura.printjob.read cura.printjob.write " \ - "cura.mesh.read cura.mesh.write" + "cura.mesh.read cura.mesh.write cura.material.write" def __init__(self, application: "CuraApplication", parent = None) -> None: super().__init__(parent) From 5ed57e403c082d5d78c0f311c489b98368d974f6 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Mon, 18 Oct 2021 13:17:22 +0200 Subject: [PATCH 82/89] Delete existing log-in information to force the user to log in again Otherwise they won't be able to sync material profiles. Contributes to issue CURA-8609. --- .../VersionUpgrade411to412.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/plugins/VersionUpgrade/VersionUpgrade411to412/VersionUpgrade411to412.py b/plugins/VersionUpgrade/VersionUpgrade411to412/VersionUpgrade411to412.py index 4fb18486c2..2ae94a11f7 100644 --- a/plugins/VersionUpgrade/VersionUpgrade411to412/VersionUpgrade411to412.py +++ b/plugins/VersionUpgrade/VersionUpgrade411to412/VersionUpgrade411to412.py @@ -3,6 +3,7 @@ import configparser import io +import json import os.path from typing import List, Tuple @@ -49,6 +50,28 @@ class VersionUpgrade411to412(VersionUpgrade): # Update version number. parser["metadata"]["setting_version"] = "19" + # If the account scope in 4.11 is outdated, delete it so that the user is enforced to log in again and get the + # correct permissions. + new_scopes = {"account.user.read", + "drive.backup.read", + "drive.backup.write", + "packages.download", + "packages.rating.read", + "packages.rating.write", + "connect.cluster.read", + "connect.cluster.write", + "library.project.read", + "library.project.write", + "cura.printjob.read", + "cura.printjob.write", + "cura.mesh.read", + "cura.mesh.write", + "cura.material.write"} + if "ultimaker_auth_data" in parser["general"]: + ultimaker_auth_data = json.loads(parser["general"]["ultimaker_auth_data"]) + if new_scopes - set(ultimaker_auth_data["scope"].split(" ")): + parser["general"]["ultimaker_auth_data"] = "{}" + result = io.StringIO() parser.write(result) return [filename], [result.getvalue()] From c9d53cbbde38fa978351d134cebc991ade7287db Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Mon, 18 Oct 2021 15:15:05 +0200 Subject: [PATCH 83/89] Renamed OAuth scope This got renamed since the API was communicated to us. Contributes to issue CURA-8609. --- cura/API/Account.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cura/API/Account.py b/cura/API/Account.py index 86cd094f11..f922c89977 100644 --- a/cura/API/Account.py +++ b/cura/API/Account.py @@ -59,9 +59,9 @@ class Account(QObject): updatePackagesEnabledChanged = pyqtSignal(bool) CLIENT_SCOPES = "account.user.read drive.backup.read drive.backup.write packages.download " \ - "packages.rating.read packages.rating.write connect.cluster.read connect.cluster.write " \ + "packages.rating.read packages.rating.write connect.cluster.read connect.cluster.write connect.material.write " \ "library.project.read library.project.write cura.printjob.read cura.printjob.write " \ - "cura.mesh.read cura.mesh.write cura.material.write" + "cura.mesh.read cura.mesh.write" def __init__(self, application: "CuraApplication", parent = None) -> None: super().__init__(parent) From 8bd6fe7c2b3072a99c33e6a7d4390482b7bb7b36 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Mon, 18 Oct 2021 15:15:37 +0200 Subject: [PATCH 84/89] API changed: material_profile_name instead of file_name Contributes to issue CURA-8609. --- cura/PrinterOutput/UploadMaterialsJob.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cura/PrinterOutput/UploadMaterialsJob.py b/cura/PrinterOutput/UploadMaterialsJob.py index 9ef05bd3d7..99484a4430 100644 --- a/cura/PrinterOutput/UploadMaterialsJob.py +++ b/cura/PrinterOutput/UploadMaterialsJob.py @@ -105,7 +105,7 @@ class UploadMaterialsJob(Job): request_metadata = { "data": { "file_size": file_size, - "file_name": "cura.umm", # File name can be anything as long as it's .umm. It's not used by anyone. + "material_profile_name": "cura.umm", # File name can be anything as long as it's .umm. It's not used by anyone. "content_type": "application/zip", # This endpoint won't receive files of different MIME types. "origin": "cura" # Some identifier against hackers intercepting this upload request, apparently. } From 1c6ad019a3e9fd35999edc838b1296701ae358dc Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Wed, 27 Oct 2021 13:16:39 +0200 Subject: [PATCH 85/89] Response data is contained in sub-field 'data' The entire response is contained in a lone 'data' field in the response. Why this is necessary I don't know, because indeed everything the server can tell us is data so everything would be in a 'data' field. But that's how the API reacts so that's how we'll have to parse it. Contributes to issue CURA-8609. --- cura/PrinterOutput/UploadMaterialsJob.py | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/cura/PrinterOutput/UploadMaterialsJob.py b/cura/PrinterOutput/UploadMaterialsJob.py index 99484a4430..73352258d4 100644 --- a/cura/PrinterOutput/UploadMaterialsJob.py +++ b/cura/PrinterOutput/UploadMaterialsJob.py @@ -133,17 +133,21 @@ class UploadMaterialsJob(Job): Logger.error(f"Invalid response to material upload request. Could not parse JSON data.") self.failed(UploadMaterialsError(catalog.i18nc("@text:error", "The response from Digital Factory appears to be corrupted."))) return - if "upload_url" not in response_data: + if "data" not in response_data: + Logger.error(f"Invalid response to material upload request: Missing 'data' field that contains the entire response.") + self.failed(UploadMaterialsError(catalog.i18nc("@text:error", "The response from Digital Factory is missing important information."))) + return + if "upload_url" not in response_data["data"]: Logger.error(f"Invalid response to material upload request: Missing 'upload_url' field to upload archive to.") self.failed(UploadMaterialsError(catalog.i18nc("@text:error", "The response from Digital Factory is missing important information."))) return - if "material_profile_id" not in response_data: + if "material_profile_id" not in response_data["data"]: Logger.error(f"Invalid response to material upload request: Missing 'material_profile_id' to communicate about the materials with the server.") self.failed(UploadMaterialsError(catalog.i18nc("@text:error", "The response from Digital Factory is missing important information."))) return - upload_url = response_data["upload_url"] - self._archive_remote_id = response_data["material_profile_id"] + upload_url = response_data["data"]["upload_url"] + self._archive_remote_id = response_data["data"]["material_profile_id"] try: with open(cast(str, self._archive_filename), "rb") as f: file_data = f.read() From f99fedc58b28d2c94dca3afb7ce1cf413c1cb01a Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Wed, 27 Oct 2021 13:38:58 +0200 Subject: [PATCH 86/89] Fix API misalignments with confirm material upload action The API endpoint got renamed (without my awareness). It also needed to be a POST request, probably since the beginning. And apparently it needs everything to be in a sub-field called 'data' for some reason. Contributes to issue CURA-8609. --- cura/PrinterOutput/UploadMaterialsJob.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/cura/PrinterOutput/UploadMaterialsJob.py b/cura/PrinterOutput/UploadMaterialsJob.py index 73352258d4..ebadb35257 100644 --- a/cura/PrinterOutput/UploadMaterialsJob.py +++ b/cura/PrinterOutput/UploadMaterialsJob.py @@ -46,7 +46,7 @@ class UploadMaterialsJob(Job): """ UPLOAD_REQUEST_URL = f"{UltimakerCloudConstants.CuraCloudAPIRoot}/connect/v1/materials/upload" - UPLOAD_CONFIRM_URL = UltimakerCloudConstants.CuraCloudAPIRoot + "/connect/v1/clusters/{cluster_id}/printers/{cluster_printer_id}/action/confirm_material_upload" + UPLOAD_CONFIRM_URL = UltimakerCloudConstants.CuraCloudAPIRoot + "/connect/v1/clusters/{cluster_id}/printers/{cluster_printer_id}/action/import_material" class Result(enum.IntEnum): SUCCESS = 0 @@ -175,11 +175,12 @@ class UploadMaterialsJob(Job): printer_id = container_stack["host_guid"] http = HttpRequestManager.getInstance() - http.get( + http.post( url = self.UPLOAD_CONFIRM_URL.format(cluster_id = cluster_id, cluster_printer_id = printer_id), - callback = lambda reply, error: self.onUploadConfirmed(printer_id, reply, error), + callback = lambda reply: self.onUploadConfirmed(printer_id, reply, None), error_callback = lambda reply, error: self.onUploadConfirmed(printer_id, reply, error), # Let this same function handle the error too. - scope = self._scope + scope = self._scope, + data = json.dumps({"data": {"material_profile_id": self._archive_remote_id}}).encode("UTF-8") ) def onUploadConfirmed(self, printer_id: str, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"] = None) -> None: From 116046a8b2a90b3e71ad0522831b0e23438c0065 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Wed, 27 Oct 2021 14:06:04 +0200 Subject: [PATCH 87/89] Fix binding printer_id to response callbacks With the lambda it would capture the variable of printer_id. It wouldn't actually store the value of printer_id in teh created lambda. As a result, it was using the current value of printer_id when the lambda executes, rather than the value of printer_id when the lambda is constructed. A bit weird how that works in Python's lambdas. With partial functions it works properly. Contributes to issue CURA-8609. --- cura/PrinterOutput/UploadMaterialsJob.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/cura/PrinterOutput/UploadMaterialsJob.py b/cura/PrinterOutput/UploadMaterialsJob.py index ebadb35257..166b692ea5 100644 --- a/cura/PrinterOutput/UploadMaterialsJob.py +++ b/cura/PrinterOutput/UploadMaterialsJob.py @@ -2,6 +2,7 @@ # Cura is released under the terms of the LGPLv3 or higher. import enum +import functools # For partial methods to use as callbacks with information pre-filled. import json # To serialise metadata for API calls. import os # To delete the archive when we're done. from PyQt5.QtCore import QUrl @@ -177,8 +178,8 @@ class UploadMaterialsJob(Job): http = HttpRequestManager.getInstance() http.post( url = self.UPLOAD_CONFIRM_URL.format(cluster_id = cluster_id, cluster_printer_id = printer_id), - callback = lambda reply: self.onUploadConfirmed(printer_id, reply, None), - error_callback = lambda reply, error: self.onUploadConfirmed(printer_id, reply, error), # Let this same function handle the error too. + callback = functools.partial(self.onUploadConfirmed, printer_id), + error_callback = functools.partial(self.onUploadConfirmed, printer_id), # Let this same function handle the error too. scope = self._scope, data = json.dumps({"data": {"material_profile_id": self._archive_remote_id}}).encode("UTF-8") ) From 273e93314581c5d78ce3291167ebaaf3b8800128 Mon Sep 17 00:00:00 2001 From: Ghostkeeper Date: Wed, 27 Oct 2021 14:13:49 +0200 Subject: [PATCH 88/89] Reset sync status when closing and re-opening sync window Otherwise when you want to sync again, it'll just say that you're done. Not what a user would expect, I reckon. Contributes to issue CURA-8609. --- cura/Machines/Models/MaterialManagementModel.py | 4 +++- cura/UltimakerCloud/CloudMaterialSync.py | 8 +++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/cura/Machines/Models/MaterialManagementModel.py b/cura/Machines/Models/MaterialManagementModel.py index 61d8e23acd..3595d3025a 100644 --- a/cura/Machines/Models/MaterialManagementModel.py +++ b/cura/Machines/Models/MaterialManagementModel.py @@ -272,6 +272,8 @@ class MaterialManagementModel(QObject): """ Opens the window to sync all materials. """ + self._material_sync.reset() + if self._material_sync.sync_all_dialog is None: qml_path = Resources.getPath(cura.CuraApplication.CuraApplication.ResourceTypes.QmlFiles, "Preferences", "Materials", "MaterialsSyncDialog.qml") self._material_sync.sync_all_dialog = cura.CuraApplication.CuraApplication.getInstance().createQmlComponent(qml_path, {}) @@ -279,4 +281,4 @@ class MaterialManagementModel(QObject): return self._material_sync.sync_all_dialog.setProperty("syncModel", self._material_sync) self._material_sync.sync_all_dialog.setProperty("pageIndex", 0) # Return to first page. - self._material_sync.sync_all_dialog.show() \ No newline at end of file + self._material_sync.sync_all_dialog.show() diff --git a/cura/UltimakerCloud/CloudMaterialSync.py b/cura/UltimakerCloud/CloudMaterialSync.py index 3a9402c36e..05f65bb822 100644 --- a/cura/UltimakerCloud/CloudMaterialSync.py +++ b/cura/UltimakerCloud/CloudMaterialSync.py @@ -191,4 +191,10 @@ class CloudMaterialSync(QObject): @pyqtProperty("QVariantMap", fset = setPrinterStatus, notify = printerStatusChanged) def printerStatus(self) -> Dict[str, str]: - return self._printer_status \ No newline at end of file + return self._printer_status + + def reset(self) -> None: + self.setPrinterStatus({}) + self.setExportProgress(0.0) + self._export_upload_status = "idle" + self.exportUploadStatusChanged.emit() From 79117d5898b77c2c9645fbb8ffa47c6b18d5770e Mon Sep 17 00:00:00 2001 From: Jaime van Kessel Date: Wed, 27 Oct 2021 14:42:00 +0200 Subject: [PATCH 89/89] Fix merge mistakes CURA-8609 --- cura/Machines/Models/MaterialManagementModel.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/cura/Machines/Models/MaterialManagementModel.py b/cura/Machines/Models/MaterialManagementModel.py index fd32f26089..de91703ecf 100644 --- a/cura/Machines/Models/MaterialManagementModel.py +++ b/cura/Machines/Models/MaterialManagementModel.py @@ -2,10 +2,12 @@ # Cura is released under the terms of the LGPLv3 or higher. import copy # To duplicate materials. -from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject +from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject, QUrl +from PyQt5.QtGui import QDesktopServices from typing import Any, Dict, Optional, TYPE_CHECKING import uuid # To generate new GUIDs for new materials. +from UM.Message import Message from UM.i18n import i18nCatalog from UM.Logger import Logger from UM.Resources import Resources # To find QML files. @@ -29,13 +31,9 @@ class MaterialManagementModel(QObject): :param The base file of the material is provided as parameter when this emits """ -<<<<<<< HEAD - def __init__(self, parent: QObject = None): - super().__init__(parent) - self._material_sync = CloudMaterialSync(parent = self) -======= def __init__(self, parent: Optional[QObject] = None) -> None: super().__init__(parent = parent) + self._material_sync = CloudMaterialSync(parent=self) self._checkIfNewMaterialsWereInstalled() def _checkIfNewMaterialsWereInstalled(self) -> None: @@ -91,7 +89,7 @@ class MaterialManagementModel(QObject): sync_message.hide() elif sync_message_action == "learn_more": QDesktopServices.openUrl(QUrl("https://support.ultimaker.com/hc/en-us/articles/360013137919?utm_source=cura&utm_medium=software&utm_campaign=sync-material-printer-message")) ->>>>>>> master + @pyqtSlot("QVariant", result = bool) def canMaterialBeRemoved(self, material_node: "MaterialNode") -> bool: