From 71000180fc8aae759054503286b0a67c5788231e Mon Sep 17 00:00:00 2001 From: Nino van Hooff Date: Mon, 6 Jan 2020 16:04:22 +0100 Subject: [PATCH] Refactor cloud sync out of Toolbox.py CURA-6983 --- plugins/Toolbox/__init__.py | 6 +- plugins/Toolbox/src/CloudApiModel.py | 18 +++ plugins/Toolbox/src/SubscriptionChecker.py | 124 +++++++++++++++++++++ plugins/Toolbox/src/Toolbox.py | 75 ++----------- 4 files changed, 156 insertions(+), 67 deletions(-) create mode 100644 plugins/Toolbox/src/CloudApiModel.py create mode 100644 plugins/Toolbox/src/SubscriptionChecker.py diff --git a/plugins/Toolbox/__init__.py b/plugins/Toolbox/__init__.py index 70c00ed07c..5bdb1c5dce 100644 --- a/plugins/Toolbox/__init__.py +++ b/plugins/Toolbox/__init__.py @@ -2,6 +2,7 @@ # Toolbox is released under the terms of the LGPLv3 or higher. from .src import Toolbox +from .src.SubscriptionChecker import SubscriptionChecker def getMetaData(): @@ -9,4 +10,7 @@ def getMetaData(): def register(app): - return {"extension": Toolbox.Toolbox(app)} + return { + "extension": Toolbox.Toolbox(app), + "subscription_checker": SubscriptionChecker(app) + } diff --git a/plugins/Toolbox/src/CloudApiModel.py b/plugins/Toolbox/src/CloudApiModel.py new file mode 100644 index 0000000000..082a28a24c --- /dev/null +++ b/plugins/Toolbox/src/CloudApiModel.py @@ -0,0 +1,18 @@ +from cura import ApplicationMetadata, UltimakerCloudAuthentication + + +class CloudApiModel: + sdk_version = ApplicationMetadata.CuraSDKVersion # type: Union[str, int] + cloud_api_version = UltimakerCloudAuthentication.CuraCloudAPIVersion # type: str + cloud_api_root = UltimakerCloudAuthentication.CuraCloudAPIRoot # type: str + api_url = "{cloud_api_root}/cura-packages/v{cloud_api_version}/cura/v{sdk_version}".format( + cloud_api_root = cloud_api_root, + cloud_api_version = cloud_api_version, + sdk_version = sdk_version + ) # type: str + + # https://api.ultimaker.com/cura-packages/v1/user/packages + api_url_user_packages = "{cloud_api_root}/cura-packages/v{cloud_api_version}/user/packages".format( + cloud_api_root=cloud_api_root, + cloud_api_version=cloud_api_version, + ) diff --git a/plugins/Toolbox/src/SubscriptionChecker.py b/plugins/Toolbox/src/SubscriptionChecker.py new file mode 100644 index 0000000000..caac84566a --- /dev/null +++ b/plugins/Toolbox/src/SubscriptionChecker.py @@ -0,0 +1,124 @@ +import json +import os +import platform +from typing import Dict, Optional + +from PyQt5.QtCore import QObject +from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest + +from UM.Extension import Extension +from UM.Logger import Logger +from UM.Message import Message +from UM.PluginRegistry import PluginRegistry +from cura.CuraApplication import CuraApplication +from plugins.Toolbox.src.CloudApiModel import CloudApiModel +from plugins.Toolbox.src.SubscribedPackagesModel import SubscribedPackagesModel +from plugins.Toolbox.src.Toolbox import i18n_catalog + + +class SubscriptionChecker(QObject, Extension): + + def __init__(self, application: CuraApplication) -> None: + super().__init__() + + self._application = application # type: CuraApplication + self._model = SubscribedPackagesModel() + + self._application.initializationFinished.connect(self._onAppInitialized) + self._application.getCuraAPI().account.accessTokenChanged.connect(self._updateRequestHeader) + + # This is a plugin, so most of the components required are not ready when + # this is initialized. Therefore, we wait until the application is ready. + def _onAppInitialized(self) -> None: + self._package_manager = self._application.getPackageManager() + + # initial check + self._fetchUserSubscribedPackages() + # check again whenever the login state changes + self._application.getCuraAPI().account.loginStateChanged.connect(self._fetchUserSubscribedPackages) + + def _fetchUserSubscribedPackages(self): + if self._application.getCuraAPI().account.isLoggedIn: + self._getUserPackages("subscribed_packages") + + def _handleCompatibilityData(self, json_data) -> None: + user_subscribed_packages = [plugin["package_id"] for plugin in json_data] + user_installed_packages = self._package_manager.getUserInstalledPackages() + + # We check if there are packages installed in Cloud Marketplace but not in Cura marketplace (discrepancy) + package_discrepancy = list(set(user_subscribed_packages).difference(user_installed_packages)) + + self._model.setMetadata(json_data) + self._model.addValue(package_discrepancy) + self._model.update() + + if package_discrepancy: + self._handlePackageDiscrepancies(package_discrepancy) + + def _handlePackageDiscrepancies(self, package_discrepancy): + Logger.log("d", "Discrepancy found between Cloud subscribed packages and Cura installed packages") + sync_message = Message(i18n_catalog.i18nc( + "@info:generic", + "\nDo you want to sync material and software packages with your account?"), + lifetime=0, + title=i18n_catalog.i18nc("@info:title", "Changes detected from your Ultimaker account", )) + sync_message.addAction("sync", + name=i18n_catalog.i18nc("@action:button", "Sync"), + icon="", + description="Sync your Cloud subscribed packages to your local environment.", + button_align=Message.ActionButtonAlignment.ALIGN_RIGHT) + sync_message.actionTriggered.connect(self._onSyncButtonClicked) + sync_message.show() + + def _onSyncButtonClicked(self, sync_message: Message, sync_message_action: str) -> None: + sync_message.hide() + compatibility_dialog_path = "resources/qml/dialogs/CompatibilityDialog.qml" + plugin_path_prefix = PluginRegistry.getInstance().getPluginPath(self.getPluginId()) + if plugin_path_prefix: + path = os.path.join(plugin_path_prefix, compatibility_dialog_path) + self.compatibility_dialog_view = self._application.getInstance().createQmlComponent(path, {"toolbox": self}) + + def _getUserPackages(self, request_type: str) -> None: + Logger.log("d", "Requesting [%s] metadata from server.", request_type) + self._updateRequestHeader() + url = CloudApiModel.api_url_user_packages + + self._application.getHttpRequestManager().get(url, + headers_dict = self._request_headers, + callback = self._onUserPackagesRequestFinished, + error_callback = self._onUserPackagesRequestFinished) + + def _onUserPackagesRequestFinished(self, + reply: "QNetworkReply", + error: Optional["QNetworkReply.NetworkError"] = None) -> None: + if error is not None or reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) != 200: + Logger.log("w", + "Requesting user packages failed, response code %s while trying to connect to %s", + reply.attribute(QNetworkRequest.HttpStatusCodeAttribute), reply.url()) + return + + try: + json_data = json.loads(bytes(reply.readAll()).decode("utf-8")) + + # Check for errors: + if "errors" in json_data: + for error in json_data["errors"]: + Logger.log("e", "%s", error["title"]) + return + + self._handleCompatibilityData(json_data["data"]) + except json.decoder.JSONDecodeError: + Logger.log("w", "Received invalid JSON for user packages") + + def _updateRequestHeader(self): + # todo DRY, copied from Toolbox. To RequestManager? + self._request_headers = { + "User-Agent": "%s/%s (%s %s)" % (self._application.getApplicationName(), + self._application.getVersion(), + platform.system(), + platform.machine()) + } + access_token = self._application.getCuraAPI().account.accessToken + if access_token: + self._request_headers["Authorization"] = "Bearer {}".format(access_token) + diff --git a/plugins/Toolbox/src/Toolbox.py b/plugins/Toolbox/src/Toolbox.py index f28178b99e..624ba34094 100644 --- a/plugins/Toolbox/src/Toolbox.py +++ b/plugins/Toolbox/src/Toolbox.py @@ -15,12 +15,12 @@ from UM.PluginRegistry import PluginRegistry from UM.Extension import Extension from UM.i18n import i18nCatalog from UM.Version import Version -from UM.Message import Message from cura import ApplicationMetadata from cura import UltimakerCloudAuthentication from cura.CuraApplication import CuraApplication from cura.Machines.ContainerTree import ContainerTree +from plugins.Toolbox.src.CloudApiModel import CloudApiModel from .AuthorsModel import AuthorsModel from .PackagesModel import PackagesModel @@ -32,8 +32,7 @@ if TYPE_CHECKING: i18n_catalog = i18nCatalog("cura") - -## The Toolbox class is responsible of communicating with the server through the API +## Provides a marketplace for users to download plugins an materials class Toolbox(QObject, Extension): def __init__(self, application: CuraApplication) -> None: super().__init__() @@ -41,9 +40,6 @@ class Toolbox(QObject, Extension): self._application = application # type: CuraApplication self._sdk_version = ApplicationMetadata.CuraSDKVersion # type: Union[str, int] - self._cloud_api_version = UltimakerCloudAuthentication.CuraCloudAPIVersion # type: str - self._cloud_api_root = UltimakerCloudAuthentication.CuraCloudAPIRoot # type: str - self._api_url = None # type: Optional[str] # Network: self._download_request_data = None # type: Optional[HttpRequestData] @@ -61,17 +57,15 @@ class Toolbox(QObject, Extension): self._server_response_data = { "authors": [], "packages": [], - "updates": [], - "subscribed_packages": [], + "updates": [] } # type: Dict[str, List[Any]] # Models: self._models = { "authors": AuthorsModel(self), "packages": PackagesModel(self), - "updates": PackagesModel(self), - "subscribed_packages": SubscribedPackagesModel(self), - } # type: Dict[str, Union[AuthorsModel, PackagesModel, SubscribedPackagesModel]] + "updates": PackagesModel(self) + } # type: Dict[str, Union[AuthorsModel, PackagesModel]] self._plugins_showcase_model = PackagesModel(self) self._plugins_available_model = PackagesModel(self) @@ -159,7 +153,7 @@ class Toolbox(QObject, Extension): @pyqtSlot(str, int) def ratePackage(self, package_id: str, rating: int) -> None: - url = "{base_url}/packages/{package_id}/ratings".format(base_url = self._api_url, package_id = package_id) + url = "{base_url}/packages/{package_id}/ratings".format(base_url = CloudApiModel.api_url, package_id = package_id) data = "{\"data\": {\"cura_version\": \"%s\", \"rating\": %i}}" % (Version(self._application.getVersion()), rating) self._application.getHttpRequestManager().put(url, headers_dict = self._request_headers, @@ -196,16 +190,6 @@ class Toolbox(QObject, Extension): def _onAppInitialized(self) -> None: self._plugin_registry = self._application.getPluginRegistry() self._package_manager = self._application.getPackageManager() - self._api_url = "{cloud_api_root}/cura-packages/v{cloud_api_version}/cura/v{sdk_version}".format( - cloud_api_root = self._cloud_api_root, - cloud_api_version = self._cloud_api_version, - sdk_version = self._sdk_version - ) - # https://api.ultimaker.com/cura-packages/v1/user/packages - self._api_url_user_packages = "{cloud_api_root}/cura-packages/v{cloud_api_version}/user/packages".format( - cloud_api_root = self._cloud_api_root, - cloud_api_version = self._cloud_api_version, - ) # We need to construct a query like installed_packages=ID:VERSION&installed_packages=ID:VERSION, etc. installed_package_ids_with_versions = [":".join(items) for items in @@ -213,27 +197,20 @@ class Toolbox(QObject, Extension): installed_packages_query = "&installed_packages=".join(installed_package_ids_with_versions) self._request_urls = { - "authors": "{base_url}/authors".format(base_url = self._api_url), - "packages": "{base_url}/packages".format(base_url = self._api_url), + "authors": "{base_url}/authors".format(base_url = CloudApiModel.api_url), + "packages": "{base_url}/packages".format(base_url = CloudApiModel.api_url), "updates": "{base_url}/packages/package-updates?installed_packages={query}".format( - base_url = self._api_url, query = installed_packages_query), - "subscribed_packages": self._api_url_user_packages, + base_url = CloudApiModel.api_url, query = installed_packages_query) } self._application.getCuraAPI().account.loginStateChanged.connect(self._restart) - self._application.getCuraAPI().account.loginStateChanged.connect(self._fetchUserSubscribedPackages) # On boot we check which packages have updates. if CuraApplication.getInstance().getPreferences().getValue("info/automatic_update_check") and len(installed_package_ids_with_versions) > 0: # Request the latest and greatest! self._makeRequestByType("updates") - self._fetchUserSubscribedPackages() - def _fetchUserSubscribedPackages(self): - if self._application.getCuraAPI().account.isLoggedIn: - self._makeRequestByType("subscribed_packages") - def _fetchPackageData(self) -> None: self._makeRequestByType("packages") self._makeRequestByType("authors") @@ -652,46 +629,12 @@ class Toolbox(QObject, Extension): # Tell the package manager that there's a new set of updates available. packages = set([pkg["package_id"] for pkg in self._server_response_data[request_type]]) self._package_manager.setPackagesWithUpdate(packages) - elif request_type == "subscribed_packages": - self._checkCompatibilities(json_data["data"]) self.metadataChanged.emit() if self.isLoadingComplete(): self.setViewPage("overview") - def _checkCompatibilities(self, json_data) -> None: - user_subscribed_packages = [plugin["package_id"] for plugin in json_data] - user_installed_packages = self._package_manager.getUserInstalledPackages() - - # We check if there are packages installed in Cloud Marketplace but not in Cura marketplace (discrepancy) - package_discrepancy = list(set(user_subscribed_packages).difference(user_installed_packages)) - if package_discrepancy: - self._models["subscribed_packages"].addValue(package_discrepancy) - self._models["subscribed_packages"].update() - Logger.log("d", "Discrepancy found between Cloud subscribed packages and Cura installed packages") - sync_message = Message(i18n_catalog.i18nc( - "@info:generic", - "\nDo you want to sync material and software packages with your account?"), - lifetime=0, - title=i18n_catalog.i18nc("@info:title", "Changes detected from your Ultimaker account", )) - sync_message.addAction("sync", - name=i18n_catalog.i18nc("@action:button", "Sync"), - icon="", - description="Sync your Cloud subscribed packages to your local environment.", - button_align=Message.ActionButtonAlignment.ALIGN_RIGHT) - - sync_message.actionTriggered.connect(self._onSyncButtonClicked) - sync_message.show() - - def _onSyncButtonClicked(self, sync_message: Message, sync_message_action: str) -> None: - sync_message.hide() - compatibility_dialog_path = "resources/qml/dialogs/CompatibilityDialog.qml" - plugin_path_prefix = PluginRegistry.getInstance().getPluginPath(self.getPluginId()) - if plugin_path_prefix: - path = os.path.join(plugin_path_prefix, compatibility_dialog_path) - self.compatibility_dialog_view = self._application.getInstance().createQmlComponent(path, {"toolbox": self}) - # This function goes through all known remote versions of a package and notifies the package manager of this change def _notifyPackageManager(self): for package in self._server_response_data["packages"]: