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.
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
)

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.
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

View File

@ -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)

View File

@ -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
}
}
}

View File

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

View File

@ -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

View File

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

View File

@ -69,7 +69,7 @@ Component
top: printers.bottom
topMargin: 48 * screenScaleFactor // TODO: Theme!
}
visible: OutputDevice.supportsPrintJobQueue
visible: OutputDevice.supportsPrintJobQueue && OutputDevice.canReadPrintJobs
}
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.
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."""

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.
import os
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.
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")

View File

@ -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)
}

View File

@ -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;
}
}
}
}

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
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)