Просмотр исходного кода

Re-add the CloudSync functionality

It got removed in the marketplace upgrade.

CURA-9146
Jaime van Kessel 2 лет назад
Родитель
Сommit
5ce5ce769e

+ 29 - 0
plugins/Marketplace/CloudApiModel.py

@@ -0,0 +1,29 @@
+from typing import Union
+
+from cura import ApplicationMetadata
+from cura.UltimakerCloud import UltimakerCloudConstants
+
+
+class CloudApiModel:
+    sdk_version = ApplicationMetadata.CuraSDKVersion  # type: Union[str, int]
+    cloud_api_version = UltimakerCloudConstants.CuraCloudAPIVersion  # type: str
+    cloud_api_root = UltimakerCloudConstants.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,
+    )
+
+    @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
+        )

+ 52 - 0
plugins/Marketplace/CloudSync/CloudApiClient.py

@@ -0,0 +1,52 @@
+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(UltimakerCloudScope(app))  # type: JsonDecoratorScope
+
+        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)

+ 166 - 0
plugins/Marketplace/CloudSync/CloudPackageChecker.py

@@ -0,0 +1,166 @@
+# Copyright (c) 2020 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 = application  # type: CuraApplication
+        self._scope = JsonDecoratorScope(UltimakerCloudScope(application))
+        self._model = SubscribedPackagesModel()
+        self._message = None  # type: Optional[Message]
+
+        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)

+ 41 - 0
plugins/Marketplace/CloudSync/DiscrepanciesPresenter.py

@@ -0,0 +1,41 @@
+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 = None  # type: Optional[QObject]
+        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)

+ 153 - 0
plugins/Marketplace/CloudSync/DownloadPresenter.py

