|
@@ -1,15 +1,16 @@
|
|
|
# Copyright (c) 2018 Ultimaker B.V.
|
|
|
# Cura is released under the terms of the LGPLv3 or higher.
|
|
|
-from typing import Optional, Dict, TYPE_CHECKING
|
|
|
+from datetime import datetime
|
|
|
+from typing import Optional, Dict, TYPE_CHECKING, Callable
|
|
|
|
|
|
-from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot, pyqtProperty
|
|
|
+from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot, pyqtProperty, QTimer, Q_ENUMS
|
|
|
|
|
|
-from UM.i18n import i18nCatalog
|
|
|
+from UM.Logger import Logger
|
|
|
from UM.Message import Message
|
|
|
-from cura import UltimakerCloudAuthentication
|
|
|
-
|
|
|
+from UM.i18n import i18nCatalog
|
|
|
from cura.OAuth2.AuthorizationService import AuthorizationService
|
|
|
from cura.OAuth2.Models import OAuth2Settings
|
|
|
+from cura.UltimakerCloud import UltimakerCloudConstants
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
|
from cura.CuraApplication import CuraApplication
|
|
@@ -17,29 +18,61 @@ if TYPE_CHECKING:
|
|
|
i18n_catalog = i18nCatalog("cura")
|
|
|
|
|
|
|
|
|
-## The account API provides a version-proof bridge to use Ultimaker Accounts
|
|
|
-#
|
|
|
-# Usage:
|
|
|
-# ``from cura.API import CuraAPI
|
|
|
-# api = CuraAPI()
|
|
|
-# api.account.login()
|
|
|
-# api.account.logout()
|
|
|
-# api.account.userProfile # Who is logged in``
|
|
|
-#
|
|
|
+class SyncState:
|
|
|
+ """QML: Cura.AccountSyncState"""
|
|
|
+ SYNCING = 0
|
|
|
+ SUCCESS = 1
|
|
|
+ ERROR = 2
|
|
|
+ IDLE = 3
|
|
|
+
|
|
|
class Account(QObject):
|
|
|
- # Signal emitted when user logged in or out.
|
|
|
+ """The account API provides a version-proof bridge to use Ultimaker Accounts
|
|
|
+
|
|
|
+ Usage:
|
|
|
+
|
|
|
+ .. code-block:: python
|
|
|
+
|
|
|
+ from cura.API import CuraAPI
|
|
|
+ api = CuraAPI()
|
|
|
+ api.account.login()
|
|
|
+ api.account.logout()
|
|
|
+ api.account.userProfile # Who is logged in
|
|
|
+ """
|
|
|
+
|
|
|
+ # The interval in which sync services are automatically triggered
|
|
|
+ SYNC_INTERVAL = 30.0 # seconds
|
|
|
+ Q_ENUMS(SyncState)
|
|
|
+
|
|
|
loginStateChanged = pyqtSignal(bool)
|
|
|
+ """Signal emitted when user logged in or out"""
|
|
|
+
|
|
|
accessTokenChanged = pyqtSignal()
|
|
|
+ syncRequested = pyqtSignal()
|
|
|
+ """Sync services may connect to this signal to receive sync triggers.
|
|
|
+ Services should be resilient to receiving a signal while they are still syncing,
|
|
|
+ either by ignoring subsequent signals or restarting a sync.
|
|
|
+ See setSyncState() for providing user feedback on the state of your service.
|
|
|
+ """
|
|
|
+ lastSyncDateTimeChanged = pyqtSignal()
|
|
|
+ syncStateChanged = pyqtSignal(int) # because SyncState is an int Enum
|
|
|
+ manualSyncEnabledChanged = pyqtSignal(bool)
|
|
|
+ updatePackagesEnabledChanged = pyqtSignal(bool)
|
|
|
|
|
|
def __init__(self, application: "CuraApplication", parent = None) -> None:
|
|
|
super().__init__(parent)
|
|
|
self._application = application
|
|
|
+ self._new_cloud_printers_detected = False
|
|
|
|
|
|
self._error_message = None # type: Optional[Message]
|
|
|
self._logged_in = False
|
|
|
+ self._sync_state = SyncState.IDLE
|
|
|
+ self._manual_sync_enabled = False
|
|
|
+ self._update_packages_enabled = False
|
|
|
+ self._update_packages_action = None # type: Optional[Callable]
|
|
|
+ self._last_sync_str = "-"
|
|
|
|
|
|
self._callback_port = 32118
|
|
|
- self._oauth_root = UltimakerCloudAuthentication.CuraCloudAccountAPIRoot
|
|
|
+ self._oauth_root = UltimakerCloudConstants.CuraCloudAccountAPIRoot
|
|
|
|
|
|
self._oauth_settings = OAuth2Settings(
|
|
|
OAUTH_SERVER_URL= self._oauth_root,
|
|
@@ -56,6 +89,16 @@ class Account(QObject):
|
|
|
|
|
|
self._authorization_service = AuthorizationService(self._oauth_settings)
|
|
|
|
|
|
+ # Create a timer for automatic account sync
|
|
|
+ self._update_timer = QTimer()
|
|
|
+ self._update_timer.setInterval(int(self.SYNC_INTERVAL * 1000))
|
|
|
+ # The timer is restarted explicitly after an update was processed. This prevents 2 concurrent updates
|
|
|
+ self._update_timer.setSingleShot(True)
|
|
|
+ self._update_timer.timeout.connect(self.sync)
|
|
|
+
|
|
|
+ self._sync_services = {} # type: Dict[str, int]
|
|
|
+ """contains entries "service_name" : SyncState"""
|
|
|
+
|
|
|
def initialize(self) -> None:
|
|
|
self._authorization_service.initialize(self._application.getPreferences())
|
|
|
self._authorization_service.onAuthStateChanged.connect(self._onLoginStateChanged)
|
|
@@ -63,12 +106,65 @@ class Account(QObject):
|
|
|
self._authorization_service.accessTokenChanged.connect(self._onAccessTokenChanged)
|
|
|
self._authorization_service.loadAuthDataFromPreferences()
|
|
|
|
|
|
+
|
|
|
+ @pyqtProperty(int, notify=syncStateChanged)
|
|
|
+ def syncState(self):
|
|
|
+ return self._sync_state
|
|
|
+
|
|
|
+ def setSyncState(self, service_name: str, state: int) -> None:
|
|
|
+ """ Can be used to register sync services and update account sync states
|
|
|
+
|
|
|
+ Contract: A sync service is expected exit syncing state in all cases, within reasonable time
|
|
|
+
|
|
|
+ Example: `setSyncState("PluginSyncService", SyncState.SYNCING)`
|
|
|
+ :param service_name: A unique name for your service, such as `plugins` or `backups`
|
|
|
+ :param state: One of SyncState
|
|
|
+ """
|
|
|
+ prev_state = self._sync_state
|
|
|
+
|
|
|
+ self._sync_services[service_name] = state
|
|
|
+
|
|
|
+ if any(val == SyncState.SYNCING for val in self._sync_services.values()):
|
|
|
+ self._sync_state = SyncState.SYNCING
|
|
|
+ self._setManualSyncEnabled(False)
|
|
|
+ elif any(val == SyncState.ERROR for val in self._sync_services.values()):
|
|
|
+ self._sync_state = SyncState.ERROR
|
|
|
+ self._setManualSyncEnabled(True)
|
|
|
+ else:
|
|
|
+ self._sync_state = SyncState.SUCCESS
|
|
|
+ self._setManualSyncEnabled(False)
|
|
|
+
|
|
|
+ if self._sync_state != prev_state:
|
|
|
+ self.syncStateChanged.emit(self._sync_state)
|
|
|
+
|
|
|
+ if self._sync_state == SyncState.SUCCESS:
|
|
|
+ self._last_sync_str = datetime.now().strftime("%d/%m/%Y %H:%M")
|
|
|
+ self.lastSyncDateTimeChanged.emit()
|
|
|
+
|
|
|
+ if self._sync_state != SyncState.SYNCING:
|
|
|
+ # schedule new auto update after syncing completed (for whatever reason)
|
|
|
+ if not self._update_timer.isActive():
|
|
|
+ self._update_timer.start()
|
|
|
+
|
|
|
+ def setUpdatePackagesAction(self, action: Callable) -> None:
|
|
|
+ """ Set the callback which will be invoked when the user clicks the update packages button
|
|
|
+
|
|
|
+ Should be invoked after your service sets the sync state to SYNCING and before setting the
|
|
|
+ sync state to SUCCESS.
|
|
|
+
|
|
|
+ Action will be reset to None when the next sync starts
|
|
|
+ """
|
|
|
+ self._update_packages_action = action
|
|
|
+ self._update_packages_enabled = True
|
|
|
+ self.updatePackagesEnabledChanged.emit(self._update_packages_enabled)
|
|
|
+
|
|
|
def _onAccessTokenChanged(self):
|
|
|
self.accessTokenChanged.emit()
|
|
|
|
|
|
- ## Returns a boolean indicating whether the given authentication is applied against staging or not.
|
|
|
@property
|
|
|
def is_staging(self) -> bool:
|
|
|
+ """Indication whether the given authentication is applied against staging or not."""
|
|
|
+
|
|
|
return "staging" in self._oauth_root
|
|
|
|
|
|
@pyqtProperty(bool, notify=loginStateChanged)
|
|
@@ -83,18 +179,60 @@ class Account(QObject):
|
|
|
self._error_message.show()
|
|
|
self._logged_in = False
|
|
|
self.loginStateChanged.emit(False)
|
|
|
+ if self._update_timer.isActive():
|
|
|
+ self._update_timer.stop()
|
|
|
return
|
|
|
|
|
|
if self._logged_in != logged_in:
|
|
|
self._logged_in = logged_in
|
|
|
self.loginStateChanged.emit(logged_in)
|
|
|
+ if logged_in:
|
|
|
+ self._setManualSyncEnabled(False)
|
|
|
+ self._sync()
|
|
|
+ else:
|
|
|
+ if self._update_timer.isActive():
|
|
|
+ self._update_timer.stop()
|
|
|
+
|
|
|
+ def _sync(self) -> None:
|
|
|
+ """Signals all sync services to start syncing
|
|
|
+
|
|
|
+ This can be considered a forced sync: even when a
|
|
|
+ sync is currently running, a sync will be requested.
|
|
|
+ """
|
|
|
+
|
|
|
+ self._update_packages_action = None
|
|
|
+ self._update_packages_enabled = False
|
|
|
+ self.updatePackagesEnabledChanged.emit(self._update_packages_enabled)
|
|
|
+ if self._update_timer.isActive():
|
|
|
+ self._update_timer.stop()
|
|
|
+ elif self._sync_state == SyncState.SYNCING:
|
|
|
+ Logger.warning("Starting a new sync while previous sync was not completed\n{}", str(self._sync_services))
|
|
|
+
|
|
|
+ self.syncRequested.emit()
|
|
|
+
|
|
|
+ def _setManualSyncEnabled(self, enabled: bool) -> None:
|
|
|
+ if self._manual_sync_enabled != enabled:
|
|
|
+ self._manual_sync_enabled = enabled
|
|
|
+ self.manualSyncEnabledChanged.emit(enabled)
|
|
|
|
|
|
@pyqtSlot()
|
|
|
- def login(self) -> None:
|
|
|
+ @pyqtSlot(bool)
|
|
|
+ def login(self, force_logout_before_login: bool = False) -> None:
|
|
|
+ """
|
|
|
+ Initializes the login process. If the user is logged in already and force_logout_before_login is true, Cura will
|
|
|
+ logout from the account before initiating the authorization flow. If the user is logged in and
|
|
|
+ force_logout_before_login is false, the function will return, as there is nothing to do.
|
|
|
+
|
|
|
+ :param force_logout_before_login: Optional boolean parameter
|
|
|
+ :return: None
|
|
|
+ """
|
|
|
if self._logged_in:
|
|
|
- # Nothing to do, user already logged in.
|
|
|
- return
|
|
|
- self._authorization_service.startAuthorizationFlow()
|
|
|
+ if force_logout_before_login:
|
|
|
+ self.logout()
|
|
|
+ else:
|
|
|
+ # Nothing to do, user already logged in.
|
|
|
+ return
|
|
|
+ self._authorization_service.startAuthorizationFlow(force_logout_before_login)
|
|
|
|
|
|
@pyqtProperty(str, notify=loginStateChanged)
|
|
|
def userName(self):
|
|
@@ -114,15 +252,44 @@ class Account(QObject):
|
|
|
def accessToken(self) -> Optional[str]:
|
|
|
return self._authorization_service.getAccessToken()
|
|
|
|
|
|
- # Get the profile of the logged in user
|
|
|
- # @returns None if no user is logged in, a dict containing user_id, username and profile_image_url
|
|
|
@pyqtProperty("QVariantMap", notify = loginStateChanged)
|
|
|
def userProfile(self) -> Optional[Dict[str, Optional[str]]]:
|
|
|
+ """None if no user is logged in otherwise the logged in user as a dict containing containing user_id, username and profile_image_url """
|
|
|
+
|
|
|
user_profile = self._authorization_service.getUserProfile()
|
|
|
if not user_profile:
|
|
|
return None
|
|
|
return user_profile.__dict__
|
|
|
|
|
|
+ @pyqtProperty(str, notify=lastSyncDateTimeChanged)
|
|
|
+ def lastSyncDateTime(self) -> str:
|
|
|
+ return self._last_sync_str
|
|
|
+
|
|
|
+ @pyqtProperty(bool, notify=manualSyncEnabledChanged)
|
|
|
+ def manualSyncEnabled(self) -> bool:
|
|
|
+ return self._manual_sync_enabled
|
|
|
+
|
|
|
+ @pyqtProperty(bool, notify=updatePackagesEnabledChanged)
|
|
|
+ def updatePackagesEnabled(self) -> bool:
|
|
|
+ return self._update_packages_enabled
|
|
|
+
|
|
|
+ @pyqtSlot()
|
|
|
+ @pyqtSlot(bool)
|
|
|
+ def sync(self, user_initiated: bool = False) -> None:
|
|
|
+ if user_initiated:
|
|
|
+ self._setManualSyncEnabled(False)
|
|
|
+
|
|
|
+ self._sync()
|
|
|
+
|
|
|
+ @pyqtSlot()
|
|
|
+ def onUpdatePackagesClicked(self) -> None:
|
|
|
+ if self._update_packages_action is not None:
|
|
|
+ self._update_packages_action()
|
|
|
+
|
|
|
+ @pyqtSlot()
|
|
|
+ def popupOpened(self) -> None:
|
|
|
+ self._setManualSyncEnabled(True)
|
|
|
+
|
|
|
@pyqtSlot()
|
|
|
def logout(self) -> None:
|
|
|
if not self._logged_in:
|