Browse Source

Merge pull request #10607 from Ultimaker/CURA-8609_sync_materials_to_printer

Sync materials to printers via cloud
Jaime van Kessel 3 years ago
parent
commit
f47738f558

+ 1 - 1
cura/API/Account.py

@@ -62,7 +62,7 @@ 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"
 

+ 45 - 3
cura/Machines/Models/GlobalStacksModel.py

@@ -1,7 +1,8 @@
-# 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 typing import Optional
 
 from UM.Qt.ListModel import ListModel
 from UM.i18n import i18nCatalog
@@ -20,6 +21,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,18 +33,49 @@ 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)
         self._change_timer.setSingleShot(True)
         self._change_timer.timeout.connect(self._update)
 
+        self._filter_connection_type = None  # type: Optional[ConnectionType]
+        self._filter_online_only = False
+
         # 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: Optional[ConnectionType]) -> 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.
+        """
+        if self._filter_connection_type is None:
+            return -1
+        return self._filter_connection_type.value
+
+    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"""
 
@@ -58,6 +91,10 @@ class GlobalStacksModel(ListModel):
 
         container_stacks = CuraContainerRegistry.getInstance().findContainerStacks(type = "machine")
         for container_stack in container_stacks:
+            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.
+
             has_remote_connection = False
 
             for connection_type in container_stack.configuredConnectionTypes:
@@ -67,6 +104,10 @@ 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)
@@ -82,6 +123,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)

+ 18 - 50
cura/Machines/Models/MaterialManagementModel.py

@@ -2,21 +2,21 @@
 # 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.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.
-import zipfile  # To export all materials in a .zip archive.
-
-from PyQt5.QtGui import QDesktopServices
 
+from UM.Message import Message
 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.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
@@ -33,6 +33,7 @@ class MaterialManagementModel(QObject):
 
     def __init__(self, parent: Optional[QObject] = None) -> None:
         super().__init__(parent = parent)
+        self._material_sync = CloudMaterialSync(parent=self)
         self._checkIfNewMaterialsWereInstalled()
 
     def _checkIfNewMaterialsWereInstalled(self) -> None:
@@ -89,6 +90,7 @@ class MaterialManagementModel(QObject):
         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("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?
@@ -323,52 +325,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(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.
+    @pyqtSlot()
+    def openSyncAllWindow(self) -> None:
         """
-        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:
+        Opens the window to sync all materials.
         """
-        Export all materials to a certain file path.
-        :param file_path: The path to export the materials to.
-        """
-        registry = CuraContainerRegistry.getInstance()
+        self._material_sync.reset()
 
-        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("@error:text Followed by an error message of why it could not save", "Could not save material archive to {filename}:").format(filename = file_path.toLocalFile()) + " " + str(e),
-                title = catalog.i18nc("@error:title", "Failed to save material archive"),
-                message_type = Message.MessageType.ERROR
-            )
-            error_message.show()
+        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, {})
+        if self._material_sync.sync_all_dialog is None:  # Failed to load QML file.
             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}.")
+        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()

+ 9 - 4
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())

+ 20 - 3
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:

+ 256 - 0
cura/PrinterOutput/UploadMaterialsJob.py

@@ -0,0 +1,256 @@
+# Copyright (c) 2021 Ultimaker B.V.
+# 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
+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.
+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
+
+from typing import Any, cast, Dict, List, Optional, TYPE_CHECKING
+if TYPE_CHECKING:
+    from PyQt5.QtNetwork import QNetworkReply
+    from cura.UltimakerCloud.CloudMaterialSync import CloudMaterialSync
+
+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.
+
+    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"
+    UPLOAD_CONFIRM_URL = UltimakerCloudConstants.CuraCloudAPIRoot + "/connect/v1/clusters/{cluster_id}/printers/{cluster_printer_id}/action/import_material"
+
+    class Result(enum.IntEnum):
+        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
+        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 = {}  # type: Dict[str, str]
+        self._printer_metadata = []  # type: List[Dict[str, Any]]
+        self.processProgressChanged.connect(self._onProcessProgressChanged)
+
+    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) -> 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.
+            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"]] = self.PrinterStatus.UPLOADING.value
+
+        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
+
+        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": {
+                "file_size": file_size,
+                "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.
+            }
+        }
+        request_payload = json.dumps(request_metadata).encode("UTF-8")
+
+        http = HttpRequestManager.getInstance()
+        http.put(
+            url = self.UPLOAD_REQUEST_URL,
+            data = request_payload,
+            callback = self.onUploadRequestCompleted,
+            error_callback = self.onError,
+            scope = self._scope
+        )
+
+    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.
+        """
+        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.failed(UploadMaterialsError(catalog.i18nc("@text:error", "The response from Digital Factory appears to be corrupted.")))
+            return
+        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["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["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()
+        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,
+            data = file_data,
+            callback = self.onUploadCompleted,
+            error_callback = self.onError,
+            scope = self._scope
+        )
+
+    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.
+        """
+        for container_stack in self._printer_metadata:
+            cluster_id = container_stack["um_cloud_cluster_id"]
+            printer_id = container_stack["host_guid"]
+
+            http = HttpRequestManager.getInstance()
+            http.post(
+                url = self.UPLOAD_CONFIRM_URL.format(cluster_id = cluster_id, cluster_printer_id = printer_id),
+                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")
+            )
+
+    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.
+
+        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
+        else:
+            self._printer_sync_status[printer_id] = self.PrinterStatus.SUCCESS.value
+
+        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 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:
+                self.setResult(self.Result.SUCCESS)
+            self.uploadCompleted.emit(self.getResult(), self.getError())
+
+    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:
+        """
+        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] = self.PrinterStatus.FAILED.value
+        self.uploadProgressChanged.emit(1.0, self.getPrinterSyncStatus())
+        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.

+ 200 - 0
cura/UltimakerCloud/CloudMaterialSync.py

@@ -0,0 +1,200 @@
+# 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 Dict, 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, UploadMaterialsError  # 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
+
+if TYPE_CHECKING:
+    from UM.Signal import Signal
+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()
+        self._export_progress = 0.0
+        self._printer_status = {}  # type: Dict[str, str]
+
+    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, 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:
+            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
+
+        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.
+                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) -> str:
+        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.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 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))
+            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"
+        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
+
+    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
+
+    def reset(self) -> None:
+        self.setPrinterStatus({})
+        self.setExportProgress(0.0)
+        self._export_upload_status = "idle"
+        self.exportUploadStatusChanged.emit()

+ 9 - 3
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
+    from cura.API.Account import Account
 
 
 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

+ 16 - 1
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)
@@ -154,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)
@@ -255,6 +260,16 @@ class CloudOutputDeviceManager:
         message_text = self.i18n_catalog.i18nc("info:status", "Printers added from Digital Factory:") + "<ul>" + device_names + "</ul>"
         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

+ 23 - 0
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()]

Some files were not shown because too many files changed in this diff