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/cmake/CuraTests.cmake b/cmake/CuraTests.cmake index b1d3e0ddc4..251bec5781 100644 --- a/cmake/CuraTests.cmake +++ b/cmake/CuraTests.cmake @@ -56,6 +56,13 @@ function(cura_add_test) endif() endfunction() +#Add test for import statements which are not compatible with all builds +add_test( + NAME "invalid-imports" + COMMAND ${Python3_EXECUTABLE} scripts/check_invalid_imports.py + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} +) + cura_add_test(NAME pytest-main DIRECTORY ${CMAKE_SOURCE_DIR}/tests PYTHONPATH "${CMAKE_SOURCE_DIR}|${URANIUM_DIR}") file(GLOB_RECURSE _plugins plugins/*/__init__.py) 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/CuraApplication.py b/cura/CuraApplication.py index f778cb0fab..221ccf9fb0 100755 --- a/cura/CuraApplication.py +++ b/cura/CuraApplication.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 os @@ -1827,15 +1827,21 @@ class CuraApplication(QtApplication): def _onContextMenuRequested(self, x: float, y: float) -> None: # Ensure we select the object if we request a context menu over an object without having a selection. - if not Selection.hasSelection(): - node = self.getController().getScene().findObject(cast(SelectionPass, self.getRenderer().getRenderPass("selection")).getIdAtPosition(x, y)) - if node: - parent = node.getParent() - while(parent and parent.callDecoration("isGroup")): - node = parent - parent = node.getParent() + if Selection.hasSelection(): + return + selection_pass = cast(SelectionPass, self.getRenderer().getRenderPass("selection")) + if not selection_pass: # If you right-click before the rendering has been initialised there might not be a selection pass yet. + print("--------------ding! Got the crash.") + return + node = self.getController().getScene().findObject(selection_pass.getIdAtPosition(x, y)) + if not node: + return + parent = node.getParent() + while parent and parent.callDecoration("isGroup"): + node = parent + parent = node.getParent() - Selection.add(node) + Selection.add(node) @pyqtSlot() def showMoreInformationDialogForAnonymousDataCollection(self): 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..a500663c64 100755 --- a/docker/build.sh +++ b/docker/build.sh @@ -13,28 +13,46 @@ export PKG_CONFIG_PATH="${CURA_BUILD_ENV_PATH}/lib/pkgconfig:${PKG_CONFIG_PATH}" 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..32b4da4823 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) + } + } + } } } } @@ -132,13 +152,16 @@ UM.Dialog{ } // End of ScrollView - Cura.ActionButton + Cura.PrimaryButton { id: nextButton anchors.bottom: parent.bottom anchors.right: parent.right anchors.margins: UM.Theme.getSize("default_margin").height text: catalog.i18nc("@button", "Next") + onClicked: accept() + leftPadding: UM.Theme.getSize("dialog_primary_button_padding").width + rightPadding: UM.Theme.getSize("dialog_primary_button_padding").width } } } diff --git a/plugins/Toolbox/resources/qml/dialogs/ToolboxLicenseDialog.qml b/plugins/Toolbox/resources/qml/dialogs/ToolboxLicenseDialog.qml index 3e8d686741..3e7cdc9df8 100644 --- a/plugins/Toolbox/resources/qml/dialogs/ToolboxLicenseDialog.qml +++ b/plugins/Toolbox/resources/qml/dialogs/ToolboxLicenseDialog.qml @@ -10,65 +10,65 @@ import QtQuick.Controls.Styles 1.4 // TODO: Switch to QtQuick.Controls 2.x and remove QtQuick.Controls.Styles import UM 1.1 as UM +import Cura 1.6 as Cura 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: [ - Button + Cura.PrimaryButton { - 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); - } - }, - Button + leftPadding: UM.Theme.getSize("dialog_primary_button_padding").width + rightPadding: UM.Theme.getSize("dialog_primary_button_padding").width + + text: catalog.i18nc("@button", "Agree") + onClicked: { handler.onLicenseAccepted() } + } + ] + + leftButtons: + [ + Cura.SecondaryButton { id: declineButton - anchors.margins: UM.Theme.getSize("default_margin").width - text: catalog.i18nc("@action:button", "Decline") - onClicked: - { - licenseDialog.close(); - } + text: catalog.i18nc("@button", "Decline and remove from account") + 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..556d54cf88 --- /dev/null +++ b/plugins/Toolbox/src/CloudApiModel.py @@ -0,0 +1,28 @@ +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, + ) + + ## https://api.ultimaker.com/cura-packages/v1/user/packages/{package_id} + @classmethod + def userPackageUrl(cls, package_id: str) -> str: + + return (CloudApiModel.api_url_user_packages + "/{package_id}").format( + package_id=package_id + ) 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..0cbc9eaa7a --- /dev/null +++ b/plugins/Toolbox/src/CloudSync/CloudPackageManager.py @@ -0,0 +1,23 @@ +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 + ) + + def unsubscribe(self, package_id: str) -> None: + url = CloudApiModel.userPackageUrl(package_id) + self._request_manager.delete(url=url, 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..e97bdbcbc4 --- /dev/null +++ b/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py @@ -0,0 +1,102 @@ +import os +from typing import List, Dict, Any, cast + +from UM import i18n_catalog +from UM.Extension import Extension +from UM.Logger import Logger +from UM.Message import Message +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: + if error_items: + message = i18n_catalog.i18nc("@info:generic", "{} plugins failed to download".format(len(error_items))) + self._showErrorMessage(message) + + 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"]): + message = "Could not install {}".format(item["package_id"]) + self._showErrorMessage(message) + continue + self._cloud_package_manager.subscribe(item["package_id"]) + has_changes = True + else: + self._cloud_package_manager.unsubscribe(item["package_id"]) + # delete temp file + os.remove(item["package_path"]) + + if has_changes: + self._restart_presenter.present() + + ## Logs an error and shows it to the user + def _showErrorMessage(self, text: str): + Logger.error(text) + Message(text, lifetime=0).show() 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/definitions/fdmprinter.def.json b/resources/definitions/fdmprinter.def.json index ca70f0d7de..b5d423c28b 100644 --- a/resources/definitions/fdmprinter.def.json +++ b/resources/definitions/fdmprinter.def.json @@ -5688,7 +5688,7 @@ "unit": "mm", "enabled": "resolveOrValue('prime_tower_enable')", "default_value": 200, - "value": "machine_width - max(extruderValue(adhesion_extruder_nr, 'brim_width') * extruderValue(adhesion_extruder_nr, 'initial_layer_line_width_factor') / 100 if adhesion_type == 'brim' or (prime_tower_brim_enable and adhesion_type != 'raft') else (extruderValue(adhesion_extruder_nr, 'raft_margin') if adhesion_type == 'raft' else (extruderValue(adhesion_extruder_nr, 'skirt_gap') if adhesion_type == 'skirt' else 0)), max(extruderValues('travel_avoid_distance'))) - max(extruderValues('support_offset')) - sum(extruderValues('skirt_brim_line_width')) * extruderValue(adhesion_extruder_nr, 'initial_layer_line_width_factor') / 100 - (resolveOrValue('draft_shield_dist') if resolveOrValue('draft_shield_enabled') else 0) - 1", + "value": "machine_width - max(extruderValue(adhesion_extruder_nr, 'brim_width') * extruderValue(adhesion_extruder_nr, 'initial_layer_line_width_factor') / 100 if adhesion_type == 'brim' or (prime_tower_brim_enable and adhesion_type != 'raft') else (extruderValue(adhesion_extruder_nr, 'raft_margin') if adhesion_type == 'raft' else (extruderValue(adhesion_extruder_nr, 'skirt_gap') if adhesion_type == 'skirt' else 0)), max(extruderValues('travel_avoid_distance'))) - max(extruderValues('support_offset')) - sum(extruderValues('skirt_brim_line_width')) * extruderValue(adhesion_extruder_nr, 'initial_layer_line_width_factor') / 100 - (resolveOrValue('draft_shield_dist') if resolveOrValue('draft_shield_enabled') else 0) - max(map(abs, extruderValues('machine_nozzle_offset_x'))) - 1", "maximum_value": "machine_width / 2 if machine_center_is_zero else machine_width", "minimum_value": "resolveOrValue('prime_tower_size') - machine_width / 2 if machine_center_is_zero else resolveOrValue('prime_tower_size')", "settable_per_mesh": false, @@ -5702,7 +5702,7 @@ "unit": "mm", "enabled": "resolveOrValue('prime_tower_enable')", "default_value": 200, - "value": "machine_depth - prime_tower_size - max(extruderValue(adhesion_extruder_nr, 'brim_width') * extruderValue(adhesion_extruder_nr, 'initial_layer_line_width_factor') / 100 if adhesion_type == 'brim' or (prime_tower_brim_enable and adhesion_type != 'raft') else (extruderValue(adhesion_extruder_nr, 'raft_margin') if adhesion_type == 'raft' else (extruderValue(adhesion_extruder_nr, 'skirt_gap') if adhesion_type == 'skirt' else 0)), max(extruderValues('travel_avoid_distance'))) - max(extruderValues('support_offset')) - sum(extruderValues('skirt_brim_line_width')) * extruderValue(adhesion_extruder_nr, 'initial_layer_line_width_factor') / 100 - (resolveOrValue('draft_shield_dist') if resolveOrValue('draft_shield_enabled') else 0) - 1", + "value": "machine_depth - prime_tower_size - max(extruderValue(adhesion_extruder_nr, 'brim_width') * extruderValue(adhesion_extruder_nr, 'initial_layer_line_width_factor') / 100 if adhesion_type == 'brim' or (prime_tower_brim_enable and adhesion_type != 'raft') else (extruderValue(adhesion_extruder_nr, 'raft_margin') if adhesion_type == 'raft' else (extruderValue(adhesion_extruder_nr, 'skirt_gap') if adhesion_type == 'skirt' else 0)), max(extruderValues('travel_avoid_distance'))) - max(extruderValues('support_offset')) - sum(extruderValues('skirt_brim_line_width')) * extruderValue(adhesion_extruder_nr, 'initial_layer_line_width_factor') / 100 - (resolveOrValue('draft_shield_dist') if resolveOrValue('draft_shield_enabled') else 0) - max(map(abs, extruderValues('machine_nozzle_offset_y'))) - 1", "maximum_value": "machine_depth / 2 - resolveOrValue('prime_tower_size') if machine_center_is_zero else machine_depth - resolveOrValue('prime_tower_size')", "minimum_value": "machine_depth / -2 if machine_center_is_zero else 0", "settable_per_mesh": false, diff --git a/resources/definitions/hms434.def.json b/resources/definitions/hms434.def.json index be78df9daa..e5f32283d8 100644 --- a/resources/definitions/hms434.def.json +++ b/resources/definitions/hms434.def.json @@ -12,7 +12,7 @@ "exclude_materials": [ "chromatik_pla", "dsm_arnitel2045_175", "dsm_novamid1070_175", - "emotiontech_abs", "emotiontech_petg", "emotiontech_pla", "emotiontech_pva-m", "emotiontech_pva-oks", "emotiontech_pva-s", "emotiontech_tpu98a", + "emotiontech_abs", "emotiontech_asax", "emotiontech_hips", "emotiontech_petg", "emotiontech_pla", "emotiontech_pva-m", "emotiontech_pva-oks", "emotiontech_pva-s", "emotiontech_tpu98a", "fabtotum_abs", "fabtotum_nylon", "fabtotum_pla", "fabtotum_tpu", "fiberlogy_hd_pla", "filo3d_pla", "filo3d_pla_green", "filo3d_pla_red", @@ -67,7 +67,7 @@ "material_print_temp_wait": {"default_value": false }, "material_bed_temp_wait": {"default_value": false }, "machine_max_feedrate_z": {"default_value": 10 }, - "machine_acceleration": {"default_value": 500 }, + "machine_acceleration": {"default_value": 180 }, "machine_start_gcode": {"default_value": "\n;Neither Hybrid AM Systems nor any of Hybrid AM Systems representatives has any liabilities or gives any warranties on this .gcode file, or on any or all objects made with this .gcode file.\n\nM140 S{material_bed_temperature_layer_0}\n\nM117 Homing Y ......\nG28 Y\nM117 Homing X ......\nG28 X\nM117 Homing Z ......\nG28 Z F100\n\nG1 Z10 F900\nG1 X-30 Y100 F12000\n\nM190 S{material_bed_temperature_layer_0}\nM117 HMS434 Printing ...\n\n" }, "machine_end_gcode": {"default_value": "" }, diff --git a/resources/definitions/makeit_pro_mx.def.json b/resources/definitions/makeit_pro_mx.def.json new file mode 100644 index 0000000000..13770e8571 --- /dev/null +++ b/resources/definitions/makeit_pro_mx.def.json @@ -0,0 +1,96 @@ +{ + "version": 2, + "name": "MAKEiT Pro-MX", + "inherits": "fdmprinter", + "metadata": { + "visible": true, + "author": "unknown", + "manufacturer": "MAKEiT 3D", + "file_formats": "text/x-gcode", + "has_materials": false, + "machine_extruder_trains": + { + "0": "makeit_mx_dual_1st", + "1": "makeit_mx_dual_2nd" + } + }, + + "overrides": { + "machine_name": { "default_value": "MAKEiT Pro-MX" }, + "machine_width": { + "default_value": 200 + }, + "machine_height": { + "default_value": 330 + }, + "machine_depth": { + "default_value": 240 + }, + "machine_center_is_zero": { + "default_value": false + }, + "machine_head_with_fans_polygon": + { + "default_value": [ + [ -200, 240 ], + [ -200, -32 ], + [ 200, 240 ], + [ 200, -32 ] + ] + }, + "gantry_height": { + "value": "200" + }, + "machine_use_extruder_offset_to_offset_coords": { + "default_value": true + }, + "machine_gcode_flavor": { + "default_value": "RepRap (Marlin/Sprinter)" + }, + "machine_start_gcode": { + "default_value": "G21 ;metric values\nG90 ;absolute positioning\nM82 ;set extruder to absolute mode\nG92 E0 ;zero the extruded length\nG28 ;home\nG1 F200 E30 ;extrude 30 mm of feed stock\nG92 E0 ;zero the extruded length\nG1 E-5 ;retract 5 mm\nG28 SC ;Do homeing, clean nozzles and let printer to know that printing started\nG92 X-6 ;Sets Curas checker board to match printers heated bed coordinates\nG1 F{speed_travel}\nM117 Printing..." + }, + "machine_end_gcode": { + "default_value": "M104 T0 S0 ;1st extruder heater off\nM104 T1 S0 ;2nd extruder heater off\nM140 S0 ;heated bed heater off (if you have it)\nG91 ;relative positioning\nG1 E-5 F9000 ;retract the filament a bit before lifting the nozzle, to release some of the pressure\nG1 Z+5 X+20 Y+20 F9000 ;move Z up a bit\nM117 MAKEiT Pro@Done\nG28 X0 Y0 ;move X/Y to min endstops, so the head is out of the way\nM84 ;steppers off\nG90 ;absolute positioning\nM81" + }, + "machine_extruder_count": { + "default_value": 2 + }, + "print_sequence": { + "enabled": false + }, + "prime_tower_position_x": { + "value": "185" + }, + "prime_tower_position_y": { + "value": "160" + }, + "layer_height": { + "default_value": 0.2 + }, + "retraction_speed": { + "default_value": 180 + }, + "infill_sparse_density": { + "default_value": 20 + }, + "retraction_amount": { + "default_value": 6 + }, + "speed_print": { + "default_value": 60 + }, + "wall_thickness": { + "default_value": 1.2 + }, + "cool_min_layer_time_fan_speed_max": { + "default_value": 5 + }, + "adhesion_type": { + "default_value": "skirt" + }, + "machine_heated_bed": { + "default_value": true + } + } +} \ No newline at end of file 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/extruders/makeit_mx_dual_1st.def.json b/resources/extruders/makeit_mx_dual_1st.def.json new file mode 100644 index 0000000000..48a15bb4e7 --- /dev/null +++ b/resources/extruders/makeit_mx_dual_1st.def.json @@ -0,0 +1,27 @@ +{ + "version": 2, + "name": "1st Extruder", + "inherits": "fdmextruder", + "metadata": { + "machine": "makeit_pro_mx", + "position": "0" + }, + + "overrides": { + "extruder_nr": { + "default_value": 0, + "maximum_value": "1" + }, + "machine_nozzle_offset_x": { "default_value": 0.0 }, + "machine_nozzle_offset_y": { "default_value": 0.0 }, + "machine_nozzle_size": { "default_value": 0.4 }, + "material_diameter": { "default_value": 1.75 }, + + "machine_extruder_start_pos_abs": { "default_value": true }, + "machine_extruder_start_pos_x": { "value": "prime_tower_position_x" }, + "machine_extruder_start_pos_y": { "value": "prime_tower_position_y" }, + "machine_extruder_end_pos_abs": { "default_value": true }, + "machine_extruder_end_pos_x": { "value": "prime_tower_position_x" }, + "machine_extruder_end_pos_y": { "value": "prime_tower_position_y" } + } +} \ No newline at end of file diff --git a/resources/extruders/makeit_mx_dual_2nd.def.json b/resources/extruders/makeit_mx_dual_2nd.def.json new file mode 100644 index 0000000000..b17b1b9051 --- /dev/null +++ b/resources/extruders/makeit_mx_dual_2nd.def.json @@ -0,0 +1,27 @@ +{ + "version": 2, + "name": "2nd Extruder", + "inherits": "fdmextruder", + "metadata": { + "machine": "makeit_pro_mx", + "position": "1" + }, + + "overrides": { + "extruder_nr": { + "default_value": 1, + "maximum_value": "1" + }, + "machine_nozzle_offset_x": { "default_value": 0.0 }, + "machine_nozzle_offset_y": { "default_value": 0.0 }, + "machine_nozzle_size": { "default_value": 0.4 }, + "material_diameter": { "default_value": 1.75 }, + + "machine_extruder_start_pos_abs": { "default_value": true }, + "machine_extruder_start_pos_x": { "value": "prime_tower_position_x" }, + "machine_extruder_start_pos_y": { "value": "prime_tower_position_y" }, + "machine_extruder_end_pos_abs": { "default_value": true }, + "machine_extruder_end_pos_x": { "value": "prime_tower_position_x" }, + "machine_extruder_end_pos_y": { "value": "prime_tower_position_y" } + } +} \ No newline at end of file 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 72a371e2f5..05e349841b 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/resources/themes/cura-light/theme.json b/resources/themes/cura-light/theme.json index e5009d8633..de4c9ccb42 100644 --- a/resources/themes/cura-light/theme.json +++ b/resources/themes/cura-light/theme.json @@ -520,6 +520,7 @@ "action_button": [15.0, 2.5], "action_button_icon": [1.0, 1.0], "action_button_radius": [0.15, 0.15], + "dialog_primary_button_padding": [3.0, 0], "radio_button": [1.3, 1.3], diff --git a/scripts/check_invalid_imports.py b/scripts/check_invalid_imports.py new file mode 100644 index 0000000000..ba21b9f822 --- /dev/null +++ b/scripts/check_invalid_imports.py @@ -0,0 +1,65 @@ +import os +import re +import sys +from pathlib import Path + +""" +Run this file with the Cura project root as the working directory +Checks for invalid imports. When importing from plugins, there will be no problems when running from source, +but for some build types the plugins dir is not on the path, so relative imports should be used instead. eg: +from ..UltimakerCloudScope import UltimakerCloudScope <-- OK +import plugins.Toolbox.src ... <-- NOT OK +""" + + +class InvalidImportsChecker: + # compile regex + REGEX = re.compile(r"^\s*(from plugins|import plugins)") + + def check(self): + """ Checks for invalid imports + + :return: True if checks passed, False when the test fails + """ + cwd = os.getcwd() + cura_result = checker.check_dir(os.path.join(cwd, "cura")) + plugins_result = checker.check_dir(os.path.join(cwd, "plugins")) + result = cura_result and plugins_result + if not result: + print("error: sources contain invalid imports. Use relative imports when referencing plugin source files") + + return result + + def check_dir(self, root_dir: str) -> bool: + """ Checks a directory for invalid imports + + :return: True if checks passed, False when the test fails + """ + passed = True + for path_like in Path(root_dir).rglob('*.py'): + if not self.check_file(str(path_like)): + passed = False + + return passed + + def check_file(self, file_path): + """ Checks a file for invalid imports + + :return: True if checks passed, False when the test fails + """ + passed = True + with open(file_path, 'r', encoding = "utf-8") as inputFile: + # loop through each line in file + for line_i, line in enumerate(inputFile, 1): + # check if we have a regex match + match = self.REGEX.search(line) + if match: + path = os.path.relpath(file_path) + print("{path}:{line_i}:{match}".format(path=path, line_i=line_i, match=match.group(1))) + passed = False + return passed + + +if __name__ == "__main__": + checker = InvalidImportsChecker() + sys.exit(0 if checker.check() else 1) 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