diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 40acbc44f3..ff0923f9b6 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -6,6 +6,7 @@ on: - master - 'WIP**' - '4.*' + - 'CURA-*' pull_request: jobs: build: diff --git a/cura/Arranging/Arrange.py b/cura/Arranging/Arrange.py index d6b8e44cea..c0aca9a893 100644 --- a/cura/Arranging/Arrange.py +++ b/cura/Arranging/Arrange.py @@ -69,7 +69,7 @@ class Arrange: points = copy.deepcopy(vertices._points) # After scaling (like up to 0.1 mm) the node might not have points - if not points: + if not points.size: continue shape_arr = ShapeArray.fromPolygon(points, scale = scale) diff --git a/cura/CuraPackageManager.py b/cura/CuraPackageManager.py index 99f2072644..a0d3a8d44a 100644 --- a/cura/CuraPackageManager.py +++ b/cura/CuraPackageManager.py @@ -15,7 +15,7 @@ if TYPE_CHECKING: class CuraPackageManager(PackageManager): - def __init__(self, application: "QtApplication", parent: Optional["QObject"] = None): + def __init__(self, application: "QtApplication", parent: Optional["QObject"] = None) -> None: super().__init__(application, parent) def initialize(self) -> None: diff --git a/cura/Machines/Models/MaterialBrandsModel.py b/cura/Machines/Models/MaterialBrandsModel.py index 184d27f390..b0594cb286 100644 --- a/cura/Machines/Models/MaterialBrandsModel.py +++ b/cura/Machines/Models/MaterialBrandsModel.py @@ -34,7 +34,7 @@ class MaterialBrandsModel(BaseMaterialsModel): brand_item_list = [] brand_group_dict = {} - # Part 1: Generate the entire tree of brands -> material types -> spcific materials + # Part 1: Generate the entire tree of brands -> material types -> specific materials for root_material_id, container_node in self._available_materials.items(): # Do not include the materials from a to-be-removed package if bool(container_node.getMetaDataEntry("removed", False)): diff --git a/cura/Machines/VariantNode.py b/cura/Machines/VariantNode.py index 550b5881a3..0f30782a91 100644 --- a/cura/Machines/VariantNode.py +++ b/cura/Machines/VariantNode.py @@ -51,7 +51,7 @@ class VariantNode(ContainerNode): # Find all the materials for this variant's name. else: # Printer has its own material profiles. Look for material profiles with this printer's definition. base_materials = container_registry.findInstanceContainersMetadata(type = "material", definition = "fdmprinter") - printer_specific_materials = container_registry.findInstanceContainersMetadata(type = "material", definition = self.machine.container_id, variant_name = None) + printer_specific_materials = container_registry.findInstanceContainersMetadata(type = "material", definition = self.machine.container_id) variant_specific_materials = container_registry.findInstanceContainersMetadata(type = "material", definition = self.machine.container_id, variant_name = self.variant_name) # If empty_variant, this won't return anything. materials_per_base_file = {material["base_file"]: material for material in base_materials} materials_per_base_file.update({material["base_file"]: material for material in printer_specific_materials}) # Printer-specific profiles override global ones. diff --git a/cura/OneAtATimeIterator.py b/cura/OneAtATimeIterator.py index b77e1f3982..3373f2104f 100644 --- a/cura/OneAtATimeIterator.py +++ b/cura/OneAtATimeIterator.py @@ -122,6 +122,6 @@ class _ObjectOrder: # \param order List of indices in which to print objects, ordered by printing # order. # \param todo: List of indices which are not yet inserted into the order list. - def __init__(self, order: List[SceneNode], todo: List[SceneNode]): + def __init__(self, order: List[SceneNode], todo: List[SceneNode]) -> None: self.order = order self.todo = todo diff --git a/cura/Operations/PlatformPhysicsOperation.py b/cura/Operations/PlatformPhysicsOperation.py index 5aaa2ad94f..0d69320eec 100644 --- a/cura/Operations/PlatformPhysicsOperation.py +++ b/cura/Operations/PlatformPhysicsOperation.py @@ -8,7 +8,7 @@ from UM.Scene.SceneNode import SceneNode ## A specialised operation designed specifically to modify the previous operation. class PlatformPhysicsOperation(Operation): - def __init__(self, node: SceneNode, translation: Vector): + def __init__(self, node: SceneNode, translation: Vector) -> None: super().__init__() self._node = node self._old_transformation = node.getLocalTransformation() diff --git a/cura/Operations/SetParentOperation.py b/cura/Operations/SetParentOperation.py index 7efe2618fd..7d71572a93 100644 --- a/cura/Operations/SetParentOperation.py +++ b/cura/Operations/SetParentOperation.py @@ -14,7 +14,7 @@ class SetParentOperation(Operation.Operation): # # \param node The node which will be reparented. # \param parent_node The node which will be the parent. - def __init__(self, node: SceneNode, parent_node: Optional[SceneNode]): + def __init__(self, node: SceneNode, parent_node: Optional[SceneNode]) -> None: super().__init__() self._node = node self._parent = parent_node diff --git a/cura/Settings/ExtruderManager.py b/cura/Settings/ExtruderManager.py index 62bf396878..e0ec6c4d14 100755 --- a/cura/Settings/ExtruderManager.py +++ b/cura/Settings/ExtruderManager.py @@ -1,4 +1,4 @@ -# Copyright (c) 2019 Ultimaker B.V. +# Copyright (c) 2020 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. from PyQt5.QtCore import pyqtSignal, pyqtProperty, QObject, QVariant # For communicating data and events to Qt. @@ -275,6 +275,25 @@ class ExtruderManager(QObject): Logger.log("e", "Unable to find one or more of the extruders in %s", used_extruder_stack_ids) return [] + ## Get the extruder that the print will start with. + # + # This should mirror the implementation in CuraEngine of + # ``FffGcodeWriter::getStartExtruder()``. + def getInitialExtruderNr(self) -> int: + application = cura.CuraApplication.CuraApplication.getInstance() + global_stack = application.getGlobalContainerStack() + + # Starts with the adhesion extruder. + if global_stack.getProperty("adhesion_type", "value") != "none": + return global_stack.getProperty("adhesion_extruder_nr", "value") + + # No adhesion? Well maybe there is still support brim. + if (global_stack.getProperty("support_enable", "value") or global_stack.getProperty("support_tree_enable", "value")) and global_stack.getProperty("support_brim_enable", "value"): + return global_stack.getProperty("support_infill_extruder_nr", "value") + + # REALLY no adhesion? Use the first used extruder. + return self.getUsedExtruderStacks()[0].getProperty("extruder_nr", "value") + ## Removes the container stack and user profile for the extruders for a specific machine. # # \param machine_id The machine to remove the extruders for. diff --git a/docker/build.sh b/docker/build.sh index 6aa0678ca3..5b035ca08a 100755 --- a/docker/build.sh +++ b/docker/build.sh @@ -17,24 +17,40 @@ cd "${PROJECT_DIR}" # Clone Uranium and set PYTHONPATH first # -# Check the branch to use: -# 1. Use the Uranium branch with the branch same if it exists. -# 2. Otherwise, use the default branch name "master" +# Check the branch to use for Uranium. +# It tries the following branch names and uses the first one that's available. +# - GITHUB_HEAD_REF: the branch name of a PR. If it's not a PR, it will be empty. +# - GITHUB_BASE_REF: the branch a PR is based on. If it's not a PR, it will be empty. +# - GITHUB_REF: the branch name if it's a branch on the repository; +# refs/pull/123/merge if it's a pull_request. +# - master: the master branch. It should always exist. + +# For debugging. echo "GITHUB_REF: ${GITHUB_REF}" +echo "GITHUB_HEAD_REF: ${GITHUB_HEAD_REF}" echo "GITHUB_BASE_REF: ${GITHUB_BASE_REF}" -GIT_REF_NAME="${GITHUB_REF}" -if [ -n "${GITHUB_BASE_REF}" ]; then - GIT_REF_NAME="${GITHUB_BASE_REF}" -fi -GIT_REF_NAME="$(basename "${GIT_REF_NAME}")" - -URANIUM_BRANCH="${GIT_REF_NAME:-master}" -output="$(git ls-remote --heads https://github.com/Ultimaker/Uranium.git "${URANIUM_BRANCH}")" -if [ -z "${output}" ]; then - echo "Could not find Uranium banch ${URANIUM_BRANCH}, fallback to use master." - URANIUM_BRANCH="master" -fi +GIT_REF_NAME_LIST=( "${GITHUB_HEAD_REF}" "${GITHUB_BASE_REF}" "${GITHUB_REF}" "master" ) +for git_ref_name in "${GIT_REF_NAME_LIST[@]}" +do + if [ -z "${git_ref_name}" ]; then + continue + fi + git_ref_name="$(basename "${git_ref_name}")" + # Skip refs/pull/1234/merge as pull requests use it as GITHUB_REF + if [[ "${git_ref_name}" == "merge" ]]; then + echo "Skip [${git_ref_name}]" + continue + fi + URANIUM_BRANCH="${git_ref_name}" + output="$(git ls-remote --heads https://github.com/Ultimaker/Uranium.git "${URANIUM_BRANCH}")" + if [ -n "${output}" ]; then + echo "Found Uranium branch [${URANIUM_BRANCH}]." + break + else + echo "Could not find Uranium banch [${URANIUM_BRANCH}], try next." + fi +done echo "Using Uranium branch ${URANIUM_BRANCH} ..." git clone --depth=1 -b "${URANIUM_BRANCH}" https://github.com/Ultimaker/Uranium.git "${PROJECT_DIR}"/Uranium diff --git a/plugins/CuraEngineBackend/StartSliceJob.py b/plugins/CuraEngineBackend/StartSliceJob.py index d30a77177f..c6841c6ea9 100644 --- a/plugins/CuraEngineBackend/StartSliceJob.py +++ b/plugins/CuraEngineBackend/StartSliceJob.py @@ -1,4 +1,4 @@ -# Copyright (c) 2019 Ultimaker B.V. +# Copyright (c) 2020 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. import numpy @@ -171,146 +171,145 @@ class StartSliceJob(Job): self.setResult(StartJobResult.ObjectSettingError) return - with self._scene.getSceneLock(): - # Remove old layer data. - for node in DepthFirstIterator(self._scene.getRoot()): - if node.callDecoration("getLayerData") and node.callDecoration("getBuildPlateNumber") == self._build_plate_number: - # Singe we walk through all nodes in the scene, they always have a parent. - cast(SceneNode, node.getParent()).removeChild(node) - break + # Remove old layer data. + for node in DepthFirstIterator(self._scene.getRoot()): + if node.callDecoration("getLayerData") and node.callDecoration("getBuildPlateNumber") == self._build_plate_number: + # Singe we walk through all nodes in the scene, they always have a parent. + cast(SceneNode, node.getParent()).removeChild(node) + break - # Get the objects in their groups to print. - object_groups = [] - if stack.getProperty("print_sequence", "value") == "one_at_a_time": - for node in OneAtATimeIterator(self._scene.getRoot()): - temp_list = [] - - # Node can't be printed, so don't bother sending it. - if getattr(node, "_outside_buildarea", False): - continue - - # Filter on current build plate - build_plate_number = node.callDecoration("getBuildPlateNumber") - if build_plate_number is not None and build_plate_number != self._build_plate_number: - continue - - children = node.getAllChildren() - children.append(node) - for child_node in children: - mesh_data = child_node.getMeshData() - if mesh_data and mesh_data.getVertices() is not None: - temp_list.append(child_node) - - if temp_list: - object_groups.append(temp_list) - Job.yieldThread() - if len(object_groups) == 0: - Logger.log("w", "No objects suitable for one at a time found, or no correct order found") - else: + # Get the objects in their groups to print. + object_groups = [] + if stack.getProperty("print_sequence", "value") == "one_at_a_time": + for node in OneAtATimeIterator(self._scene.getRoot()): temp_list = [] - has_printing_mesh = False - for node in DepthFirstIterator(self._scene.getRoot()): - mesh_data = node.getMeshData() - if node.callDecoration("isSliceable") and mesh_data and mesh_data.getVertices() is not None: - is_non_printing_mesh = bool(node.callDecoration("isNonPrintingMesh")) - # Find a reason not to add the node - if node.callDecoration("getBuildPlateNumber") != self._build_plate_number: - continue - if getattr(node, "_outside_buildarea", False) and not is_non_printing_mesh: - continue + # Node can't be printed, so don't bother sending it. + if getattr(node, "_outside_buildarea", False): + continue - temp_list.append(node) - if not is_non_printing_mesh: - has_printing_mesh = True + # Filter on current build plate + build_plate_number = node.callDecoration("getBuildPlateNumber") + if build_plate_number is not None and build_plate_number != self._build_plate_number: + continue - Job.yieldThread() - - # If the list doesn't have any model with suitable settings then clean the list - # otherwise CuraEngine will crash - if not has_printing_mesh: - temp_list.clear() + children = node.getAllChildren() + children.append(node) + for child_node in children: + mesh_data = child_node.getMeshData() + if mesh_data and mesh_data.getVertices() is not None: + temp_list.append(child_node) if temp_list: object_groups.append(temp_list) + Job.yieldThread() + if len(object_groups) == 0: + Logger.log("w", "No objects suitable for one at a time found, or no correct order found") + else: + temp_list = [] + has_printing_mesh = False + for node in DepthFirstIterator(self._scene.getRoot()): + mesh_data = node.getMeshData() + if node.callDecoration("isSliceable") and mesh_data and mesh_data.getVertices() is not None: + is_non_printing_mesh = bool(node.callDecoration("isNonPrintingMesh")) - global_stack = CuraApplication.getInstance().getGlobalContainerStack() - if not global_stack: - return - extruders_enabled = {position: stack.isEnabled for position, stack in global_stack.extruders.items()} - filtered_object_groups = [] - has_model_with_disabled_extruders = False - associated_disabled_extruders = set() - for group in object_groups: - stack = global_stack - skip_group = False - for node in group: - # Only check if the printing extruder is enabled for printing meshes - is_non_printing_mesh = node.callDecoration("evaluateIsNonPrintingMesh") - extruder_position = node.callDecoration("getActiveExtruderPosition") - if not is_non_printing_mesh and not extruders_enabled[extruder_position]: - skip_group = True - has_model_with_disabled_extruders = True - associated_disabled_extruders.add(extruder_position) - if not skip_group: - filtered_object_groups.append(group) - - if has_model_with_disabled_extruders: - self.setResult(StartJobResult.ObjectsWithDisabledExtruder) - associated_disabled_extruders = {str(c) for c in sorted([int(p) + 1 for p in associated_disabled_extruders])} - self.setMessage(", ".join(associated_disabled_extruders)) - return - - # There are cases when there is nothing to slice. This can happen due to one at a time slicing not being - # able to find a possible sequence or because there are no objects on the build plate (or they are outside - # the build volume) - if not filtered_object_groups: - self.setResult(StartJobResult.NothingToSlice) - return - - self._buildGlobalSettingsMessage(stack) - self._buildGlobalInheritsStackMessage(stack) - - # Build messages for extruder stacks - for extruder_stack in global_stack.extruderList: - self._buildExtruderMessage(extruder_stack) - - for group in filtered_object_groups: - group_message = self._slice_message.addRepeatedMessage("object_lists") - parent = group[0].getParent() - if parent is not None and parent.callDecoration("isGroup"): - self._handlePerObjectSettings(cast(CuraSceneNode, parent), group_message) - - for object in group: - mesh_data = object.getMeshData() - if mesh_data is None: + # Find a reason not to add the node + if node.callDecoration("getBuildPlateNumber") != self._build_plate_number: + continue + if getattr(node, "_outside_buildarea", False) and not is_non_printing_mesh: continue - rot_scale = object.getWorldTransformation().getTransposed().getData()[0:3, 0:3] - translate = object.getWorldTransformation().getData()[:3, 3] - # This effectively performs a limited form of MeshData.getTransformed that ignores normals. - verts = mesh_data.getVertices() - verts = verts.dot(rot_scale) - verts += translate + temp_list.append(node) + if not is_non_printing_mesh: + has_printing_mesh = True - # Convert from Y up axes to Z up axes. Equals a 90 degree rotation. - verts[:, [1, 2]] = verts[:, [2, 1]] - verts[:, 1] *= -1 + Job.yieldThread() - obj = group_message.addRepeatedMessage("objects") - obj.id = id(object) - obj.name = object.getName() - indices = mesh_data.getIndices() - if indices is not None: - flat_verts = numpy.take(verts, indices.flatten(), axis=0) - else: - flat_verts = numpy.array(verts) + # If the list doesn't have any model with suitable settings then clean the list + # otherwise CuraEngine will crash + if not has_printing_mesh: + temp_list.clear() - obj.vertices = flat_verts + if temp_list: + object_groups.append(temp_list) - self._handlePerObjectSettings(cast(CuraSceneNode, object), obj) + global_stack = CuraApplication.getInstance().getGlobalContainerStack() + if not global_stack: + return + extruders_enabled = {position: stack.isEnabled for position, stack in global_stack.extruders.items()} + filtered_object_groups = [] + has_model_with_disabled_extruders = False + associated_disabled_extruders = set() + for group in object_groups: + stack = global_stack + skip_group = False + for node in group: + # Only check if the printing extruder is enabled for printing meshes + is_non_printing_mesh = node.callDecoration("evaluateIsNonPrintingMesh") + extruder_position = node.callDecoration("getActiveExtruderPosition") + if not is_non_printing_mesh and not extruders_enabled[extruder_position]: + skip_group = True + has_model_with_disabled_extruders = True + associated_disabled_extruders.add(extruder_position) + if not skip_group: + filtered_object_groups.append(group) - Job.yieldThread() + if has_model_with_disabled_extruders: + self.setResult(StartJobResult.ObjectsWithDisabledExtruder) + associated_disabled_extruders = {str(c) for c in sorted([int(p) + 1 for p in associated_disabled_extruders])} + self.setMessage(", ".join(associated_disabled_extruders)) + return + + # There are cases when there is nothing to slice. This can happen due to one at a time slicing not being + # able to find a possible sequence or because there are no objects on the build plate (or they are outside + # the build volume) + if not filtered_object_groups: + self.setResult(StartJobResult.NothingToSlice) + return + + self._buildGlobalSettingsMessage(stack) + self._buildGlobalInheritsStackMessage(stack) + + # Build messages for extruder stacks + for extruder_stack in global_stack.extruderList: + self._buildExtruderMessage(extruder_stack) + + for group in filtered_object_groups: + group_message = self._slice_message.addRepeatedMessage("object_lists") + parent = group[0].getParent() + if parent is not None and parent.callDecoration("isGroup"): + self._handlePerObjectSettings(cast(CuraSceneNode, parent), group_message) + + for object in group: + mesh_data = object.getMeshData() + if mesh_data is None: + continue + rot_scale = object.getWorldTransformation().getTransposed().getData()[0:3, 0:3] + translate = object.getWorldTransformation().getData()[:3, 3] + + # This effectively performs a limited form of MeshData.getTransformed that ignores normals. + verts = mesh_data.getVertices() + verts = verts.dot(rot_scale) + verts += translate + + # Convert from Y up axes to Z up axes. Equals a 90 degree rotation. + verts[:, [1, 2]] = verts[:, [2, 1]] + verts[:, 1] *= -1 + + obj = group_message.addRepeatedMessage("objects") + obj.id = id(object) + obj.name = object.getName() + indices = mesh_data.getIndices() + if indices is not None: + flat_verts = numpy.take(verts, indices.flatten(), axis=0) + else: + flat_verts = numpy.array(verts) + + obj.vertices = flat_verts + + self._handlePerObjectSettings(cast(CuraSceneNode, object), obj) + + Job.yieldThread() self.setResult(StartJobResult.Finished) @@ -344,10 +343,7 @@ class StartSliceJob(Job): result["time"] = time.strftime("%H:%M:%S") #Some extra settings. result["date"] = time.strftime("%d-%m-%Y") result["day"] = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][int(time.strftime("%w"))] - - initial_extruder_stack = CuraApplication.getInstance().getExtruderManager().getUsedExtruderStacks()[0] - initial_extruder_nr = initial_extruder_stack.getProperty("extruder_nr", "value") - result["initial_extruder_nr"] = initial_extruder_nr + result["initial_extruder_nr"] = CuraApplication.getInstance().getExtruderManager().getInitialExtruderNr() return result diff --git a/plugins/PostProcessingPlugin/scripts/Stretch.py b/plugins/PostProcessingPlugin/scripts/Stretch.py index 20eef60ef2..3899bd2c50 100644 --- a/plugins/PostProcessingPlugin/scripts/Stretch.py +++ b/plugins/PostProcessingPlugin/scripts/Stretch.py @@ -35,7 +35,7 @@ class GCodeStep(): Class to store the current value of each G_Code parameter for any G-Code step """ - def __init__(self, step, in_relative_movement: bool = False): + def __init__(self, step, in_relative_movement: bool = False) -> None: self.step = step self.step_x = 0 self.step_y = 0 diff --git a/plugins/SimulationView/SimulationViewProxy.py b/plugins/SimulationView/SimulationViewProxy.py index 1183244ab3..ce2c336257 100644 --- a/plugins/SimulationView/SimulationViewProxy.py +++ b/plugins/SimulationView/SimulationViewProxy.py @@ -11,7 +11,7 @@ if TYPE_CHECKING: class SimulationViewProxy(QObject): - def __init__(self, simulation_view: "SimulationView", parent=None): + def __init__(self, simulation_view: "SimulationView", parent=None) -> None: super().__init__(parent) self._simulation_view = simulation_view self._current_layer = 0 diff --git a/plugins/Toolbox/__init__.py b/plugins/Toolbox/__init__.py index 70c00ed07c..51f1b643d0 100644 --- a/plugins/Toolbox/__init__.py +++ b/plugins/Toolbox/__init__.py @@ -2,6 +2,7 @@ # Toolbox is released under the terms of the LGPLv3 or higher. from .src import Toolbox +from .src.CloudSync.SyncOrchestrator import SyncOrchestrator def getMetaData(): @@ -9,4 +10,6 @@ def getMetaData(): def register(app): - return {"extension": Toolbox.Toolbox(app)} + return { + "extension": [Toolbox.Toolbox(app), SyncOrchestrator(app)] + } diff --git a/plugins/Toolbox/resources/qml/Toolbox.qml b/plugins/Toolbox/resources/qml/Toolbox.qml index d6d862b5f6..bb487e86b1 100644 --- a/plugins/Toolbox/resources/qml/Toolbox.qml +++ b/plugins/Toolbox/resources/qml/Toolbox.qml @@ -96,17 +96,12 @@ Window visible: toolbox.restartRequired height: visible ? UM.Theme.getSize("toolbox_footer").height : 0 } - // TODO: Clean this up: + Connections { target: toolbox - onShowLicenseDialog: - { - licenseDialog.pluginName = toolbox.getLicenseDialogPluginName(); - licenseDialog.licenseContent = toolbox.getLicenseDialogLicenseContent(); - licenseDialog.pluginFileLocation = toolbox.getLicenseDialogPluginFileLocation(); - licenseDialog.show(); - } + onShowLicenseDialog: { licenseDialog.show() } + onCloseLicenseDialog: { licenseDialog.close() } } ToolboxLicenseDialog diff --git a/plugins/Toolbox/resources/qml/dialogs/CompatibilityDialog.qml b/plugins/Toolbox/resources/qml/dialogs/CompatibilityDialog.qml index a6ce7fc865..06c1102811 100644 --- a/plugins/Toolbox/resources/qml/dialogs/CompatibilityDialog.qml +++ b/plugins/Toolbox/resources/qml/dialogs/CompatibilityDialog.qml @@ -48,13 +48,13 @@ UM.Dialog{ { font: UM.Theme.getFont("default") text: catalog.i18nc("@label", "The following packages will be added:") - visible: toolbox.has_compatible_packages + visible: subscribedPackagesModel.hasCompatiblePackages color: UM.Theme.getColor("text") height: contentHeight + UM.Theme.getSize("default_margin").height } Repeater { - model: toolbox.subscribedPackagesModel + model: subscribedPackagesModel Component { Item @@ -74,7 +74,7 @@ UM.Dialog{ } Label { - text: model.name + text: model.display_name font: UM.Theme.getFont("medium_bold") anchors.left: packageIcon.right anchors.leftMargin: UM.Theme.getSize("default_margin").width @@ -91,20 +91,20 @@ UM.Dialog{ { font: UM.Theme.getFont("default") text: catalog.i18nc("@label", "The following packages can not be installed because of incompatible Cura version:") - visible: toolbox.has_incompatible_packages + visible: subscribedPackagesModel.hasIncompatiblePackages color: UM.Theme.getColor("text") height: contentHeight + UM.Theme.getSize("default_margin").height } Repeater { - model: toolbox.subscribedPackagesModel + model: subscribedPackagesModel Component { Item { width: parent.width property int lineHeight: 60 - visible: !model.is_compatible + visible: !model.is_compatible && !model.is_dismissed height: visible ? (lineHeight + UM.Theme.getSize("default_margin").height) : 0 // We only show the incompatible packages here Image { @@ -117,7 +117,7 @@ UM.Dialog{ } Label { - text: model.name + text: model.display_name font: UM.Theme.getFont("medium_bold") anchors.left: packageIcon.right anchors.leftMargin: UM.Theme.getSize("default_margin").width @@ -125,6 +125,26 @@ UM.Dialog{ color: UM.Theme.getColor("text") elide: Text.ElideRight } + UM.TooltipArea + { + width: childrenRect.width; + height: childrenRect.height; + text: catalog.i18nc("@info:tooltip", "Dismisses the package and won't be shown in this dialog anymore") + anchors.right: parent.right + anchors.verticalCenter: packageIcon.verticalCenter + Label + { + text: "(Dismiss)" + font: UM.Theme.getFont("small") + color: UM.Theme.getColor("text") + MouseArea + { + cursorShape: Qt.PointingHandCursor + anchors.fill: parent + onClicked: handler.dismissIncompatiblePackage(subscribedPackagesModel, model.package_id) + } + } + } } } } @@ -139,6 +159,7 @@ UM.Dialog{ anchors.right: parent.right anchors.margins: UM.Theme.getSize("default_margin").height text: catalog.i18nc("@button", "Next") + onClicked: accept() } } } diff --git a/plugins/Toolbox/resources/qml/dialogs/ToolboxLicenseDialog.qml b/plugins/Toolbox/resources/qml/dialogs/ToolboxLicenseDialog.qml index 3e8d686741..2c88ac6d5f 100644 --- a/plugins/Toolbox/resources/qml/dialogs/ToolboxLicenseDialog.qml +++ b/plugins/Toolbox/resources/qml/dialogs/ToolboxLicenseDialog.qml @@ -13,37 +13,40 @@ import UM 1.1 as UM UM.Dialog { - title: catalog.i18nc("@title:window", "Plugin License Agreement") + id: licenseDialog + title: licenseModel.dialogTitle minimumWidth: UM.Theme.getSize("license_window_minimum").width minimumHeight: UM.Theme.getSize("license_window_minimum").height width: minimumWidth height: minimumHeight - property var pluginName; - property var licenseContent; - property var pluginFileLocation; + Item { anchors.fill: parent + + UM.I18nCatalog{id: catalog; name: "cura"} + + Label { - id: licenseTitle + id: licenseHeader anchors.top: parent.top anchors.left: parent.left anchors.right: parent.right - text: licenseDialog.pluginName + ": " + catalog.i18nc("@label", "This plugin contains a license.\nYou need to accept this license to install this plugin.\nDo you agree with the terms below?") + text: licenseModel.headerText wrapMode: Text.Wrap renderType: Text.NativeRendering } TextArea { id: licenseText - anchors.top: licenseTitle.bottom + anchors.top: licenseHeader.bottom anchors.bottom: parent.bottom anchors.left: parent.left anchors.right: parent.right anchors.topMargin: UM.Theme.getSize("default_margin").height readOnly: true - text: licenseDialog.licenseContent || "" + text: licenseModel.licenseText } } rightButtons: @@ -53,22 +56,14 @@ UM.Dialog id: acceptButton anchors.margins: UM.Theme.getSize("default_margin").width text: catalog.i18nc("@action:button", "Accept") - onClicked: - { - licenseDialog.close(); - toolbox.install(licenseDialog.pluginFileLocation); - toolbox.subscribe(licenseDialog.pluginName); - } + onClicked: { handler.onLicenseAccepted() } }, Button { id: declineButton anchors.margins: UM.Theme.getSize("default_margin").width text: catalog.i18nc("@action:button", "Decline") - onClicked: - { - licenseDialog.close(); - } + onClicked: { handler.onLicenseDeclined() } } ] } diff --git a/plugins/Toolbox/src/AuthorsModel.py b/plugins/Toolbox/src/AuthorsModel.py index 7bfc58df04..81158978b0 100644 --- a/plugins/Toolbox/src/AuthorsModel.py +++ b/plugins/Toolbox/src/AuthorsModel.py @@ -4,7 +4,7 @@ import re from typing import Dict, List, Optional, Union -from PyQt5.QtCore import Qt, pyqtProperty, pyqtSignal +from PyQt5.QtCore import Qt, pyqtProperty from UM.Qt.ListModel import ListModel diff --git a/plugins/Toolbox/src/CloudApiModel.py b/plugins/Toolbox/src/CloudApiModel.py new file mode 100644 index 0000000000..31c3139262 --- /dev/null +++ b/plugins/Toolbox/src/CloudApiModel.py @@ -0,0 +1,20 @@ +from typing import Union + +from cura import ApplicationMetadata, UltimakerCloudAuthentication + + +class CloudApiModel: + sdk_version = ApplicationMetadata.CuraSDKVersion # type: Union[str, int] + cloud_api_version = UltimakerCloudAuthentication.CuraCloudAPIVersion # type: str + cloud_api_root = UltimakerCloudAuthentication.CuraCloudAPIRoot # type: str + api_url = "{cloud_api_root}/cura-packages/v{cloud_api_version}/cura/v{sdk_version}".format( + cloud_api_root = cloud_api_root, + cloud_api_version = cloud_api_version, + sdk_version = sdk_version + ) # type: str + + # https://api.ultimaker.com/cura-packages/v1/user/packages + api_url_user_packages = "{cloud_api_root}/cura-packages/v{cloud_api_version}/user/packages".format( + cloud_api_root=cloud_api_root, + cloud_api_version=cloud_api_version, + ) diff --git a/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py b/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py new file mode 100644 index 0000000000..78d13f34fe --- /dev/null +++ b/plugins/Toolbox/src/CloudSync/CloudPackageChecker.py @@ -0,0 +1,110 @@ +import json +from typing import Optional + +from PyQt5.QtCore import QObject +from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest + +from UM import i18nCatalog +from UM.Logger import Logger +from UM.Message import Message +from UM.Signal import Signal +from cura.CuraApplication import CuraApplication +from ..CloudApiModel import CloudApiModel +from .SubscribedPackagesModel import SubscribedPackagesModel +from ..UltimakerCloudScope import UltimakerCloudScope + + +class CloudPackageChecker(QObject): + def __init__(self, application: CuraApplication) -> None: + super().__init__() + + self.discrepancies = Signal() # Emits SubscribedPackagesModel + self._application = application # type: CuraApplication + self._scope = UltimakerCloudScope(application) + self._model = SubscribedPackagesModel() + + self._application.initializationFinished.connect(self._onAppInitialized) + self._i18n_catalog = i18nCatalog("cura") + + # This is a plugin, so most of the components required are not ready when + # this is initialized. Therefore, we wait until the application is ready. + def _onAppInitialized(self) -> None: + self._package_manager = self._application.getPackageManager() + + # initial check + self._fetchUserSubscribedPackages() + # check again whenever the login state changes + self._application.getCuraAPI().account.loginStateChanged.connect(self._fetchUserSubscribedPackages) + + def _fetchUserSubscribedPackages(self) -> None: + if self._application.getCuraAPI().account.isLoggedIn: + self._getUserPackages() + + def _handleCompatibilityData(self, json_data) -> None: + user_subscribed_packages = [plugin["package_id"] for plugin in json_data] + user_installed_packages = self._package_manager.getUserInstalledPackages() + user_dismissed_packages = self._package_manager.getDismissedPackages() + if user_dismissed_packages: + user_installed_packages += user_dismissed_packages + # We check if there are packages installed in Cloud Marketplace but not in Cura marketplace + package_discrepancy = list(set(user_subscribed_packages).difference(user_installed_packages)) + + self._model.setMetadata(json_data) + self._model.addDiscrepancies(package_discrepancy) + self._model.initialize() + + if not self._model.hasCompatiblePackages: + return None + + if package_discrepancy: + self._handlePackageDiscrepancies() + + def _handlePackageDiscrepancies(self) -> None: + Logger.log("d", "Discrepancy found between Cloud subscribed packages and Cura installed packages") + sync_message = Message(self._i18n_catalog.i18nc( + "@info:generic", + "\nDo you want to sync material and software packages with your account?"), + lifetime=0, + title=self._i18n_catalog.i18nc("@info:title", "Changes detected from your Ultimaker account", )) + sync_message.addAction("sync", + name=self._i18n_catalog.i18nc("@action:button", "Sync"), + icon="", + description="Sync your Cloud subscribed packages to your local environment.", + button_align=Message.ActionButtonAlignment.ALIGN_RIGHT) + sync_message.actionTriggered.connect(self._onSyncButtonClicked) + sync_message.show() + + def _onSyncButtonClicked(self, sync_message: Message, sync_message_action: str) -> None: + sync_message.hide() + self.discrepancies.emit(self._model) + + def _getUserPackages(self) -> None: + Logger.log("d", "Requesting subscribed packages metadata from server.") + url = CloudApiModel.api_url_user_packages + + self._application.getHttpRequestManager().get(url, + callback = self._onUserPackagesRequestFinished, + error_callback = self._onUserPackagesRequestFinished, + scope = self._scope) + + def _onUserPackagesRequestFinished(self, + reply: "QNetworkReply", + error: Optional["QNetworkReply.NetworkError"] = None) -> None: + if error is not None or reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) != 200: + Logger.log("w", + "Requesting user packages failed, response code %s while trying to connect to %s", + reply.attribute(QNetworkRequest.HttpStatusCodeAttribute), reply.url()) + return + + try: + json_data = json.loads(bytes(reply.readAll()).decode("utf-8")) + + # Check for errors: + if "errors" in json_data: + for error in json_data["errors"]: + Logger.log("e", "%s", error["title"]) + return + + self._handleCompatibilityData(json_data["data"]) + except json.decoder.JSONDecodeError: + Logger.log("w", "Received invalid JSON for user packages") diff --git a/plugins/Toolbox/src/CloudSync/CloudPackageManager.py b/plugins/Toolbox/src/CloudSync/CloudPackageManager.py new file mode 100644 index 0000000000..ee57a1b90d --- /dev/null +++ b/plugins/Toolbox/src/CloudSync/CloudPackageManager.py @@ -0,0 +1,18 @@ +from cura.CuraApplication import CuraApplication +from ..CloudApiModel import CloudApiModel +from ..UltimakerCloudScope import UltimakerCloudScope + + +## Manages Cloud subscriptions. When a package is added to a user's account, the user is 'subscribed' to that package +# Whenever the user logs in on another instance of Cura, these subscriptions can be used to sync the user's plugins +class CloudPackageManager: + def __init__(self, app: CuraApplication) -> None: + self._request_manager = app.getHttpRequestManager() + self._scope = UltimakerCloudScope(app) + + def subscribe(self, package_id: str) -> None: + data = "{\"data\": {\"package_id\": \"%s\", \"sdk_version\": \"%s\"}}" % (package_id, CloudApiModel.sdk_version) + self._request_manager.put(url=CloudApiModel.api_url_user_packages, + data=data.encode(), + scope=self._scope + ) diff --git a/plugins/Toolbox/src/CloudSync/DiscrepanciesPresenter.py b/plugins/Toolbox/src/CloudSync/DiscrepanciesPresenter.py new file mode 100644 index 0000000000..f6b5622aad --- /dev/null +++ b/plugins/Toolbox/src/CloudSync/DiscrepanciesPresenter.py @@ -0,0 +1,40 @@ +import os +from typing import Optional + +from PyQt5.QtCore import QObject, pyqtSlot + +from UM.Qt.QtApplication import QtApplication +from UM.Signal import Signal +from .SubscribedPackagesModel import SubscribedPackagesModel + + +## Shows a list of packages to be added or removed. The user can select which packages to (un)install. The user's +# choices are emitted on the `packageMutations` Signal. +class DiscrepanciesPresenter(QObject): + + def __init__(self, app: QtApplication) -> None: + super().__init__(app) + + self.packageMutations = Signal() # Emits SubscribedPackagesModel + + self._app = app + self._package_manager = app.getPackageManager() + self._dialog = None # type: Optional[QObject] + self._compatibility_dialog_path = "resources/qml/dialogs/CompatibilityDialog.qml" + + def present(self, plugin_path: str, model: SubscribedPackagesModel) -> None: + path = os.path.join(plugin_path, self._compatibility_dialog_path) + self._dialog = self._app.createQmlComponent(path, {"subscribedPackagesModel": model, "handler": self}) + assert self._dialog + self._dialog.accepted.connect(lambda: self._onConfirmClicked(model)) + + @pyqtSlot("QVariant", str) + def dismissIncompatiblePackage(self, model: SubscribedPackagesModel, package_id: str) -> None: + model.dismissPackage(package_id) # update the model to update the view + self._package_manager.dismissPackage(package_id) # adds this package_id as dismissed in the user config file + + def _onConfirmClicked(self, model: SubscribedPackagesModel) -> None: + # For now, all compatible packages presented to the user should be installed. + # Later, we might remove items for which the user unselected the package + model.setItems(model.getCompatiblePackages()) + self.packageMutations.emit(model) diff --git a/plugins/Toolbox/src/CloudSync/DownloadPresenter.py b/plugins/Toolbox/src/CloudSync/DownloadPresenter.py new file mode 100644 index 0000000000..f19cac047a --- /dev/null +++ b/plugins/Toolbox/src/CloudSync/DownloadPresenter.py @@ -0,0 +1,136 @@ +import tempfile +from typing import Dict, List, Any + +from PyQt5.QtNetwork import QNetworkReply + +from UM import i18n_catalog +from UM.Logger import Logger +from UM.Message import Message +from UM.Signal import Signal +from UM.TaskManagement.HttpRequestManager import HttpRequestManager +from cura.CuraApplication import CuraApplication +from .SubscribedPackagesModel import SubscribedPackagesModel +from ..UltimakerCloudScope import UltimakerCloudScope + + +## Downloads a set of packages from the Ultimaker Cloud Marketplace +# use download() exactly once: should not be used for multiple sets of downloads since this class contains state +class DownloadPresenter: + + DISK_WRITE_BUFFER_SIZE = 256 * 1024 # 256 KB + + def __init__(self, app: CuraApplication) -> None: + # Emits (Dict[str, str], List[str]) # (success_items, error_items) + # Dict{success_package_id, temp_file_path} + # List[errored_package_id] + self.done = Signal() + + self._app = app + self._scope = UltimakerCloudScope(app) + + self._started = False + self._progress_message = self._createProgressMessage() + self._progress = {} # type: Dict[str, Dict[str, Any]] # package_id, Dict + self._error = [] # type: List[str] # package_id + + def download(self, model: SubscribedPackagesModel) -> None: + if self._started: + Logger.error("Download already started. Create a new %s instead", self.__class__.__name__) + return + + manager = HttpRequestManager.getInstance() + for item in model.items: + package_id = item["package_id"] + + def finishedCallback(reply: QNetworkReply, pid = package_id) -> None: + self._onFinished(pid, reply) + + def progressCallback(rx: int, rt: int, pid = package_id) -> None: + self._onProgress(pid, rx, rt) + + def errorCallback(reply: QNetworkReply, error: QNetworkReply.NetworkError, pid = package_id) -> None: + self._onError(pid) + + request_data = manager.get( + item["download_url"], + callback = finishedCallback, + download_progress_callback = progressCallback, + error_callback = errorCallback, + scope = self._scope) + + self._progress[package_id] = { + "received": 0, + "total": 1, # make sure this is not considered done yet. Also divByZero-safe + "file_written": None, + "request_data": request_data + } + + self._started = True + self._progress_message.show() + + def abort(self) -> None: + manager = HttpRequestManager.getInstance() + for item in self._progress.values(): + manager.abortRequest(item["request_data"]) + + # Aborts all current operations and returns a copy with the same settings such as app and scope + def resetCopy(self) -> "DownloadPresenter": + self.abort() + self.done.disconnectAll() + return DownloadPresenter(self._app) + + def _createProgressMessage(self) -> Message: + return Message(i18n_catalog.i18nc( + "@info:generic", + "\nSyncing..."), + lifetime = 0, + use_inactivity_timer=False, + progress = 0.0, + title = i18n_catalog.i18nc("@info:title", "Changes detected from your Ultimaker account", )) + + def _onFinished(self, package_id: str, reply: QNetworkReply) -> None: + self._progress[package_id]["received"] = self._progress[package_id]["total"] + + try: + with tempfile.NamedTemporaryFile(mode ="wb+", suffix =".curapackage", delete = False) as temp_file: + bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE) + while bytes_read: + temp_file.write(bytes_read) + bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE) + self._app.processEvents() + self._progress[package_id]["file_written"] = temp_file.name + except IOError as e: + Logger.logException("e", "Failed to write downloaded package to temp file", e) + self._onError(package_id) + temp_file.close() + + self._checkDone() + + def _onProgress(self, package_id: str, rx: int, rt: int) -> None: + self._progress[package_id]["received"] = rx + self._progress[package_id]["total"] = rt + + received = 0 + total = 0 + for item in self._progress.values(): + received += item["received"] + total += item["total"] + + self._progress_message.setProgress(100.0 * (received / total)) # [0 .. 100] % + + def _onError(self, package_id: str) -> None: + self._progress.pop(package_id) + self._error.append(package_id) + self._checkDone() + + def _checkDone(self) -> bool: + for item in self._progress.values(): + if not item["file_written"]: + return False + + success_items = {package_id : value["file_written"] for package_id, value in self._progress.items()} + error_items = [package_id for package_id in self._error] + + self._progress_message.hide() + self.done.emit(success_items, error_items) + return True diff --git a/plugins/Toolbox/src/CloudSync/LicenseModel.py b/plugins/Toolbox/src/CloudSync/LicenseModel.py new file mode 100644 index 0000000000..c3b5ee5d31 --- /dev/null +++ b/plugins/Toolbox/src/CloudSync/LicenseModel.py @@ -0,0 +1,55 @@ +from PyQt5.QtCore import QObject, pyqtProperty, pyqtSignal +from UM.i18n import i18nCatalog + +catalog = i18nCatalog("cura") + + +# Model for the ToolboxLicenseDialog +class LicenseModel(QObject): + dialogTitleChanged = pyqtSignal() + headerChanged = pyqtSignal() + licenseTextChanged = pyqtSignal() + + def __init__(self) -> None: + super().__init__() + + self._current_page_idx = 0 + self._page_count = 1 + self._dialogTitle = "" + self._header_text = "" + self._license_text = "" + self._package_name = "" + + @pyqtProperty(str, notify=dialogTitleChanged) + def dialogTitle(self) -> str: + return self._dialogTitle + + @pyqtProperty(str, notify=headerChanged) + def headerText(self) -> str: + return self._header_text + + def setPackageName(self, name: str) -> None: + self._header_text = name + ": " + catalog.i18nc("@label", "This plugin contains a license.\nYou need to accept this license to install this plugin.\nDo you agree with the terms below?") + self.headerChanged.emit() + + @pyqtProperty(str, notify=licenseTextChanged) + def licenseText(self) -> str: + return self._license_text + + def setLicenseText(self, license_text: str) -> None: + if self._license_text != license_text: + self._license_text = license_text + self.licenseTextChanged.emit() + + def setCurrentPageIdx(self, idx: int) -> None: + self._current_page_idx = idx + self._updateDialogTitle() + + def setPageCount(self, count: int) -> None: + self._page_count = count + self._updateDialogTitle() + + def _updateDialogTitle(self): + self._dialogTitle = catalog.i18nc("@title:window", "Plugin License Agreement ({}/{})" + .format(self._current_page_idx + 1, self._page_count)) + self.dialogTitleChanged.emit() diff --git a/plugins/Toolbox/src/CloudSync/LicensePresenter.py b/plugins/Toolbox/src/CloudSync/LicensePresenter.py new file mode 100644 index 0000000000..cefe6f4037 --- /dev/null +++ b/plugins/Toolbox/src/CloudSync/LicensePresenter.py @@ -0,0 +1,97 @@ +import os +from typing import Dict, Optional, List + +from PyQt5.QtCore import QObject, pyqtSlot + +from UM.PackageManager import PackageManager +from UM.Signal import Signal +from cura.CuraApplication import CuraApplication +from UM.i18n import i18nCatalog + +from .LicenseModel import LicenseModel + + +## Call present() to show a licenseDialog for a set of packages +# licenseAnswers emits a list of Dicts containing answers when the user has made a choice for all provided packages +class LicensePresenter(QObject): + + def __init__(self, app: CuraApplication) -> None: + super().__init__() + self._dialog = None # type: Optional[QObject] + self._package_manager = app.getPackageManager() # type: PackageManager + # Emits List[Dict[str, [Any]] containing for example + # [{ "package_id": "BarbarianPlugin", "package_path" : "/tmp/dg345as", "accepted" : True }] + self.licenseAnswers = Signal() + + self._current_package_idx = 0 + self._package_models = [] # type: List[Dict] + self._license_model = LicenseModel() # type: LicenseModel + + self._app = app + + self._compatibility_dialog_path = "resources/qml/dialogs/ToolboxLicenseDialog.qml" + + ## Show a license dialog for multiple packages where users can read a license and accept or decline them + # \param plugin_path: Root directory of the Toolbox plugin + # \param packages: Dict[package id, file path] + def present(self, plugin_path: str, packages: Dict[str, str]) -> None: + path = os.path.join(plugin_path, self._compatibility_dialog_path) + + self._initState(packages) + + if self._dialog is None: + + context_properties = { + "catalog": i18nCatalog("cura"), + "licenseModel": self._license_model, + "handler": self + } + self._dialog = self._app.createQmlComponent(path, context_properties) + self._license_model.setPageCount(len(self._package_models)) + self._presentCurrentPackage() + + @pyqtSlot() + def onLicenseAccepted(self) -> None: + self._package_models[self._current_package_idx]["accepted"] = True + self._checkNextPage() + + @pyqtSlot() + def onLicenseDeclined(self) -> None: + self._package_models[self._current_package_idx]["accepted"] = False + self._checkNextPage() + + def _initState(self, packages: Dict[str, str]) -> None: + self._package_models = [ + { + "package_id" : package_id, + "package_path" : package_path, + "accepted" : None #: None: no answer yet + } + for package_id, package_path in packages.items() + ] + + def _presentCurrentPackage(self) -> None: + package_model = self._package_models[self._current_package_idx] + license_content = self._package_manager.getPackageLicense(package_model["package_path"]) + if license_content is None: + # Implicitly accept when there is no license + self.onLicenseAccepted() + return + + self._license_model.setCurrentPageIdx(self._current_package_idx) + self._license_model.setPackageName(package_model["package_id"]) + self._license_model.setLicenseText(license_content) + if self._dialog: + self._dialog.open() # Does nothing if already open + + def _checkNextPage(self) -> None: + if self._current_package_idx + 1 < len(self._package_models): + self._current_package_idx += 1 + self._presentCurrentPackage() + else: + if self._dialog: + self._dialog.close() + self.licenseAnswers.emit(self._package_models) + + + diff --git a/plugins/Toolbox/src/CloudSync/RestartApplicationPresenter.py b/plugins/Toolbox/src/CloudSync/RestartApplicationPresenter.py new file mode 100644 index 0000000000..6e2bc53e7e --- /dev/null +++ b/plugins/Toolbox/src/CloudSync/RestartApplicationPresenter.py @@ -0,0 +1,31 @@ +from UM import i18nCatalog +from UM.Message import Message +from cura.CuraApplication import CuraApplication + + +## Presents a dialog telling the user that a restart is required to apply changes +# Since we cannot restart Cura, the app is closed instead when the button is clicked +class RestartApplicationPresenter: + def __init__(self, app: CuraApplication) -> None: + self._app = app + self._i18n_catalog = i18nCatalog("cura") + + def present(self) -> None: + app_name = self._app.getApplicationDisplayName() + + message = Message(self._i18n_catalog.i18nc( + "@info:generic", + "You need to quit and restart {} before changes have effect.", app_name + )) + + message.addAction("quit", + name="Quit " + app_name, + icon = "", + description="Close the application", + button_align=Message.ActionButtonAlignment.ALIGN_RIGHT) + + message.actionTriggered.connect(self._quitClicked) + message.show() + + def _quitClicked(self, *_): + self._app.windowClosed() diff --git a/plugins/Toolbox/src/CloudSync/SubscribedPackagesModel.py b/plugins/Toolbox/src/CloudSync/SubscribedPackagesModel.py new file mode 100644 index 0000000000..4a0f559748 --- /dev/null +++ b/plugins/Toolbox/src/CloudSync/SubscribedPackagesModel.py @@ -0,0 +1,82 @@ +# Copyright (c) 2020 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. + +from PyQt5.QtCore import Qt, pyqtProperty, pyqtSlot +from UM.Qt.ListModel import ListModel +from cura import ApplicationMetadata +from UM.Logger import Logger +from typing import List, Dict, Any + + +class SubscribedPackagesModel(ListModel): + def __init__(self, parent = None): + super().__init__(parent) + + self._items = [] + self._metadata = None + self._discrepancies = None + self._sdk_version = ApplicationMetadata.CuraSDKVersion + + self.addRoleName(Qt.UserRole + 1, "package_id") + self.addRoleName(Qt.UserRole + 2, "display_name") + self.addRoleName(Qt.UserRole + 3, "icon_url") + self.addRoleName(Qt.UserRole + 4, "is_compatible") + self.addRoleName(Qt.UserRole + 5, "is_dismissed") + + @pyqtProperty(bool, constant=True) + def hasCompatiblePackages(self) -> bool: + for item in self._items: + if item['is_compatible']: + return True + return False + + @pyqtProperty(bool, constant=True) + def hasIncompatiblePackages(self) -> bool: + for item in self._items: + if not item['is_compatible']: + return True + return False + + # Sets the "is_compatible" to True for the given package, in memory + + @pyqtSlot() + def dismissPackage(self, package_id: str) -> None: + package = self.find(key="package_id", value=package_id) + if package != -1: + self.setProperty(package, property="is_dismissed", value=True) + Logger.debug("Package {} has been dismissed".format(package_id)) + + def setMetadata(self, data: List[Dict[str, List[Any]]]) -> None: + self._metadata = data + + def addDiscrepancies(self, discrepancy: List[str]) -> None: + self._discrepancies = discrepancy + + def getCompatiblePackages(self): + return [x for x in self._items if x["is_compatible"]] + + def initialize(self) -> None: + self._items.clear() + for item in self._metadata: + if item["package_id"] not in self._discrepancies: + continue + package = { + "package_id": item["package_id"], + "display_name": item["display_name"], + "sdk_versions": item["sdk_versions"], + "download_url": item["download_url"], + "md5_hash": item["md5_hash"], + "is_dismissed": False, + } + if self._sdk_version not in item["sdk_versions"]: + package.update({"is_compatible": False}) + else: + package.update({"is_compatible": True}) + try: + package.update({"icon_url": item["icon_url"]}) + except KeyError: # There is no 'icon_url" in the response payload for this package + package.update({"icon_url": ""}) + self._items.append(package) + self.setItems(self._items) + + diff --git a/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py b/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py new file mode 100644 index 0000000000..674fb68729 --- /dev/null +++ b/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py @@ -0,0 +1,92 @@ +import os +from typing import List, Dict, Any, cast + +from UM.Extension import Extension +from UM.Logger import Logger +from UM.PluginRegistry import PluginRegistry +from cura.CuraApplication import CuraApplication +from .CloudPackageChecker import CloudPackageChecker +from .CloudPackageManager import CloudPackageManager +from .DiscrepanciesPresenter import DiscrepanciesPresenter +from .DownloadPresenter import DownloadPresenter +from .LicensePresenter import LicensePresenter +from .RestartApplicationPresenter import RestartApplicationPresenter +from .SubscribedPackagesModel import SubscribedPackagesModel + + +## Orchestrates the synchronizing of packages from the user account to the installed packages +# Example flow: +# - CloudPackageChecker compares a list of packages the user `subscribed` to in their account +# If there are `discrepancies` between the account and locally installed packages, they are emitted +# - DiscrepanciesPresenter shows a list of packages to be added or removed to the user. It emits the `packageMutations` +# the user selected to be performed +# - The SyncOrchestrator uses PackageManager to remove local packages the users wants to see removed +# - The DownloadPresenter shows a download progress dialog. It emits A tuple of succeeded and failed downloads +# - The LicensePresenter extracts licenses from the downloaded packages and presents a license for each package to +# be installed. It emits the `licenseAnswers` signal for accept or declines +# - The CloudPackageManager removes the declined packages from the account +# - The SyncOrchestrator uses PackageManager to install the downloaded packages and delete temp files. +# - The RestartApplicationPresenter notifies the user that a restart is required for changes to take effect +class SyncOrchestrator(Extension): + + def __init__(self, app: CuraApplication) -> None: + super().__init__() + # Differentiate This PluginObject from the Toolbox. self.getId() includes _name. + # getPluginId() will return the same value for The toolbox extension and this one + self._name = "SyncOrchestrator" + + self._package_manager = app.getPackageManager() + self._cloud_package_manager = CloudPackageManager(app) + + self._checker = CloudPackageChecker(app) # type: CloudPackageChecker + self._checker.discrepancies.connect(self._onDiscrepancies) + + self._discrepancies_presenter = DiscrepanciesPresenter(app) # type: DiscrepanciesPresenter + self._discrepancies_presenter.packageMutations.connect(self._onPackageMutations) + + self._download_presenter = DownloadPresenter(app) # type: DownloadPresenter + + self._license_presenter = LicensePresenter(app) # type: LicensePresenter + self._license_presenter.licenseAnswers.connect(self._onLicenseAnswers) + + self._restart_presenter = RestartApplicationPresenter(app) + + def _onDiscrepancies(self, model: SubscribedPackagesModel) -> None: + plugin_path = cast(str, PluginRegistry.getInstance().getPluginPath(self.getPluginId())) + self._discrepancies_presenter.present(plugin_path, model) + + def _onPackageMutations(self, mutations: SubscribedPackagesModel) -> None: + self._download_presenter = self._download_presenter.resetCopy() + self._download_presenter.done.connect(self._onDownloadFinished) + self._download_presenter.download(mutations) + + ## Called when a set of packages have finished downloading + # \param success_items: Dict[package_id, file_path] + # \param error_items: List[package_id] + def _onDownloadFinished(self, success_items: Dict[str, str], error_items: List[str]) -> None: + # todo handle error items + plugin_path = cast(str, PluginRegistry.getInstance().getPluginPath(self.getPluginId())) + self._license_presenter.present(plugin_path, success_items) + + # Called when user has accepted / declined all licenses for the downloaded packages + def _onLicenseAnswers(self, answers: List[Dict[str, Any]]) -> None: + Logger.debug("Got license answers: {}", answers) + + has_changes = False # True when at least one package is installed + + for item in answers: + if item["accepted"]: + # install and subscribe packages + if not self._package_manager.installPackage(item["package_path"]): + Logger.error("could not install {}".format(item["package_id"])) + continue + self._cloud_package_manager.subscribe(item["package_id"]) + has_changes = True + else: + # todo unsubscribe declined packages + pass + # delete temp file + os.remove(item["package_path"]) + + if has_changes: + self._restart_presenter.present() diff --git a/plugins/Toolbox/src/CloudSync/__init__.py b/plugins/Toolbox/src/CloudSync/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/plugins/Toolbox/src/ConfigsModel.py b/plugins/Toolbox/src/ConfigsModel.py index 9ba65caaa4..a92f9c0d93 100644 --- a/plugins/Toolbox/src/ConfigsModel.py +++ b/plugins/Toolbox/src/ConfigsModel.py @@ -1,7 +1,8 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from PyQt5.QtCore import Qt, pyqtProperty +from PyQt5.QtCore import Qt + from UM.Qt.ListModel import ListModel diff --git a/plugins/Toolbox/src/SubscribedPackagesModel.py b/plugins/Toolbox/src/SubscribedPackagesModel.py deleted file mode 100644 index cf0d07c153..0000000000 --- a/plugins/Toolbox/src/SubscribedPackagesModel.py +++ /dev/null @@ -1,61 +0,0 @@ -# Copyright (c) 2020 Ultimaker B.V. -# Cura is released under the terms of the LGPLv3 or higher. - -from PyQt5.QtCore import Qt -from UM.Qt.ListModel import ListModel -from cura import ApplicationMetadata - - -class SubscribedPackagesModel(ListModel): - def __init__(self, parent = None): - super().__init__(parent) - - self._items = [] - self._metadata = None - self._discrepancies = None - self._sdk_version = ApplicationMetadata.CuraSDKVersion - - self.addRoleName(Qt.UserRole + 1, "name") - self.addRoleName(Qt.UserRole + 2, "icon_url") - self.addRoleName(Qt.UserRole + 3, "is_compatible") - - def setMetadata(self, data): - if self._metadata != data: - self._metadata = data - - def addValue(self, discrepancy): - if self._discrepancies != discrepancy: - self._discrepancies = discrepancy - - def update(self): - self._items.clear() - - for item in self._metadata: - if item["package_id"] not in self._discrepancies: - continue - package = {"name": item["display_name"], "sdk_versions": item["sdk_versions"]} - if self._sdk_version not in item["sdk_versions"]: - package.update({"is_compatible": False}) - else: - package.update({"is_compatible": True}) - try: - package.update({"icon_url": item["icon_url"]}) - except KeyError: # There is no 'icon_url" in the response payload for this package - package.update({"icon_url": ""}) - - self._items.append(package) - self.setItems(self._items) - - def hasCompatiblePackages(self) -> bool: - has_compatible_items = False - for item in self._items: - if item['is_compatible'] == True: - has_compatible_items = True - return has_compatible_items - - def hasIncompatiblePackages(self) -> bool: - has_incompatible_items = False - for item in self._items: - if item['is_compatible'] == False: - has_incompatible_items = True - return has_incompatible_items \ No newline at end of file diff --git a/plugins/Toolbox/src/Toolbox.py b/plugins/Toolbox/src/Toolbox.py index ee260f6808..e0d04bed5b 100644 --- a/plugins/Toolbox/src/Toolbox.py +++ b/plugins/Toolbox/src/Toolbox.py @@ -4,7 +4,6 @@ import json import os import tempfile -import platform from typing import cast, Any, Dict, List, Set, TYPE_CHECKING, Tuple, Optional, Union from PyQt5.QtCore import QObject, pyqtProperty, pyqtSignal, pyqtSlot @@ -15,16 +14,17 @@ from UM.PluginRegistry import PluginRegistry from UM.Extension import Extension from UM.i18n import i18nCatalog from UM.Version import Version -from UM.Message import Message from cura import ApplicationMetadata -from cura import UltimakerCloudAuthentication from cura.CuraApplication import CuraApplication from cura.Machines.ContainerTree import ContainerTree +from .CloudApiModel import CloudApiModel from .AuthorsModel import AuthorsModel +from .CloudSync.CloudPackageManager import CloudPackageManager +from .CloudSync.LicenseModel import LicenseModel from .PackagesModel import PackagesModel -from .SubscribedPackagesModel import SubscribedPackagesModel +from .UltimakerCloudScope import UltimakerCloudScope if TYPE_CHECKING: from UM.TaskManagement.HttpRequestData import HttpRequestData @@ -32,8 +32,9 @@ if TYPE_CHECKING: i18n_catalog = i18nCatalog("cura") +# todo Remove license and download dialog, use SyncOrchestrator instead -## The Toolbox class is responsible of communicating with the server through the API +## Provides a marketplace for users to download plugins an materials class Toolbox(QObject, Extension): def __init__(self, application: CuraApplication) -> None: super().__init__() @@ -41,16 +42,13 @@ class Toolbox(QObject, Extension): self._application = application # type: CuraApplication self._sdk_version = ApplicationMetadata.CuraSDKVersion # type: Union[str, int] - self._cloud_api_version = UltimakerCloudAuthentication.CuraCloudAPIVersion # type: str - self._cloud_api_root = UltimakerCloudAuthentication.CuraCloudAPIRoot # type: str - self._api_url = None # type: Optional[str] # Network: + self._cloud_package_manager = CloudPackageManager(application) # type: CloudPackageManager self._download_request_data = None # type: Optional[HttpRequestData] self._download_progress = 0 # type: float self._is_downloading = False # type: bool - self._request_headers = dict() # type: Dict[str, str] - self._updateRequestHeader() + self._scope = UltimakerCloudScope(application) # type: UltimakerCloudScope self._request_urls = {} # type: Dict[str, str] self._to_update = [] # type: List[str] # Package_ids that are waiting to be updated @@ -61,17 +59,15 @@ class Toolbox(QObject, Extension): self._server_response_data = { "authors": [], "packages": [], - "updates": [], - "subscribed_packages": [], + "updates": [] } # type: Dict[str, List[Any]] # Models: self._models = { "authors": AuthorsModel(self), "packages": PackagesModel(self), - "updates": PackagesModel(self), - "subscribed_packages": SubscribedPackagesModel(self), - } # type: Dict[str, Union[AuthorsModel, PackagesModel, SubscribedPackagesModel]] + "updates": PackagesModel(self) + } # type: Dict[str, Union[AuthorsModel, PackagesModel]] self._plugins_showcase_model = PackagesModel(self) self._plugins_available_model = PackagesModel(self) @@ -82,6 +78,8 @@ class Toolbox(QObject, Extension): self._materials_installed_model = PackagesModel(self) self._materials_generic_model = PackagesModel(self) + self._license_model = LicenseModel() + # These properties are for keeping track of the UI state: # ---------------------------------------------------------------------- # View category defines which filter to use, and therefore effectively @@ -104,13 +102,9 @@ class Toolbox(QObject, Extension): self._restart_required = False # type: bool # variables for the license agreement dialog - self._license_dialog_plugin_name = "" # type: str - self._license_dialog_license_content = "" # type: str self._license_dialog_plugin_file_location = "" # type: str - self._restart_dialog_message = "" # type: str self._application.initializationFinished.connect(self._onAppInitialized) - self._application.getCuraAPI().account.accessTokenChanged.connect(self._updateRequestHeader) # Signals: # -------------------------------------------------------------------------- @@ -128,11 +122,11 @@ class Toolbox(QObject, Extension): filterChanged = pyqtSignal() metadataChanged = pyqtSignal() showLicenseDialog = pyqtSignal() + closeLicenseDialog = pyqtSignal() uninstallVariablesChanged = pyqtSignal() ## Go back to the start state (welcome screen or loading if no login required) def _restart(self): - self._updateRequestHeader() # For an Essentials build, login is mandatory if not self._application.getCuraAPI().account.isLoggedIn and ApplicationMetadata.IsEnterpriseVersion: self.setViewPage("welcome") @@ -140,17 +134,6 @@ class Toolbox(QObject, Extension): self.setViewPage("loading") self._fetchPackageData() - def _updateRequestHeader(self): - self._request_headers = { - "User-Agent": "%s/%s (%s %s)" % (self._application.getApplicationName(), - self._application.getVersion(), - platform.system(), - platform.machine()) - } - access_token = self._application.getCuraAPI().account.accessToken - if access_token: - self._request_headers["Authorization"] = "Bearer {}".format(access_token) - def _resetUninstallVariables(self) -> None: self._package_id_to_uninstall = None # type: Optional[str] self._package_name_to_uninstall = "" @@ -159,35 +142,25 @@ class Toolbox(QObject, Extension): @pyqtSlot(str, int) def ratePackage(self, package_id: str, rating: int) -> None: - url = "{base_url}/packages/{package_id}/ratings".format(base_url = self._api_url, package_id = package_id) + url = "{base_url}/packages/{package_id}/ratings".format(base_url = CloudApiModel.api_url, package_id = package_id) data = "{\"data\": {\"cura_version\": \"%s\", \"rating\": %i}}" % (Version(self._application.getVersion()), rating) - self._application.getHttpRequestManager().put(url, headers_dict = self._request_headers, - data = data.encode()) + self._application.getHttpRequestManager().put(url, data = data.encode(), scope = self._scope) + @pyqtSlot(str) def subscribe(self, package_id: str) -> None: - if self._application.getCuraAPI().account.isLoggedIn: - data = "{\"data\": {\"package_id\": \"%s\", \"sdk_version\": \"%s\"}}" % (package_id, self._sdk_version) - self._application.getHttpRequestManager().put(url=self._api_url_user_packages, - headers_dict=self._request_headers, - data=data.encode() - ) + self._cloud_package_manager.subscribe(package_id) - @pyqtSlot(result = str) - def getLicenseDialogPluginName(self) -> str: - return self._license_dialog_plugin_name - - @pyqtSlot(result = str) def getLicenseDialogPluginFileLocation(self) -> str: return self._license_dialog_plugin_file_location - @pyqtSlot(result = str) - def getLicenseDialogLicenseContent(self) -> str: - return self._license_dialog_license_content - def openLicenseDialog(self, plugin_name: str, license_content: str, plugin_file_location: str) -> None: - self._license_dialog_plugin_name = plugin_name - self._license_dialog_license_content = license_content + # Set page 1/1 when opening the dialog for a single package + self._license_model.setCurrentPageIdx(0) + self._license_model.setPageCount(1) + + self._license_model.setPackageName(plugin_name) + self._license_model.setLicenseText(license_content) self._license_dialog_plugin_file_location = plugin_file_location self.showLicenseDialog.emit() @@ -196,16 +169,6 @@ class Toolbox(QObject, Extension): def _onAppInitialized(self) -> None: self._plugin_registry = self._application.getPluginRegistry() self._package_manager = self._application.getPackageManager() - self._api_url = "{cloud_api_root}/cura-packages/v{cloud_api_version}/cura/v{sdk_version}".format( - cloud_api_root = self._cloud_api_root, - cloud_api_version = self._cloud_api_version, - sdk_version = self._sdk_version - ) - # https://api.ultimaker.com/cura-packages/v1/user/packages - self._api_url_user_packages = "{cloud_api_root}/cura-packages/v{cloud_api_version}/user/packages".format( - cloud_api_root = self._cloud_api_root, - cloud_api_version = self._cloud_api_version, - ) # We need to construct a query like installed_packages=ID:VERSION&installed_packages=ID:VERSION, etc. installed_package_ids_with_versions = [":".join(items) for items in @@ -213,27 +176,20 @@ class Toolbox(QObject, Extension): installed_packages_query = "&installed_packages=".join(installed_package_ids_with_versions) self._request_urls = { - "authors": "{base_url}/authors".format(base_url = self._api_url), - "packages": "{base_url}/packages".format(base_url = self._api_url), + "authors": "{base_url}/authors".format(base_url = CloudApiModel.api_url), + "packages": "{base_url}/packages".format(base_url = CloudApiModel.api_url), "updates": "{base_url}/packages/package-updates?installed_packages={query}".format( - base_url = self._api_url, query = installed_packages_query), - "subscribed_packages": self._api_url_user_packages, + base_url = CloudApiModel.api_url, query = installed_packages_query) } self._application.getCuraAPI().account.loginStateChanged.connect(self._restart) - self._application.getCuraAPI().account.loginStateChanged.connect(self._fetchUserSubscribedPackages) # On boot we check which packages have updates. if CuraApplication.getInstance().getPreferences().getValue("info/automatic_update_check") and len(installed_package_ids_with_versions) > 0: # Request the latest and greatest! self._makeRequestByType("updates") - self._fetchUserSubscribedPackages() - def _fetchUserSubscribedPackages(self): - if self._application.getCuraAPI().account.isLoggedIn: - self._makeRequestByType("subscribed_packages") - def _fetchPackageData(self) -> None: self._makeRequestByType("packages") self._makeRequestByType("authors") @@ -262,7 +218,11 @@ class Toolbox(QObject, Extension): return None path = os.path.join(plugin_path, "resources", "qml", qml_name) - dialog = self._application.createQmlComponent(path, {"toolbox": self}) + dialog = self._application.createQmlComponent(path, { + "toolbox": self, + "handler": self, + "licenseModel": self._license_model + }) if not dialog: raise Exception("Failed to create Marketplace dialog") return dialog @@ -333,13 +293,14 @@ class Toolbox(QObject, Extension): self.metadataChanged.emit() @pyqtSlot(str) - def install(self, file_path: str) -> None: - self._package_manager.installPackage(file_path) + def install(self, file_path: str) -> Optional[str]: + package_id = self._package_manager.installPackage(file_path) self.installChanged.emit() self._updateInstalledModels() self.metadataChanged.emit() self._restart_required = True self.restartRequiredChanged.emit() + return package_id ## Check package usage and uninstall # If the package is in use, you'll get a confirmation dialog to set everything to default @@ -411,6 +372,17 @@ class Toolbox(QObject, Extension): self._resetUninstallVariables() self.closeConfirmResetDialog() + @pyqtSlot() + def onLicenseAccepted(self): + self.closeLicenseDialog.emit() + package_id = self.install(self.getLicenseDialogPluginFileLocation()) + self.subscribe(package_id) + + + @pyqtSlot() + def onLicenseDeclined(self): + self.closeLicenseDialog.emit() + def _markPackageMaterialsAsToBeUninstalled(self, package_id: str) -> None: container_registry = self._application.getContainerRegistry() @@ -560,15 +532,14 @@ class Toolbox(QObject, Extension): # -------------------------------------------------------------------------- def _makeRequestByType(self, request_type: str) -> None: Logger.log("d", "Requesting [%s] metadata from server.", request_type) - self._updateRequestHeader() url = self._request_urls[request_type] callback = lambda r, rt = request_type: self._onDataRequestFinished(rt, r) error_callback = lambda r, e, rt = request_type: self._onDataRequestError(rt, r, e) self._application.getHttpRequestManager().get(url, - headers_dict = self._request_headers, callback = callback, - error_callback = error_callback) + error_callback = error_callback, + scope=self._scope) @pyqtSlot(str) def startDownload(self, url: str) -> None: @@ -577,10 +548,12 @@ class Toolbox(QObject, Extension): callback = lambda r: self._onDownloadFinished(r) error_callback = lambda r, e: self._onDownloadFailed(r, e) download_progress_callback = self._onDownloadProgress - request_data = self._application.getHttpRequestManager().get(url, headers_dict = self._request_headers, + request_data = self._application.getHttpRequestManager().get(url, callback = callback, error_callback = error_callback, - download_progress_callback = download_progress_callback) + download_progress_callback = download_progress_callback, + scope=self._scope + ) self._download_request_data = request_data self.setDownloadProgress(0) @@ -652,46 +625,12 @@ class Toolbox(QObject, Extension): # Tell the package manager that there's a new set of updates available. packages = set([pkg["package_id"] for pkg in self._server_response_data[request_type]]) self._package_manager.setPackagesWithUpdate(packages) - elif request_type == "subscribed_packages": - self._checkCompatibilities(json_data["data"]) self.metadataChanged.emit() if self.isLoadingComplete(): self.setViewPage("overview") - def _checkCompatibilities(self, json_data) -> None: - user_subscribed_packages = [plugin["package_id"] for plugin in json_data] - user_installed_packages = self._package_manager.getUserInstalledPackages() - - # We check if there are packages installed in Cloud Marketplace but not in Cura marketplace (discrepancy) - package_discrepancy = list(set(user_subscribed_packages).difference(user_installed_packages)) - if package_discrepancy: - self._models["subscribed_packages"].addValue(package_discrepancy) - self._models["subscribed_packages"].update() - Logger.log("d", "Discrepancy found between Cloud subscribed packages and Cura installed packages") - sync_message = Message(i18n_catalog.i18nc( - "@info:generic", - "\nDo you want to sync material and software packages with your account?"), - lifetime=0, - title=i18n_catalog.i18nc("@info:title", "Changes detected from your Ultimaker account", )) - sync_message.addAction("sync", - name=i18n_catalog.i18nc("@action:button", "Sync"), - icon="", - description="Sync your Cloud subscribed packages to your local environment.", - button_align=Message.ActionButtonAlignment.ALIGN_RIGHT) - - sync_message.actionTriggered.connect(self._onSyncButtonClicked) - sync_message.show() - - def _onSyncButtonClicked(self, sync_message: Message, sync_message_action: str) -> None: - sync_message.hide() - compatibility_dialog_path = "resources/qml/dialogs/CompatibilityDialog.qml" - plugin_path_prefix = PluginRegistry.getInstance().getPluginPath(self.getPluginId()) - if plugin_path_prefix: - path = os.path.join(plugin_path_prefix, compatibility_dialog_path) - self.compatibility_dialog_view = self._application.getInstance().createQmlComponent(path, {"toolbox": self}) - # This function goes through all known remote versions of a package and notifies the package manager of this change def _notifyPackageManager(self): for package in self._server_response_data["packages"]: @@ -735,8 +674,10 @@ class Toolbox(QObject, Extension): self.openLicenseDialog(package_info["package_id"], license_content, file_path) return - self.install(file_path) - self.subscribe(package_info["package_id"]) + package_id = self.install(file_path) + if package_id != package_info["package_id"]: + Logger.error("Installed package {} does not match {}".format(package_id, package_info["package_id"])) + self.subscribe(package_id) # Getter & Setters for Properties: # -------------------------------------------------------------------------- @@ -773,6 +714,11 @@ class Toolbox(QObject, Extension): self._view_category = category self.viewChanged.emit() + ## Function explicitly defined so that it can be called through the callExtensionsMethod + # which cannot receive arguments. + def setViewCategoryToMaterials(self) -> None: + self.setViewCategory("material") + @pyqtProperty(str, fset = setViewCategory, notify = viewChanged) def viewCategory(self) -> str: return self._view_category @@ -792,18 +738,6 @@ class Toolbox(QObject, Extension): def authorsModel(self) -> AuthorsModel: return cast(AuthorsModel, self._models["authors"]) - @pyqtProperty(QObject, constant = True) - def subscribedPackagesModel(self) -> SubscribedPackagesModel: - return cast(SubscribedPackagesModel, self._models["subscribed_packages"]) - - @pyqtProperty(bool, constant=True) - def has_compatible_packages(self) -> bool: - return self._models["subscribed_packages"].hasCompatiblePackages() - - @pyqtProperty(bool, constant=True) - def has_incompatible_packages(self) -> bool: - return self._models["subscribed_packages"].hasIncompatiblePackages() - @pyqtProperty(QObject, constant = True) def packagesModel(self) -> PackagesModel: return cast(PackagesModel, self._models["packages"]) diff --git a/plugins/Toolbox/src/UltimakerCloudScope.py b/plugins/Toolbox/src/UltimakerCloudScope.py new file mode 100644 index 0000000000..f7707957e6 --- /dev/null +++ b/plugins/Toolbox/src/UltimakerCloudScope.py @@ -0,0 +1,25 @@ +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 + + +class UltimakerCloudScope(DefaultUserAgentScope): + def __init__(self, application: CuraApplication): + super().__init__(application) + api = application.getCuraAPI() + self._account = api.account # type: Account + + def request_hook(self, request: QNetworkRequest): + super().request_hook(request) + token = self._account.accessToken + if not self._account.isLoggedIn or token is None: + Logger.warning("Cannot add authorization to Cloud Api request") + return + + header_dict = { + "Authorization": "Bearer {}".format(token) + } + self.add_headers(request, header_dict) diff --git a/plugins/UM3NetworkPrinting/src/Models/Http/ClusterPrinterMaterialStationSlot.py b/plugins/UM3NetworkPrinting/src/Models/Http/ClusterPrinterMaterialStationSlot.py index 80deb1c9a8..b9c40592e5 100644 --- a/plugins/UM3NetworkPrinting/src/Models/Http/ClusterPrinterMaterialStationSlot.py +++ b/plugins/UM3NetworkPrinting/src/Models/Http/ClusterPrinterMaterialStationSlot.py @@ -14,7 +14,7 @@ class ClusterPrinterMaterialStationSlot(ClusterPrintCoreConfiguration): # \param material_remaining: How much material is remaining on the spool (between 0 and 1, or -1 for missing data). # \param material_empty: Whether the material spool is too empty to be used. def __init__(self, slot_index: int, compatible: bool, material_remaining: float, - material_empty: Optional[bool] = False, **kwargs): + material_empty: Optional[bool] = False, **kwargs) -> None: self.slot_index = slot_index self.compatible = compatible self.material_remaining = material_remaining diff --git a/resources/definitions/3dtech_semi_professional.def.json b/resources/definitions/3dtech_semi_professional.def.json new file mode 100644 index 0000000000..df4479befb --- /dev/null +++ b/resources/definitions/3dtech_semi_professional.def.json @@ -0,0 +1,41 @@ +{ + "version": 2, + "name": "3DTech Semi-Professional", + "inherits": "fdmprinter", + "metadata": { + "visible": true, + "author": "3DTech", + "manufacturer": "3DTech", + "file_formats": "text/x-gcode", + "platform": "3dtech_semi_professional_platform.stl", + "platform_offset": [0, -2.5, 0 ], + "machine_extruder_trains": + { + "0": "3dtech_semi_professional_extruder_0" + } + }, + "overrides": { + "machine_name": { "default_value": "3DTECH SP Control" }, + "machine_width": { + "default_value": 250 + }, + "machine_depth": { + "default_value": 250 + }, + "machine_height": { + "default_value": 300 + }, + "machine_center_is_zero": { + "default_value": false + }, + "machine_gcode_flavor": { + "default_value": "RepRap (Marlin/Sprinter)" + }, + "machine_start_gcode": { + "default_value": "G28 ; home all axes\nG29 ;\nG1 Z5 F3000 ; lift\nG1 X5 Y25 F5000 ; move to prime\nG1 Z0.2 F3000 ; get ready to prime\nG92 E0 ; reset extrusion distance\nG1 Y100 E20 F600 ; prime nozzle\nG1 Y140 F5000 ; quick wipe" + }, + "machine_end_gcode": { + "default_value": "M104 S0\nM140 S0 ; Retract the filament\nG92 E1\nG1 E-1 F300\nG28 X0 Y0\nM84" + } + } +} diff --git a/resources/extruders/3dtech_semi_professional_extruder_0.def.json b/resources/extruders/3dtech_semi_professional_extruder_0.def.json new file mode 100644 index 0000000000..4952d274d9 --- /dev/null +++ b/resources/extruders/3dtech_semi_professional_extruder_0.def.json @@ -0,0 +1,15 @@ +{ + "version": 2, + "name": "Extruder 1", + "inherits": "fdmextruder", + "metadata": { + "machine": "3dtech_semi_professional", + "position": "0" + }, + + "overrides": { + "extruder_nr": { "default_value": 0 }, + "machine_nozzle_size": { "default_value": 0.4 }, + "material_diameter": { "default_value": 1.75 } + } +} diff --git a/resources/meshes/3dtech_semi_professional_platform.stl b/resources/meshes/3dtech_semi_professional_platform.stl new file mode 100644 index 0000000000..8f83d21563 Binary files /dev/null and b/resources/meshes/3dtech_semi_professional_platform.stl differ diff --git a/resources/qml/Actions.qml b/resources/qml/Actions.qml index e5b39c6ba5..c62b0cb89a 100644 --- a/resources/qml/Actions.qml +++ b/resources/qml/Actions.qml @@ -54,6 +54,7 @@ Item property alias manageProfiles: manageProfilesAction; property alias manageMaterials: manageMaterialsAction; + property alias marketplaceMaterials: marketplaceMaterialsAction; property alias preferences: preferencesAction; @@ -188,6 +189,12 @@ Item shortcut: "Ctrl+K" } + Action + { + id: marketplaceMaterialsAction + text: catalog.i18nc("@action:inmenu", "Add more materials from Marketplace") + } + Action { id: updateProfileAction; diff --git a/resources/qml/Cura.qml b/resources/qml/Cura.qml index 8dcf60018f..d6f50f939b 100644 --- a/resources/qml/Cura.qml +++ b/resources/qml/Cura.qml @@ -1,4 +1,4 @@ -// Copyright (c) 2019 Ultimaker B.V. +// Copyright (c) 2020 Ultimaker B.V. // Cura is released under the terms of the LGPLv3 or higher. import QtQuick 2.7 @@ -21,7 +21,16 @@ UM.MainWindow id: base // Cura application window title - title: PrintInformation.jobName + " - " + catalog.i18nc("@title:window", CuraApplication.applicationDisplayName) + title: + { + let result = ""; + if(PrintInformation.jobName != "") + { + result += PrintInformation.jobName + " - "; + } + result += CuraApplication.applicationDisplayName; + return result; + } backgroundColor: UM.Theme.getColor("viewport_background") @@ -244,23 +253,6 @@ UM.MainWindow } } - Toolbar - { - // The toolbar is the left bar that is populated by all the tools (which are dynamicly populated by - // plugins) - id: toolbar - - property int mouseX: base.mouseX - property int mouseY: base.mouseY - - anchors - { - verticalCenter: parent.verticalCenter - left: parent.left - } - visible: CuraApplication.platformActivity && !PrintInformation.preSliced - } - ObjectSelector { id: objectSelector @@ -302,6 +294,23 @@ UM.MainWindow } } + Toolbar + { + // The toolbar is the left bar that is populated by all the tools (which are dynamicly populated by + // plugins) + id: toolbar + + property int mouseX: base.mouseX + property int mouseY: base.mouseY + + anchors + { + verticalCenter: parent.verticalCenter + left: parent.left + } + visible: CuraApplication.platformActivity && !PrintInformation.preSliced + } + // A hint for the loaded content view. Overlay items / controls can safely be placed in this area Item { id: mainSafeArea diff --git a/resources/qml/MainWindow/ApplicationMenu.qml b/resources/qml/MainWindow/ApplicationMenu.qml index 30e44d7d3b..1ddb0410b2 100644 --- a/resources/qml/MainWindow/ApplicationMenu.qml +++ b/resources/qml/MainWindow/ApplicationMenu.qml @@ -154,7 +154,7 @@ Item } } - // show the plugin browser dialog + // show the Toolbox Connections { target: Cura.Actions.browsePackages @@ -163,4 +163,15 @@ Item curaExtensions.callExtensionMethod("Toolbox", "launch") } } + + // Show the Marketplace dialog at the materials tab + Connections + { + target: Cura.Actions.marketplaceMaterials + onTriggered: + { + curaExtensions.callExtensionMethod("Toolbox", "launch") + curaExtensions.callExtensionMethod("Toolbox", "setViewCategoryToMaterials") + } + } } \ No newline at end of file diff --git a/resources/qml/Menus/MaterialMenu.qml b/resources/qml/Menus/MaterialMenu.qml index c101f56da5..b733ead40b 100644 --- a/resources/qml/Menus/MaterialMenu.qml +++ b/resources/qml/Menus/MaterialMenu.qml @@ -157,4 +157,11 @@ Menu { action: Cura.Actions.manageMaterials } + + MenuSeparator {} + + MenuItem + { + action: Cura.Actions.marketplaceMaterials + } } diff --git a/test-in-docker.sh b/test-in-docker.sh new file mode 100755 index 0000000000..e5a1116646 --- /dev/null +++ b/test-in-docker.sh @@ -0,0 +1,5 @@ +sudo rm -rf ./build ./Uranium +sudo docker run -it --rm \ + -v "$(pwd):/srv/cura" ultimaker/cura-build-environment \ + /srv/cura/docker/build.sh +sudo rm -rf ./build ./Uranium