-
-
Notifications
You must be signed in to change notification settings - Fork 2.1k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge branch 'CURA-9146_account_sync' into 5.0
- Loading branch information
Showing
18 changed files
with
1,152 additions
and
18 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
from typing import Union | ||
|
||
from cura import ApplicationMetadata | ||
from cura.UltimakerCloud import UltimakerCloudConstants | ||
|
||
|
||
class CloudApiModel: | ||
sdk_version: Union[str, int] = ApplicationMetadata.CuraSDKVersion | ||
cloud_api_version: str = UltimakerCloudConstants.CuraCloudAPIVersion | ||
cloud_api_root: str = UltimakerCloudConstants.CuraCloudAPIRoot | ||
api_url: str = "{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 | ||
) | ||
|
||
# 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, | ||
) | ||
|
||
@classmethod | ||
def userPackageUrl(cls, package_id: str) -> str: | ||
"""https://api.ultimaker.com/cura-packages/v1/user/packages/{package_id}""" | ||
|
||
return (CloudApiModel.api_url_user_packages + "/{package_id}").format( | ||
package_id = package_id | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,55 @@ | ||
# Copyright (c) 2022 Ultimaker B.V. | ||
# Cura is released under the terms of the LGPLv3 or higher. | ||
|
||
from UM.Logger import Logger | ||
from UM.TaskManagement.HttpRequestManager import HttpRequestManager | ||
from UM.TaskManagement.HttpRequestScope import JsonDecoratorScope | ||
from cura.CuraApplication import CuraApplication | ||
from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope | ||
from ..CloudApiModel import CloudApiModel | ||
|
||
|
||
class CloudApiClient: | ||
"""Manages Cloud subscriptions | ||
When a package is added to a user's account, the user is 'subscribed' to that package. | ||
Whenever the user logs in on another instance of Cura, these subscriptions can be used to sync the user's plugins | ||
Singleton: use CloudApiClient.getInstance() instead of CloudApiClient() | ||
""" | ||
|
||
__instance = None | ||
|
||
@classmethod | ||
def getInstance(cls, app: CuraApplication): | ||
if not cls.__instance: | ||
cls.__instance = CloudApiClient(app) | ||
return cls.__instance | ||
|
||
def __init__(self, app: CuraApplication) -> None: | ||
if self.__instance is not None: | ||
raise RuntimeError("This is a Singleton. use getInstance()") | ||
|
||
self._scope: JsonDecoratorScope = JsonDecoratorScope(UltimakerCloudScope(app)) | ||
|
||
app.getPackageManager().packageInstalled.connect(self._onPackageInstalled) | ||
|
||
def unsubscribe(self, package_id: str) -> None: | ||
url = CloudApiModel.userPackageUrl(package_id) | ||
HttpRequestManager.getInstance().delete(url = url, scope = self._scope) | ||
|
||
def _subscribe(self, package_id: str) -> None: | ||
"""You probably don't want to use this directly. All installed packages will be automatically subscribed.""" | ||
|
||
Logger.debug("Subscribing to {}", package_id) | ||
data = "{\"data\": {\"package_id\": \"%s\", \"sdk_version\": \"%s\"}}" % (package_id, CloudApiModel.sdk_version) | ||
HttpRequestManager.getInstance().put( | ||
url = CloudApiModel.api_url_user_packages, | ||
data = data.encode(), | ||
scope = self._scope | ||
) | ||
|
||
def _onPackageInstalled(self, package_id: str): | ||
if CuraApplication.getInstance().getCuraAPI().account.isLoggedIn: | ||
# We might already be subscribed, but checking would take one extra request. Instead, simply subscribe | ||
self._subscribe(package_id) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,166 @@ | ||
# Copyright (c) 2022 Ultimaker B.V. | ||
# Cura is released under the terms of the LGPLv3 or higher. | ||
|
||
import json | ||
from typing import List, Dict, Any, Set | ||
from typing import Optional | ||
|
||
from PyQt6.QtCore import QObject | ||
from PyQt6.QtNetwork import QNetworkReply | ||
|
||
from UM import i18nCatalog | ||
from UM.Logger import Logger | ||
from UM.Message import Message | ||
from UM.Signal import Signal | ||
from UM.TaskManagement.HttpRequestManager import HttpRequestManager | ||
from UM.TaskManagement.HttpRequestScope import JsonDecoratorScope | ||
from cura.API.Account import SyncState | ||
from cura.CuraApplication import CuraApplication, ApplicationMetadata | ||
from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope | ||
from .SubscribedPackagesModel import SubscribedPackagesModel | ||
from ..CloudApiModel import CloudApiModel | ||
|
||
|
||
class CloudPackageChecker(QObject): | ||
|
||
SYNC_SERVICE_NAME = "CloudPackageChecker" | ||
|
||
def __init__(self, application: CuraApplication) -> None: | ||
super().__init__() | ||
|
||
self.discrepancies = Signal() # Emits SubscribedPackagesModel | ||
self._application: CuraApplication = application | ||
self._scope = JsonDecoratorScope(UltimakerCloudScope(application)) | ||
self._model = SubscribedPackagesModel() | ||
self._message: Optional[Message] = None | ||
|
||
self._application.initializationFinished.connect(self._onAppInitialized) | ||
self._i18n_catalog = i18nCatalog("cura") | ||
self._sdk_version = ApplicationMetadata.CuraSDKVersion | ||
|
||
self._last_notified_packages = set() # type: Set[str] | ||
"""Packages for which a notification has been shown. No need to bother the user twice for equal content""" | ||
|
||
# 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._getPackagesIfLoggedIn() | ||
|
||
self._application.getCuraAPI().account.loginStateChanged.connect(self._onLoginStateChanged) | ||
self._application.getCuraAPI().account.syncRequested.connect(self._getPackagesIfLoggedIn) | ||
|
||
def _onLoginStateChanged(self) -> None: | ||
# reset session | ||
self._last_notified_packages = set() | ||
self._getPackagesIfLoggedIn() | ||
|
||
def _getPackagesIfLoggedIn(self) -> None: | ||
if self._application.getCuraAPI().account.isLoggedIn: | ||
self._getUserSubscribedPackages() | ||
else: | ||
self._hideSyncMessage() | ||
|
||
def _getUserSubscribedPackages(self) -> None: | ||
self._application.getCuraAPI().account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.SYNCING) | ||
url = CloudApiModel.api_url_user_packages | ||
self._application.getHttpRequestManager().get(url, | ||
callback = self._onUserPackagesRequestFinished, | ||
error_callback = self._onUserPackagesRequestFinished, | ||
timeout = 10, | ||
scope = self._scope) | ||
|
||
def _onUserPackagesRequestFinished(self, reply: "QNetworkReply", error: Optional["QNetworkReply.NetworkError"] = None) -> None: | ||
if error is not None or HttpRequestManager.safeHttpStatus(reply) != 200: | ||
Logger.log("w", | ||
"Requesting user packages failed, response code %s while trying to connect to %s", | ||
HttpRequestManager.safeHttpStatus(reply), reply.url()) | ||
self._application.getCuraAPI().account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.ERROR) | ||
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"]) | ||
self._application.getCuraAPI().account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.ERROR) | ||
return | ||
self._handleCompatibilityData(json_data["data"]) | ||
except json.decoder.JSONDecodeError: | ||
Logger.log("w", "Received invalid JSON for user subscribed packages from the Web Marketplace") | ||
|
||
self._application.getCuraAPI().account.setSyncState(self.SYNC_SERVICE_NAME, SyncState.SUCCESS) | ||
|
||
def _handleCompatibilityData(self, subscribed_packages_payload: List[Dict[str, Any]]) -> None: | ||
user_subscribed_packages = {plugin["package_id"] for plugin in subscribed_packages_payload} | ||
user_installed_packages = self._package_manager.getAllInstalledPackageIDs() | ||
|
||
# We need to re-evaluate the dismissed packages | ||
# (i.e. some package might got updated to the correct SDK version in the meantime, | ||
# hence remove them from the Dismissed Incompatible list) | ||
self._package_manager.reEvaluateDismissedPackages(subscribed_packages_payload, self._sdk_version) | ||
user_dismissed_packages = self._package_manager.getDismissedPackages() | ||
if user_dismissed_packages: | ||
user_installed_packages.update(user_dismissed_packages) | ||
|
||
# We check if there are packages installed in Web Marketplace but not in Cura marketplace | ||
package_discrepancy = list(user_subscribed_packages.difference(user_installed_packages)) | ||
|
||
if user_subscribed_packages != self._last_notified_packages: | ||
# scenario: | ||
# 1. user subscribes to a package | ||
# 2. dismisses the license/unsubscribes | ||
# 3. subscribes to the same package again | ||
# in this scenario we want to notify the user again. To capture that there was a change during | ||
# step 2, we clear the last_notified after step 2. This way, the user will be notified after | ||
# step 3 even though the list of packages for step 1 and 3 are equal | ||
self._last_notified_packages = set() | ||
|
||
if package_discrepancy: | ||
account = self._application.getCuraAPI().account | ||
account.setUpdatePackagesAction(lambda: self._onSyncButtonClicked(None, None)) | ||
|
||
if user_subscribed_packages == self._last_notified_packages: | ||
# already notified user about these | ||
return | ||
|
||
Logger.log("d", "Discrepancy found between Cloud subscribed packages and Cura installed packages") | ||
self._model.addDiscrepancies(package_discrepancy) | ||
self._model.initialize(self._package_manager, subscribed_packages_payload) | ||
self._showSyncMessage() | ||
self._last_notified_packages = user_subscribed_packages | ||
|
||
def _showSyncMessage(self) -> None: | ||
"""Show the message if it is not already shown""" | ||
|
||
if self._message is not None: | ||
self._message.show() | ||
return | ||
|
||
sync_message = Message(self._i18n_catalog.i18nc( | ||
"@info:generic", | ||
"Do you want to sync material and software packages with your account?"), | ||
title = self._i18n_catalog.i18nc("@info:title", "Changes detected from your Ultimaker account", )) | ||
sync_message.addAction("sync", | ||
name = self._i18n_catalog.i18nc("@action:button", "Sync"), | ||
icon = "", | ||
description = "Sync your plugins and print profiles to Ultimaker Cura.", | ||
button_align = Message.ActionButtonAlignment.ALIGN_RIGHT) | ||
sync_message.actionTriggered.connect(self._onSyncButtonClicked) | ||
sync_message.show() | ||
self._message = sync_message | ||
|
||
def _hideSyncMessage(self) -> None: | ||
"""Hide the message if it is showing""" | ||
|
||
if self._message is not None: | ||
self._message.hide() | ||
self._message = None | ||
|
||
def _onSyncButtonClicked(self, sync_message: Optional[Message], sync_message_action: Optional[str]) -> None: | ||
if sync_message is not None: | ||
sync_message.hide() | ||
self._hideSyncMessage() # Should be the same message, but also sets _message to None | ||
self.discrepancies.emit(self._model) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,44 @@ | ||
# Copyright (c) 2022 Ultimaker B.V. | ||
# Cura is released under the terms of the LGPLv3 or higher. | ||
|
||
import os | ||
from typing import Optional | ||
|
||
from PyQt6.QtCore import QObject | ||
|
||
from UM.Qt.QtApplication import QtApplication | ||
from UM.Signal import Signal | ||
from .SubscribedPackagesModel import SubscribedPackagesModel | ||
|
||
|
||
class DiscrepanciesPresenter(QObject): | ||
"""Shows a list of packages to be added or removed. The user can select which packages to (un)install. The user's | ||
choices are emitted on the `packageMutations` Signal. | ||
""" | ||
|
||
def __init__(self, app: QtApplication) -> None: | ||
super().__init__() | ||
|
||
self.packageMutations = Signal() # Emits SubscribedPackagesModel | ||
|
||
self._app = app | ||
self._package_manager = app.getPackageManager() | ||
self._dialog: Optional[QObject] = None | ||
self._compatibility_dialog_path = "resources/qml/CompatibilityDialog.qml" | ||
|
||
def present(self, plugin_path: str, model: SubscribedPackagesModel) -> None: | ||
path = os.path.join(plugin_path, self._compatibility_dialog_path) | ||
self._dialog = self._app.createQmlComponent(path, {"subscribedPackagesModel": model, "handler": self}) | ||
assert self._dialog | ||
self._dialog.accepted.connect(lambda: self._onConfirmClicked(model)) | ||
|
||
def _onConfirmClicked(self, model: SubscribedPackagesModel) -> None: | ||
# If there are incompatible packages - automatically dismiss them | ||
if model.getIncompatiblePackages(): | ||
self._package_manager.dismissAllIncompatiblePackages(model.getIncompatiblePackages()) | ||
# For now, all compatible packages presented to the user should be installed. | ||
# Later, we might remove items for which the user unselected the package | ||
if model.getCompatiblePackages(): | ||
model.setItems(model.getCompatiblePackages()) | ||
self.packageMutations.emit(model) |
Oops, something went wrong.