Merge pull request #12866 from Ultimaker/CURA-9220_hide_if_no_permission

Hide cloud interaction buttons if the user has no permissions to them
This commit is contained in:
Jelle Spijker 2022-08-01 12:41:11 +02:00 committed by GitHub
commit 7b768ca810
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 361 additions and 93 deletions

View File

@ -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. # Cura is released under the terms of the LGPLv3 or higher.
import enum import enum
from datetime import datetime from datetime import datetime
import json
from PyQt6.QtCore import QObject, pyqtSignal, pyqtSlot, pyqtProperty, QTimer, pyqtEnum 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.Logger import Logger
from UM.Message import Message from UM.Message import Message
from UM.i18n import i18nCatalog 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.AuthorizationService import AuthorizationService
from cura.OAuth2.Models import OAuth2Settings, UserProfile from cura.OAuth2.Models import OAuth2Settings, UserProfile
from cura.UltimakerCloud import UltimakerCloudConstants from cura.UltimakerCloud import UltimakerCloudConstants
from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope
if TYPE_CHECKING: if TYPE_CHECKING:
from cura.CuraApplication import CuraApplication from cura.CuraApplication import CuraApplication
from PyQt6.QtNetwork import QNetworkReply
i18n_catalog = i18nCatalog("cura") i18n_catalog = i18nCatalog("cura")
@ -78,6 +85,7 @@ class Account(QObject):
self._logged_in = False self._logged_in = False
self._user_profile: Optional[UserProfile] = None self._user_profile: Optional[UserProfile] = None
self._additional_rights: Dict[str, Any] = {} 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._sync_state = SyncState.IDLE
self._manual_sync_enabled = False self._manual_sync_enabled = False
self._update_packages_enabled = False self._update_packages_enabled = False
@ -109,6 +117,7 @@ class Account(QObject):
self._sync_services: Dict[str, int] = {} self._sync_services: Dict[str, int] = {}
"""contains entries "service_name" : SyncState""" """contains entries "service_name" : SyncState"""
self.syncRequested.connect(self._updatePermissions)
def initialize(self) -> None: def initialize(self) -> None:
self._authorization_service.initialize(self._application.getPreferences()) self._authorization_service.initialize(self._application.getPreferences())
@ -311,13 +320,63 @@ class Account(QObject):
self._authorization_service.deleteAuthData() self._authorization_service.deleteAuthData()
@deprecated("Get permissions from the 'permissions' property", since = "5.2.0")
def updateAdditionalRight(self, **kwargs) -> None: def updateAdditionalRight(self, **kwargs) -> None:
"""Update the additional rights of the account. """Update the additional rights of the account.
The argument(s) are the rights that need to be set""" The argument(s) are the rights that need to be set"""
self._additional_rights.update(kwargs) self._additional_rights.update(kwargs)
self.additionalRightsChanged.emit(self._additional_rights) self.additionalRightsChanged.emit(self._additional_rights)
@deprecated("Get permissions from the 'permissions' property", since = "5.2.0")
@pyqtProperty("QVariantMap", notify = additionalRightsChanged) @pyqtProperty("QVariantMap", notify = additionalRightsChanged)
def additionalRights(self) -> Dict[str, Any]: def additionalRights(self) -> Dict[str, Any]:
"""A dictionary which can be queried for additional account rights.""" """A dictionary which can be queried for additional account rights."""
return self._additional_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
)

View File

@ -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. # Cura is released under the terms of the LGPLv3 or higher.
from typing import Optional, TYPE_CHECKING, List 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.QtCore import pyqtSignal, pyqtProperty, QObject, pyqtSlot, QUrl
from PyQt6.QtGui import QImage from PyQt6.QtGui import QImage
from cura.CuraApplication import CuraApplication
if TYPE_CHECKING: if TYPE_CHECKING:
from cura.PrinterOutput.PrinterOutputController import PrinterOutputController from cura.PrinterOutput.PrinterOutputController import PrinterOutputController
from cura.PrinterOutput.Models.PrinterOutputModel import PrinterOutputModel from cura.PrinterOutput.Models.PrinterOutputModel import PrinterOutputModel
@ -86,6 +88,18 @@ class PrintJobOutputModel(QObject):
self._owner = owner self._owner = owner
self.ownerChanged.emit() 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) @pyqtProperty(QObject, notify=assignedPrinterChanged)
def assignedPrinter(self): def assignedPrinter(self):
return self._assigned_printer return self._assigned_printer

