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); } }