@@ -0,0 +1,153 @@
+# Copyright (c) 2020 Ultimaker B.V.
+# Cura is released under the terms of the LGPLv3 or higher.
+
+import tempfile
+from typing import Dict, List, Any
+
+from PyQt6.QtNetwork import QNetworkReply
+
+from UM.i18n import i18nCatalog
+from UM.Logger import Logger
+from UM.Message import Message
+from UM.Signal import Signal
+from UM.TaskManagement.HttpRequestManager import HttpRequestManager
+from cura.CuraApplication import CuraApplication
+from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope
+from .SubscribedPackagesModel import SubscribedPackagesModel
+
+i18n_catalog = i18nCatalog("cura")
+
+
+class DownloadPresenter:
+    """Downloads a set of packages from the Ultimaker Cloud Marketplace
+
+    use download() exactly once: should not be used for multiple sets of downloads since this class contains state
+    """
+
+    DISK_WRITE_BUFFER_SIZE = 256 * 1024  # 256 KB
+
+    def __init__(self, app: CuraApplication) -> None:
+        # Emits (Dict[str, str], List[str]) # (success_items, error_items)
+        # Dict{success_package_id, temp_file_path}
+        # List[errored_package_id]
+        self.done = Signal()
+
+        self._app = app
+        self._scope = UltimakerCloudScope(app)
+
+        self._started = False
+        self._progress_message = self._createProgressMessage()
+        self._progress = {}  # type: Dict[str, Dict[str, Any]] # package_id, Dict
+        self._error = []  # type: List[str] # package_id
+
+    def download(self, model: SubscribedPackagesModel) -> None:
+        if self._started:
+            Logger.error("Download already started. Create a new %s instead", self.__class__.__name__)
+            return
+
+        manager = HttpRequestManager.getInstance()
+        for item in model.items:
+            package_id = item["package_id"]
+
+            def finishedCallback(reply: QNetworkReply, pid = package_id) -> None:
+                self._onFinished(pid, reply)
+
+            def progressCallback(rx: int, rt: int, pid = package_id) -> None:
+                self._onProgress(pid, rx, rt)
+
+            def errorCallback(reply: QNetworkReply, error: QNetworkReply.NetworkError, pid = package_id) -> None:
+                self._onError(pid)
+
+            request_data = manager.get(
+                item["download_url"],
+                callback = finishedCallback,
+                download_progress_callback = progressCallback,
+                error_callback = errorCallback,
+                scope = self._scope)
+
+            self._progress[package_id] = {
+                "received": 0,
+                "total": 1,  # make sure this is not considered done yet. Also divByZero-safe
+                "file_written": None,
+                "request_data": request_data,
+                "package_model": item
+            }
+
+        self._started = True
+        self._progress_message.show()
+
+    def abort(self) -> None:
+        manager = HttpRequestManager.getInstance()
+        for item in self._progress.values():
+            manager.abortRequest(item["request_data"])
+
+    # Aborts all current operations and returns a copy with the same settings such as app and scope
+    def resetCopy(self) -> "DownloadPresenter":
+        self.abort()
+        self.done.disconnectAll()
+        return DownloadPresenter(self._app)
+
+    def _createProgressMessage(self) -> Message:
+        return Message(i18n_catalog.i18nc("@info:generic", "Syncing..."),
+            lifetime = 0,
+            use_inactivity_timer = False,
+            progress = 0.0,
+            title = i18n_catalog.i18nc("@info:title", "Changes detected from your Ultimaker account"))
+
+    def _onFinished(self, package_id: str, reply: QNetworkReply) -> None:
+        self._progress[package_id]["received"] = self._progress[package_id]["total"]
+
+        try:
+            with tempfile.NamedTemporaryFile(mode = "wb+", suffix = ".curapackage", delete = False) as temp_file:
+                bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE)
+                while bytes_read:
+                    temp_file.write(bytes_read)
+                    bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE)
+                    self._app.processEvents()
+                self._progress[package_id]["file_written"] = temp_file.name
+        except IOError as e:
+            Logger.logException("e", "Failed to write downloaded package to temp file", e)
+            self._onError(package_id)
+        temp_file.close()
+
+        self._checkDone()
+
+    def _onProgress(self, package_id: str, rx: int, rt: int) -> None:
+        self._progress[package_id]["received"] = rx
+        self._progress[package_id]["total"] = rt
+
+        received = 0
+        total = 0
+        for item in self._progress.values():
+            received += item["received"]
+            total += item["total"]
+
+        if total == 0:  # Total download size is 0, or unknown, or there are no progress items at all.
+            self._progress_message.setProgress(100.0)
+            return
+
+        self._progress_message.setProgress(100.0 * (received / total))  # [0 .. 100] %
+
+    def _onError(self, package_id: str) -> None:
+        self._progress.pop(package_id)
+        self._error.append(package_id)
+        self._checkDone()
+
+    def _checkDone(self) -> bool:
+        for item in self._progress.values():
+            if not item["file_written"]:
+                return False
+
+        success_items = {
+            package_id:
+                {
+                    "package_path": value["file_written"],
+                    "icon_url": value["package_model"]["icon_url"]
+                }
+            for package_id, value in self._progress.items()
+        }
+        error_items = [package_id for package_id in self._error]
+
+        self._progress_message.hide()
+        self.done.emit(success_items, error_items)
+        return True

+ 77 - 0
plugins/Marketplace/CloudSync/LicenseModel.py