View File

@ -71,8 +71,6 @@ class DigitalFactoryApiClient:
has_access = response.library_max_private_projects == -1 or response.library_max_private_projects > 0 has_access = response.library_max_private_projects == -1 or response.library_max_private_projects > 0
callback(has_access) callback(has_access)
self._library_max_private_projects = response.library_max_private_projects 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: else:
Logger.warning(f"Digital Factory: Response is not a feature budget, likely an error: {str(response)}") Logger.warning(f"Digital Factory: Response is not a feature budget, likely an error: {str(response)}")
callback(False) callback(False)

View File

@ -13,10 +13,98 @@ import Cura 1.6 as Cura
*/ */
Item Item
{ {
id: monitorContextMenu
property alias target: popUp.target property alias target: popUp.target
property var printJob: null 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 GenericPopUp
{ {
id: popUp id: popUp
@ -46,56 +134,54 @@ Item
spacing: Math.floor(UM.Theme.getSize("default_margin").height / 2) spacing: Math.floor(UM.Theme.getSize("default_margin").height / 2)
PrintJobContextMenuItem { PrintJobContextMenuItem
onClicked: { {
onClicked:
{
sendToTopConfirmationDialog.visible = true; sendToTopConfirmationDialog.visible = true;
popUp.close(); popUp.close();
} }
text: catalog.i18nc("@label", "Move to top"); text: catalog.i18nc("@label", "Move to top");
visible: { visible: monitorContextMenu.sendToTopVisible
if (printJob && (printJob.state == "queued" || printJob.state == "error") && !isAssigned(printJob)) {
if (OutputDevice && OutputDevice.queuedPrintJobs[0]) {
return OutputDevice.queuedPrintJobs[0].key != printJob.key;
}
}
return false;
}
} }
PrintJobContextMenuItem { PrintJobContextMenuItem
onClicked: { {
onClicked:
{
deleteConfirmationDialog.visible = true; deleteConfirmationDialog.visible = true;
popUp.close(); popUp.close();
} }
text: catalog.i18nc("@label", "Delete"); text: catalog.i18nc("@label", "Delete");
visible: { visible: monitorContextMenu.deleteVisible
if (!printJob) {
return false;
}
var states = ["queued", "error", "sent_to_printer"];
return states.indexOf(printJob.state) !== -1;
}
} }
PrintJobContextMenuItem { PrintJobContextMenuItem
{
enabled: visible && !(printJob.state == "pausing" || printJob.state == "resuming"); enabled: visible && !(printJob.state == "pausing" || printJob.state == "resuming");
onClicked: { onClicked:
if (printJob.state == "paused") { {
if (printJob.state == "paused")
{
printJob.setState("resume"); printJob.setState("resume");
popUp.close(); popUp.close();
return; return;
} }
if (printJob.state == "printing") { if (printJob.state == "printing")
{
printJob.setState("pause"); printJob.setState("pause");
popUp.close(); popUp.close();
return; return;
} }
} }
text: { text:
if (!printJob) { {
if(!printJob)
{
return ""; return "";
} }
switch(printJob.state) { switch(printJob.state)
{
case "paused": case "paused":
return catalog.i18nc("@label", "Resume"); return catalog.i18nc("@label", "Resume");
case "pausing": case "pausing":
@ -106,29 +192,19 @@ Item
catalog.i18nc("@label", "Pause"); catalog.i18nc("@label", "Pause");
} }
} }
visible: { visible: monitorContextMenu.pauseVisible
if (!printJob) {
return false;
}
var states = ["printing", "pausing", "paused", "resuming"];
return states.indexOf(printJob.state) !== -1;
}
} }
PrintJobContextMenuItem { PrintJobContextMenuItem
{
enabled: visible && printJob.state !== "aborting"; enabled: visible && printJob.state !== "aborting";
onClicked: { onClicked:
{
abortConfirmationDialog.visible = true; abortConfirmationDialog.visible = true;
popUp.close(); popUp.close();
} }
text: printJob && printJob.state == "aborting" ? catalog.i18nc("@label", "Aborting...") : catalog.i18nc("@label", "Abort"); text: printJob && printJob.state == "aborting" ? catalog.i18nc("@label", "Aborting...") : catalog.i18nc("@label", "Abort");
visible: { visible: monitorContextMenu.abortVisible
if (!printJob) {
return false;
}
var states = ["pre_print", "printing", "pausing", "paused", "resuming"];
return states.indexOf(printJob.state) !== -1;
}
} }
} }
} }

