diff --git a/cura/API/Account.py b/cura/API/Account.py index b63983e0cc..2651037e9e 100644 --- a/cura/API/Account.py +++ b/cura/API/Account.py @@ -1,19 +1,26 @@ -# Copyright (c) 2021 Ultimaker B.V. +# Copyright (c) 2022 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. import enum from datetime import datetime +import json from PyQt6.QtCore import QObject, pyqtSignal, pyqtSlot, pyqtProperty, QTimer, pyqtEnum -from typing import Any, Optional, Dict, TYPE_CHECKING, Callable +from PyQt6.QtNetwork import QNetworkRequest +from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING +from UM.Decorators import deprecated from UM.Logger import Logger from UM.Message import Message from UM.i18n import i18nCatalog +from UM.TaskManagement.HttpRequestManager import HttpRequestManager +from UM.TaskManagement.HttpRequestScope import JsonDecoratorScope from cura.OAuth2.AuthorizationService import AuthorizationService from cura.OAuth2.Models import OAuth2Settings, UserProfile from cura.UltimakerCloud import UltimakerCloudConstants +from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope if TYPE_CHECKING: from cura.CuraApplication import CuraApplication + from PyQt6.QtNetwork import QNetworkReply i18n_catalog = i18nCatalog("cura") @@ -78,6 +85,7 @@ class Account(QObject): self._logged_in = False self._user_profile: Optional[UserProfile] = None self._additional_rights: Dict[str, Any] = {} + self._permissions: List[str] = [] # List of account permission keys, e.g. ["digital-factory.print-job.write"] self._sync_state = SyncState.IDLE self._manual_sync_enabled = False self._update_packages_enabled = False @@ -109,6 +117,7 @@ class Account(QObject): self._sync_services: Dict[str, int] = {} """contains entries "service_name" : SyncState""" + self.syncRequested.connect(self._updatePermissions) def initialize(self) -> None: self._authorization_service.initialize(self._application.getPreferences()) @@ -311,13 +320,63 @@ class Account(QObject): self._authorization_service.deleteAuthData() + @deprecated("Get permissions from the 'permissions' property", since = "5.2.0") def updateAdditionalRight(self, **kwargs) -> None: """Update the additional rights of the account. The argument(s) are the rights that need to be set""" self._additional_rights.update(kwargs) self.additionalRightsChanged.emit(self._additional_rights) + @deprecated("Get permissions from the 'permissions' property", since = "5.2.0") @pyqtProperty("QVariantMap", notify = additionalRightsChanged) def additionalRights(self) -> Dict[str, Any]: """A dictionary which can be queried for additional account rights.""" return self._additional_rights + + permissionsChanged = pyqtSignal() + @pyqtProperty("QVariantList", notify = permissionsChanged) + def permissions(self) -> List[str]: + """ + The permission keys that the user has in his account. + """ + return self._permissions + + def _updatePermissions(self) -> None: + """ + Update the list of permissions that the user has. + """ + def callback(reply: "QNetworkReply"): + status_code = reply.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute) + if status_code is None: + Logger.error("Server did not respond to request to get list of permissions.") + return + if status_code >= 300: + Logger.error(f"Request to get list of permission resulted in HTTP error {status_code}") + return + + try: + reply_data = json.loads(bytes(reply.readAll()).decode("UTF-8")) + except (UnicodeDecodeError, json.JSONDecodeError, ValueError) as e: + Logger.logException("e", f"Could not parse response to permission list request: {e}") + return + if "errors" in reply_data: + Logger.error(f"Request to get list of permission resulted in error response: {reply_data['errors']}") + return + + if "data" in reply_data and "permissions" in reply_data["data"]: + permissions = sorted(reply_data["data"]["permissions"]) + if permissions != self._permissions: + self._permissions = permissions + self.permissionsChanged.emit() + + def error_callback(reply: "QNetworkReply", error: "QNetworkReply.NetworkError"): + Logger.error(f"Request for user permissions list failed. Network error: {error}") + + HttpRequestManager.getInstance().get( + url = f"{self._oauth_root}/users/permissions", + scope = JsonDecoratorScope(UltimakerCloudScope(self._application)), + callback = callback, + error_callback = error_callback, + timeout = 10 + ) + diff --git a/cura/PrinterOutput/Models/PrintJobOutputModel.py b/cura/PrinterOutput/Models/PrintJobOutputModel.py index 164dc5cb67..6b8e07b29e 100644 --- a/cura/PrinterOutput/Models/PrintJobOutputModel.py +++ b/cura/PrinterOutput/Models/PrintJobOutputModel.py @@ -1,4 +1,4 @@ -# Copyright (c) 2018 Ultimaker B.V. +# Copyright (c) 2022 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. from typing import Optional, TYPE_CHECKING, List @@ -6,6 +6,8 @@ from typing import Optional, TYPE_CHECKING, List from PyQt6.QtCore import pyqtSignal, pyqtProperty, QObject, pyqtSlot, QUrl from PyQt6.QtGui import QImage +from cura.CuraApplication import CuraApplication + if TYPE_CHECKING: from cura.PrinterOutput.PrinterOutputController import PrinterOutputController from cura.PrinterOutput.Models.PrinterOutputModel import PrinterOutputModel @@ -86,6 +88,18 @@ class PrintJobOutputModel(QObject): self._owner = owner self.ownerChanged.emit() + @pyqtProperty(bool, notify = ownerChanged) + def isMine(self) -> bool: + """ + Returns whether this print job was sent by the currently logged in user. + + This checks the owner of the print job with the owner of the currently + logged in account. Both of these are human-readable account names which + may be duplicate. In practice the harm here is limited, but it's the + best we can do with the information available to the API. + """ + return self._owner == CuraApplication.getInstance().getCuraAPI().account.userName + @pyqtProperty(QObject, notify=assignedPrinterChanged) def assignedPrinter(self): return self._assigned_printer diff --git a/plugins/DigitalLibrary/src/DigitalFactoryApiClient.py b/plugins/DigitalLibrary/src/DigitalFactoryApiClient.py index de09ea2a09..13c65f79c4 100644 --- a/plugins/DigitalLibrary/src/DigitalFactoryApiClient.py +++ b/plugins/DigitalLibrary/src/DigitalFactoryApiClient.py @@ -71,8 +71,6 @@ class DigitalFactoryApiClient: has_access = response.library_max_private_projects == -1 or response.library_max_private_projects > 0 callback(has_access) self._library_max_private_projects = response.library_max_private_projects - # update the account with the additional user rights - self._account.updateAdditionalRight(df_access = has_access) else: Logger.warning(f"Digital Factory: Response is not a feature budget, likely an error: {str(response)}") callback(False) diff --git a/plugins/UM3NetworkPrinting/resources/qml/MonitorContextMenu.qml b/plugins/UM3NetworkPrinting/resources/qml/MonitorContextMenu.qml index 03e9477d08..33fdb0eb38 100644 --- a/plugins/UM3NetworkPrinting/resources/qml/MonitorContextMenu.qml +++ b/plugins/UM3NetworkPrinting/resources/qml/MonitorContextMenu.qml @@ -13,10 +13,98 @@ import Cura 1.6 as Cura */ Item { + id: monitorContextMenu property alias target: popUp.target property var printJob: null + //Everything in the pop-up only gets evaluated when showing the pop-up. + //However we want to show the button for showing the pop-up only if there is anything visible inside it. + //So compute here the visibility of the menu items, so that we can use it for the visibility of the button. + property bool sendToTopVisible: + { + if (printJob && printJob.state in ("queued", "error") && !isAssigned(printJob)) { + if (OutputDevice && OutputDevice.queuedPrintJobs[0] && OutputDevice.canWriteOthersPrintJobs) { + return OutputDevice.queuedPrintJobs[0].key != printJob.key; + } + } + return false; + } + + property bool deleteVisible: + { + if(!printJob) + { + return false; + } + if(printJob.isMine) + { + if(!OutputDevice.canWriteOwnPrintJobs) + { + return false; + } + } + else + { + if(!OutputDevice.canWriteOthersPrintJobs) + { + return false; + } + } + var states = ["queued", "error", "sent_to_printer"]; + return states.indexOf(printJob.state) !== -1; + } + + property bool pauseVisible: + { + if(!printJob) + { + return false; + } + if(printJob.isMine) + { + if(!OutputDevice.canWriteOwnPrintJobs) + { + return false; + } + } + else + { + if(!OutputDevice.canWriteOthersPrintJobs) + { + return false; + } + } + var states = ["printing", "pausing", "paused", "resuming"]; + return states.indexOf(printJob.state) !== -1; + } + + property bool abortVisible: + { + if(!printJob) + { + return false; + } + if(printJob.isMine) + { + if(!OutputDevice.canWriteOwnPrintJobs) + { + return false; + } + } + else + { + if(!OutputDevice.canWriteOthersPrintJobs) + { + return false; + } + } + var states = ["pre_print", "printing", "pausing", "paused", "resuming"]; + return states.indexOf(printJob.state) !== -1; + } + + property bool hasItems: sendToTopVisible || deleteVisible || pauseVisible || abortVisible + GenericPopUp { id: popUp @@ -46,56 +134,54 @@ Item spacing: Math.floor(UM.Theme.getSize("default_margin").height / 2) - PrintJobContextMenuItem { - onClicked: { + PrintJobContextMenuItem + { + onClicked: + { sendToTopConfirmationDialog.visible = true; popUp.close(); } text: catalog.i18nc("@label", "Move to top"); - visible: { - if (printJob && (printJob.state == "queued" || printJob.state == "error") && !isAssigned(printJob)) { - if (OutputDevice && OutputDevice.queuedPrintJobs[0]) { - return OutputDevice.queuedPrintJobs[0].key != printJob.key; - } - } - return false; - } + visible: monitorContextMenu.sendToTopVisible } - PrintJobContextMenuItem { - onClicked: { + PrintJobContextMenuItem + { + onClicked: + { deleteConfirmationDialog.visible = true; popUp.close(); } text: catalog.i18nc("@label", "Delete"); - visible: { - if (!printJob) { - return false; - } - var states = ["queued", "error", "sent_to_printer"]; - return states.indexOf(printJob.state) !== -1; - } + visible: monitorContextMenu.deleteVisible } - PrintJobContextMenuItem { + PrintJobContextMenuItem + { enabled: visible && !(printJob.state == "pausing" || printJob.state == "resuming"); - onClicked: { - if (printJob.state == "paused") { + onClicked: + { + if (printJob.state == "paused") + { printJob.setState("resume"); popUp.close(); return; } - if (printJob.state == "printing") { + if (printJob.state == "printing") + { printJob.setState("pause"); popUp.close(); return; } } - text: { - if (!printJob) { + text: + { + if(!printJob) + { return ""; } - switch(printJob.state) { + switch(printJob.state) + { case "paused": return catalog.i18nc("@label", "Resume"); case "pausing": @@ -106,29 +192,19 @@ Item catalog.i18nc("@label", "Pause"); } } - visible: { - if (!printJob) { - return false; - } - var states = ["printing", "pausing", "paused", "resuming"]; - return states.indexOf(printJob.state) !== -1; - } + visible: monitorContextMenu.pauseVisible } - PrintJobContextMenuItem { + PrintJobContextMenuItem + { enabled: visible && printJob.state !== "aborting"; - onClicked: { + onClicked: + { abortConfirmationDialog.visible = true; popUp.close(); } text: printJob && printJob.state == "aborting" ? catalog.i18nc("@label", "Aborting...") : catalog.i18nc("@label", "Abort"); - visible: { - if (!printJob) { - return false; - } - var states = ["pre_print", "printing", "pausing", "paused", "resuming"]; - return states.indexOf(printJob.state) !== -1; - } + visible: monitorContextMenu.abortVisible } } } diff --git a/plugins/UM3NetworkPrinting/resources/qml/MonitorPrintJobCard.qml b/plugins/UM3NetworkPrinting/resources/qml/MonitorPrintJobCard.qml index 2974e5ce6b..64aa4e7a9c 100644 --- a/plugins/UM3NetworkPrinting/resources/qml/MonitorPrintJobCard.qml +++ b/plugins/UM3NetworkPrinting/resources/qml/MonitorPrintJobCard.qml @@ -206,7 +206,11 @@ Item onClicked: enabled ? contextMenu.switchPopupState() : {} visible: { - if (!printJob) + if(!printJob) + { + return false; + } + if(!contextMenu.hasItems) { return false; } diff --git a/plugins/UM3NetworkPrinting/resources/qml/MonitorPrinterCard.qml b/plugins/UM3NetworkPrinting/resources/qml/MonitorPrinterCard.qml index 0069d017f6..67f308a64e 100644 --- a/plugins/UM3NetworkPrinting/resources/qml/MonitorPrinterCard.qml +++ b/plugins/UM3NetworkPrinting/resources/qml/MonitorPrinterCard.qml @@ -209,8 +209,13 @@ Item onClicked: enabled ? contextMenu.switchPopupState() : {} visible: { - if (!printer || !printer.activePrintJob) { - return false + if(!printer || !printer.activePrintJob) + { + return false; + } + if(!contextMenu.hasItems) + { + return false; } var states = ["queued", "error", "sent_to_printer", "pre_print", "printing", "pausing", "paused", "resuming"] return states.indexOf(printer.activePrintJob.state) !== -1 diff --git a/plugins/UM3NetworkPrinting/resources/qml/MonitorQueue.qml b/plugins/UM3NetworkPrinting/resources/qml/MonitorQueue.qml index 3fd500cfca..d559e99cc1 100644 --- a/plugins/UM3NetworkPrinting/resources/qml/MonitorQueue.qml +++ b/plugins/UM3NetworkPrinting/resources/qml/MonitorQueue.qml @@ -39,6 +39,7 @@ Item } height: 18 * screenScaleFactor // TODO: Theme! width: childrenRect.width + visible: OutputDevice.canReadPrinterDetails UM.ColorImage { diff --git a/plugins/UM3NetworkPrinting/resources/qml/MonitorStage.qml b/plugins/UM3NetworkPrinting/resources/qml/MonitorStage.qml index 9e73662e1d..6e8f6b4ebd 100644 --- a/plugins/UM3NetworkPrinting/resources/qml/MonitorStage.qml +++ b/plugins/UM3NetworkPrinting/resources/qml/MonitorStage.qml @@ -69,7 +69,7 @@ Component top: printers.bottom topMargin: 48 * screenScaleFactor // TODO: Theme! } - visible: OutputDevice.supportsPrintJobQueue + visible: OutputDevice.supportsPrintJobQueue && OutputDevice.canReadPrintJobs } PrinterVideoStream diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index 8c45ce537f..6431d09b7b 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -1,4 +1,4 @@ -# Copyright (c) 2021 Ultimaker B.V. +# Copyright (c) 2022 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. from time import time @@ -96,6 +96,8 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice): # Trigger the printersChanged signal when the private signal is triggered. self.printersChanged.connect(self._cloudClusterPrintersChanged) + # Trigger the permissionsChanged signal when the account's permissions change. + self._account.permissionsChanged.connect(self.permissionsChanged) # Keep server string of the last generated time to avoid updating models more than once for the same response self._received_printers = None # type: Optional[List[ClusterPrinterStatus]] @@ -340,6 +342,37 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice): def openPrinterControlPanel(self) -> None: QDesktopServices.openUrl(QUrl(self.clusterCloudUrl + "?utm_source=cura&utm_medium=software&utm_campaign=monitor-manage-printer")) + permissionsChanged = pyqtSignal() + + @pyqtProperty(bool, notify = permissionsChanged) + def canReadPrintJobs(self) -> bool: + """ + Whether this user can read the list of print jobs and their properties. + """ + return "digital-factory.print-job.read" in self._account.permissions + + @pyqtProperty(bool, notify = permissionsChanged) + def canWriteOthersPrintJobs(self) -> bool: + """ + Whether this user can change things about print jobs made by other + people. + """ + return "digital-factory.print-job.write" in self._account.permissions + + @pyqtProperty(bool, notify = permissionsChanged) + def canWriteOwnPrintJobs(self) -> bool: + """ + Whether this user can change things about print jobs made by themself. + """ + return "digital-factory.print-job.write.own" in self._account.permissions + + @pyqtProperty(bool, constant = True) + def canReadPrinterDetails(self) -> bool: + """ + Whether this user can read the status of the printer. + """ + return "digital-factory.printer.read" in self._account.permissions + @property def clusterData(self) -> CloudClusterResponse: """Gets the cluster response from which this device was created.""" diff --git a/plugins/UM3NetworkPrinting/src/Network/LocalClusterOutputDevice.py b/plugins/UM3NetworkPrinting/src/Network/LocalClusterOutputDevice.py index 2d27b7c3be..f5afb0b14e 100644 --- a/plugins/UM3NetworkPrinting/src/Network/LocalClusterOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Network/LocalClusterOutputDevice.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 from typing import Optional, Dict, List, Callable, Any diff --git a/plugins/UM3NetworkPrinting/src/UltimakerNetworkedPrinterOutputDevice.py b/plugins/UM3NetworkPrinting/src/UltimakerNetworkedPrinterOutputDevice.py index 769e92610a..8f25df37db 100644 --- a/plugins/UM3NetworkPrinting/src/UltimakerNetworkedPrinterOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/UltimakerNetworkedPrinterOutputDevice.py @@ -1,4 +1,4 @@ -# Copyright (c) 2020 Ultimaker B.V. +# Copyright (c) 2022 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. import os from time import time @@ -184,6 +184,42 @@ class UltimakerNetworkedPrinterOutputDevice(NetworkedPrinterOutputDevice): def forceSendJob(self, print_job_uuid: str) -> None: raise NotImplementedError("forceSendJob must be implemented") + @pyqtProperty(bool, constant = True) + def supportsPrintJobQueue(self) -> bool: + """ + Whether this printer knows about queueing print jobs. + """ + return True # This API always supports print job queueing. + + @pyqtProperty(bool, constant = True) + def canReadPrintJobs(self) -> bool: + """ + Whether this user can read the list of print jobs and their properties. + """ + return True + + @pyqtProperty(bool, constant = True) + def canWriteOthersPrintJobs(self) -> bool: + """ + Whether this user can change things about print jobs made by other + people. + """ + return True + + @pyqtProperty(bool, constant = True) + def canWriteOwnPrintJobs(self) -> bool: + """ + Whether this user can change things about print jobs made by themself. + """ + return True + + @pyqtProperty(bool, constant = True) + def canReadPrinterDetails(self) -> bool: + """ + Whether this user can read the status of the printer. + """ + return True + @pyqtSlot(name="openPrintJobControlPanel") def openPrintJobControlPanel(self) -> None: raise NotImplementedError("openPrintJobControlPanel must be implemented") diff --git a/resources/qml/ApplicationSwitcher/ApplicationSwitcherPopup.qml b/resources/qml/ApplicationSwitcher/ApplicationSwitcherPopup.qml index ede42fcd5f..453f2ed9e0 100644 --- a/resources/qml/ApplicationSwitcher/ApplicationSwitcherPopup.qml +++ b/resources/qml/ApplicationSwitcher/ApplicationSwitcherPopup.qml @@ -33,63 +33,63 @@ Popup thumbnail: UM.Theme.getIcon("PrinterTriple", "high"), description: catalog.i18nc("@tooltip:button", "Monitor printers in Ultimaker Digital Factory."), link: "https://digitalfactory.ultimaker.com/app/printers?utm_source=cura&utm_medium=software&utm_campaign=switcher-digital-factory-printers", - DFAccessRequired: true + permissionsRequired: ["digital-factory.printer.read"] }, { displayName: "Digital Library", //Not translated, since it's a brand name. thumbnail: UM.Theme.getIcon("Library", "high"), description: catalog.i18nc("@tooltip:button", "Create print projects in Digital Library."), link: "https://digitalfactory.ultimaker.com/app/library?utm_source=cura&utm_medium=software&utm_campaign=switcher-library", - DFAccessRequired: true + permissionsRequired: ["digital-factory.project.read.shared"] }, { displayName: catalog.i18nc("@label:button", "Print jobs"), thumbnail: UM.Theme.getIcon("FoodBeverages"), description: catalog.i18nc("@tooltip:button", "Monitor print jobs and reprint from your print history."), link: "https://digitalfactory.ultimaker.com/app/print-jobs?utm_source=cura&utm_medium=software&utm_campaign=switcher-digital-factory-printjobs", - DFAccessRequired: true + permissionsRequired: ["digital-factory.print-job.read"] }, { displayName: "Ultimaker Marketplace", //Not translated, since it's a brand name. thumbnail: UM.Theme.getIcon("Shop", "high"), description: catalog.i18nc("@tooltip:button", "Extend Ultimaker Cura with plugins and material profiles."), link: "https://marketplace.ultimaker.com/?utm_source=cura&utm_medium=software&utm_campaign=switcher-marketplace-materials", - DFAccessRequired: false + permissionsRequired: [] }, { displayName: "Ultimaker Academy", //Not translated, since it's a brand name. thumbnail: UM.Theme.getIcon("Knowledge"), description: catalog.i18nc("@tooltip:button", "Become a 3D printing expert with Ultimaker e-learning."), link: "https://academy.ultimaker.com/?utm_source=cura&utm_medium=software&utm_campaign=switcher-academy", - DFAccessRequired: false + permissionsRequired: [] }, { displayName: catalog.i18nc("@label:button", "Ultimaker support"), thumbnail: UM.Theme.getIcon("Help", "high"), description: catalog.i18nc("@tooltip:button", "Learn how to get started with Ultimaker Cura."), link: "https://support.ultimaker.com/?utm_source=cura&utm_medium=software&utm_campaign=switcher-support", - DFAccessRequired: false + permissionsRequired: [] }, { displayName: catalog.i18nc("@label:button", "Ask a question"), thumbnail: UM.Theme.getIcon("Speak", "high"), description: catalog.i18nc("@tooltip:button", "Consult the Ultimaker Community."), link: "https://community.ultimaker.com/?utm_source=cura&utm_medium=software&utm_campaign=switcher-community", - DFAccessRequired: false + permissionsRequired: [] }, { displayName: catalog.i18nc("@label:button", "Report a bug"), thumbnail: UM.Theme.getIcon("Bug", "high"), description: catalog.i18nc("@tooltip:button", "Let developers know that something is going wrong."), link: "https://github.com/Ultimaker/Cura/issues/new/choose", - DFAccessRequired: false + permissionsRequired: [] }, { displayName: "Ultimaker.com", //Not translated, since it's a URL. thumbnail: UM.Theme.getIcon("Browser"), description: catalog.i18nc("@tooltip:button", "Visit the Ultimaker website."), link: "https://ultimaker.com/?utm_source=cura&utm_medium=software&utm_campaign=switcher-umwebsite", - DFAccessRequired: false + permissionsRequired: [] } ] @@ -99,7 +99,24 @@ Popup iconSource: modelData.thumbnail tooltipText: modelData.description isExternalLink: true - visible: modelData.DFAccessRequired ? Cura.API.account.isLoggedIn & Cura.API.account.additionalRights["df_access"] : true + visible: + { + try + { + modelData.permissionsRequired.forEach(function(permission) + { + if(!Cura.API.account.isLoggedIn || !Cura.API.account.permissions.includes(permission)) //This required permission is not in the account. + { + throw "No permission to use this application."; //Can't return from within this lambda. Throw instead. + } + }); + } + catch(e) + { + return false; + } + return true; + } onClicked: Qt.openUrlExternally(modelData.link) } diff --git a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml index 4fd82b3e02..ce3f9c4303 100644 --- a/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml +++ b/resources/qml/Preferences/Materials/MaterialsSyncDialog.qml @@ -88,7 +88,15 @@ UM.Window { if(Cura.API.account.isLoggedIn) { - swipeView.currentIndex += 2; //Skip sign in page. + if(Cura.API.account.permissions.includes("digital-factory.printer.write")) + { + swipeView.currentIndex += 2; //Skip sign in page. Continue to sync via cloud. + } + else + { + //Logged in, but no permissions to start syncing. Direct them to USB. + swipeView.currentIndex = removableDriveSyncPage.SwipeView.index; + } } else { @@ -112,7 +120,15 @@ UM.Window { if(is_logged_in && signinPage.SwipeView.isCurrentItem) { - swipeView.currentIndex += 1; + if(Cura.API.account.permissions.includes("digital-factory.printer.write")) + { + swipeView.currentIndex += 1; + } + else + { + //Logged in, but no permissions to start syncing. Direct them to USB. + swipeView.currentIndex = removableDriveSyncPage.SwipeView.index; + } } } } diff --git a/tests/API/TestAccount.py b/tests/API/TestAccount.py index 1ad73462c2..9d62646eff 100644 --- a/tests/API/TestAccount.py +++ b/tests/API/TestAccount.py @@ -1,3 +1,6 @@ +# Copyright (c) 2022 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. + from unittest.mock import MagicMock, patch import pytest @@ -26,7 +29,8 @@ def test_login(): mocked_auth_service.startAuthorizationFlow.assert_called_once_with(False) # Fake a successful login - account._onLoginStateChanged(True) + with patch("UM.TaskManagement.HttpRequestManager.HttpRequestManager.getInstance"): # Don't want triggers for account information to actually make HTTP requests. + account._onLoginStateChanged(True) # Attempting to log in again shouldn't change anything. account.login() @@ -59,7 +63,8 @@ def test_logout(): assert not account.isLoggedIn # Pretend the stage changed - account._onLoginStateChanged(True) + with patch("UM.TaskManagement.HttpRequestManager.HttpRequestManager.getInstance"): # Don't want triggers for account information to actually make HTTP requests. + account._onLoginStateChanged(True) assert account.isLoggedIn account.logout() @@ -72,12 +77,14 @@ def test_errorLoginState(application): account._authorization_service = mocked_auth_service account.loginStateChanged = MagicMock() - account._onLoginStateChanged(True, "BLARG!") + with patch("UM.TaskManagement.HttpRequestManager.HttpRequestManager.getInstance"): # Don't want triggers for account information to actually make HTTP requests. + account._onLoginStateChanged(True, "BLARG!") # Even though we said that the login worked, it had an error message, so the login failed. account.loginStateChanged.emit.called_with(False) - account._onLoginStateChanged(True) - account._onLoginStateChanged(False, "OMGZOMG!") + with patch("UM.TaskManagement.HttpRequestManager.HttpRequestManager.getInstance"): + account._onLoginStateChanged(True) + account._onLoginStateChanged(False, "OMGZOMG!") account.loginStateChanged.emit.called_with(False) def test_sync_success(): @@ -86,18 +93,19 @@ def test_sync_success(): service1 = "test_service1" service2 = "test_service2" - account.setSyncState(service1, SyncState.SYNCING) - assert account.syncState == SyncState.SYNCING + with patch("UM.TaskManagement.HttpRequestManager.HttpRequestManager.getInstance"): # Don't want triggers for account information to actually make HTTP requests. + account.setSyncState(service1, SyncState.SYNCING) + assert account.syncState == SyncState.SYNCING - account.setSyncState(service2, SyncState.SYNCING) - assert account.syncState == SyncState.SYNCING + account.setSyncState(service2, SyncState.SYNCING) + assert account.syncState == SyncState.SYNCING - account.setSyncState(service1, SyncState.SUCCESS) - # service2 still syncing - assert account.syncState == SyncState.SYNCING + account.setSyncState(service1, SyncState.SUCCESS) + # service2 still syncing + assert account.syncState == SyncState.SYNCING - account.setSyncState(service2, SyncState.SUCCESS) - assert account.syncState == SyncState.SUCCESS + account.setSyncState(service2, SyncState.SUCCESS) + assert account.syncState == SyncState.SUCCESS def test_sync_update_action(): @@ -107,23 +115,24 @@ def test_sync_update_action(): mockUpdateCallback = MagicMock() - account.setSyncState(service1, SyncState.SYNCING) - assert account.syncState == SyncState.SYNCING + with patch("UM.TaskManagement.HttpRequestManager.HttpRequestManager.getInstance"): # Don't want triggers for account information to actually make HTTP requests. + account.setSyncState(service1, SyncState.SYNCING) + assert account.syncState == SyncState.SYNCING - account.setUpdatePackagesAction(mockUpdateCallback) - account.onUpdatePackagesClicked() - mockUpdateCallback.assert_called_once_with() - account.setSyncState(service1, SyncState.SUCCESS) + account.setUpdatePackagesAction(mockUpdateCallback) + account.onUpdatePackagesClicked() + mockUpdateCallback.assert_called_once_with() + account.setSyncState(service1, SyncState.SUCCESS) - account.sync() # starting a new sync resets the update action to None + account.sync() # starting a new sync resets the update action to None - account.setSyncState(service1, SyncState.SYNCING) - assert account.syncState == SyncState.SYNCING + account.setSyncState(service1, SyncState.SYNCING) + assert account.syncState == SyncState.SYNCING - account.onUpdatePackagesClicked() # Should not be connected to an action anymore - mockUpdateCallback.assert_called_once_with() # No additional calls - assert account.updatePackagesEnabled is False - account.setSyncState(service1, SyncState.SUCCESS) + account.onUpdatePackagesClicked() # Should not be connected to an action anymore + mockUpdateCallback.assert_called_once_with() # No additional calls + assert account.updatePackagesEnabled is False + account.setSyncState(service1, SyncState.SUCCESS)