@@ -0,0 +1,77 @@
+from PyQt6.QtCore import QObject, pyqtProperty, pyqtSignal
+from UM.i18n import i18nCatalog
+
+catalog = i18nCatalog("cura")
+
+
+# Model for the ToolboxLicenseDialog
+class LicenseModel(QObject):
+    DEFAULT_DECLINE_BUTTON_TEXT = catalog.i18nc("@button", "Decline")
+    ACCEPT_BUTTON_TEXT = catalog.i18nc("@button", "Agree")
+
+    dialogTitleChanged = pyqtSignal()
+    packageNameChanged = pyqtSignal()
+    licenseTextChanged = pyqtSignal()
+    iconChanged = pyqtSignal()
+
+    def __init__(self, decline_button_text: str = DEFAULT_DECLINE_BUTTON_TEXT, parent = None) -> None:
+        super().__init__(parent)
+
+        self._current_page_idx = 0
+        self._page_count = 1
+        self._dialogTitle = ""
+        self._license_text = ""
+        self._package_name = ""
+        self._icon_url = ""
+        self._decline_button_text = decline_button_text
+
+    @pyqtProperty(str, constant = True)
+    def acceptButtonText(self):
+        return self.ACCEPT_BUTTON_TEXT
+
+    @pyqtProperty(str, constant = True)
+    def declineButtonText(self):
+        return self._decline_button_text
+
+    @pyqtProperty(str, notify=dialogTitleChanged)
+    def dialogTitle(self) -> str:
+        return self._dialogTitle
+
+    @pyqtProperty(str, notify=packageNameChanged)
+    def packageName(self) -> str:
+        return self._package_name
+
+    def setPackageName(self, name: str) -> None:
+        self._package_name = name
+        self.packageNameChanged.emit()
+
+    @pyqtProperty(str, notify=iconChanged)
+    def iconUrl(self) -> str:
+        return self._icon_url
+
+    def setIconUrl(self, url: str):
+        self._icon_url = url
+        self.iconChanged.emit()
+
+    @pyqtProperty(str, notify=licenseTextChanged)
+    def licenseText(self) -> str:
+        return self._license_text
+
+    def setLicenseText(self, license_text: str) -> None:
+        if self._license_text != license_text:
+            self._license_text = license_text
+            self.licenseTextChanged.emit()
+
+    def setCurrentPageIdx(self, idx: int) -> None:
+        self._current_page_idx = idx
+        self._updateDialogTitle()
+
+    def setPageCount(self, count: int) -> None:
+        self._page_count = count
+        self._updateDialogTitle()
+
+    def _updateDialogTitle(self):
+        self._dialogTitle = catalog.i18nc("@title:window", "Plugin License Agreement")
+        if self._page_count > 1:
+            self._dialogTitle = self._dialogTitle + " ({}/{})".format(self._current_page_idx + 1, self._page_count)
+        self.dialogTitleChanged.emit()

+ 139 - 0
plugins/Marketplace/CloudSync/LicensePresenter.py