View File

@ -206,7 +206,11 @@ Item
onClicked: enabled ? contextMenu.switchPopupState() : {} onClicked: enabled ? contextMenu.switchPopupState() : {}
visible: visible:
{ {
if (!printJob) if(!printJob)
{
return false;
}
if(!contextMenu.hasItems)
{ {
return false; return false;
} }

View File

@ -209,8 +209,13 @@ Item
onClicked: enabled ? contextMenu.switchPopupState() : {} onClicked: enabled ? contextMenu.switchPopupState() : {}
visible: visible:
{ {
if (!printer || !printer.activePrintJob) { if(!printer || !printer.activePrintJob)
return false {
return false;
}
if(!contextMenu.hasItems)
{
return false;
} }
var states = ["queued", "error", "sent_to_printer", "pre_print", "printing", "pausing", "paused", "resuming"] var states = ["queued", "error", "sent_to_printer", "pre_print", "printing", "pausing", "paused", "resuming"]
return states.indexOf(printer.activePrintJob.state) !== -1 return states.indexOf(printer.activePrintJob.state) !== -1

View File

@ -39,6 +39,7 @@ Item
} }
height: 18 * screenScaleFactor // TODO: Theme! height: 18 * screenScaleFactor // TODO: Theme!
width: childrenRect.width width: childrenRect.width
visible: OutputDevice.canReadPrinterDetails
UM.ColorImage UM.ColorImage
{ {

View File

@ -69,7 +69,7 @@ Component
top: printers.bottom top: printers.bottom
topMargin: 48 * screenScaleFactor // TODO: Theme! topMargin: 48 * screenScaleFactor // TODO: Theme!
} }
visible: OutputDevice.supportsPrintJobQueue visible: OutputDevice.supportsPrintJobQueue && OutputDevice.canReadPrintJobs
} }
PrinterVideoStream PrinterVideoStream

View File

@ -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. # Cura is released under the terms of the LGPLv3 or higher.
from time import time from time import time
@ -96,6 +96,8 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice):
# Trigger the printersChanged signal when the private signal is triggered. # Trigger the printersChanged signal when the private signal is triggered.
self.printersChanged.connect(self._cloudClusterPrintersChanged) 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 # 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]] self._received_printers = None # type: Optional[List[ClusterPrinterStatus]]
@ -340,6 +342,37 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice):
def openPrinterControlPanel(self) -> None: def openPrinterControlPanel(self) -> None:
QDesktopServices.openUrl(QUrl(self.clusterCloudUrl + "?utm_source=cura&utm_medium=software&utm_campaign=monitor-manage-printer")) 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 @property
def clusterData(self) -> CloudClusterResponse: def clusterData(self) -> CloudClusterResponse:
"""Gets the cluster response from which this device was created.""" """Gets the cluster response from which this device was created."""

View File

@ -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. # Cura is released under the terms of the LGPLv3 or higher.
import os import os
from typing import Optional, Dict, List, Callable, Any from typing import Optional, Dict, List, Callable, Any

View File

@ -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. # Cura is released under the terms of the LGPLv3 or higher.
import os import os
from time import time from time import time
@ -184,6 +184,42 @@ class UltimakerNetworkedPrinterOutputDevice(NetworkedPrinterOutputDevice):
def forceSendJob(self, print_job_uuid: str) -> None: def forceSendJob(self, print_job_uuid: str) -> None:
raise NotImplementedError("forceSendJob must be implemented") 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") @pyqtSlot(name="openPrintJobControlPanel")
def openPrintJobControlPanel(self) -> None: def openPrintJobControlPanel(self) -> None:
raise NotImplementedError("openPrintJobControlPanel must be implemented") raise NotImplementedError("openPrintJobControlPanel must be implemented")

