diff --git a/cura/Machines/MachineNode.py b/cura/Machines/MachineNode.py index 72652f2987..8d69ffdc8d 100644 --- a/cura/Machines/MachineNode.py +++ b/cura/Machines/MachineNode.py @@ -140,7 +140,7 @@ class MachineNode(ContainerNode): elif groups_by_name[name].intent_category == "default": # Intent category should be stored as "default" if everything is default or as the intent if any of the extruder have an actual intent. groups_by_name[name].intent_category = quality_changes.get("intent_category", "default") - if "position" in quality_changes: # An extruder profile. + if quality_changes.get("position") is not None: # An extruder profile. groups_by_name[name].metadata_per_extruder[int(quality_changes["position"])] = quality_changes else: # Global profile. groups_by_name[name].metadata_for_global = quality_changes diff --git a/cura/Machines/Models/BaseMaterialsModel.py b/cura/Machines/Models/BaseMaterialsModel.py index 73d9d48b22..db660704b5 100644 --- a/cura/Machines/Models/BaseMaterialsModel.py +++ b/cura/Machines/Models/BaseMaterialsModel.py @@ -6,6 +6,7 @@ from typing import Dict, Set from PyQt5.QtCore import Qt, QTimer, pyqtSignal, pyqtProperty from UM.Qt.ListModel import ListModel +from UM.Logger import Logger import cura.CuraApplication # Imported like this to prevent a circular reference. from cura.Machines.ContainerTree import ContainerTree @@ -153,7 +154,12 @@ class BaseMaterialsModel(ListModel): if not extruder_stack: return nozzle_name = extruder_stack.variant.getName() - materials = ContainerTree.getInstance().machines[global_stack.definition.getId()].variants[nozzle_name].materials + machine_node = ContainerTree.getInstance().machines[global_stack.definition.getId()] + if nozzle_name not in machine_node.variants: + Logger.log("w", "Unable to find variant %s in container tree", nozzle_name) + self._available_materials = {} + return + materials = machine_node.variants[nozzle_name].materials approximate_material_diameter = extruder_stack.getApproximateMaterialDiameter() self._available_materials = {key: material for key, material in materials.items() if float(material.getMetaDataEntry("approximate_diameter", -1)) == approximate_material_diameter} diff --git a/cura/Machines/Models/IntentCategoryModel.py b/cura/Machines/Models/IntentCategoryModel.py index 48889b1144..202d79bb15 100644 --- a/cura/Machines/Models/IntentCategoryModel.py +++ b/cura/Machines/Models/IntentCategoryModel.py @@ -1,9 +1,10 @@ #Copyright (c) 2019 Ultimaker B.V. #Cura is released under the terms of the LGPLv3 or higher. -from PyQt5.QtCore import Qt, QTimer import collections +from PyQt5.QtCore import Qt, QTimer from typing import TYPE_CHECKING, Optional, Dict +from cura.Machines.Models.IntentTranslations import intent_translations from cura.Machines.Models.IntentModel import IntentModel from cura.Settings.IntentManager import IntentManager @@ -53,7 +54,6 @@ class IntentCategoryModel(ListModel): } return cls._translations - ## Creates a new model for a certain intent category. # \param The category to list the intent profiles for. def __init__(self, intent_category: str) -> None: @@ -110,7 +110,7 @@ class IntentCategoryModel(ListModel): result.sort(key = lambda k: k["weight"]) self.setItems(result) - ## Get a display value for a category. See IntenCategoryModel._translations + ## Get a display value for a category. ## for categories and keys @staticmethod def translation(category: str, key: str, default: Optional[str] = None): diff --git a/cura/Machines/Models/IntentTranslations.py b/cura/Machines/Models/IntentTranslations.py new file mode 100644 index 0000000000..f78e8df128 --- /dev/null +++ b/cura/Machines/Models/IntentTranslations.py @@ -0,0 +1,21 @@ +import collections +from UM.i18n import i18nCatalog +from typing import Dict, Optional +catalog = i18nCatalog("cura") + +intent_translations = collections.OrderedDict() # type: "collections.OrderedDict[str, Dict[str, Optional[str]]]" +intent_translations["default"] = { + "name": catalog.i18nc("@label", "Default") +} +intent_translations["visual"] = { + "name": catalog.i18nc("@label", "Visual"), + "description": catalog.i18nc("@text", "The visual profile is designed to print visual prototypes and models with the intent of high visual and surface quality.") +} +intent_translations["engineering"] = { + "name": catalog.i18nc("@label", "Engineering"), + "description": catalog.i18nc("@text", "The engineering profile is designed to print functional prototypes and end-use parts with the intent of better accuracy and for closer tolerances.") +} +intent_translations["quick"] = { + "name": catalog.i18nc("@label", "Draft"), + "description": catalog.i18nc("@text", "The draft profile is designed to print initial prototypes and concept validation with the intent of significant print time reduction.") +} \ No newline at end of file diff --git a/cura/Machines/Models/QualityManagementModel.py b/cura/Machines/Models/QualityManagementModel.py index d8b0785778..74dc8649d0 100644 --- a/cura/Machines/Models/QualityManagementModel.py +++ b/cura/Machines/Models/QualityManagementModel.py @@ -14,6 +14,7 @@ from cura.Machines.ContainerTree import ContainerTree from cura.Settings.cura_empty_instance_containers import empty_quality_changes_container from cura.Settings.IntentManager import IntentManager from cura.Machines.Models.MachineModelUtils import fetchLayerHeight +from cura.Machines.Models.IntentTranslations import intent_translations from UM.i18n import i18nCatalog catalog = i18nCatalog("cura") @@ -336,10 +337,11 @@ class QualityManagementModel(ListModel): "quality_type": quality_type, "quality_changes_group": None, "intent_category": intent_category, - "section_name": catalog.i18nc("@label", intent_category.capitalize()), + "section_name": catalog.i18nc("@label", intent_translations.get(intent_category, {}).get("name", catalog.i18nc("@label", "Unknown"))), }) # Sort by quality_type for each intent category - result = sorted(result, key = lambda x: (x["intent_category"], x["quality_type"])) + + result = sorted(result, key = lambda x: (list(intent_translations).index(x["intent_category"]), x["quality_type"])) item_list += result # Create quality_changes group items diff --git a/cura/Machines/VariantNode.py b/cura/Machines/VariantNode.py index 8275c70e62..b2115ca099 100644 --- a/cura/Machines/VariantNode.py +++ b/cura/Machines/VariantNode.py @@ -85,11 +85,20 @@ class VariantNode(ContainerNode): for base_material, material_node in self.materials.items(): if self.machine.preferred_material == base_material and approximate_diameter == int(material_node.getMetaDataEntry("approximate_diameter")): return material_node - # First fallback: Choose any material with matching diameter. + + # First fallback: Check if we should be checking for the 175 variant. + if approximate_diameter == 2: + preferred_material = self.machine.preferred_material + "_175" + for base_material, material_node in self.materials.items(): + if preferred_material == base_material and approximate_diameter == int(material_node.getMetaDataEntry("approximate_diameter")): + return material_node + + # Second fallback: Choose any material with matching diameter. for material_node in self.materials.values(): if material_node.getMetaDataEntry("approximate_diameter") and approximate_diameter == int(material_node.getMetaDataEntry("approximate_diameter")): Logger.log("w", "Could not find preferred material %s, falling back to whatever works", self.machine.preferred_material) return material_node + fallback = next(iter(self.materials.values())) # Should only happen with empty material node. Logger.log("w", "Could not find preferred material {preferred_material} with diameter {diameter} for variant {variant_id}, falling back to {fallback}.".format( preferred_material = self.machine.preferred_material, diff --git a/cura/OAuth2/AuthorizationHelpers.py b/cura/OAuth2/AuthorizationHelpers.py index 08309fa30e..9fc01ba50b 100644 --- a/cura/OAuth2/AuthorizationHelpers.py +++ b/cura/OAuth2/AuthorizationHelpers.py @@ -99,7 +99,7 @@ class AuthorizationHelpers: }) except requests.exceptions.ConnectionError: # Connection was suddenly dropped. Nothing we can do about that. - Logger.log("w", "Something failed while attempting to parse the JWT token") + Logger.logException("w", "Something failed while attempting to parse the JWT token") return None if token_request.status_code not in (200, 201): Logger.log("w", "Could not retrieve token data from auth server: %s", token_request.text) diff --git a/cura/Settings/ContainerManager.py b/cura/Settings/ContainerManager.py index 4a4a7b64dd..ff129d35e2 100644 --- a/cura/Settings/ContainerManager.py +++ b/cura/Settings/ContainerManager.py @@ -339,11 +339,11 @@ class ContainerManager(QObject): # \return A list of names of materials with the same GUID. @pyqtSlot("QVariant", bool, result = "QStringList") def getLinkedMaterials(self, material_node: "MaterialNode", exclude_self: bool = False) -> List[str]: - same_guid = ContainerRegistry.getInstance().findInstanceContainersMetadata(guid = material_node.guid) + same_guid = ContainerRegistry.getInstance().findInstanceContainersMetadata(GUID = material_node.guid) if exclude_self: - return [metadata["name"] for metadata in same_guid if metadata["base_file"] != material_node.base_file] + return list({meta["name"] for meta in same_guid if meta["base_file"] != material_node.base_file}) else: - return [metadata["name"] for metadata in same_guid] + return list({meta["name"] for meta in same_guid}) ## Unlink a material from all other materials by creating a new GUID # \param material_id \type{str} the id of the material to create a new GUID for. diff --git a/cura/Settings/IntentManager.py b/cura/Settings/IntentManager.py index c1d59fb84a..c53022fb6c 100644 --- a/cura/Settings/IntentManager.py +++ b/cura/Settings/IntentManager.py @@ -3,11 +3,13 @@ from PyQt5.QtCore import QObject, pyqtProperty, pyqtSignal, pyqtSlot from typing import Any, Dict, List, Optional, Set, Tuple, TYPE_CHECKING -import cura.CuraApplication + from UM.Logger import Logger +from UM.Settings.InstanceContainer import InstanceContainer + +import cura.CuraApplication from cura.Machines.ContainerTree import ContainerTree from cura.Settings.cura_empty_instance_containers import empty_intent_container -from UM.Settings.InstanceContainer import InstanceContainer if TYPE_CHECKING: from UM.Settings.InstanceContainer import InstanceContainer @@ -36,8 +38,12 @@ class IntentManager(QObject): # \return A list of metadata dictionaries matching the search criteria, or # an empty list if nothing was found. def intentMetadatas(self, definition_id: str, nozzle_name: str, material_base_file: str) -> List[Dict[str, Any]]: - material_node = ContainerTree.getInstance().machines[definition_id].variants[nozzle_name].materials[material_base_file] - intent_metadatas = [] + intent_metadatas = [] # type: List[Dict[str, Any]] + materials = ContainerTree.getInstance().machines[definition_id].variants[nozzle_name].materials + if material_base_file not in materials: + return intent_metadatas + + material_node = materials[material_base_file] for quality_node in material_node.qualities.values(): for intent_node in quality_node.intents.values(): intent_metadatas.append(intent_node.getMetadata()) diff --git a/plugins/Toolbox/src/Toolbox.py b/plugins/Toolbox/src/Toolbox.py index 08b6c80b1d..be62873a2f 100644 --- a/plugins/Toolbox/src/Toolbox.py +++ b/plugins/Toolbox/src/Toolbox.py @@ -48,7 +48,7 @@ class Toolbox(QObject, Extension): self._download_progress = 0 # type: float self._is_downloading = False # type: bool self._network_manager = None # type: Optional[QNetworkAccessManager] - self._request_headers = [] # type: List[Tuple[bytes, bytes]] + self._request_headers = [] # type: List[Tuple[bytes, bytes]] self._updateRequestHeader() self._request_urls = {} # type: Dict[str, QUrl] @@ -59,13 +59,15 @@ class Toolbox(QObject, Extension): # The responses as given by the server parsed to a list. self._server_response_data = { "authors": [], - "packages": [] + "packages": [], + "updates": [], } # type: Dict[str, List[Any]] # Models: self._models = { "authors": AuthorsModel(self), "packages": PackagesModel(self), + "updates": PackagesModel(self), } # type: Dict[str, Union[AuthorsModel, PackagesModel]] self._plugins_showcase_model = PackagesModel(self) @@ -186,18 +188,24 @@ class Toolbox(QObject, Extension): cloud_api_version = self._cloud_api_version, sdk_version = self._sdk_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 + self._package_manager.getAllInstalledPackageIdsAndVersions()] + installed_packages_query = "&installed_packages=".join(installed_package_ids_with_versions) + self._request_urls = { "authors": QUrl("{base_url}/authors".format(base_url = self._api_url)), - "packages": QUrl("{base_url}/packages".format(base_url = self._api_url)) + "packages": QUrl("{base_url}/packages".format(base_url = self._api_url)), + "updates": QUrl("{base_url}/packages/package-updates?installed_packages={query}".format( + base_url = self._api_url, query = installed_packages_query)) } - # Request the latest and greatest! - self._fetchPackageData() + # On boot we check which packages have updates. + if len(installed_package_ids_with_versions) > 0: + self._fetchPackageUpdates() - def _fetchPackageData(self): - # Create the network manager: - # This was formerly its own function but really had no reason to be as - # it was never called more than once ever. + def _prepareNetworkManager(self): if self._network_manager is not None: self._network_manager.finished.disconnect(self._onRequestFinished) self._network_manager.networkAccessibleChanged.disconnect(self._onNetworkAccessibleChanged) @@ -205,10 +213,15 @@ class Toolbox(QObject, Extension): self._network_manager.finished.connect(self._onRequestFinished) self._network_manager.networkAccessibleChanged.connect(self._onNetworkAccessibleChanged) + def _fetchPackageUpdates(self): + self._prepareNetworkManager() + self._makeRequestByType("updates") + + def _fetchPackageData(self): + self._prepareNetworkManager() # Make remote requests: self._makeRequestByType("packages") self._makeRequestByType("authors") - # Gather installed packages: self._updateInstalledModels() @@ -234,7 +247,7 @@ class Toolbox(QObject, Extension): if not plugin_path: return None path = os.path.join(plugin_path, "resources", "qml", qml_name) - + dialog = self._application.createQmlComponent(path, {"toolbox": self}) if not dialog: raise Exception("Failed to create Marketplace dialog") @@ -618,7 +631,7 @@ class Toolbox(QObject, Extension): if not self._models[response_type]: Logger.log("e", "Could not find the %s model.", response_type) break - + self._server_response_data[response_type] = json_data["data"] self._models[response_type].setMetadata(self._server_response_data[response_type]) @@ -630,6 +643,10 @@ class Toolbox(QObject, Extension): elif response_type == "authors": self._models[response_type].setFilter({"package_types": "material"}) self._models[response_type].setFilter({"tags": "generic"}) + elif response_type == "updates": + # 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[response_type]]) + self._package_manager.setPackagesWithUpdate(packages) self.metadataChanged.emit() @@ -660,7 +677,7 @@ class Toolbox(QObject, Extension): self.setIsDownloading(False) self._download_reply = cast(QNetworkReply, self._download_reply) self._download_reply.downloadProgress.disconnect(self._onDownloadProgress) - + # Check if the download was sucessfull if self._download_reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) != 200: try: diff --git a/resources/qml/Dialogs/DiscardOrKeepProfileChangesDialog.qml b/resources/qml/Dialogs/DiscardOrKeepProfileChangesDialog.qml index afa9fda0bd..316878a2bd 100644 --- a/resources/qml/Dialogs/DiscardOrKeepProfileChangesDialog.qml +++ b/resources/qml/Dialogs/DiscardOrKeepProfileChangesDialog.qml @@ -100,7 +100,6 @@ UM.Dialog { text: styleData.value font: UM.Theme.getFont("system") - color: UM.Theme.getColor("setting_control_disabled_text") } } diff --git a/resources/qml/Preferences/Materials/MaterialsView.qml b/resources/qml/Preferences/Materials/MaterialsView.qml index f781497081..57253b9dff 100644 --- a/resources/qml/Preferences/Materials/MaterialsView.qml +++ b/resources/qml/Preferences/Materials/MaterialsView.qml @@ -46,7 +46,7 @@ TabView { return "" } - return linkedMaterials.join(", "); + return linkedMaterials; } function getApproximateDiameter(diameter) diff --git a/resources/qml/PrintSetupSelector/Recommended/RecommendedQualityProfileSelector.qml b/resources/qml/PrintSetupSelector/Recommended/RecommendedQualityProfileSelector.qml index 5e58faec78..337aff573f 100644 --- a/resources/qml/PrintSetupSelector/Recommended/RecommendedQualityProfileSelector.qml +++ b/resources/qml/PrintSetupSelector/Recommended/RecommendedQualityProfileSelector.qml @@ -160,15 +160,25 @@ Item enabled: model.description !== undefined acceptedButtons: Qt.NoButton // react to hover only, don't steal clicks - onEntered: + Timer { - base.showTooltip( + id: intentTooltipTimer + interval: 500 + running: false + repeat: false + onTriggered: base.showTooltip( intentCategoryLabel, Qt.point(-(intentCategoryLabel.x - qualityRow.x) - UM.Theme.getSize("thick_margin").width, 0), model.description ) } - onExited: base.hideTooltip() + + onEntered: intentTooltipTimer.start() + onExited: + { + base.hideTooltip() + intentTooltipTimer.stop() + } } NoIntentIcon // This icon has hover priority over intentDescriptionHoverArea, so draw it above it. diff --git a/tests/Machines/TestVariantNode.py b/tests/Machines/TestVariantNode.py index bc860058a1..6e3a469877 100644 --- a/tests/Machines/TestVariantNode.py +++ b/tests/Machines/TestVariantNode.py @@ -139,6 +139,7 @@ def test_preferredMaterialExactMatch(empty_variant_node): "some_different_material": MagicMock(getMetaDataEntry = lambda x: 3), "preferred_material_base_file": MagicMock(getMetaDataEntry = lambda x: 3) # Exact match. } + empty_variant_node.machine.preferred_material = "preferred_material_base_file" assert empty_variant_node.preferredMaterial(approximate_diameter = 3) == empty_variant_node.materials["preferred_material_base_file"], "It should match exactly on this one since it's the preferred material." @@ -149,6 +150,7 @@ def test_preferredMaterialSubmaterial(empty_variant_node): "some_different_material": MagicMock(getMetaDataEntry = lambda x: 3), "preferred_material_base_file_aa04": MagicMock(getMetaDataEntry = lambda x: 3) # This is a submaterial of the preferred material. } + empty_variant_node.machine.preferred_material = "preferred_material_base_file_aa04" assert empty_variant_node.preferredMaterial(approximate_diameter = 3) == empty_variant_node.materials["preferred_material_base_file_aa04"], "It should match on the submaterial just as well." @@ -160,6 +162,7 @@ def test_preferredMaterialDiameter(empty_variant_node): "preferred_material_wrong_diameter": MagicMock(getMetaDataEntry = lambda x: 2), # Approximate diameter is 2 instead of 3. "preferred_material_correct_diameter": MagicMock(getMetaDataEntry = lambda x: 3) # Correct approximate diameter. } + empty_variant_node.machine.preferred_material = "preferred_material_correct_diameter" assert empty_variant_node.preferredMaterial(approximate_diameter = 3) == empty_variant_node.materials["preferred_material_correct_diameter"], "It should match only on the material with correct diameter."