@@ -0,0 +1,139 @@
+# Copyright (c) 2021 Ultimaker B.V.
+# Cura is released under the terms of the LGPLv3 or higher.
+
+import os
+from typing import Dict, Optional, List, Any
+
+from PyQt6.QtCore import QObject, pyqtSlot
+
+from UM.Logger import Logger
+from UM.PackageManager import PackageManager
+from UM.Signal import Signal
+from cura.CuraApplication import CuraApplication
+from UM.i18n import i18nCatalog
+
+from .LicenseModel import LicenseModel
+
+
+class LicensePresenter(QObject):
+    """Presents licenses for a set of packages for the user to accept or reject.
+
+    Call present() exactly once to show a licenseDialog for a set of packages
+    Before presenting another set of licenses, create a new instance using resetCopy().
+
+    licenseAnswers emits a list of Dicts containing answers when the user has made a choice for all provided packages.
+    """
+
+    def __init__(self, app: CuraApplication) -> None:
+        super().__init__()
+        self._presented = False
+        """Whether present() has been called and state is expected to be initialized"""
+        self._catalog = i18nCatalog("cura")
+        self._dialog = None  # type: Optional[QObject]
+        self._package_manager = app.getPackageManager()  # type: PackageManager
+        # Emits List[Dict[str, [Any]] containing for example
+        # [{ "package_id": "BarbarianPlugin", "package_path" : "/tmp/dg345as", "accepted" : True }]
+        self.licenseAnswers = Signal()
+
+        self._current_package_idx = 0
+        self._package_models = []  # type: List[Dict]
+        decline_button_text = self._catalog.i18nc("@button", "Decline and remove from account")
+        self._license_model = LicenseModel(decline_button_text=decline_button_text)  # type: LicenseModel
+        self._page_count = 0
+
+        self._app = app
+
+        self._compatibility_dialog_path = "resources/qml/MarketplaceLicenseDialog.qml"
+
+    def present(self, plugin_path: str, packages: Dict[str, Dict[str, str]]) -> None:
+        """Show a license dialog for multiple packages where users can read a license and accept or decline them
+
+        :param plugin_path: Root directory of the Toolbox plugin
+        :param packages: Dict[package id, file path]
+        """
+        if self._presented:
+            Logger.error("{clazz} is single-use. Create a new {clazz} instead", clazz=self.__class__.__name__)
+            return
+
+        path = os.path.join(plugin_path, self._compatibility_dialog_path)
+
+        self._initState(packages)
+
+        if self._page_count == 0:
+            self.licenseAnswers.emit(self._package_models)
+            return
+
+        if self._dialog is None:
+            context_properties = {
+                "licenseModel": self._license_model,
+                "handler": self
+            }
+            self._dialog = self._app.createQmlComponent(path, context_properties)
+        self._presentCurrentPackage()
+        self._presented = True
+
+    def resetCopy(self) -> "LicensePresenter":
+        """Clean up and return a new copy with the same settings such as app"""
+        if self._dialog:
+            self._dialog.close()
+        self.licenseAnswers.disconnectAll()
+        return LicensePresenter(self._app)
+
+    @pyqtSlot()
+    def onLicenseAccepted(self) -> None:
+        self._package_models[self._current_package_idx]["accepted"] = True
+        self._checkNextPage()
+
+    @pyqtSlot()
+    def onLicenseDeclined(self) -> None:
+        self._package_models[self._current_package_idx]["accepted"] = False
+        self._checkNextPage()
+
+    def _initState(self, packages: Dict[str, Dict[str, Any]]) -> None:
+
+        implicitly_accepted_count = 0
+
+        for package_id, item in packages.items():
+            item["package_id"] = package_id
+            try:
+                item["licence_content"] = self._package_manager.getPackageLicense(item["package_path"])
+            except EnvironmentError as e:
+                Logger.error(f"Could not open downloaded package {package_id} to read license file! {type(e)} - {e}")
+                continue  # Skip this package.
+            if item["licence_content"] is None:
+                # Implicitly accept when there is no license
+                item["accepted"] = True
+                implicitly_accepted_count = implicitly_accepted_count + 1
+                self._package_models.append(item)
+            else:
+                item["accepted"] = None  #: None: no answer yet
+                # When presenting the packages, we want to show packages which have a license first.
+                # In fact, we don't want to show the others at all because they are implicitly accepted
+                self._package_models.insert(0, item)
+            CuraApplication.getInstance().processEvents()
+        self._page_count = len(self._package_models) - implicitly_accepted_count
+        self._license_model.setPageCount(self._page_count)
+
+
+    def _presentCurrentPackage(self) -> None:
+        package_model = self._package_models[self._current_package_idx]
+        package_info = self._package_manager.getPackageInfo(package_model["package_path"])
+
+        self._license_model.setCurrentPageIdx(self._current_package_idx)
+        self._license_model.setPackageName(package_info["display_name"])
+        self._license_model.setIconUrl(package_model["icon_url"])
+        self._license_model.setLicenseText(package_model["licence_content"])
+        if self._dialog:
+            self._dialog.open()  # Does nothing if already open
+
+    def _checkNextPage(self) -> None:
+        if self._current_package_idx + 1 < self._page_count:
+            self._current_package_idx += 1
+            self._presentCurrentPackage()
+        else:
+            if self._dialog:
+                self._dialog.close()
+            self.licenseAnswers.emit(self._package_models)
+
+
+

+ 32 - 0
plugins/Marketplace/CloudSync/RestartApplicationPresenter.py

@@ -0,0 +1,32 @@
+from UM import i18nCatalog
+from UM.Message import Message
+from cura.CuraApplication import CuraApplication
+
+
+class RestartApplicationPresenter:
+    """Presents a dialog telling the user that a restart is required to apply changes
+
+    Since we cannot restart Cura, the app is closed instead when the button is clicked
+    """
+    def __init__(self, app: CuraApplication) -> None:
+        self._app = app
+        self._i18n_catalog = i18nCatalog("cura")
+
+    def present(self) -> None:
+        app_name = self._app.getApplicationDisplayName()
+
+        message = Message(self._i18n_catalog.i18nc("@info:generic",
+                                                   "You need to quit and restart {} before changes have effect.",
+                                                   app_name))
+
+        message.addAction("quit",
+                          name="Quit " + app_name,
+                          icon = "",
+                          description="Close the application",
+                          button_align=Message.ActionButtonAlignment.ALIGN_RIGHT)
+
+        message.actionTriggered.connect(self._quitClicked)
+        message.show()
+
+    def _quitClicked(self, *_):
+        self._app.windowClosed()

+ 74 - 0
plugins/Marketplace/CloudSync/SubscribedPackagesModel.py