View File

@ -33,63 +33,63 @@ Popup
thumbnail: UM.Theme.getIcon("PrinterTriple", "high"), thumbnail: UM.Theme.getIcon("PrinterTriple", "high"),
description: catalog.i18nc("@tooltip:button", "Monitor printers in Ultimaker Digital Factory."), 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", 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. displayName: "Digital Library", //Not translated, since it's a brand name.
thumbnail: UM.Theme.getIcon("Library", "high"), thumbnail: UM.Theme.getIcon("Library", "high"),
description: catalog.i18nc("@tooltip:button", "Create print projects in Digital Library."), 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", 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"), displayName: catalog.i18nc("@label:button", "Print jobs"),
thumbnail: UM.Theme.getIcon("FoodBeverages"), thumbnail: UM.Theme.getIcon("FoodBeverages"),
description: catalog.i18nc("@tooltip:button", "Monitor print jobs and reprint from your print history."), 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", 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. displayName: "Ultimaker Marketplace", //Not translated, since it's a brand name.
thumbnail: UM.Theme.getIcon("Shop", "high"), thumbnail: UM.Theme.getIcon("Shop", "high"),
description: catalog.i18nc("@tooltip:button", "Extend Ultimaker Cura with plugins and material profiles."), 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", 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. displayName: "Ultimaker Academy", //Not translated, since it's a brand name.
thumbnail: UM.Theme.getIcon("Knowledge"), thumbnail: UM.Theme.getIcon("Knowledge"),
description: catalog.i18nc("@tooltip:button", "Become a 3D printing expert with Ultimaker e-learning."), 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", 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"), displayName: catalog.i18nc("@label:button", "Ultimaker support"),
thumbnail: UM.Theme.getIcon("Help", "high"), thumbnail: UM.Theme.getIcon("Help", "high"),
description: catalog.i18nc("@tooltip:button", "Learn how to get started with Ultimaker Cura."), 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", 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"), displayName: catalog.i18nc("@label:button", "Ask a question"),
thumbnail: UM.Theme.getIcon("Speak", "high"), thumbnail: UM.Theme.getIcon("Speak", "high"),
description: catalog.i18nc("@tooltip:button", "Consult the Ultimaker Community."), description: catalog.i18nc("@tooltip:button", "Consult the Ultimaker Community."),
link: "https://community.ultimaker.com/?utm_source=cura&utm_medium=software&utm_campaign=switcher-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"), displayName: catalog.i18nc("@label:button", "Report a bug"),
thumbnail: UM.Theme.getIcon("Bug", "high"), thumbnail: UM.Theme.getIcon("Bug", "high"),
description: catalog.i18nc("@tooltip:button", "Let developers know that something is going wrong."), description: catalog.i18nc("@tooltip:button", "Let developers know that something is going wrong."),
link: "https://github.com/Ultimaker/Cura/issues/new/choose", link: "https://github.com/Ultimaker/Cura/issues/new/choose",
DFAccessRequired: false permissionsRequired: []
}, },
{ {
displayName: "Ultimaker.com", //Not translated, since it's a URL. displayName: "Ultimaker.com", //Not translated, since it's a URL.
thumbnail: UM.Theme.getIcon("Browser"), thumbnail: UM.Theme.getIcon("Browser"),
description: catalog.i18nc("@tooltip:button", "Visit the Ultimaker website."), description: catalog.i18nc("@tooltip:button", "Visit the Ultimaker website."),
link: "https://ultimaker.com/?utm_source=cura&utm_medium=software&utm_campaign=switcher-umwebsite", 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 iconSource: modelData.thumbnail
tooltipText: modelData.description tooltipText: modelData.description
isExternalLink: true 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) onClicked: Qt.openUrlExternally(modelData.link)
} }

View File

@ -88,7 +88,15 @@ UM.Window
{ {
if(Cura.API.account.isLoggedIn) 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 else
{ {
@ -112,7 +120,15 @@ UM.Window
{ {
if(is_logged_in && signinPage.SwipeView.isCurrentItem) 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;
}
} }
} }
} }

