From 47237cda5f8b68181f68029efe9d1e5c70f99ff2 Mon Sep 17 00:00:00 2001 From: ChrisTerBeke Date: Wed, 28 Aug 2019 22:17:39 +0200 Subject: [PATCH] Fix syncing materials via API, show nice message --- DartConfiguration.tcl | 115 +++++++++ plugins/UM3NetworkPrinting/plugin.json | 2 +- .../src/Messages/MaterialSyncMessage.py | 39 +++ .../src/Models/{ => Http}/ClusterMaterial.py | 2 +- .../src/Models/Http/__init__.py | 0 .../src/Network/ClusterApiClient.py | 10 +- .../src/Network/LocalClusterOutputDevice.py | 12 +- .../src/{ => Network}/SendMaterialJob.py | 86 ++---- .../tests/TestSendMaterialJob.py | 244 ------------------ plugins/UM3NetworkPrinting/tests/__init__.py | 2 - ...AskOpenAsProjectOrModelsDialog.qmlc.GmDuWF | Bin 0 -> 16240 bytes .../qml/Settings/SettingCategory.qmlc.WLvRZb | Bin 0 -> 49726 bytes 12 files changed, 198 insertions(+), 314 deletions(-) create mode 100644 DartConfiguration.tcl create mode 100644 plugins/UM3NetworkPrinting/src/Messages/MaterialSyncMessage.py rename plugins/UM3NetworkPrinting/src/Models/{ => Http}/ClusterMaterial.py (94%) create mode 100644 plugins/UM3NetworkPrinting/src/Models/Http/__init__.py rename plugins/UM3NetworkPrinting/src/{ => Network}/SendMaterialJob.py (68%) delete mode 100644 plugins/UM3NetworkPrinting/tests/TestSendMaterialJob.py delete mode 100644 plugins/UM3NetworkPrinting/tests/__init__.py create mode 100644 resources/qml/Dialogs/AskOpenAsProjectOrModelsDialog.qmlc.GmDuWF create mode 100644 resources/qml/Settings/SettingCategory.qmlc.WLvRZb diff --git a/DartConfiguration.tcl b/DartConfiguration.tcl new file mode 100644 index 0000000000..47726e8484 --- /dev/null +++ b/DartConfiguration.tcl @@ -0,0 +1,115 @@ +# This file is configured by CMake automatically as DartConfiguration.tcl +# If you choose not to use CMake, this file may be hand configured, by +# filling in the required variables. + + +# Configuration directories and files +SourceDirectory: /home/cterbeke/Code/Ultimaker/cura/Cura +BuildDirectory: /home/cterbeke/Code/Ultimaker/cura/Cura + +# Where to place the cost data store +CostDataFile: + +# Site is something like machine.domain, i.e. pragmatic.crd +Site: UM-LAPTOP-394 + +# Build name is osname-revision-compiler, i.e. Linux-2.4.2-2smp-c++ +BuildName: Linux-c++ + +# Subprojects +LabelsForSubprojects: + +# Submission information +IsCDash: +CDashVersion: +QueryCDashVersion: +DropSite: +DropLocation: +DropSiteUser: +DropSitePassword: +DropSiteMode: +DropMethod: http +TriggerSite: +ScpCommand: /usr/bin/scp + +# Dashboard start time +NightlyStartTime: 00:00:00 EDT + +# Commands for the build/test/submit cycle +ConfigureCommand: "/usr/bin/cmake" "/home/cterbeke/Code/Ultimaker/cura/Cura" +MakeCommand: /usr/bin/cmake --build . --config "${CTEST_CONFIGURATION_TYPE}" +DefaultCTestConfigurationType: Release + +# version control +UpdateVersionOnly: + +# CVS options +# Default is "-d -P -A" +CVSCommand: CVSCOMMAND-NOTFOUND +CVSUpdateOptions: -d -A -P + +# Subversion options +SVNCommand: SVNCOMMAND-NOTFOUND +SVNOptions: +SVNUpdateOptions: + +# Git options +GITCommand: /usr/bin/git +GITInitSubmodules: +GITUpdateOptions: +GITUpdateCustom: + +# Perforce options +P4Command: P4COMMAND-NOTFOUND +P4Client: +P4Options: +P4UpdateOptions: +P4UpdateCustom: + +# Generic update command +UpdateCommand: /usr/bin/git +UpdateOptions: +UpdateType: git + +# Compiler info +Compiler: /usr/bin/c++ +CompilerVersion: 7.4.0 + +# Dynamic analysis (MemCheck) +PurifyCommand: +ValgrindCommand: +ValgrindCommandOptions: +MemoryCheckType: +MemoryCheckSanitizerOptions: +MemoryCheckCommand: MEMORYCHECK_COMMAND-NOTFOUND +MemoryCheckCommandOptions: +MemoryCheckSuppressionFile: + +# Coverage +CoverageCommand: /usr/bin/gcov +CoverageExtraFlags: -l + +# Cluster commands +SlurmBatchCommand: SLURM_SBATCH_COMMAND-NOTFOUND +SlurmRunCommand: SLURM_SRUN_COMMAND-NOTFOUND + +# Testing options +# TimeOut is the amount of time in seconds to wait for processes +# to complete during testing. After TimeOut seconds, the +# process will be summarily terminated. +# Currently set to 25 minutes +TimeOut: 1500 + +# During parallel testing CTest will not start a new test if doing +# so would cause the system load to exceed this value. +TestLoad: + +UseLaunchers: +CurlOptions: +# warning, if you add new options here that have to do with submit, +# you have to update cmCTestSubmitCommand.cxx + +# For CTest submissions that timeout, these options +# specify behavior for retrying the submission +CTestSubmitRetryDelay: 5 +CTestSubmitRetryCount: 3 diff --git a/plugins/UM3NetworkPrinting/plugin.json b/plugins/UM3NetworkPrinting/plugin.json index 894fc41815..039b412643 100644 --- a/plugins/UM3NetworkPrinting/plugin.json +++ b/plugins/UM3NetworkPrinting/plugin.json @@ -2,7 +2,7 @@ "name": "Ultimaker Network Connection", "author": "Ultimaker B.V.", "description": "Manages network connections to Ultimaker networked printers.", - "version": "1.0.1", + "version": "2.0.0", "api": "6.0", "i18n-catalog": "cura" } diff --git a/plugins/UM3NetworkPrinting/src/Messages/MaterialSyncMessage.py b/plugins/UM3NetworkPrinting/src/Messages/MaterialSyncMessage.py new file mode 100644 index 0000000000..e021b2ae99 --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Messages/MaterialSyncMessage.py @@ -0,0 +1,39 @@ +# Copyright (c) 2019 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from typing import TYPE_CHECKING + +from UM import i18nCatalog +from UM.Message import Message + + +if TYPE_CHECKING: + from ..UltimakerNetworkedPrinterOutputDevice import UltimakerNetworkedPrinterOutputDevice + + +I18N_CATALOG = i18nCatalog("cura") + + +## Message shown when sending material files to cluster host. +class MaterialSyncMessage(Message): + + # Singleton used to prevent duplicate messages of this type at the same time. + __is_visible = False + + def __init__(self, device: "UltimakerNetworkedPrinterOutputDevice") -> None: + super().__init__( + text = I18N_CATALOG.i18nc("@info:status", "Cura has detected material profiles that were not yet installed " + "on the host printer of group {0}.", device.name), + title = I18N_CATALOG.i18nc("@info:title", "Sending materials to printer"), + lifetime = 10, + dismissable = True + ) + + def show(self) -> None: + if MaterialSyncMessage.__is_visible: + return + super().show() + MaterialSyncMessage.__is_visible = True + + def hide(self, send_signal = True) -> None: + super().hide(send_signal) + MaterialSyncMessage.__is_visible = False diff --git a/plugins/UM3NetworkPrinting/src/Models/ClusterMaterial.py b/plugins/UM3NetworkPrinting/src/Models/Http/ClusterMaterial.py similarity index 94% rename from plugins/UM3NetworkPrinting/src/Models/ClusterMaterial.py rename to plugins/UM3NetworkPrinting/src/Models/Http/ClusterMaterial.py index a441f28292..afc0851211 100644 --- a/plugins/UM3NetworkPrinting/src/Models/ClusterMaterial.py +++ b/plugins/UM3NetworkPrinting/src/Models/Http/ClusterMaterial.py @@ -1,6 +1,6 @@ # Copyright (c) 2019 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from .BaseModel import BaseModel +from ..BaseModel import BaseModel class ClusterMaterial(BaseModel): diff --git a/plugins/UM3NetworkPrinting/src/Models/Http/__init__.py b/plugins/UM3NetworkPrinting/src/Models/Http/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/plugins/UM3NetworkPrinting/src/Network/ClusterApiClient.py b/plugins/UM3NetworkPrinting/src/Network/ClusterApiClient.py index 982c3a885d..951b69977d 100644 --- a/plugins/UM3NetworkPrinting/src/Network/ClusterApiClient.py +++ b/plugins/UM3NetworkPrinting/src/Network/ClusterApiClient.py @@ -13,6 +13,7 @@ from ..Models.BaseModel import BaseModel from ..Models.Http.ClusterPrintJobStatus import ClusterPrintJobStatus from ..Models.Http.ClusterPrinterStatus import ClusterPrinterStatus from ..Models.Http.PrinterSystemStatus import PrinterSystemStatus +from ..Models.Http.ClusterMaterial import ClusterMaterial ## The generic type variable used to document the methods below. @@ -44,6 +45,13 @@ class ClusterApiClient: reply = self._manager.get(self._createEmptyRequest(url)) self._addCallback(reply, on_finished, PrinterSystemStatus) + ## Get the installed materials on the printer. + # \param on_finished: The callback in case the response is successful. + def getMaterials(self, on_finished: Callable[[List[ClusterMaterial]], Any]) -> None: + url = "{}/materials".format(self.CLUSTER_API_PREFIX) + reply = self._manager.get(self._createEmptyRequest(url)) + self._addCallback(reply, on_finished, ClusterMaterial) + ## Get the printers in the cluster. # \param on_finished: The callback in case the response is successful. def getPrinters(self, on_finished: Callable[[List[ClusterPrinterStatus]], Any]) -> None: @@ -62,7 +70,7 @@ class ClusterApiClient: def movePrintJobToTop(self, print_job_uuid: str) -> None: url = "{}/print_jobs/{}/action/move".format(self.CLUSTER_API_PREFIX, print_job_uuid) self._manager.post(self._createEmptyRequest(url), json.dumps({"to_position": 0, "list": "queued"}).encode()) - + ## Override print job configuration and force it to be printed. def forcePrintJob(self, print_job_uuid: str) -> None: url = "{}/print_jobs/{}".format(self.CLUSTER_API_PREFIX, print_job_uuid) diff --git a/plugins/UM3NetworkPrinting/src/Network/LocalClusterOutputDevice.py b/plugins/UM3NetworkPrinting/src/Network/LocalClusterOutputDevice.py index 3d71429ef8..a5a885a4ed 100644 --- a/plugins/UM3NetworkPrinting/src/Network/LocalClusterOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Network/LocalClusterOutputDevice.py @@ -1,6 +1,6 @@ # Copyright (c) 2019 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from typing import Optional, Dict, List +from typing import Optional, Dict, List, Callable, Any from PyQt5.QtGui import QDesktopServices from PyQt5.QtCore import pyqtSlot, QUrl, pyqtSignal, pyqtProperty @@ -13,12 +13,13 @@ from cura.PrinterOutput.NetworkedPrinterOutputDevice import AuthState from cura.PrinterOutput.PrinterOutputDevice import ConnectionType from .ClusterApiClient import ClusterApiClient +from .SendMaterialJob import SendMaterialJob from ..ExportFileJob import ExportFileJob -from ..SendMaterialJob import SendMaterialJob from ..UltimakerNetworkedPrinterOutputDevice import UltimakerNetworkedPrinterOutputDevice from ..Messages.PrintJobUploadBlockedMessage import PrintJobUploadBlockedMessage from ..Messages.PrintJobUploadErrorMessage import PrintJobUploadErrorMessage from ..Messages.PrintJobUploadSuccessMessage import PrintJobUploadSuccessMessage +from ..Models.Http.ClusterMaterial import ClusterMaterial I18N_CATALOG = i18nCatalog("cura") @@ -100,10 +101,14 @@ class LocalClusterOutputDevice(UltimakerNetworkedPrinterOutputDevice): self._getApiClient().getPrintJobs(self._updatePrintJobs) self._updatePrintJobPreviewImages() + ## Get a list of materials that are installed on the cluster host. + def getMaterials(self, on_finished: Callable[[List[ClusterMaterial]], Any]) -> None: + self._getApiClient().getMaterials(on_finished = on_finished) + ## Sync the material profiles in Cura with the printer. # This gets called when connecting to a printer as well as when sending a print. def sendMaterialProfiles(self) -> None: - job = SendMaterialJob(device=self) + job = SendMaterialJob(device = self) job.run() ## Send a print job to the cluster. @@ -133,6 +138,7 @@ class LocalClusterOutputDevice(UltimakerNetworkedPrinterOutputDevice): self._createFormPart("name=owner", bytes(self._getUserName(), "utf-8"), "text/plain"), self._createFormPart("name=\"file\"; filename=\"%s\"" % job.getFileName(), job.getOutput()) ] + # FIXME: move form posting to API client self.postFormWithParts("/cluster-api/v1/print_jobs/", parts, on_finished=self._onPrintUploadCompleted, on_progress=self._onPrintJobUploadProgress) diff --git a/plugins/UM3NetworkPrinting/src/SendMaterialJob.py b/plugins/UM3NetworkPrinting/src/Network/SendMaterialJob.py similarity index 68% rename from plugins/UM3NetworkPrinting/src/SendMaterialJob.py rename to plugins/UM3NetworkPrinting/src/Network/SendMaterialJob.py index 0186783f1f..709f2e845b 100644 --- a/plugins/UM3NetworkPrinting/src/SendMaterialJob.py +++ b/plugins/UM3NetworkPrinting/src/Network/SendMaterialJob.py @@ -1,19 +1,19 @@ # Copyright (c) 2019 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -import json import os -from typing import Dict, TYPE_CHECKING, Set, Optional +from typing import Dict, TYPE_CHECKING, Set, List from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest from UM.Job import Job from UM.Logger import Logger from cura.CuraApplication import CuraApplication -from .Models.ClusterMaterial import ClusterMaterial -from .Models.LocalMaterial import LocalMaterial +from ..Models.Http.ClusterMaterial import ClusterMaterial +from ..Models.LocalMaterial import LocalMaterial +from ..Messages.MaterialSyncMessage import MaterialSyncMessage if TYPE_CHECKING: - from .Network.LocalClusterOutputDevice import LocalClusterOutputDevice + from .LocalClusterOutputDevice import LocalClusterOutputDevice ## Asynchronous job to send material profiles to the printer. @@ -27,64 +27,49 @@ class SendMaterialJob(Job): ## Send the request to the printer and register a callback def run(self) -> None: - self.device.get("materials/", on_finished = self._onGetRemoteMaterials) + self.device.getMaterials(on_finished = self._onGetMaterials) - ## Process the materials reply from the printer. - # - # \param reply The reply from the printer, a json file. - def _onGetRemoteMaterials(self, reply: QNetworkReply) -> None: - # Got an error from the HTTP request. If we did not receive a 200 something happened. - if reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) != 200: - Logger.log("e", "Error fetching materials from printer: %s", reply.errorString()) - return - - # Collect materials from the printer's reply and send the missing ones if needed. - remote_materials_by_guid = self._parseReply(reply) - if remote_materials_by_guid: - self._sendMissingMaterials(remote_materials_by_guid) + ## Callback for when the remote materials were returned. + def _onGetMaterials(self, materials: List[ClusterMaterial]) -> None: + remote_materials_by_guid = {material.guid: material for material in materials} + self._sendMissingMaterials(remote_materials_by_guid) ## Determine which materials should be updated and send them to the printer. - # # \param remote_materials_by_guid The remote materials by GUID. def _sendMissingMaterials(self, remote_materials_by_guid: Dict[str, ClusterMaterial]) -> None: - # Collect local materials local_materials_by_guid = self._getLocalMaterials() if len(local_materials_by_guid) == 0: Logger.log("d", "There are no local materials to synchronize with the printer.") return - - # Find out what materials are new or updated and must be sent to the printer material_ids_to_send = self._determineMaterialsToSend(local_materials_by_guid, remote_materials_by_guid) if len(material_ids_to_send) == 0: Logger.log("d", "There are no remote materials to update.") return - - # Send materials to the printer self._sendMaterials(material_ids_to_send) ## From the local and remote materials, determine which ones should be synchronized. - # # Makes a Set of id's containing only the id's of the materials that are not on the printer yet or the ones that # are newer in Cura. - # # \param local_materials The local materials by GUID. # \param remote_materials The remote materials by GUID. @staticmethod def _determineMaterialsToSend(local_materials: Dict[str, LocalMaterial], remote_materials: Dict[str, ClusterMaterial]) -> Set[str]: return { - material.id - for guid, material in local_materials.items() - if guid not in remote_materials or material.version > remote_materials[guid].version + local_material.id + for guid, local_material in local_materials.items() + if guid not in remote_materials.keys() or local_material.version > remote_materials[guid].version } ## Send the materials to the printer. - # # The given materials will be loaded from disk en sent to to printer. # The given id's will be matched with filenames of the locally stored materials. - # # \param materials_to_send A set with id's of materials that must be sent. def _sendMaterials(self, materials_to_send: Set[str]) -> None: + + # Inform the user of this process. + MaterialSyncMessage(self.device).show() + container_registry = CuraApplication.getInstance().getContainerRegistry() material_manager = CuraApplication.getInstance().getMaterialManager() material_group_dict = material_manager.getAllMaterialGroups() @@ -103,9 +88,7 @@ class SendMaterialJob(Job): self._sendMaterialFile(file_path, file_name, root_material_id) ## Send a single material file to the printer. - # # Also add the material signature file if that is available. - # # \param file_path The path of the material file. # \param file_name The name of the material file. # \param material_id The ID of the material in the file. @@ -125,48 +108,27 @@ class SendMaterialJob(Job): parts.append(self.device.createFormPart("name=\"signature_file\"; filename=\"{file_name}\"" .format(file_name = signature_file_name), f.read())) - Logger.log("d", "Syncing material {material_id} with cluster.".format(material_id = material_id)) - self.device.postFormWithParts(target = "/materials/", parts = parts, on_finished = self.sendingFinished) + Logger.log("d", "Syncing material %s with cluster.", material_id) + # FIXME: move form posting to API client + self.device.postFormWithParts(target = "/cluster-api/v1/materials/", parts = parts, + on_finished = self._sendingFinished) ## Check a reply from an upload to the printer and log an error when the call failed @staticmethod - def sendingFinished(reply: QNetworkReply) -> None: + def _sendingFinished(reply: QNetworkReply) -> None: if reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) != 200: Logger.log("e", "Received error code from printer when syncing material: {code}, {text}".format( code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute), text = reply.errorString() )) - ## Parse the reply from the printer - # - # Parses the reply to a "/materials" request to the printer - # - # \return a dictionary of ClusterMaterial objects by GUID - # \throw KeyError Raised when on of the materials does not include a valid guid - @classmethod - def _parseReply(cls, reply: QNetworkReply) -> Optional[Dict[str, ClusterMaterial]]: - try: - remote_materials = json.loads(reply.readAll().data().decode("utf-8")) - return {material["guid"]: ClusterMaterial(**material) for material in remote_materials} - except UnicodeDecodeError: - Logger.log("e", "Request material storage on printer: I didn't understand the printer's answer.") - except json.JSONDecodeError: - Logger.log("e", "Request material storage on printer: I didn't understand the printer's answer.") - except ValueError: - Logger.log("e", "Request material storage on printer: Printer's answer had an incorrect value.") - except TypeError: - Logger.log("e", "Request material storage on printer: Printer's answer was missing a required value.") - return None - ## Retrieves a list of local materials - # # Only the new newest version of the local materials is returned - # # \return a dictionary of LocalMaterial objects by GUID - def _getLocalMaterials(self) -> Dict[str, LocalMaterial]: + @staticmethod + def _getLocalMaterials() -> Dict[str, LocalMaterial]: result = {} # type: Dict[str, LocalMaterial] material_manager = CuraApplication.getInstance().getMaterialManager() - material_group_dict = material_manager.getAllMaterialGroups() # Find the latest version of all material containers in the registry. diff --git a/plugins/UM3NetworkPrinting/tests/TestSendMaterialJob.py b/plugins/UM3NetworkPrinting/tests/TestSendMaterialJob.py deleted file mode 100644 index 61323861b6..0000000000 --- a/plugins/UM3NetworkPrinting/tests/TestSendMaterialJob.py +++ /dev/null @@ -1,244 +0,0 @@ -# Copyright (c) 2019 Ultimaker B.V. -# Copyright (c) 2019 Ultimaker B.V. -# Cura is released under the terms of the LGPLv3 or higher. -import io -import json -from unittest import TestCase, mock -from unittest.mock import patch, call, MagicMock - -from PyQt5.QtCore import QByteArray - -from UM.Application import Application - -from cura.Machines.MaterialGroup import MaterialGroup -from cura.Machines.MaterialNode import MaterialNode - -from ..src.SendMaterialJob import SendMaterialJob - -_FILES_MAP = {"generic_pla_white": "/materials/generic_pla_white.xml.fdm_material", - "generic_pla_black": "/materials/generic_pla_black.xml.fdm_material", - } - - -@patch("builtins.open", lambda _, __: io.StringIO("")) -class TestSendMaterialJob(TestCase): - # version 1 - _LOCAL_MATERIAL_WHITE = {"type": "material", "status": "unknown", "id": "generic_pla_white", - "base_file": "generic_pla_white", "setting_version": "5", "name": "White PLA", - "brand": "Generic", "material": "PLA", "color_name": "White", - "GUID": "badb0ee7-87c8-4f3f-9398-938587b67dce", "version": "1", "color_code": "#ffffff", - "description": "Test PLA White", "adhesion_info": "Use glue.", "approximate_diameter": "3", - "properties": {"density": "1.00", "diameter": "2.85", "weight": "750"}, - "definition": "fdmprinter", "compatible": True} - - # version 2 - _LOCAL_MATERIAL_WHITE_NEWER = {"type": "material", "status": "unknown", "id": "generic_pla_white", - "base_file": "generic_pla_white", "setting_version": "5", "name": "White PLA", - "brand": "Generic", "material": "PLA", "color_name": "White", - "GUID": "badb0ee7-87c8-4f3f-9398-938587b67dce", "version": "2", - "color_code": "#ffffff", - "description": "Test PLA White", "adhesion_info": "Use glue.", - "approximate_diameter": "3", - "properties": {"density": "1.00", "diameter": "2.85", "weight": "750"}, - "definition": "fdmprinter", "compatible": True} - - # invalid version: "one" - _LOCAL_MATERIAL_WHITE_INVALID_VERSION = {"type": "material", "status": "unknown", "id": "generic_pla_white", - "base_file": "generic_pla_white", "setting_version": "5", "name": "White PLA", - "brand": "Generic", "material": "PLA", "color_name": "White", - "GUID": "badb0ee7-87c8-4f3f-9398-938587b67dce", "version": "one", - "color_code": "#ffffff", - "description": "Test PLA White", "adhesion_info": "Use glue.", - "approximate_diameter": "3", - "properties": {"density": "1.00", "diameter": "2.85", "weight": "750"}, - "definition": "fdmprinter", "compatible": True} - - _LOCAL_MATERIAL_WHITE_ALL_RESULT = {"generic_pla_white": MaterialGroup("generic_pla_white", - MaterialNode(_LOCAL_MATERIAL_WHITE))} - - _LOCAL_MATERIAL_WHITE_NEWER_ALL_RESULT = {"generic_pla_white": MaterialGroup("generic_pla_white", - MaterialNode(_LOCAL_MATERIAL_WHITE_NEWER))} - - _LOCAL_MATERIAL_WHITE_INVALID_VERSION_ALL_RESULT = {"generic_pla_white": MaterialGroup("generic_pla_white", - MaterialNode(_LOCAL_MATERIAL_WHITE_INVALID_VERSION))} - - _LOCAL_MATERIAL_BLACK = {"type": "material", "status": "unknown", "id": "generic_pla_black", - "base_file": "generic_pla_black", "setting_version": "5", "name": "Yellow CPE", - "brand": "Ultimaker", "material": "CPE", "color_name": "Black", - "GUID": "5fbb362a-41f9-4818-bb43-15ea6df34aa4", "version": "1", "color_code": "#000000", - "description": "Test PLA Black", "adhesion_info": "Use glue.", "approximate_diameter": "3", - "properties": {"density": "1.01", "diameter": "2.85", "weight": "750"}, - "definition": "fdmprinter", "compatible": True} - - _LOCAL_MATERIAL_BLACK_ALL_RESULT = {"generic_pla_black": MaterialGroup("generic_pla_black", - MaterialNode(_LOCAL_MATERIAL_BLACK))} - - _REMOTE_MATERIAL_WHITE = { - "guid": "badb0ee7-87c8-4f3f-9398-938587b67dce", - "material": "PLA", - "brand": "Generic", - "version": 1, - "color": "White", - "density": 1.00 - } - - _REMOTE_MATERIAL_BLACK = { - "guid": "5fbb362a-41f9-4818-bb43-15ea6df34aa4", - "material": "PLA", - "brand": "Generic", - "version": 2, - "color": "Black", - "density": 1.00 - } - - def test_run(self): - device_mock = MagicMock() - job = SendMaterialJob(device_mock) - job.run() - - # We expect the materials endpoint to be called when the job runs. - device_mock.get.assert_called_with("materials/", on_finished = job._onGetRemoteMaterials) - - def test__onGetRemoteMaterials_withFailedRequest(self): - reply_mock = MagicMock() - device_mock = MagicMock() - reply_mock.attribute.return_value = 404 - job = SendMaterialJob(device_mock) - job._onGetRemoteMaterials(reply_mock) - - # We expect the device not to be called for any follow up. - self.assertEqual(0, device_mock.createFormPart.call_count) - - def test__onGetRemoteMaterials_withWrongEncoding(self): - reply_mock = MagicMock() - device_mock = MagicMock() - reply_mock.attribute.return_value = 200 - reply_mock.readAll.return_value = QByteArray(json.dumps([self._REMOTE_MATERIAL_WHITE]).encode("cp500")) - job = SendMaterialJob(device_mock) - job._onGetRemoteMaterials(reply_mock) - - # Given that the parsing fails we do no expect the device to be called for any follow up. - self.assertEqual(0, device_mock.createFormPart.call_count) - - def test__onGetRemoteMaterials_withBadJsonAnswer(self): - reply_mock = MagicMock() - device_mock = MagicMock() - reply_mock.attribute.return_value = 200 - reply_mock.readAll.return_value = QByteArray(b"Six sick hicks nick six slick bricks with picks and sticks.") - job = SendMaterialJob(device_mock) - job._onGetRemoteMaterials(reply_mock) - - # Given that the parsing fails we do no expect the device to be called for any follow up. - self.assertEqual(0, device_mock.createFormPart.call_count) - - def test__onGetRemoteMaterials_withMissingGuidInRemoteMaterial(self): - reply_mock = MagicMock() - device_mock = MagicMock() - reply_mock.attribute.return_value = 200 - remote_material_without_guid = self._REMOTE_MATERIAL_WHITE.copy() - del remote_material_without_guid["guid"] - reply_mock.readAll.return_value = QByteArray(json.dumps([remote_material_without_guid]).encode("ascii")) - job = SendMaterialJob(device_mock) - job._onGetRemoteMaterials(reply_mock) - - # Given that parsing fails we do not expect the device to be called for any follow up. - self.assertEqual(0, device_mock.createFormPart.call_count) - - @patch("cura.Machines.MaterialManager.MaterialManager") - @patch("cura.Settings.CuraContainerRegistry") - @patch("UM.Application") - def test__onGetRemoteMaterials_withInvalidVersionInLocalMaterial(self, application_mock, container_registry_mock, - material_manager_mock): - reply_mock = MagicMock() - device_mock = MagicMock() - application_mock.getContainerRegistry.return_value = container_registry_mock - application_mock.getMaterialManager.return_value = material_manager_mock - - reply_mock.attribute.return_value = 200 - reply_mock.readAll.return_value = QByteArray(json.dumps([self._REMOTE_MATERIAL_WHITE]).encode("ascii")) - - material_manager_mock.getAllMaterialGroups.return_value = self._LOCAL_MATERIAL_WHITE_INVALID_VERSION_ALL_RESULT.copy() - - with mock.patch.object(Application, "getInstance", new = lambda: application_mock): - job = SendMaterialJob(device_mock) - job._onGetRemoteMaterials(reply_mock) - - self.assertEqual(0, device_mock.createFormPart.call_count) - - @patch("UM.Application.Application.getInstance") - def test__onGetRemoteMaterials_withNoUpdate(self, application_mock): - reply_mock = MagicMock() - device_mock = MagicMock() - container_registry_mock = application_mock.getContainerRegistry.return_value - material_manager_mock = application_mock.getMaterialManager.return_value - - device_mock.createFormPart.return_value = "_xXx_" - - material_manager_mock.getAllMaterialGroups.return_value = self._LOCAL_MATERIAL_WHITE_ALL_RESULT.copy() - - reply_mock.attribute.return_value = 200 - reply_mock.readAll.return_value = QByteArray(json.dumps([self._REMOTE_MATERIAL_WHITE]).encode("ascii")) - - with mock.patch.object(Application, "getInstance", new = lambda: application_mock): - job = SendMaterialJob(device_mock) - job._onGetRemoteMaterials(reply_mock) - - self.assertEqual(0, device_mock.createFormPart.call_count) - self.assertEqual(0, device_mock.postFormWithParts.call_count) - - @patch("UM.Application.Application.getInstance") - def test__onGetRemoteMaterials_withUpdatedMaterial(self, get_instance_mock): - reply_mock = MagicMock() - device_mock = MagicMock() - application_mock = get_instance_mock.return_value - container_registry_mock = application_mock.getContainerRegistry.return_value - material_manager_mock = application_mock.getMaterialManager.return_value - - container_registry_mock.getContainerFilePathById = lambda x: _FILES_MAP.get(x) - - device_mock.createFormPart.return_value = "_xXx_" - - material_manager_mock.getAllMaterialGroups.return_value = self._LOCAL_MATERIAL_WHITE_NEWER_ALL_RESULT.copy() - - reply_mock.attribute.return_value = 200 - reply_mock.readAll.return_value = QByteArray(json.dumps([self._REMOTE_MATERIAL_WHITE]).encode("ascii")) - - job = SendMaterialJob(device_mock) - job._onGetRemoteMaterials(reply_mock) - - self.assertEqual(1, device_mock.createFormPart.call_count) - self.assertEqual(1, device_mock.postFormWithParts.call_count) - self.assertEqual( - [call.createFormPart("name=\"file\"; filename=\"generic_pla_white.xml.fdm_material\"", ""), - call.postFormWithParts(target = "/materials/", parts = ["_xXx_"], on_finished = job.sendingFinished)], - device_mock.method_calls) - - @patch("UM.Application.Application.getInstance") - def test__onGetRemoteMaterials_withNewMaterial(self, application_mock): - reply_mock = MagicMock() - device_mock = MagicMock() - container_registry_mock = application_mock.getContainerRegistry.return_value - material_manager_mock = application_mock.getMaterialManager.return_value - - container_registry_mock.getContainerFilePathById = lambda x: _FILES_MAP.get(x) - - device_mock.createFormPart.return_value = "_xXx_" - - all_results = self._LOCAL_MATERIAL_WHITE_ALL_RESULT.copy() - for key, value in self._LOCAL_MATERIAL_BLACK_ALL_RESULT.items(): - all_results[key] = value - material_manager_mock.getAllMaterialGroups.return_value = all_results - - reply_mock.attribute.return_value = 200 - reply_mock.readAll.return_value = QByteArray(json.dumps([self._REMOTE_MATERIAL_BLACK]).encode("ascii")) - - with mock.patch.object(Application, "getInstance", new = lambda: application_mock): - job = SendMaterialJob(device_mock) - job._onGetRemoteMaterials(reply_mock) - - self.assertEqual(1, device_mock.createFormPart.call_count) - self.assertEqual(1, device_mock.postFormWithParts.call_count) - self.assertEqual( - [call.createFormPart("name=\"file\"; filename=\"generic_pla_white.xml.fdm_material\"", ""), - call.postFormWithParts(target = "/materials/", parts = ["_xXx_"], on_finished = job.sendingFinished)], - device_mock.method_calls) diff --git a/plugins/UM3NetworkPrinting/tests/__init__.py b/plugins/UM3NetworkPrinting/tests/__init__.py deleted file mode 100644 index d5641e902f..0000000000 --- a/plugins/UM3NetworkPrinting/tests/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -# Copyright (c) 2019 Ultimaker B.V. -# Cura is released under the terms of the LGPLv3 or higher. diff --git a/resources/qml/Dialogs/AskOpenAsProjectOrModelsDialog.qmlc.GmDuWF b/resources/qml/Dialogs/AskOpenAsProjectOrModelsDialog.qmlc.GmDuWF new file mode 100644 index 0000000000000000000000000000000000000000..f2eb0327b8caaf895812f0975b2061fb50cdc1c0 GIT binary patch literal 16240 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 literal 0 HcmV?d00001 diff --git a/resources/qml/Settings/SettingCategory.qmlc.WLvRZb b/resources/qml/Settings/SettingCategory.qmlc.WLvRZb new file mode 100644 index 0000000000000000000000000000000000000000..2aa592652784dbf35a4030af7ca755adcf7fa249 GIT binary patch literal 49726 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 H#__U-RFO zclDq1DNYfXK&MOtIN#jzXh$c<&M+8d<>Vxg*00HqcoVI3HTa@S zm}3u+2J>~!x~}t7`r~DAZ5&_Tbe|>LzYEg^CoK3W$+a>*`==!HY+(A`;G5t-JqFT` z$w&Q!?NOFb$R(!$j?5r>K*%*LH-fh{_|*(L~cS2T$ipy%|_fFk%kNd9Ny_*7cob2}Ipcr)F2 zPuLc7_=WvdzWc*gD&_7v8SjaFEprOmN531o7EO~<@-n+&v?|?;3VZ2QjHNl9zfKF3C_^3Qym`O%Ge7Zv9Y}4z)5e!~VDN*o zq79MnI}eh-l`7sBy{1O`*q=1ZjQqw;FT~W%moOrI?6ajs_xiYM$4RxwR}JKgb_M>& z$96H;?j5_h`)%@BR8@V^=24m9*dTD8D!mH*K~ zJnKG40xYKar`d;Pjhy*x|H#+hc;j)f(d(yeJNI|}&<$MjL+7%}Bn+JcOA-6owEM{y z(MhD2UTzfA+O}D~AIEIzba;<7u;FyF0?F<0=KMb#MHYo-Ir(o^I6Zy5C#`?b2R1nU z1GTUQk({tJ&Os~j{Cperru^QF+Ca9sey>$c_q_iPcL0<|TA_c6K1MESsEooeY{;Q!>0A^P_%|+0esySdjBn#lyVahx&8T|2*m1{zg3wDc