@@ -0,0 +1,74 @@
+# Copyright (c) 2020 Ultimaker B.V.
+# Cura is released under the terms of the LGPLv3 or higher.
+
+from PyQt6.QtCore import Qt, pyqtProperty
+
+from UM.PackageManager import PackageManager
+from UM.Qt.ListModel import ListModel
+from UM.Version import Version
+
+from cura import ApplicationMetadata
+from typing import List, Dict, Any
+
+
+class SubscribedPackagesModel(ListModel):
+    def __init__(self, parent = None):
+        super().__init__(parent)
+
+        self._items = []
+        self._metadata = None
+        self._discrepancies = None
+        self._sdk_version = ApplicationMetadata.CuraSDKVersion
+
+        self.addRoleName(Qt.ItemDataRole.UserRole + 1, "package_id")
+        self.addRoleName(Qt.ItemDataRole.UserRole + 2, "display_name")
+        self.addRoleName(Qt.ItemDataRole.UserRole + 3, "icon_url")
+        self.addRoleName(Qt.ItemDataRole.UserRole + 4, "is_compatible")
+        self.addRoleName(Qt.ItemDataRole.UserRole + 5, "is_dismissed")
+
+    @pyqtProperty(bool, constant=True)
+    def hasCompatiblePackages(self) -> bool:
+        for item in self._items:
+            if item['is_compatible']:
+                return True
+        return False
+
+    @pyqtProperty(bool, constant=True)
+    def hasIncompatiblePackages(self) -> bool:
+        for item in self._items:
+            if not item['is_compatible']:
+                return True
+        return False
+
+    def addDiscrepancies(self, discrepancy: List[str]) -> None:
+        self._discrepancies = discrepancy
+
+    def getCompatiblePackages(self) -> List[Dict[str, Any]]:
+        return [package for package in self._items if package["is_compatible"]]
+
+    def getIncompatiblePackages(self) -> List[str]:
+        return [package["package_id"] for package in self._items if not package["is_compatible"]]
+
+    def initialize(self, package_manager: PackageManager, subscribed_packages_payload: List[Dict[str, Any]]) -> None:
+        self._items.clear()
+        for item in subscribed_packages_payload:
+            if item["package_id"] not in self._discrepancies:
+                continue
+            package = {
+                "package_id": item["package_id"],
+                "display_name": item["display_name"],
+                "sdk_versions": item["sdk_versions"],
+                "download_url": item["download_url"],
+                "md5_hash": item["md5_hash"],
+                "is_dismissed": False,
+            }
+
+            compatible = any(package_manager.isPackageCompatible(Version(version)) for version in item["sdk_versions"])
+            package.update({"is_compatible": compatible})
+
+            try:
+                package.update({"icon_url": item["icon_url"]})
+            except KeyError:  # There is no 'icon_url" in the response payload for this package
+                package.update({"icon_url": ""})
+            self._items.append(package)
+        self.setItems(self._items)

+ 114 - 0
plugins/Marketplace/CloudSync/SyncOrchestrator.py