View File

@ -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 from unittest.mock import MagicMock, patch
import pytest import pytest
@ -26,7 +29,8 @@ def test_login():
mocked_auth_service.startAuthorizationFlow.assert_called_once_with(False) mocked_auth_service.startAuthorizationFlow.assert_called_once_with(False)
# Fake a successful login # 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. # Attempting to log in again shouldn't change anything.
account.login() account.login()
@ -59,7 +63,8 @@ def test_logout():
assert not account.isLoggedIn assert not account.isLoggedIn
# Pretend the stage changed # 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 assert account.isLoggedIn
account.logout() account.logout()
@ -72,12 +77,14 @@ def test_errorLoginState(application):
account._authorization_service = mocked_auth_service account._authorization_service = mocked_auth_service
account.loginStateChanged = MagicMock() 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. # Even though we said that the login worked, it had an error message, so the login failed.
account.loginStateChanged.emit.called_with(False) account.loginStateChanged.emit.called_with(False)
account._onLoginStateChanged(True) with patch("UM.TaskManagement.HttpRequestManager.HttpRequestManager.getInstance"):
account._onLoginStateChanged(False, "OMGZOMG!") account._onLoginStateChanged(True)
account._onLoginStateChanged(False, "OMGZOMG!")
account.loginStateChanged.emit.called_with(False) account.loginStateChanged.emit.called_with(False)
def test_sync_success(): def test_sync_success():
@ -86,18 +93,19 @@ def test_sync_success():
service1 = "test_service1" service1 = "test_service1"
service2 = "test_service2" service2 = "test_service2"
account.setSyncState(service1, SyncState.SYNCING) with patch("UM.TaskManagement.HttpRequestManager.HttpRequestManager.getInstance"): # Don't want triggers for account information to actually make HTTP requests.
assert account.syncState == SyncState.SYNCING account.setSyncState(service1, SyncState.SYNCING)
assert account.syncState == SyncState.SYNCING
account.setSyncState(service2, SyncState.SYNCING) account.setSyncState(service2, SyncState.SYNCING)
assert account.syncState == SyncState.SYNCING assert account.syncState == SyncState.SYNCING
account.setSyncState(service1, SyncState.SUCCESS) account.setSyncState(service1, SyncState.SUCCESS)
# service2 still syncing # service2 still syncing
assert account.syncState == SyncState.SYNCING assert account.syncState == SyncState.SYNCING
account.setSyncState(service2, SyncState.SUCCESS) account.setSyncState(service2, SyncState.SUCCESS)
assert account.syncState == SyncState.SUCCESS assert account.syncState == SyncState.SUCCESS
def test_sync_update_action(): def test_sync_update_action():
@ -107,23 +115,24 @@ def test_sync_update_action():
mockUpdateCallback = MagicMock() mockUpdateCallback = MagicMock()
account.setSyncState(service1, SyncState.SYNCING) with patch("UM.TaskManagement.HttpRequestManager.HttpRequestManager.getInstance"): # Don't want triggers for account information to actually make HTTP requests.
assert account.syncState == SyncState.SYNCING account.setSyncState(service1, SyncState.SYNCING)
assert account.syncState == SyncState.SYNCING
account.setUpdatePackagesAction(mockUpdateCallback) account.setUpdatePackagesAction(mockUpdateCallback)
account.onUpdatePackagesClicked() account.onUpdatePackagesClicked()
mockUpdateCallback.assert_called_once_with() mockUpdateCallback.assert_called_once_with()
account.setSyncState(service1, SyncState.SUCCESS) 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) account.setSyncState(service1, SyncState.SYNCING)
assert account.syncState == SyncState.SYNCING assert account.syncState == SyncState.SYNCING
account.onUpdatePackagesClicked() # Should not be connected to an action anymore account.onUpdatePackagesClicked() # Should not be connected to an action anymore
mockUpdateCallback.assert_called_once_with() # No additional calls mockUpdateCallback.assert_called_once_with() # No additional calls
assert account.updatePackagesEnabled is False assert account.updatePackagesEnabled is False
account.setSyncState(service1, SyncState.SUCCESS) account.setSyncState(service1, SyncState.SUCCESS)