diff --git a/cura/API/Account.py b/cura/API/Account.py new file mode 100644 index 0000000000..bc1ce8c2b9 --- /dev/null +++ b/cura/API/Account.py @@ -0,0 +1,117 @@ +# 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 PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot, pyqtProperty + +from UM.i18n import i18nCatalog +from UM.Message import Message + +from cura.OAuth2.AuthorizationService import AuthorizationService +from cura.OAuth2.Models import OAuth2Settings + +if TYPE_CHECKING: + from cura.CuraApplication import CuraApplication + +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 Account(QObject): + # Signal emitted when user logged in or out. + loginStateChanged = pyqtSignal(bool) + + def __init__(self, application: "CuraApplication", parent = None) -> None: + super().__init__(parent) + self._application = application + + self._error_message = None # type: Optional[Message] + self._logged_in = False + + self._callback_port = 32118 + self._oauth_root = "https://account.ultimaker.com" + self._cloud_api_root = "https://api.ultimaker.com" + + self._oauth_settings = OAuth2Settings( + OAUTH_SERVER_URL= self._oauth_root, + CALLBACK_PORT=self._callback_port, + CALLBACK_URL="http://localhost:{}/callback".format(self._callback_port), + CLIENT_ID="um---------------ultimaker_cura_drive_plugin", + CLIENT_SCOPES="user.read drive.backups.read drive.backups.write", + AUTH_DATA_PREFERENCE_KEY="general/ultimaker_auth_data", + AUTH_SUCCESS_REDIRECT="{}/app/auth-success".format(self._oauth_root), + AUTH_FAILED_REDIRECT="{}/app/auth-error".format(self._oauth_root) + ) + + self._authorization_service = AuthorizationService(self._oauth_settings) + + def initialize(self) -> None: + self._authorization_service.initialize(self._application.getPreferences()) + + self._authorization_service.onAuthStateChanged.connect(self._onLoginStateChanged) + self._authorization_service.onAuthenticationError.connect(self._onLoginStateChanged) + self._authorization_service.loadAuthDataFromPreferences() + + @pyqtProperty(bool, notify=loginStateChanged) + def isLoggedIn(self) -> bool: + return self._logged_in + + def _onLoginStateChanged(self, logged_in: bool = False, error_message: Optional[str] = None) -> None: + if error_message: + if self._error_message: + self._error_message.hide() + self._error_message = Message(error_message, title = i18n_catalog.i18nc("@info:title", "Login failed")) + self._error_message.show() + + if self._logged_in != logged_in: + self._logged_in = logged_in + self.loginStateChanged.emit(logged_in) + + @pyqtSlot() + def login(self) -> None: + if self._logged_in: + # Nothing to do, user already logged in. + return + self._authorization_service.startAuthorizationFlow() + + @pyqtProperty(str, notify=loginStateChanged) + def userName(self): + user_profile = self._authorization_service.getUserProfile() + if not user_profile: + return None + return user_profile.username + + @pyqtProperty(str, notify = loginStateChanged) + def profileImageUrl(self): + user_profile = self._authorization_service.getUserProfile() + if not user_profile: + return None + return user_profile.profile_image_url + + @pyqtProperty(str, notify=loginStateChanged) + 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]]]: + user_profile = self._authorization_service.getUserProfile() + if not user_profile: + return None + return user_profile.__dict__ + + @pyqtSlot() + def logout(self) -> None: + if not self._logged_in: + return # Nothing to do, user isn't logged in. + + self._authorization_service.deleteAuthData() diff --git a/cura/API/Backups.py b/cura/API/Backups.py index f31933c844..8e5cd7b83a 100644 --- a/cura/API/Backups.py +++ b/cura/API/Backups.py @@ -1,9 +1,12 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from typing import Tuple, Optional +from typing import Tuple, Optional, TYPE_CHECKING from cura.Backups.BackupsManager import BackupsManager +if TYPE_CHECKING: + from cura.CuraApplication import CuraApplication + ## The back-ups API provides a version-proof bridge between Cura's # BackupManager and plug-ins that hook into it. @@ -13,9 +16,10 @@ from cura.Backups.BackupsManager import BackupsManager # api = CuraAPI() # api.backups.createBackup() # api.backups.restoreBackup(my_zip_file, {"cura_release": "3.1"})`` - class Backups: - manager = BackupsManager() # Re-used instance of the backups manager. + + def __init__(self, application: "CuraApplication") -> None: + self.manager = BackupsManager(application) ## Create a new back-up using the BackupsManager. # \return Tuple containing a ZIP file with the back-up data and a dict diff --git a/cura/API/Interface/Settings.py b/cura/API/Interface/Settings.py index 2889db7022..371c40c14c 100644 --- a/cura/API/Interface/Settings.py +++ b/cura/API/Interface/Settings.py @@ -1,7 +1,11 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from cura.CuraApplication import CuraApplication +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from cura.CuraApplication import CuraApplication + ## The Interface.Settings API provides a version-proof bridge between Cura's # (currently) sidebar UI and plug-ins that hook into it. @@ -19,8 +23,9 @@ from cura.CuraApplication import CuraApplication # api.interface.settings.addContextMenuItem(data)`` class Settings: - # Re-used instance of Cura: - application = CuraApplication.getInstance() # type: CuraApplication + + def __init__(self, application: "CuraApplication") -> None: + self.application = application ## Add items to the sidebar context menu. # \param menu_item dict containing the menu item to add. @@ -30,4 +35,4 @@ class Settings: ## Get all custom items currently added to the sidebar context menu. # \return List containing all custom context menu items. def getContextMenuItems(self) -> list: - return self.application.getSidebarCustomMenuItems() \ No newline at end of file + return self.application.getSidebarCustomMenuItems() diff --git a/cura/API/Interface/__init__.py b/cura/API/Interface/__init__.py index b38118949b..742254a1a4 100644 --- a/cura/API/Interface/__init__.py +++ b/cura/API/Interface/__init__.py @@ -1,9 +1,15 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. +from typing import TYPE_CHECKING + from UM.PluginRegistry import PluginRegistry from cura.API.Interface.Settings import Settings +if TYPE_CHECKING: + from cura.CuraApplication import CuraApplication + + ## The Interface class serves as a common root for the specific API # methods for each interface element. # @@ -20,5 +26,6 @@ class Interface: # For now we use the same API version to be consistent. VERSION = PluginRegistry.APIVersion - # API methods specific to the settings portion of the UI - settings = Settings() + def __init__(self, application: "CuraApplication") -> None: + # API methods specific to the settings portion of the UI + self.settings = Settings(application) diff --git a/cura/API/__init__.py b/cura/API/__init__.py index 64d636903d..ad07452c1a 100644 --- a/cura/API/__init__.py +++ b/cura/API/__init__.py @@ -1,8 +1,17 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. +from typing import Optional, TYPE_CHECKING + +from PyQt5.QtCore import QObject, pyqtProperty + from UM.PluginRegistry import PluginRegistry from cura.API.Backups import Backups from cura.API.Interface import Interface +from cura.API.Account import Account + +if TYPE_CHECKING: + from cura.CuraApplication import CuraApplication + ## The official Cura API that plug-ins can use to interact with Cura. # @@ -10,14 +19,47 @@ from cura.API.Interface import Interface # this API provides a version-safe interface with proper deprecation warnings # etc. Usage of any other methods than the ones provided in this API can cause # plug-ins to be unstable. - -class CuraAPI: +class CuraAPI(QObject): # For now we use the same API version to be consistent. VERSION = PluginRegistry.APIVersion + __instance = None # type: "CuraAPI" + _application = None # type: CuraApplication - # Backups API - backups = Backups() + # This is done to ensure that the first time an instance is created, it's forced that the application is set. + # The main reason for this is that we want to prevent consumers of API to have a dependency on CuraApplication. + # Since the API is intended to be used by plugins, the cura application should have already created this. + def __new__(cls, application: Optional["CuraApplication"] = None): + if cls.__instance is None: + if application is None: + raise Exception("Upon first time creation, the application must be set.") + cls.__instance = super(CuraAPI, cls).__new__(cls) + cls._application = application + return cls.__instance - # Interface API - interface = Interface() + def __init__(self, application: Optional["CuraApplication"] = None) -> None: + super().__init__(parent = CuraAPI._application) + + # Accounts API + self._account = Account(self._application) + + # Backups API + self._backups = Backups(self._application) + + # Interface API + self._interface = Interface(self._application) + + def initialize(self) -> None: + self._account.initialize() + + @pyqtProperty(QObject, constant = True) + def account(self) -> "Account": + return self._account + + @property + def backups(self) -> "Backups": + return self._backups + + @property + def interface(self) -> "Interface": + return self._interface \ No newline at end of file diff --git a/cura/Backups/Backup.py b/cura/Backups/Backup.py index cc47df770e..897d5fa979 100644 --- a/cura/Backups/Backup.py +++ b/cura/Backups/Backup.py @@ -4,18 +4,18 @@ import io import os import re - import shutil - -from typing import Dict, Optional from zipfile import ZipFile, ZIP_DEFLATED, BadZipfile +from typing import Dict, Optional, TYPE_CHECKING from UM import i18nCatalog from UM.Logger import Logger from UM.Message import Message from UM.Platform import Platform from UM.Resources import Resources -from cura.CuraApplication import CuraApplication + +if TYPE_CHECKING: + from cura.CuraApplication import CuraApplication ## The back-up class holds all data about a back-up. @@ -29,24 +29,25 @@ class Backup: # Re-use translation catalog. catalog = i18nCatalog("cura") - def __init__(self, zip_file: bytes = None, meta_data: Dict[str, str] = None) -> None: + def __init__(self, application: "CuraApplication", zip_file: bytes = None, meta_data: Dict[str, str] = None) -> None: + self._application = application self.zip_file = zip_file # type: Optional[bytes] self.meta_data = meta_data # type: Optional[Dict[str, str]] ## Create a back-up from the current user config folder. def makeFromCurrent(self) -> None: - cura_release = CuraApplication.getInstance().getVersion() + cura_release = self._application.getVersion() version_data_dir = Resources.getDataStoragePath() Logger.log("d", "Creating backup for Cura %s, using folder %s", cura_release, version_data_dir) # Ensure all current settings are saved. - CuraApplication.getInstance().saveSettings() + self._application.saveSettings() # We copy the preferences file to the user data directory in Linux as it's in a different location there. # When restoring a backup on Linux, we move it back. if Platform.isLinux(): - preferences_file_name = CuraApplication.getInstance().getApplicationName() + preferences_file_name = self._application.getApplicationName() preferences_file = Resources.getPath(Resources.Preferences, "{}.cfg".format(preferences_file_name)) backup_preferences_file = os.path.join(version_data_dir, "{}.cfg".format(preferences_file_name)) Logger.log("d", "Copying preferences file from %s to %s", preferences_file, backup_preferences_file) @@ -58,7 +59,7 @@ class Backup: if archive is None: return files = archive.namelist() - + # Count the metadata items. We do this in a rather naive way at the moment. machine_count = len([s for s in files if "machine_instances/" in s]) - 1 material_count = len([s for s in files if "materials/" in s]) - 1 @@ -112,7 +113,7 @@ class Backup: "Tried to restore a Cura backup without having proper data or meta data.")) return False - current_version = CuraApplication.getInstance().getVersion() + current_version = self._application.getVersion() version_to_restore = self.meta_data.get("cura_release", "master") if current_version != version_to_restore: # Cannot restore version older or newer than current because settings might have changed. @@ -128,7 +129,7 @@ class Backup: # Under Linux, preferences are stored elsewhere, so we copy the file to there. if Platform.isLinux(): - preferences_file_name = CuraApplication.getInstance().getApplicationName() + preferences_file_name = self._application.getApplicationName() preferences_file = Resources.getPath(Resources.Preferences, "{}.cfg".format(preferences_file_name)) backup_preferences_file = os.path.join(version_data_dir, "{}.cfg".format(preferences_file_name)) Logger.log("d", "Moving preferences file from %s to %s", backup_preferences_file, preferences_file) diff --git a/cura/Backups/BackupsManager.py b/cura/Backups/BackupsManager.py index 67e2a222f1..a0d3881209 100644 --- a/cura/Backups/BackupsManager.py +++ b/cura/Backups/BackupsManager.py @@ -1,11 +1,13 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from typing import Dict, Optional, Tuple +from typing import Dict, Optional, Tuple, TYPE_CHECKING from UM.Logger import Logger from cura.Backups.Backup import Backup -from cura.CuraApplication import CuraApplication + +if TYPE_CHECKING: + from cura.CuraApplication import CuraApplication ## The BackupsManager is responsible for managing the creating and restoring of @@ -13,15 +15,15 @@ from cura.CuraApplication import CuraApplication # # Back-ups themselves are represented in a different class. class BackupsManager: - def __init__(self): - self._application = CuraApplication.getInstance() + def __init__(self, application: "CuraApplication") -> None: + self._application = application ## Get a back-up of the current configuration. # \return A tuple containing a ZipFile (the actual back-up) and a dict # containing some metadata (like version). def createBackup(self) -> Tuple[Optional[bytes], Optional[Dict[str, str]]]: self._disableAutoSave() - backup = Backup() + backup = Backup(self._application) backup.makeFromCurrent() self._enableAutoSave() # We don't return a Backup here because we want plugins only to interact with our API and not full objects. @@ -39,7 +41,7 @@ class BackupsManager: self._disableAutoSave() - backup = Backup(zip_file = zip_file, meta_data = meta_data) + backup = Backup(self._application, zip_file = zip_file, meta_data = meta_data) restored = backup.restore() if restored: # At this point, Cura will need to restart for the changes to take effect. diff --git a/cura/CuraApplication.py b/cura/CuraApplication.py index 65e95f1c11..67bdd5805e 100755 --- a/cura/CuraApplication.py +++ b/cura/CuraApplication.py @@ -44,6 +44,7 @@ from UM.Operations.RemoveSceneNodeOperation import RemoveSceneNodeOperation from UM.Operations.GroupedOperation import GroupedOperation from UM.Operations.SetTransformOperation import SetTransformOperation +from cura.API import CuraAPI from cura.Arranging.Arrange import Arrange from cura.Arranging.ArrangeObjectsJob import ArrangeObjectsJob from cura.Arranging.ArrangeObjectsAllBuildPlatesJob import ArrangeObjectsAllBuildPlatesJob @@ -61,6 +62,7 @@ from cura.Scene.CuraSceneController import CuraSceneController from UM.Settings.SettingDefinition import SettingDefinition, DefinitionPropertyType from UM.Settings.ContainerRegistry import ContainerRegistry from UM.Settings.SettingFunction import SettingFunction +from cura.Settings.CuraContainerRegistry import CuraContainerRegistry from cura.Settings.MachineNameValidator import MachineNameValidator from cura.Machines.Models.BuildPlateModel import BuildPlateModel @@ -203,6 +205,7 @@ class CuraApplication(QtApplication): self._quality_profile_drop_down_menu_model = None self._custom_quality_profile_drop_down_menu_model = None + self._cura_API = CuraAPI(self) self._physics = None self._volume = None @@ -241,6 +244,8 @@ class CuraApplication(QtApplication): from cura.Settings.CuraContainerRegistry import CuraContainerRegistry self._container_registry_class = CuraContainerRegistry + # Redefined here in order to please the typing. + self._container_registry = None # type: CuraContainerRegistry from cura.CuraPackageManager import CuraPackageManager self._package_manager_class = CuraPackageManager @@ -265,6 +270,9 @@ class CuraApplication(QtApplication): help = "FOR TESTING ONLY. Trigger an early crash to show the crash dialog.") self._cli_parser.add_argument("file", nargs = "*", help = "Files to load after starting the application.") + def getContainerRegistry(self) -> "CuraContainerRegistry": + return self._container_registry + def parseCliOptions(self): super().parseCliOptions() @@ -674,7 +682,7 @@ class CuraApplication(QtApplication): Logger.log("i", "Initializing quality manager") from cura.Machines.QualityManager import QualityManager - self._quality_manager = QualityManager(container_registry, parent = self) + self._quality_manager = QualityManager(self, parent = self) self._quality_manager.initialize() Logger.log("i", "Initializing machine manager") @@ -706,6 +714,9 @@ class CuraApplication(QtApplication): default_visibility_profile = self._setting_visibility_presets_model.getItem(0) self.getPreferences().setDefault("general/visible_settings", ";".join(default_visibility_profile["settings"])) + # Initialize Cura API + self._cura_API.initialize() + # Detect in which mode to run and execute that mode if self._is_headless: self.runWithoutGUI() @@ -893,6 +904,9 @@ class CuraApplication(QtApplication): self._custom_quality_profile_drop_down_menu_model = CustomQualityProfilesDropDownMenuModel(self) return self._custom_quality_profile_drop_down_menu_model + def getCuraAPI(self, *args, **kwargs) -> "CuraAPI": + return self._cura_API + ## Registers objects for the QML engine to use. # # \param engine The QML engine. @@ -941,6 +955,9 @@ class CuraApplication(QtApplication): qmlRegisterSingletonType(ContainerManager, "Cura", 1, 0, "ContainerManager", ContainerManager.getInstance) qmlRegisterType(SidebarCustomMenuItemsModel, "Cura", 1, 0, "SidebarCustomMenuItemsModel") + from cura.API import CuraAPI + qmlRegisterSingletonType(CuraAPI, "Cura", 1, 1, "API", self.getCuraAPI) + # As of Qt5.7, it is necessary to get rid of any ".." in the path for the singleton to work. actions_url = QUrl.fromLocalFile(os.path.abspath(Resources.getPath(CuraApplication.ResourceTypes.QmlFiles, "Actions.qml"))) qmlRegisterSingletonType(actions_url, "Cura", 1, 0, "Actions") diff --git a/cura/Machines/Models/SettingVisibilityPresetsModel.py b/cura/Machines/Models/SettingVisibilityPresetsModel.py index d5fa51d20a..7e098197a9 100644 --- a/cura/Machines/Models/SettingVisibilityPresetsModel.py +++ b/cura/Machines/Models/SettingVisibilityPresetsModel.py @@ -1,7 +1,7 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from typing import Optional +from typing import Optional, List, Dict, Union import os import urllib.parse from configparser import ConfigParser @@ -60,7 +60,7 @@ class SettingVisibilityPresetsModel(ListModel): def _populate(self) -> None: from cura.CuraApplication import CuraApplication - items = [] + items = [] # type: List[Dict[str, Union[str, int, List[str]]]] for file_path in Resources.getAllResourcesOfType(CuraApplication.ResourceTypes.SettingVisibilityPreset): try: mime_type = MimeTypeDatabase.getMimeTypeForFile(file_path) @@ -79,7 +79,7 @@ class SettingVisibilityPresetsModel(ListModel): if not parser.has_option("general", "name") or not parser.has_option("general", "weight"): continue - settings = [] + settings = [] # type: List[str] for section in parser.sections(): if section == 'general': continue @@ -98,7 +98,7 @@ class SettingVisibilityPresetsModel(ListModel): except Exception: Logger.logException("e", "Failed to load setting preset %s", file_path) - items.sort(key = lambda k: (int(k["weight"]), k["id"])) + items.sort(key = lambda k: (int(k["weight"]), k["id"])) # type: ignore # Put "custom" at the top items.insert(0, {"id": "custom", "name": "Custom selection", diff --git a/cura/Machines/QualityManager.py b/cura/Machines/QualityManager.py index 21abb5a9cc..ce19624c21 100644 --- a/cura/Machines/QualityManager.py +++ b/cura/Machines/QualityManager.py @@ -5,7 +5,6 @@ from typing import TYPE_CHECKING, Optional, cast, Dict, List from PyQt5.QtCore import QObject, QTimer, pyqtSignal, pyqtSlot -from UM.Application import Application from UM.ConfigurationErrorMessage import ConfigurationErrorMessage from UM.Logger import Logger from UM.Util import parseBool @@ -21,7 +20,6 @@ if TYPE_CHECKING: from cura.Settings.GlobalStack import GlobalStack from .QualityChangesGroup import QualityChangesGroup from cura.CuraApplication import CuraApplication - from UM.Settings.ContainerRegistry import ContainerRegistry # @@ -38,11 +36,11 @@ class QualityManager(QObject): qualitiesUpdated = pyqtSignal() - def __init__(self, container_registry: "ContainerRegistry", parent = None) -> None: + def __init__(self, application: "CuraApplication", parent = None) -> None: super().__init__(parent) - self._application = Application.getInstance() # type: CuraApplication + self._application = application self._material_manager = self._application.getMaterialManager() - self._container_registry = container_registry + self._container_registry = self._application.getContainerRegistry() self._empty_quality_container = self._application.empty_quality_container self._empty_quality_changes_container = self._application.empty_quality_changes_container @@ -458,7 +456,7 @@ class QualityManager(QObject): # stack and clear the user settings. @pyqtSlot(str) def createQualityChanges(self, base_name: str) -> None: - machine_manager = Application.getInstance().getMachineManager() + machine_manager = self._application.getMachineManager() global_stack = machine_manager.activeMachine if not global_stack: diff --git a/cura/OAuth2/AuthorizationHelpers.py b/cura/OAuth2/AuthorizationHelpers.py new file mode 100644 index 0000000000..f75ad9c9f9 --- /dev/null +++ b/cura/OAuth2/AuthorizationHelpers.py @@ -0,0 +1,112 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +import json +import random +from hashlib import sha512 +from base64 import b64encode +from typing import Dict, Optional + +import requests + +from UM.Logger import Logger + +from cura.OAuth2.Models import AuthenticationResponse, UserProfile, OAuth2Settings + + +# Class containing several helpers to deal with the authorization flow. +class AuthorizationHelpers: + def __init__(self, settings: "OAuth2Settings") -> None: + self._settings = settings + self._token_url = "{}/token".format(self._settings.OAUTH_SERVER_URL) + + @property + # The OAuth2 settings object. + def settings(self) -> "OAuth2Settings": + return self._settings + + # Request the access token from the authorization server. + # \param authorization_code: The authorization code from the 1st step. + # \param verification_code: The verification code needed for the PKCE extension. + # \return: An AuthenticationResponse object. + def getAccessTokenUsingAuthorizationCode(self, authorization_code: str, verification_code: str) -> "AuthenticationResponse": + data = { + "client_id": self._settings.CLIENT_ID if self._settings.CLIENT_ID is not None else "", + "redirect_uri": self._settings.CALLBACK_URL if self._settings.CALLBACK_URL is not None else "", + "grant_type": "authorization_code", + "code": authorization_code, + "code_verifier": verification_code, + "scope": self._settings.CLIENT_SCOPES if self._settings.CLIENT_SCOPES is not None else "", + } + return self.parseTokenResponse(requests.post(self._token_url, data = data)) # type: ignore + + # Request the access token from the authorization server using a refresh token. + # \param refresh_token: + # \return: An AuthenticationResponse object. + def getAccessTokenUsingRefreshToken(self, refresh_token: str) -> "AuthenticationResponse": + data = { + "client_id": self._settings.CLIENT_ID if self._settings.CLIENT_ID is not None else "", + "redirect_uri": self._settings.CALLBACK_URL if self._settings.CALLBACK_URL is not None else "", + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "scope": self._settings.CLIENT_SCOPES if self._settings.CLIENT_SCOPES is not None else "", + } + return self.parseTokenResponse(requests.post(self._token_url, data = data)) # type: ignore + + @staticmethod + # Parse the token response from the authorization server into an AuthenticationResponse object. + # \param token_response: The JSON string data response from the authorization server. + # \return: An AuthenticationResponse object. + def parseTokenResponse(token_response: requests.models.Response) -> "AuthenticationResponse": + token_data = None + + try: + token_data = json.loads(token_response.text) + except ValueError: + Logger.log("w", "Could not parse token response data: %s", token_response.text) + + if not token_data: + return AuthenticationResponse(success=False, err_message="Could not read response.") + + if token_response.status_code not in (200, 201): + return AuthenticationResponse(success=False, err_message=token_data["error_description"]) + + return AuthenticationResponse(success=True, + token_type=token_data["token_type"], + access_token=token_data["access_token"], + refresh_token=token_data["refresh_token"], + expires_in=token_data["expires_in"], + scope=token_data["scope"]) + + # Calls the authentication API endpoint to get the token data. + # \param access_token: The encoded JWT token. + # \return: Dict containing some profile data. + def parseJWT(self, access_token: str) -> Optional["UserProfile"]: + token_request = requests.get("{}/check-token".format(self._settings.OAUTH_SERVER_URL), headers = { + "Authorization": "Bearer {}".format(access_token) + }) + if token_request.status_code not in (200, 201): + Logger.log("w", "Could not retrieve token data from auth server: %s", token_request.text) + return None + user_data = token_request.json().get("data") + if not user_data or not isinstance(user_data, dict): + Logger.log("w", "Could not parse user data from token: %s", user_data) + return None + return UserProfile( + user_id = user_data["user_id"], + username = user_data["username"], + profile_image_url = user_data.get("profile_image_url", "") + ) + + @staticmethod + # Generate a 16-character verification code. + # \param code_length: How long should the code be? + def generateVerificationCode(code_length: int = 16) -> str: + return "".join(random.choice("0123456789ABCDEF") for i in range(code_length)) + + @staticmethod + # Generates a base64 encoded sha512 encrypted version of a given string. + # \param verification_code: + # \return: The encrypted code in base64 format. + def generateVerificationCodeChallenge(verification_code: str) -> str: + encoded = sha512(verification_code.encode()).digest() + return b64encode(encoded, altchars = b"_-").decode() diff --git a/cura/OAuth2/AuthorizationRequestHandler.py b/cura/OAuth2/AuthorizationRequestHandler.py new file mode 100644 index 0000000000..7e0a659a56 --- /dev/null +++ b/cura/OAuth2/AuthorizationRequestHandler.py @@ -0,0 +1,101 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from typing import Optional, Callable, Tuple, Dict, Any, List, TYPE_CHECKING + +from http.server import BaseHTTPRequestHandler +from urllib.parse import parse_qs, urlparse + +from cura.OAuth2.Models import AuthenticationResponse, ResponseData, HTTP_STATUS + +if TYPE_CHECKING: + from cura.OAuth2.Models import ResponseStatus + from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers + + +# This handler handles all HTTP requests on the local web server. +# It also requests the access token for the 2nd stage of the OAuth flow. +class AuthorizationRequestHandler(BaseHTTPRequestHandler): + def __init__(self, request, client_address, server) -> None: + super().__init__(request, client_address, server) + + # These values will be injected by the HTTPServer that this handler belongs to. + self.authorization_helpers = None # type: Optional["AuthorizationHelpers"] + self.authorization_callback = None # type: Optional[Callable[[AuthenticationResponse], None]] + self.verification_code = None # type: Optional[str] + + def do_GET(self) -> None: + # Extract values from the query string. + parsed_url = urlparse(self.path) + query = parse_qs(parsed_url.query) + + # Handle the possible requests + if parsed_url.path == "/callback": + server_response, token_response = self._handleCallback(query) + else: + server_response = self._handleNotFound() + token_response = None + + # Send the data to the browser. + self._sendHeaders(server_response.status, server_response.content_type, server_response.redirect_uri) + + if server_response.data_stream: + # If there is data in the response, we send it. + self._sendData(server_response.data_stream) + + if token_response and self.authorization_callback is not None: + # Trigger the callback if we got a response. + # This will cause the server to shut down, so we do it at the very end of the request handling. + self.authorization_callback(token_response) + + # Handler for the callback URL redirect. + # \param query: Dict containing the HTTP query parameters. + # \return: HTTP ResponseData containing a success page to show to the user. + def _handleCallback(self, query: Dict[Any, List]) -> Tuple[ResponseData, Optional[AuthenticationResponse]]: + code = self._queryGet(query, "code") + if code and self.authorization_helpers is not None and self.verification_code is not None: + # If the code was returned we get the access token. + token_response = self.authorization_helpers.getAccessTokenUsingAuthorizationCode( + code, self.verification_code) + + elif self._queryGet(query, "error_code") == "user_denied": + # Otherwise we show an error message (probably the user clicked "Deny" in the auth dialog). + token_response = AuthenticationResponse( + success=False, + err_message="Please give the required permissions when authorizing this application." + ) + + else: + # We don't know what went wrong here, so instruct the user to check the logs. + token_response = AuthenticationResponse( + success=False, + error_message="Something unexpected happened when trying to log in, please try again." + ) + if self.authorization_helpers is None: + return ResponseData(), token_response + + return ResponseData( + status=HTTP_STATUS["REDIRECT"], + data_stream=b"Redirecting...", + redirect_uri=self.authorization_helpers.settings.AUTH_SUCCESS_REDIRECT if token_response.success else + self.authorization_helpers.settings.AUTH_FAILED_REDIRECT + ), token_response + + @staticmethod + # Handle all other non-existing server calls. + def _handleNotFound() -> ResponseData: + return ResponseData(status=HTTP_STATUS["NOT_FOUND"], content_type="text/html", data_stream=b"Not found.") + + def _sendHeaders(self, status: "ResponseStatus", content_type: str, redirect_uri: str = None) -> None: + self.send_response(status.code, status.message) + self.send_header("Content-type", content_type) + if redirect_uri: + self.send_header("Location", redirect_uri) + self.end_headers() + + def _sendData(self, data: bytes) -> None: + self.wfile.write(data) + + @staticmethod + # Convenience Helper for getting values from a pre-parsed query string + def _queryGet(query_data: Dict[Any, List], key: str, default: Optional[str]=None) -> Optional[str]: + return query_data.get(key, [default])[0] diff --git a/cura/OAuth2/AuthorizationRequestServer.py b/cura/OAuth2/AuthorizationRequestServer.py new file mode 100644 index 0000000000..288e348ea9 --- /dev/null +++ b/cura/OAuth2/AuthorizationRequestServer.py @@ -0,0 +1,26 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from http.server import HTTPServer +from typing import Callable, Any, TYPE_CHECKING + +if TYPE_CHECKING: + from cura.OAuth2.Models import AuthenticationResponse + from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers + + +# The authorization request callback handler server. +# This subclass is needed to be able to pass some data to the request handler. +# This cannot be done on the request handler directly as the HTTPServer creates an instance of the handler after +# init. +class AuthorizationRequestServer(HTTPServer): + # Set the authorization helpers instance on the request handler. + def setAuthorizationHelpers(self, authorization_helpers: "AuthorizationHelpers") -> None: + self.RequestHandlerClass.authorization_helpers = authorization_helpers # type: ignore + + # Set the authorization callback on the request handler. + def setAuthorizationCallback(self, authorization_callback: Callable[["AuthenticationResponse"], Any]) -> None: + self.RequestHandlerClass.authorization_callback = authorization_callback # type: ignore + + # Set the verification code on the request handler. + def setVerificationCode(self, verification_code: str) -> None: + self.RequestHandlerClass.verification_code = verification_code # type: ignore diff --git a/cura/OAuth2/AuthorizationService.py b/cura/OAuth2/AuthorizationService.py new file mode 100644 index 0000000000..65b31f1ed7 --- /dev/null +++ b/cura/OAuth2/AuthorizationService.py @@ -0,0 +1,168 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +import json +import webbrowser +from typing import Optional, TYPE_CHECKING +from urllib.parse import urlencode + +from UM.Logger import Logger +from UM.Signal import Signal + +from cura.OAuth2.LocalAuthorizationServer import LocalAuthorizationServer +from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers +from cura.OAuth2.Models import AuthenticationResponse + +if TYPE_CHECKING: + from cura.OAuth2.Models import UserProfile, OAuth2Settings + from UM.Preferences import Preferences + + +class AuthorizationService: + """ + The authorization service is responsible for handling the login flow, + storing user credentials and providing account information. + """ + + # Emit signal when authentication is completed. + onAuthStateChanged = Signal() + + # Emit signal when authentication failed. + onAuthenticationError = Signal() + + def __init__(self, settings: "OAuth2Settings", preferences: Optional["Preferences"] = None) -> None: + self._settings = settings + self._auth_helpers = AuthorizationHelpers(settings) + self._auth_url = "{}/authorize".format(self._settings.OAUTH_SERVER_URL) + self._auth_data = None # type: Optional[AuthenticationResponse] + self._user_profile = None # type: Optional["UserProfile"] + self._preferences = preferences + self._server = LocalAuthorizationServer(self._auth_helpers, self._onAuthStateChanged, daemon=True) + + def initialize(self, preferences: Optional["Preferences"] = None) -> None: + if preferences is not None: + self._preferences = preferences + if self._preferences: + self._preferences.addPreference(self._settings.AUTH_DATA_PREFERENCE_KEY, "{}") + + # Get the user profile as obtained from the JWT (JSON Web Token). + # If the JWT is not yet parsed, calling this will take care of that. + # \return UserProfile if a user is logged in, None otherwise. + # \sa _parseJWT + def getUserProfile(self) -> Optional["UserProfile"]: + if not self._user_profile: + # If no user profile was stored locally, we try to get it from JWT. + self._user_profile = self._parseJWT() + if not self._user_profile: + # If there is still no user profile from the JWT, we have to log in again. + return None + + return self._user_profile + + # Tries to parse the JWT (JSON Web Token) data, which it does if all the needed data is there. + # \return UserProfile if it was able to parse, None otherwise. + def _parseJWT(self) -> Optional["UserProfile"]: + if not self._auth_data or self._auth_data.access_token is None: + # If no auth data exists, we should always log in again. + return None + user_data = self._auth_helpers.parseJWT(self._auth_data.access_token) + if user_data: + # If the profile was found, we return it immediately. + return user_data + # The JWT was expired or invalid and we should request a new one. + if self._auth_data.refresh_token is None: + return None + self._auth_data = self._auth_helpers.getAccessTokenUsingRefreshToken(self._auth_data.refresh_token) + if not self._auth_data or self._auth_data.access_token is None: + # The token could not be refreshed using the refresh token. We should login again. + return None + + return self._auth_helpers.parseJWT(self._auth_data.access_token) + + # Get the access token as provided by the repsonse data. + def getAccessToken(self) -> Optional[str]: + if not self.getUserProfile(): + # We check if we can get the user profile. + # If we can't get it, that means the access token (JWT) was invalid or expired. + return None + + if self._auth_data is None: + return None + + return self._auth_data.access_token + + # Try to refresh the access token. This should be used when it has expired. + def refreshAccessToken(self) -> None: + if self._auth_data is None or self._auth_data.refresh_token is None: + Logger.log("w", "Unable to refresh access token, since there is no refresh token.") + return + self._storeAuthData(self._auth_helpers.getAccessTokenUsingRefreshToken(self._auth_data.refresh_token)) + self.onAuthStateChanged.emit(logged_in=True) + + # Delete the authentication data that we have stored locally (eg; logout) + def deleteAuthData(self) -> None: + if self._auth_data is not None: + self._storeAuthData() + self.onAuthStateChanged.emit(logged_in=False) + + # Start the flow to become authenticated. This will start a new webbrowser tap, prompting the user to login. + def startAuthorizationFlow(self) -> None: + Logger.log("d", "Starting new OAuth2 flow...") + + # Create the tokens needed for the code challenge (PKCE) extension for OAuth2. + # This is needed because the CuraDrivePlugin is a untrusted (open source) client. + # More details can be found at https://tools.ietf.org/html/rfc7636. + verification_code = self._auth_helpers.generateVerificationCode() + challenge_code = self._auth_helpers.generateVerificationCodeChallenge(verification_code) + + # Create the query string needed for the OAuth2 flow. + query_string = urlencode({ + "client_id": self._settings.CLIENT_ID, + "redirect_uri": self._settings.CALLBACK_URL, + "scope": self._settings.CLIENT_SCOPES, + "response_type": "code", + "state": "CuraDriveIsAwesome", + "code_challenge": challenge_code, + "code_challenge_method": "S512" + }) + + # Open the authorization page in a new browser window. + webbrowser.open_new("{}?{}".format(self._auth_url, query_string)) + + # Start a local web server to receive the callback URL on. + self._server.start(verification_code) + + # Callback method for the authentication flow. + def _onAuthStateChanged(self, auth_response: AuthenticationResponse) -> None: + if auth_response.success: + self._storeAuthData(auth_response) + self.onAuthStateChanged.emit(logged_in=True) + else: + self.onAuthenticationError.emit(logged_in=False, error_message=auth_response.err_message) + self._server.stop() # Stop the web server at all times. + + # Load authentication data from preferences. + def loadAuthDataFromPreferences(self) -> None: + if self._preferences is None: + Logger.log("e", "Unable to load authentication data, since no preference has been set!") + return + try: + preferences_data = json.loads(self._preferences.getValue(self._settings.AUTH_DATA_PREFERENCE_KEY)) + if preferences_data: + self._auth_data = AuthenticationResponse(**preferences_data) + self.onAuthStateChanged.emit(logged_in=True) + except ValueError: + Logger.logException("w", "Could not load auth data from preferences") + + # Store authentication data in preferences. + def _storeAuthData(self, auth_data: Optional[AuthenticationResponse] = None) -> None: + if self._preferences is None: + Logger.log("e", "Unable to save authentication data, since no preference has been set!") + return + + self._auth_data = auth_data + if auth_data: + self._user_profile = self.getUserProfile() + self._preferences.setValue(self._settings.AUTH_DATA_PREFERENCE_KEY, json.dumps(vars(auth_data))) + else: + self._user_profile = None + self._preferences.resetPreference(self._settings.AUTH_DATA_PREFERENCE_KEY) diff --git a/cura/OAuth2/LocalAuthorizationServer.py b/cura/OAuth2/LocalAuthorizationServer.py new file mode 100644 index 0000000000..5a282d8135 --- /dev/null +++ b/cura/OAuth2/LocalAuthorizationServer.py @@ -0,0 +1,64 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +import threading +from typing import Optional, Callable, Any, TYPE_CHECKING + +from UM.Logger import Logger + +from cura.OAuth2.AuthorizationRequestServer import AuthorizationRequestServer +from cura.OAuth2.AuthorizationRequestHandler import AuthorizationRequestHandler + +if TYPE_CHECKING: + from cura.OAuth2.Models import AuthenticationResponse + from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers + + +class LocalAuthorizationServer: + # The local LocalAuthorizationServer takes care of the oauth2 callbacks. + # Once the flow is completed, this server should be closed down again by calling stop() + # \param auth_helpers: An instance of the authorization helpers class. + # \param auth_state_changed_callback: A callback function to be called when the authorization state changes. + # \param daemon: Whether the server thread should be run in daemon mode. Note: Daemon threads are abruptly stopped + # at shutdown. Their resources (e.g. open files) may never be released. + def __init__(self, auth_helpers: "AuthorizationHelpers", + auth_state_changed_callback: Callable[["AuthenticationResponse"], Any], + daemon: bool) -> None: + self._web_server = None # type: Optional[AuthorizationRequestServer] + self._web_server_thread = None # type: Optional[threading.Thread] + self._web_server_port = auth_helpers.settings.CALLBACK_PORT + self._auth_helpers = auth_helpers + self._auth_state_changed_callback = auth_state_changed_callback + self._daemon = daemon + + # Starts the local web server to handle the authorization callback. + # \param verification_code: The verification code part of the OAuth2 client identification. + def start(self, verification_code: str) -> None: + if self._web_server: + # If the server is already running (because of a previously aborted auth flow), we don't have to start it. + # We still inject the new verification code though. + self._web_server.setVerificationCode(verification_code) + return + + if self._web_server_port is None: + raise Exception("Unable to start server without specifying the port.") + + Logger.log("d", "Starting local web server to handle authorization callback on port %s", self._web_server_port) + + # Create the server and inject the callback and code. + self._web_server = AuthorizationRequestServer(("0.0.0.0", self._web_server_port), AuthorizationRequestHandler) + self._web_server.setAuthorizationHelpers(self._auth_helpers) + self._web_server.setAuthorizationCallback(self._auth_state_changed_callback) + self._web_server.setVerificationCode(verification_code) + + # Start the server on a new thread. + self._web_server_thread = threading.Thread(None, self._web_server.serve_forever, daemon = self._daemon) + self._web_server_thread.start() + + # Stops the web server if it was running. It also does some cleanup. + def stop(self) -> None: + Logger.log("d", "Stopping local oauth2 web server...") + + if self._web_server: + self._web_server.server_close() + self._web_server = None + self._web_server_thread = None diff --git a/cura/OAuth2/Models.py b/cura/OAuth2/Models.py new file mode 100644 index 0000000000..83fc22554f --- /dev/null +++ b/cura/OAuth2/Models.py @@ -0,0 +1,60 @@ +# Copyright (c) 2018 Ultimaker B.V. +from typing import Optional + + +class BaseModel: + def __init__(self, **kwargs): + self.__dict__.update(kwargs) + + +# OAuth OAuth2Settings data template. +class OAuth2Settings(BaseModel): + CALLBACK_PORT = None # type: Optional[int] + OAUTH_SERVER_URL = None # type: Optional[str] + CLIENT_ID = None # type: Optional[str] + CLIENT_SCOPES = None # type: Optional[str] + CALLBACK_URL = None # type: Optional[str] + AUTH_DATA_PREFERENCE_KEY = "" # type: str + AUTH_SUCCESS_REDIRECT = "https://ultimaker.com" # type: str + AUTH_FAILED_REDIRECT = "https://ultimaker.com" # type: str + + +# User profile data template. +class UserProfile(BaseModel): + user_id = None # type: Optional[str] + username = None # type: Optional[str] + profile_image_url = None # type: Optional[str] + + +# Authentication data template. +class AuthenticationResponse(BaseModel): + """Data comes from the token response with success flag and error message added.""" + success = True # type: bool + token_type = None # type: Optional[str] + access_token = None # type: Optional[str] + refresh_token = None # type: Optional[str] + expires_in = None # type: Optional[str] + scope = None # type: Optional[str] + err_message = None # type: Optional[str] + + +# Response status template. +class ResponseStatus(BaseModel): + code = 200 # type: int + message = "" # type str + + +# Response data template. +class ResponseData(BaseModel): + status = None # type: ResponseStatus + data_stream = None # type: Optional[bytes] + redirect_uri = None # type: Optional[str] + content_type = "text/html" # type: str + + +# Possible HTTP responses. +HTTP_STATUS = { + "OK": ResponseStatus(code=200, message="OK"), + "NOT_FOUND": ResponseStatus(code=404, message="NOT FOUND"), + "REDIRECT": ResponseStatus(code=302, message="REDIRECT") +} diff --git a/cura/OAuth2/__init__.py b/cura/OAuth2/__init__.py new file mode 100644 index 0000000000..f3f6970c54 --- /dev/null +++ b/cura/OAuth2/__init__.py @@ -0,0 +1,2 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. diff --git a/cura/Scene/ConvexHullDecorator.py b/cura/Scene/ConvexHullDecorator.py index aca5d866be..52e687832c 100644 --- a/cura/Scene/ConvexHullDecorator.py +++ b/cura/Scene/ConvexHullDecorator.py @@ -5,6 +5,7 @@ from PyQt5.QtCore import QTimer from UM.Application import Application from UM.Math.Polygon import Polygon + from UM.Scene.SceneNodeDecorator import SceneNodeDecorator from UM.Settings.ContainerRegistry import ContainerRegistry @@ -18,6 +19,8 @@ from typing import TYPE_CHECKING, Any, Optional if TYPE_CHECKING: from UM.Scene.SceneNode import SceneNode from cura.Settings.GlobalStack import GlobalStack + from UM.Mesh.MeshData import MeshData + from UM.Math.Matrix import Matrix ## The convex hull decorator is a scene node decorator that adds the convex hull functionality to a scene node. @@ -33,17 +36,17 @@ class ConvexHullDecorator(SceneNodeDecorator): # Make sure the timer is created on the main thread self._recompute_convex_hull_timer = None # type: Optional[QTimer] - - if Application.getInstance() is not None: - Application.getInstance().callLater(self.createRecomputeConvexHullTimer) + from cura.CuraApplication import CuraApplication + if CuraApplication.getInstance() is not None: + CuraApplication.getInstance().callLater(self.createRecomputeConvexHullTimer) self._raft_thickness = 0.0 - self._build_volume = Application.getInstance().getBuildVolume() + self._build_volume = CuraApplication.getInstance().getBuildVolume() self._build_volume.raftThicknessChanged.connect(self._onChanged) - Application.getInstance().globalContainerStackChanged.connect(self._onGlobalStackChanged) - Application.getInstance().getController().toolOperationStarted.connect(self._onChanged) - Application.getInstance().getController().toolOperationStopped.connect(self._onChanged) + CuraApplication.getInstance().globalContainerStackChanged.connect(self._onGlobalStackChanged) + CuraApplication.getInstance().getController().toolOperationStarted.connect(self._onChanged) + CuraApplication.getInstance().getController().toolOperationStopped.connect(self._onChanged) self._onGlobalStackChanged() @@ -61,9 +64,9 @@ class ConvexHullDecorator(SceneNodeDecorator): previous_node.parentChanged.disconnect(self._onChanged) super().setNode(node) - - self._node.transformationChanged.connect(self._onChanged) - self._node.parentChanged.connect(self._onChanged) + # Mypy doesn't understand that self._node is no longer optional, so just use the node. + node.transformationChanged.connect(self._onChanged) + node.parentChanged.connect(self._onChanged) self._onChanged() @@ -78,9 +81,9 @@ class ConvexHullDecorator(SceneNodeDecorator): hull = self._compute2DConvexHull() - if self._global_stack and self._node and hull is not None: + if self._global_stack and self._node is not None and hull is not None: # Parent can be None if node is just loaded. - if self._global_stack.getProperty("print_sequence", "value") == "one_at_a_time" and (self._node.getParent() is None or not self._node.getParent().callDecoration("isGroup")): + if self._global_stack.getProperty("print_sequence", "value") == "one_at_a_time" and not self.hasGroupAsParent(self._node): hull = hull.getMinkowskiHull(Polygon(numpy.array(self._global_stack.getProperty("machine_head_polygon", "value"), numpy.float32))) hull = self._add2DAdhesionMargin(hull) return hull @@ -92,6 +95,13 @@ class ConvexHullDecorator(SceneNodeDecorator): return self._compute2DConvexHeadFull() + @staticmethod + def hasGroupAsParent(node: "SceneNode") -> bool: + parent = node.getParent() + if parent is None: + return False + return bool(parent.callDecoration("isGroup")) + ## Get convex hull of the object + head size # In case of printing all at once this is the same as the convex hull. # For one at the time this is area with intersection of mirrored head @@ -100,8 +110,10 @@ class ConvexHullDecorator(SceneNodeDecorator): return None if self._global_stack: - if self._global_stack.getProperty("print_sequence", "value") == "one_at_a_time" and (self._node.getParent() is None or not self._node.getParent().callDecoration("isGroup")): + if self._global_stack.getProperty("print_sequence", "value") == "one_at_a_time" and not self.hasGroupAsParent(self._node): head_with_fans = self._compute2DConvexHeadMin() + if head_with_fans is None: + return None head_with_fans_with_adhesion_margin = self._add2DAdhesionMargin(head_with_fans) return head_with_fans_with_adhesion_margin return None @@ -114,7 +126,7 @@ class ConvexHullDecorator(SceneNodeDecorator): return None if self._global_stack: - if self._global_stack.getProperty("print_sequence", "value") == "one_at_a_time" and (self._node.getParent() is None or not self._node.getParent().callDecoration("isGroup")): + if self._global_stack.getProperty("print_sequence", "value") == "one_at_a_time" and not self.hasGroupAsParent(self._node): # Printing one at a time and it's not an object in a group return self._compute2DConvexHull() return None @@ -153,15 +165,17 @@ class ConvexHullDecorator(SceneNodeDecorator): def _init2DConvexHullCache(self) -> None: # Cache for the group code path in _compute2DConvexHull() - self._2d_convex_hull_group_child_polygon = None - self._2d_convex_hull_group_result = None + self._2d_convex_hull_group_child_polygon = None # type: Optional[Polygon] + self._2d_convex_hull_group_result = None # type: Optional[Polygon] # Cache for the mesh code path in _compute2DConvexHull() - self._2d_convex_hull_mesh = None - self._2d_convex_hull_mesh_world_transform = None - self._2d_convex_hull_mesh_result = None + self._2d_convex_hull_mesh = None # type: Optional[MeshData] + self._2d_convex_hull_mesh_world_transform = None # type: Optional[Matrix] + self._2d_convex_hull_mesh_result = None # type: Optional[Polygon] def _compute2DConvexHull(self) -> Optional[Polygon]: + if self._node is None: + return None if self._node.callDecoration("isGroup"): points = numpy.zeros((0, 2), dtype=numpy.int32) for child in self._node.getChildren(): @@ -187,47 +201,47 @@ class ConvexHullDecorator(SceneNodeDecorator): return offset_hull else: - offset_hull = None - if self._node.getMeshData(): - mesh = self._node.getMeshData() - world_transform = self._node.getWorldTransformation() - - # Check the cache - if mesh is self._2d_convex_hull_mesh and world_transform == self._2d_convex_hull_mesh_world_transform: - return self._2d_convex_hull_mesh_result - - vertex_data = mesh.getConvexHullTransformedVertices(world_transform) - # Don't use data below 0. - # TODO; We need a better check for this as this gives poor results for meshes with long edges. - # Do not throw away vertices: the convex hull may be too small and objects can collide. - # vertex_data = vertex_data[vertex_data[:,1] >= -0.01] - - if len(vertex_data) >= 4: - # Round the vertex data to 1/10th of a mm, then remove all duplicate vertices - # This is done to greatly speed up further convex hull calculations as the convex hull - # becomes much less complex when dealing with highly detailed models. - vertex_data = numpy.round(vertex_data, 1) - - vertex_data = vertex_data[:, [0, 2]] # Drop the Y components to project to 2D. - - # Grab the set of unique points. - # - # This basically finds the unique rows in the array by treating them as opaque groups of bytes - # which are as long as the 2 float64s in each row, and giving this view to numpy.unique() to munch. - # See http://stackoverflow.com/questions/16970982/find-unique-rows-in-numpy-array - vertex_byte_view = numpy.ascontiguousarray(vertex_data).view( - numpy.dtype((numpy.void, vertex_data.dtype.itemsize * vertex_data.shape[1]))) - _, idx = numpy.unique(vertex_byte_view, return_index=True) - vertex_data = vertex_data[idx] # Select the unique rows by index. - - hull = Polygon(vertex_data) - - if len(vertex_data) >= 3: - convex_hull = hull.getConvexHull() - offset_hull = self._offsetHull(convex_hull) - else: + offset_hull = Polygon([]) + mesh = self._node.getMeshData() + if mesh is None: return Polygon([]) # Node has no mesh data, so just return an empty Polygon. + world_transform = self._node.getWorldTransformation() + + # Check the cache + if mesh is self._2d_convex_hull_mesh and world_transform == self._2d_convex_hull_mesh_world_transform: + return self._2d_convex_hull_mesh_result + + vertex_data = mesh.getConvexHullTransformedVertices(world_transform) + # Don't use data below 0. + # TODO; We need a better check for this as this gives poor results for meshes with long edges. + # Do not throw away vertices: the convex hull may be too small and objects can collide. + # vertex_data = vertex_data[vertex_data[:,1] >= -0.01] + + if len(vertex_data) >= 4: # type: ignore # mypy and numpy don't play along well just yet. + # Round the vertex data to 1/10th of a mm, then remove all duplicate vertices + # This is done to greatly speed up further convex hull calculations as the convex hull + # becomes much less complex when dealing with highly detailed models. + vertex_data = numpy.round(vertex_data, 1) + + vertex_data = vertex_data[:, [0, 2]] # Drop the Y components to project to 2D. + + # Grab the set of unique points. + # + # This basically finds the unique rows in the array by treating them as opaque groups of bytes + # which are as long as the 2 float64s in each row, and giving this view to numpy.unique() to munch. + # See http://stackoverflow.com/questions/16970982/find-unique-rows-in-numpy-array + vertex_byte_view = numpy.ascontiguousarray(vertex_data).view( + numpy.dtype((numpy.void, vertex_data.dtype.itemsize * vertex_data.shape[1]))) + _, idx = numpy.unique(vertex_byte_view, return_index=True) + vertex_data = vertex_data[idx] # Select the unique rows by index. + + hull = Polygon(vertex_data) + + if len(vertex_data) >= 3: + convex_hull = hull.getConvexHull() + offset_hull = self._offsetHull(convex_hull) + # Store the result in the cache self._2d_convex_hull_mesh = mesh self._2d_convex_hull_mesh_world_transform = world_transform @@ -338,7 +352,7 @@ class ConvexHullDecorator(SceneNodeDecorator): ## Private convenience function to get a setting from the correct extruder (as defined by limit_to_extruder property). def _getSettingProperty(self, setting_key: str, prop: str = "value") -> Any: - if not self._global_stack: + if self._global_stack is None or self._node is None: return None per_mesh_stack = self._node.callDecoration("getStack") if per_mesh_stack: @@ -358,7 +372,7 @@ class ConvexHullDecorator(SceneNodeDecorator): return self._global_stack.getProperty(setting_key, prop) ## Returns True if node is a descendant or the same as the root node. - def __isDescendant(self, root: "SceneNode", node: "SceneNode") -> bool: + def __isDescendant(self, root: "SceneNode", node: Optional["SceneNode"]) -> bool: if node is None: return False if root is node: diff --git a/cura/Settings/ContainerManager.py b/cura/Settings/ContainerManager.py index e1a1495dac..3cfca1a944 100644 --- a/cura/Settings/ContainerManager.py +++ b/cura/Settings/ContainerManager.py @@ -28,10 +28,10 @@ if TYPE_CHECKING: from cura.Machines.MaterialNode import MaterialNode from cura.Machines.QualityChangesGroup import QualityChangesGroup from UM.PluginRegistry import PluginRegistry - from UM.Settings.ContainerRegistry import ContainerRegistry from cura.Settings.MachineManager import MachineManager from cura.Machines.MaterialManager import MaterialManager from cura.Machines.QualityManager import QualityManager + from cura.Settings.CuraContainerRegistry import CuraContainerRegistry catalog = i18nCatalog("cura") @@ -52,7 +52,7 @@ class ContainerManager(QObject): self._application = application # type: CuraApplication self._plugin_registry = self._application.getPluginRegistry() # type: PluginRegistry - self._container_registry = self._application.getContainerRegistry() # type: ContainerRegistry + self._container_registry = self._application.getContainerRegistry() # type: CuraContainerRegistry self._machine_manager = self._application.getMachineManager() # type: MachineManager self._material_manager = self._application.getMaterialManager() # type: MaterialManager self._quality_manager = self._application.getQualityManager() # type: QualityManager @@ -391,7 +391,8 @@ class ContainerManager(QObject): continue mime_type = self._container_registry.getMimeTypeForContainer(container_type) - + if mime_type is None: + continue entry = { "type": serialize_type, "mime": mime_type, diff --git a/cura/Settings/MachineManager.py b/cura/Settings/MachineManager.py index 0059b7aad2..063f894d23 100755 --- a/cura/Settings/MachineManager.py +++ b/cura/Settings/MachineManager.py @@ -1148,7 +1148,7 @@ class MachineManager(QObject): self._fixQualityChangesGroupToNotSupported(quality_changes_group) quality_changes_container = self._empty_quality_changes_container - quality_container = self._empty_quality_container + quality_container = self._empty_quality_container # type: Optional[InstanceContainer] if quality_changes_group.node_for_global and quality_changes_group.node_for_global.getContainer(): quality_changes_container = cast(InstanceContainer, quality_changes_group.node_for_global.getContainer()) if quality_group is not None and quality_group.node_for_global and quality_group.node_for_global.getContainer(): diff --git a/plugins/SimulationView/SimulationView.py b/plugins/SimulationView/SimulationView.py index 8467dfe8b2..aafbca0247 100644 --- a/plugins/SimulationView/SimulationView.py +++ b/plugins/SimulationView/SimulationView.py @@ -21,6 +21,7 @@ from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator from UM.Scene.Selection import Selection from UM.Signal import Signal +from UM.View.CompositePass import CompositePass from UM.View.GL.OpenGL import OpenGL from UM.View.GL.OpenGLContext import OpenGLContext @@ -36,7 +37,7 @@ from .SimulationViewProxy import SimulationViewProxy import numpy import os.path -from typing import Optional, TYPE_CHECKING, List +from typing import Optional, TYPE_CHECKING, List, cast if TYPE_CHECKING: from UM.Scene.SceneNode import SceneNode @@ -64,7 +65,7 @@ class SimulationView(View): self._minimum_layer_num = 0 self._current_layer_mesh = None self._current_layer_jumps = None - self._top_layers_job = None + self._top_layers_job = None # type: Optional["_CreateTopLayersJob"] self._activity = False self._old_max_layers = 0 @@ -78,10 +79,10 @@ class SimulationView(View): self._ghost_shader = None # type: Optional["ShaderProgram"] self._layer_pass = None # type: Optional[SimulationPass] - self._composite_pass = None # type: Optional[RenderPass] - self._old_layer_bindings = None + self._composite_pass = None # type: Optional[CompositePass] + self._old_layer_bindings = None # type: Optional[List[str]] self._simulationview_composite_shader = None # type: Optional["ShaderProgram"] - self._old_composite_shader = None + self._old_composite_shader = None # type: Optional["ShaderProgram"] self._global_container_stack = None # type: Optional[ContainerStack] self._proxy = SimulationViewProxy() @@ -204,9 +205,11 @@ class SimulationView(View): if not self._ghost_shader: self._ghost_shader = OpenGL.getInstance().createShaderProgram(Resources.getPath(Resources.Shaders, "color.shader")) - self._ghost_shader.setUniformValue("u_color", Color(*Application.getInstance().getTheme().getColor("layerview_ghost").getRgb())) + theme = CuraApplication.getInstance().getTheme() + if theme is not None: + self._ghost_shader.setUniformValue("u_color", Color(*theme.getColor("layerview_ghost").getRgb())) - for node in DepthFirstIterator(scene.getRoot()): + for node in DepthFirstIterator(scene.getRoot()): # type: ignore # We do not want to render ConvexHullNode as it conflicts with the bottom layers. # However, it is somewhat relevant when the node is selected, so do render it then. if type(node) is ConvexHullNode and not Selection.isSelected(node.getWatchedNode()): @@ -347,7 +350,7 @@ class SimulationView(View): self._old_max_layers = self._max_layers ## Recalculate num max layers new_max_layers = -1 - for node in DepthFirstIterator(scene.getRoot()): + for node in DepthFirstIterator(scene.getRoot()): # type: ignore layer_data = node.callDecoration("getLayerData") if not layer_data: continue @@ -398,7 +401,7 @@ class SimulationView(View): def calculateMaxPathsOnLayer(self, layer_num: int) -> None: # Update the currentPath scene = self.getController().getScene() - for node in DepthFirstIterator(scene.getRoot()): + for node in DepthFirstIterator(scene.getRoot()): # type: ignore layer_data = node.callDecoration("getLayerData") if not layer_data: continue @@ -474,15 +477,17 @@ class SimulationView(View): self._onGlobalStackChanged() if not self._simulationview_composite_shader: - self._simulationview_composite_shader = OpenGL.getInstance().createShaderProgram(os.path.join(PluginRegistry.getInstance().getPluginPath("SimulationView"), "simulationview_composite.shader")) - theme = Application.getInstance().getTheme() - self._simulationview_composite_shader.setUniformValue("u_background_color", Color(*theme.getColor("viewport_background").getRgb())) - self._simulationview_composite_shader.setUniformValue("u_outline_color", Color(*theme.getColor("model_selection_outline").getRgb())) + plugin_path = cast(str, PluginRegistry.getInstance().getPluginPath("SimulationView")) + self._simulationview_composite_shader = OpenGL.getInstance().createShaderProgram(os.path.join(plugin_path, "simulationview_composite.shader")) + theme = CuraApplication.getInstance().getTheme() + if theme is not None: + self._simulationview_composite_shader.setUniformValue("u_background_color", Color(*theme.getColor("viewport_background").getRgb())) + self._simulationview_composite_shader.setUniformValue("u_outline_color", Color(*theme.getColor("model_selection_outline").getRgb())) if not self._composite_pass: - self._composite_pass = self.getRenderer().getRenderPass("composite") + self._composite_pass = cast(CompositePass, self.getRenderer().getRenderPass("composite")) - self._old_layer_bindings = self._composite_pass.getLayerBindings()[:] # make a copy so we can restore to it later + self._old_layer_bindings = self._composite_pass.getLayerBindings()[:] # make a copy so we can restore to it later self._composite_pass.getLayerBindings().append("simulationview") self._old_composite_shader = self._composite_pass.getCompositeShader() self._composite_pass.setCompositeShader(self._simulationview_composite_shader) @@ -496,8 +501,8 @@ class SimulationView(View): self._nozzle_node.setParent(None) self.getRenderer().removeRenderPass(self._layer_pass) if self._composite_pass: - self._composite_pass.setLayerBindings(self._old_layer_bindings) - self._composite_pass.setCompositeShader(self._old_composite_shader) + self._composite_pass.setLayerBindings(cast(List[str], self._old_layer_bindings)) + self._composite_pass.setCompositeShader(cast(ShaderProgram, self._old_composite_shader)) return False @@ -606,7 +611,7 @@ class _CreateTopLayersJob(Job): def run(self) -> None: layer_data = None - for node in DepthFirstIterator(self._scene.getRoot()): + for node in DepthFirstIterator(self._scene.getRoot()): # type: ignore layer_data = node.callDecoration("getLayerData") if layer_data: break diff --git a/resources/qml/Cura.qml b/resources/qml/Cura.qml index 07154a0729..b3367471ad 100644 --- a/resources/qml/Cura.qml +++ b/resources/qml/Cura.qml @@ -8,7 +8,7 @@ import QtQuick.Layouts 1.1 import QtQuick.Dialogs 1.2 import UM 1.3 as UM -import Cura 1.0 as Cura +import Cura 1.1 as Cura import "Menus" @@ -21,7 +21,6 @@ UM.MainWindow property bool showPrintMonitor: false backgroundColor: UM.Theme.getColor("viewport_background") - // This connection is here to support legacy printer output devices that use the showPrintMonitor signal on Application to switch to the monitor stage // It should be phased out in newer plugin versions. Connections diff --git a/tests/TestOAuth2.py b/tests/TestOAuth2.py new file mode 100644 index 0000000000..608d529e9f --- /dev/null +++ b/tests/TestOAuth2.py @@ -0,0 +1,133 @@ +import webbrowser +from unittest.mock import MagicMock, patch + +from UM.Preferences import Preferences +from cura.OAuth2.AuthorizationHelpers import AuthorizationHelpers +from cura.OAuth2.AuthorizationService import AuthorizationService +from cura.OAuth2.LocalAuthorizationServer import LocalAuthorizationServer +from cura.OAuth2.Models import OAuth2Settings, AuthenticationResponse, UserProfile + +CALLBACK_PORT = 32118 +OAUTH_ROOT = "https://account.ultimaker.com" +CLOUD_API_ROOT = "https://api.ultimaker.com" + +OAUTH_SETTINGS = OAuth2Settings( + OAUTH_SERVER_URL= OAUTH_ROOT, + CALLBACK_PORT=CALLBACK_PORT, + CALLBACK_URL="http://localhost:{}/callback".format(CALLBACK_PORT), + CLIENT_ID="", + CLIENT_SCOPES="", + AUTH_DATA_PREFERENCE_KEY="test/auth_data", + AUTH_SUCCESS_REDIRECT="{}/app/auth-success".format(OAUTH_ROOT), + AUTH_FAILED_REDIRECT="{}/app/auth-error".format(OAUTH_ROOT) + ) + +FAILED_AUTH_RESPONSE = AuthenticationResponse(success = False, err_message = "FAILURE!") + +SUCCESFULL_AUTH_RESPONSE = AuthenticationResponse(access_token = "beep", refresh_token = "beep?") + +MALFORMED_AUTH_RESPONSE = AuthenticationResponse() + + +def test_cleanAuthService() -> None: + # Ensure that when setting up an AuthorizationService, no data is set. + authorization_service = AuthorizationService(OAUTH_SETTINGS, Preferences()) + authorization_service.initialize() + assert authorization_service.getUserProfile() is None + assert authorization_service.getAccessToken() is None + + +def test_failedLogin() -> None: + authorization_service = AuthorizationService(OAUTH_SETTINGS, Preferences()) + authorization_service.onAuthenticationError.emit = MagicMock() + authorization_service.onAuthStateChanged.emit = MagicMock() + authorization_service.initialize() + + # Let the service think there was a failed response + authorization_service._onAuthStateChanged(FAILED_AUTH_RESPONSE) + + # Check that the error signal was triggered + assert authorization_service.onAuthenticationError.emit.call_count == 1 + + # Since nothing changed, this should still be 0. + assert authorization_service.onAuthStateChanged.emit.call_count == 0 + + # Validate that there is no user profile or token + assert authorization_service.getUserProfile() is None + assert authorization_service.getAccessToken() is None + + +@patch.object(AuthorizationService, "getUserProfile", return_value=UserProfile()) +def test_storeAuthData(get_user_profile) -> None: + preferences = Preferences() + authorization_service = AuthorizationService(OAUTH_SETTINGS, preferences) + authorization_service.initialize() + + # Write stuff to the preferences. + authorization_service._storeAuthData(SUCCESFULL_AUTH_RESPONSE) + preference_value = preferences.getValue(OAUTH_SETTINGS.AUTH_DATA_PREFERENCE_KEY) + # Check that something was actually put in the preferences + assert preference_value is not None and preference_value != {} + + # Create a second auth service, so we can load the data. + second_auth_service = AuthorizationService(OAUTH_SETTINGS, preferences) + second_auth_service.initialize() + second_auth_service.loadAuthDataFromPreferences() + assert second_auth_service.getAccessToken() == SUCCESFULL_AUTH_RESPONSE.access_token + + +@patch.object(LocalAuthorizationServer, "stop") +@patch.object(LocalAuthorizationServer, "start") +@patch.object(webbrowser, "open_new") +def test_localAuthServer(webbrowser_open, start_auth_server, stop_auth_server) -> None: + preferences = Preferences() + authorization_service = AuthorizationService(OAUTH_SETTINGS, preferences) + authorization_service.startAuthorizationFlow() + assert webbrowser_open.call_count == 1 + + # Ensure that the Authorization service tried to start the server. + assert start_auth_server.call_count == 1 + assert stop_auth_server.call_count == 0 + authorization_service._onAuthStateChanged(FAILED_AUTH_RESPONSE) + + # Ensure that it stopped the server. + assert stop_auth_server.call_count == 1 + + +def test_loginAndLogout() -> None: + preferences = Preferences() + authorization_service = AuthorizationService(OAUTH_SETTINGS, preferences) + authorization_service.onAuthenticationError.emit = MagicMock() + authorization_service.onAuthStateChanged.emit = MagicMock() + authorization_service.initialize() + + # Let the service think there was a succesfull response + with patch.object(AuthorizationHelpers, "parseJWT", return_value=UserProfile()): + authorization_service._onAuthStateChanged(SUCCESFULL_AUTH_RESPONSE) + + # Ensure that the error signal was not triggered + assert authorization_service.onAuthenticationError.emit.call_count == 0 + + # Since we said that it went right this time, validate that we got a signal. + assert authorization_service.onAuthStateChanged.emit.call_count == 1 + assert authorization_service.getUserProfile() is not None + assert authorization_service.getAccessToken() == "beep" + + # Check that we stored the authentication data, so next time the user won't have to log in again. + assert preferences.getValue("test/auth_data") is not None + + # We're logged in now, also check if logging out works + authorization_service.deleteAuthData() + assert authorization_service.onAuthStateChanged.emit.call_count == 2 + assert authorization_service.getUserProfile() is None + + # Ensure the data is gone after we logged out. + assert preferences.getValue("test/auth_data") == "{}" + + +def test_wrongServerResponses() -> None: + authorization_service = AuthorizationService(OAUTH_SETTINGS, Preferences()) + authorization_service.initialize() + with patch.object(AuthorizationHelpers, "parseJWT", return_value=UserProfile()): + authorization_service._onAuthStateChanged(MALFORMED_AUTH_RESPONSE) + assert authorization_service.getUserProfile() is None