@@ -0,0 +1,114 @@
+# Copyright (c) 2021 Ultimaker B.V.
+# Cura is released under the terms of the LGPLv3 or higher.
+
+import os
+from typing import List, Dict, Any, cast
+
+from UM import i18n_catalog
+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 .CloudPackageChecker import CloudPackageChecker
+from .CloudApiClient import CloudApiClient
+from .DiscrepanciesPresenter import DiscrepanciesPresenter
+from .DownloadPresenter import DownloadPresenter
+from .LicensePresenter import LicensePresenter
+from .RestartApplicationPresenter import RestartApplicationPresenter
+from .SubscribedPackagesModel import SubscribedPackagesModel
+
+
+class SyncOrchestrator(Extension):
+    """Orchestrates the synchronizing of packages from the user account to the installed packages
+
+    Example flow:
+
+    - CloudPackageChecker compares a list of packages the user `subscribed` to in their account
+      If there are `discrepancies` between the account and locally installed packages, they are emitted
+    - DiscrepanciesPresenter shows a list of packages to be added or removed to the user. It emits the `packageMutations`
+      the user selected to be performed
+    - The SyncOrchestrator uses PackageManager to remove local packages the users wants to see removed
+    - The DownloadPresenter shows a download progress dialog. It emits A tuple of succeeded and failed downloads
+    - The LicensePresenter extracts licenses from the downloaded packages and presents a license for each package to
+      be installed. It emits the `licenseAnswers` signal for accept or declines
+    - The CloudApiClient removes the declined packages from the account
+    - The SyncOrchestrator uses PackageManager to install the downloaded packages and delete temp files.
+    - The RestartApplicationPresenter notifies the user that a restart is required for changes to take effect
+    """
+
+    def __init__(self, app: CuraApplication) -> None:
+        super().__init__()
+        # Differentiate This PluginObject from the Marketplace. self.getId() includes _name.
+        # getPluginId() will return the same value for The Marketplace extension and this one
+        self._name = "SyncOrchestrator"
+
+        self._package_manager = app.getPackageManager()
+        # Keep a reference to the CloudApiClient. it watches for installed packages and subscribes to them
+        self._cloud_api = CloudApiClient.getInstance(app)  # type: CloudApiClient
+
+        self._checker = CloudPackageChecker(app)  # type: CloudPackageChecker
+        self._checker.discrepancies.connect(self._onDiscrepancies)
+
+        self._discrepancies_presenter = DiscrepanciesPresenter(app)  # type: DiscrepanciesPresenter
+        self._discrepancies_presenter.packageMutations.connect(self._onPackageMutations)
+
+        self._download_presenter = DownloadPresenter(app)  # type: DownloadPresenter
+
+        self._license_presenter = LicensePresenter(app)  # type: LicensePresenter
+        self._license_presenter.licenseAnswers.connect(self._onLicenseAnswers)
+
+        self._restart_presenter = RestartApplicationPresenter(app)
+
+    def _onDiscrepancies(self, model: SubscribedPackagesModel) -> None:
+        plugin_path = cast(str, PluginRegistry.getInstance().getPluginPath(self.getPluginId()))
+        self._discrepancies_presenter.present(plugin_path, model)
+
+    def _onPackageMutations(self, mutations: SubscribedPackagesModel) -> None:
+        self._download_presenter = self._download_presenter.resetCopy()
+        self._download_presenter.done.connect(self._onDownloadFinished)
+        self._download_presenter.download(mutations)
+
+    def _onDownloadFinished(self, success_items: Dict[str, Dict[str, str]], error_items: List[str]) -> None:
+        """Called when a set of packages have finished downloading
+
+        :param success_items:: Dict[package_id, Dict[str, str]]
+        :param error_items:: List[package_id]
+        """
+        if error_items:
+            message = i18n_catalog.i18nc("@info:generic", "{} plugins failed to download".format(len(error_items)))
+            self._showErrorMessage(message)
+
+        plugin_path = cast(str, PluginRegistry.getInstance().getPluginPath(self.getPluginId()))
+        self._license_presenter = self._license_presenter.resetCopy()
+        self._license_presenter.licenseAnswers.connect(self._onLicenseAnswers)
+        self._license_presenter.present(plugin_path, success_items)
+
+    # Called when user has accepted / declined all licenses for the downloaded packages
+    def _onLicenseAnswers(self, answers: List[Dict[str, Any]]) -> None:
+        has_changes = False  # True when at least one package is installed
+
+        for item in answers:
+            if item["accepted"]:
+                # install and subscribe packages
+                if not self._package_manager.installPackage(item["package_path"]):
+                    message = "Could not install {}".format(item["package_id"])
+                    self._showErrorMessage(message)
+                    continue
+                has_changes = True
+            else:
+                self._cloud_api.unsubscribe(item["package_id"])
+            # delete temp file
+            try:
+                os.remove(item["package_path"])
+            except EnvironmentError as e:  # File was already removed, no access rights, etc.
+                Logger.error("Can't delete temporary package file: {err}".format(err = str(e)))
+
+        if has_changes:
+            self._restart_presenter.present()
+
+    def _showErrorMessage(self, text: str):
+        """Logs an error and shows it to the user"""
+
+        Logger.error(text)
+        Message(text, lifetime = 0, message_type = Message.MessageType.ERROR).show()

Некоторые файлы не были показаны из-за большого количества измененных файлов