diff --git a/plugins/Toolbox/src/CloudSync/DiscrepanciesPresenter.py b/plugins/Toolbox/src/CloudSync/DiscrepanciesPresenter.py index e0a6cce558..9e7ad518ad 100644 --- a/plugins/Toolbox/src/CloudSync/DiscrepanciesPresenter.py +++ b/plugins/Toolbox/src/CloudSync/DiscrepanciesPresenter.py @@ -1,5 +1,5 @@ import os -from typing import Optional +from typing import Optional, Dict from PyQt5.QtCore import QObject @@ -15,7 +15,7 @@ class DiscrepanciesPresenter(QObject): def __init__(self, app: QtApplication): super().__init__(app) - self.packageMutations = Signal() # {"SettingsGuide" : "install", "PrinterSettings" : "uninstall"} + self.packageMutations = Signal() # Emits SubscribedPackagesModel self._app = app self._dialog = None # type: Optional[QObject] @@ -28,6 +28,5 @@ class DiscrepanciesPresenter(QObject): def _onConfirmClicked(self, model: SubscribedPackagesModel): # For now, all packages presented to the user should be installed. - # Later, we will support uninstall ?or ignoring? of a certain package - choices = {item["package_id"]: "install" for item in model.items} - self.packageMutations.emit(choices) + # Later, we might remove items for which the user unselected the package + self.packageMutations.emit(model) diff --git a/plugins/Toolbox/src/CloudSync/DownloadPresenter.py b/plugins/Toolbox/src/CloudSync/DownloadPresenter.py new file mode 100644 index 0000000000..f64a96b9cf --- /dev/null +++ b/plugins/Toolbox/src/CloudSync/DownloadPresenter.py @@ -0,0 +1,114 @@ +import os +import tempfile +from functools import reduce +from typing import Dict, List, Optional + +from PyQt5.QtNetwork import QNetworkReply + +from UM import i18n_catalog +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 plugins.Toolbox.src.UltimakerCloudScope import UltimakerCloudScope +from plugins.Toolbox.src.CloudSync.SubscribedPackagesModel import SubscribedPackagesModel + + +## 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 +class DownloadPresenter: + + def __init__(self, app: CuraApplication): + # 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 = None # type: Optional[Message] + self._progress = {} # type: Dict[str, Dict[str, int]] # package_id, Dict + self._error = [] # type: List[str] # package_id + + def download(self, model: SubscribedPackagesModel): + 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"] + self._progress[package_id] = { + "received": 0, + "total": 1 # make sure this is not considered done yet. Also divByZero-safe + } + + manager.get( + item["download_url"], + callback = lambda reply: self._onFinished(package_id, reply), + download_progress_callback = lambda rx, rt: self._onProgress(package_id, rx, rt), + error_callback = lambda rx, rt: self._onProgress(package_id, rx, rt), + scope = self._scope) + + self._started = True + self._showProgressMessage() + + def _showProgressMessage(self): + self._progress_message = Message(i18n_catalog.i18nc( + "@info:generic", + "\nSyncing..."), + lifetime = 0, + progress = 0.0, + title = i18n_catalog.i18nc("@info:title", "Changes detected from your Ultimaker account", )) + self._progress_message.show() + + def _onFinished(self, package_id: str, reply: QNetworkReply): + self._progress[package_id]["received"] = self._progress[package_id]["total"] + + file_path = self._getTempFile(package_id) + try: + with open(file_path) as temp_file: + # todo buffer this + temp_file.write(reply.readAll()) + except IOError: + self._onError(package_id) + + self._checkDone() + + def _onProgress(self, package_id: str, rx: int, rt: int): + 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"] + + self._progress_message.setProgress(100.0 * (received / total)) # [0 .. 100] % + + self._checkDone() + + def _onError(self, package_id: str): + self._progress.pop(package_id) + self._error.append(package_id) + self._checkDone() + + def _checkDone(self) -> bool: + for item in self._progress.values(): + if item["received"] != item["total"] or item["total"] == -1: + return False + + success_items = {package_id : self._getTempFile(package_id) for package_id in self._progress.keys()} + error_items = [package_id for package_id in self._error] + + self._progress_message.hide() + self.done.emit(success_items, error_items) + + def _getTempFile(self, package_id: str) -> str: + temp_dir = tempfile.gettempdir() + return os.path.join(temp_dir, package_id) + diff --git a/plugins/Toolbox/src/CloudSync/SubscribedPackagesModel.py b/plugins/Toolbox/src/CloudSync/SubscribedPackagesModel.py index 7ab6d30fe0..d98db0ec4d 100644 --- a/plugins/Toolbox/src/CloudSync/SubscribedPackagesModel.py +++ b/plugins/Toolbox/src/CloudSync/SubscribedPackagesModel.py @@ -33,7 +33,13 @@ class SubscribedPackagesModel(ListModel): for item in self._metadata: if item["package_id"] not in self._discrepancies: continue - package = {"package_id": item["package_id"], "name": item["display_name"], "sdk_versions": item["sdk_versions"]} + package = { + "package_id": item["package_id"], + "name": item["display_name"], + "sdk_versions": item["sdk_versions"], + "download_url": item["download_url"], + "md5_hash": item["md5_hash"], + } if self._sdk_version not in item["sdk_versions"]: package.update({"is_compatible": False}) else: diff --git a/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py b/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py index aca20f98f7..05436c1db3 100644 --- a/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py +++ b/plugins/Toolbox/src/CloudSync/SyncOrchestrator.py @@ -3,6 +3,7 @@ from UM.PluginRegistry import PluginRegistry from cura.CuraApplication import CuraApplication from plugins.Toolbox import CloudPackageChecker from plugins.Toolbox.src.CloudSync.DiscrepanciesPresenter import DiscrepanciesPresenter +from plugins.Toolbox.src.CloudSync.DownloadPresenter import DownloadPresenter from plugins.Toolbox.src.CloudSync.SubscribedPackagesModel import SubscribedPackagesModel @@ -13,7 +14,7 @@ from plugins.Toolbox.src.CloudSync.SubscribedPackagesModel import SubscribedPack # - 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 +# - The DownloadPresenter shows a download progress dialog. It emits A tuple of succeeded and failed downloads # - The LicencePresenter extracts licences from the downloaded packages and presents a licence for each package to # - be installed. It emits the `licenceAnswers` {'packageId' : bool} for accept or declines # - The CloudPackageManager removes the declined packages from the account @@ -28,7 +29,13 @@ class SyncOrchestrator(Extension): self._checker.discrepancies.connect(self._onDiscrepancies) self._discrepanciesPresenter = DiscrepanciesPresenter(app) + self._discrepanciesPresenter.packageMutations.connect(self._onPackageMutations) + + self._downloadPresenter = DownloadPresenter(app) def _onDiscrepancies(self, model: SubscribedPackagesModel): plugin_path = PluginRegistry.getInstance().getPluginPath(self.getPluginId()) self._discrepanciesPresenter.present(plugin_path, model) + + def _onPackageMutations(self, mutations: SubscribedPackagesModel): + self._downloadPresenter.download(mutations)