diff options
Diffstat (limited to 'plugins/CuraDrive/src/DriveApiService.py')
-rw-r--r-- | plugins/CuraDrive/src/DriveApiService.py | 222 |
1 files changed, 72 insertions, 150 deletions
diff --git a/plugins/CuraDrive/src/DriveApiService.py b/plugins/CuraDrive/src/DriveApiService.py index d8349ccc29..2248b64389 100644 --- a/plugins/CuraDrive/src/DriveApiService.py +++ b/plugins/CuraDrive/src/DriveApiService.py @@ -1,90 +1,70 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -import base64 -import hashlib -from datetime import datetime -from tempfile import NamedTemporaryFile -from typing import Any, Optional, List, Dict +from typing import Any, Optional, List, Dict, Callable -import requests +from PyQt5.QtNetwork import QNetworkReply from UM.Logger import Logger -from UM.Message import Message from UM.Signal import Signal, signalemitter +from UM.TaskManagement.HttpRequestManager import HttpRequestManager +from UM.TaskManagement.HttpRequestScope import JsonDecoratorScope +from UM.i18n import i18nCatalog from cura.CuraApplication import CuraApplication - -from .UploadBackupJob import UploadBackupJob +from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope +from .CreateBackupJob import CreateBackupJob +from .RestoreBackupJob import RestoreBackupJob from .Settings import Settings -from UM.i18n import i18nCatalog catalog = i18nCatalog("cura") -## The DriveApiService is responsible for interacting with the CuraDrive API and Cura's backup handling. @signalemitter class DriveApiService: + """The DriveApiService is responsible for interacting with the CuraDrive API and Cura's backup handling.""" + BACKUP_URL = "{}/backups".format(Settings.DRIVE_API_URL) - # Emit signal when restoring backup started or finished. restoringStateChanged = Signal() + """Emits signal when restoring backup started or finished.""" - # Emit signal when creating backup started or finished. creatingStateChanged = Signal() + """Emits signal when creating backup started or finished.""" def __init__(self) -> None: self._cura_api = CuraApplication.getInstance().getCuraAPI() - - def getBackups(self) -> List[Dict[str, Any]]: - access_token = self._cura_api.account.accessToken - if not access_token: - Logger.log("w", "Could not get access token.") - return [] - try: - backup_list_request = requests.get(self.BACKUP_URL, headers = { - "Authorization": "Bearer {}".format(access_token) - }) - except requests.exceptions.ConnectionError: - Logger.logException("w", "Unable to connect with the server.") - return [] - - # HTTP status 300s mean redirection. 400s and 500s are errors. - # Technically 300s are not errors, but the use case here relies on "requests" to handle redirects automatically. - if backup_list_request.status_code >= 300: - Logger.log("w", "Could not get backups list from remote: %s", backup_list_request.text) - Message(catalog.i18nc("@info:backup_status", "There was an error listing your backups."), title = catalog.i18nc("@info:title", "Backup")).show() - return [] - - backup_list_response = backup_list_request.json() - if "data" not in backup_list_response: - Logger.log("w", "Could not get backups from remote, actual response body was: %s", str(backup_list_response)) - return [] - - return backup_list_response["data"] + self._json_cloud_scope = JsonDecoratorScope(UltimakerCloudScope(CuraApplication.getInstance())) + + def getBackups(self, changed: Callable[[List[Dict[str, Any]]], None]) -> None: + def callback(reply: QNetworkReply, error: Optional["QNetworkReply.NetworkError"] = None) -> None: + if error is not None: + Logger.log("w", "Could not get backups: " + str(error)) + changed([]) + return + + backup_list_response = HttpRequestManager.readJSON(reply) + if "data" not in backup_list_response: + Logger.log("w", "Could not get backups from remote, actual response body was: %s", + str(backup_list_response)) + changed([]) # empty list of backups + return + + changed(backup_list_response["data"]) + + HttpRequestManager.getInstance().get( + self.BACKUP_URL, + callback= callback, + error_callback = callback, + scope=self._json_cloud_scope + ) def createBackup(self) -> None: self.creatingStateChanged.emit(is_creating = True) - - # Create the backup. - backup_zip_file, backup_meta_data = self._cura_api.backups.createBackup() - if not backup_zip_file or not backup_meta_data: - self.creatingStateChanged.emit(is_creating = False, error_message ="Could not create backup.") - return - - # Create an upload entry for the backup. - timestamp = datetime.now().isoformat() - backup_meta_data["description"] = "{}.backup.{}.cura.zip".format(timestamp, backup_meta_data["cura_release"]) - backup_upload_url = self._requestBackupUpload(backup_meta_data, len(backup_zip_file)) - if not backup_upload_url: - self.creatingStateChanged.emit(is_creating = False, error_message ="Could not upload backup.") - return - - # Upload the backup to storage. - upload_backup_job = UploadBackupJob(backup_upload_url, backup_zip_file) + upload_backup_job = CreateBackupJob(self.BACKUP_URL) upload_backup_job.finished.connect(self._onUploadFinished) upload_backup_job.start() - def _onUploadFinished(self, job: "UploadBackupJob") -> None: + def _onUploadFinished(self, job: "CreateBackupJob") -> None: if job.backup_upload_error_message != "": # If the job contains an error message we pass it along so the UI can display it. self.creatingStateChanged.emit(is_creating = False, error_message = job.backup_upload_error_message) @@ -96,96 +76,38 @@ class DriveApiService: download_url = backup.get("download_url") if not download_url: # If there is no download URL, we can't restore the backup. - return self._emitRestoreError() - - try: - download_package = requests.get(download_url, stream = True) - except requests.exceptions.ConnectionError: - Logger.logException("e", "Unable to connect with the server") - return self._emitRestoreError() - - if download_package.status_code >= 300: - # Something went wrong when attempting to download the backup. - Logger.log("w", "Could not download backup from url %s: %s", download_url, download_package.text) - return self._emitRestoreError() - - # We store the file in a temporary path fist to ensure integrity. - temporary_backup_file = NamedTemporaryFile(delete = False) - with open(temporary_backup_file.name, "wb") as write_backup: - for chunk in download_package: - write_backup.write(chunk) - - if not self._verifyMd5Hash(temporary_backup_file.name, backup.get("md5_hash", "")): - # Don't restore the backup if the MD5 hashes do not match. - # This can happen if the download was interrupted. - Logger.log("w", "Remote and local MD5 hashes do not match, not restoring backup.") - return self._emitRestoreError() - - # Tell Cura to place the backup back in the user data folder. - with open(temporary_backup_file.name, "rb") as read_backup: - self._cura_api.backups.restoreBackup(read_backup.read(), backup.get("metadata", {})) - self.restoringStateChanged.emit(is_restoring = False) - - def _emitRestoreError(self) -> None: - self.restoringStateChanged.emit(is_restoring = False, - error_message = catalog.i18nc("@info:backup_status", - "There was an error trying to restore your backup.")) - - # Verify the MD5 hash of a file. - # \param file_path Full path to the file. - # \param known_hash The known MD5 hash of the file. - # \return: Success or not. + Logger.warning("backup download_url is missing. Aborting backup.") + self.restoringStateChanged.emit(is_restoring = False, + error_message = catalog.i18nc("@info:backup_status", + "There was an error trying to restore your backup.")) + return + + restore_backup_job = RestoreBackupJob(backup) + restore_backup_job.finished.connect(self._onRestoreFinished) + restore_backup_job.start() + + def _onRestoreFinished(self, job: "RestoreBackupJob") -> None: + if job.restore_backup_error_message != "": + # If the job contains an error message we pass it along so the UI can display it. + self.restoringStateChanged.emit(is_restoring=False) + else: + self.restoringStateChanged.emit(is_restoring = False, error_message = job.restore_backup_error_message) + + def deleteBackup(self, backup_id: str, finished_callable: Callable[[bool], None]): + + def finishedCallback(reply: QNetworkReply, ca: Callable[[bool], None] = finished_callable) -> None: + self._onDeleteRequestCompleted(reply, ca) + + def errorCallback(reply: QNetworkReply, error: QNetworkReply.NetworkError, ca: Callable[[bool], None] = finished_callable) -> None: + self._onDeleteRequestCompleted(reply, ca, error) + + HttpRequestManager.getInstance().delete( + url = "{}/{}".format(self.BACKUP_URL, backup_id), + callback = finishedCallback, + error_callback = errorCallback, + scope= self._json_cloud_scope + ) + @staticmethod - def _verifyMd5Hash(file_path: str, known_hash: str) -> bool: - with open(file_path, "rb") as read_backup: - local_md5_hash = base64.b64encode(hashlib.md5(read_backup.read()).digest(), altchars = b"_-").decode("utf-8") - return known_hash == local_md5_hash - - def deleteBackup(self, backup_id: str) -> bool: - access_token = self._cura_api.account.accessToken - if not access_token: - Logger.log("w", "Could not get access token.") - return False - - try: - delete_backup = requests.delete("{}/{}".format(self.BACKUP_URL, backup_id), headers = { - "Authorization": "Bearer {}".format(access_token) - }) - except requests.exceptions.ConnectionError: - Logger.logException("e", "Unable to connect with the server") - return False - - if delete_backup.status_code >= 300: - Logger.log("w", "Could not delete backup: %s", delete_backup.text) - return False - return True - - # Request a backup upload slot from the API. - # \param backup_metadata: A dict containing some meta data about the backup. - # \param backup_size The size of the backup file in bytes. - # \return: The upload URL for the actual backup file if successful, otherwise None. - def _requestBackupUpload(self, backup_metadata: Dict[str, Any], backup_size: int) -> Optional[str]: - access_token = self._cura_api.account.accessToken - if not access_token: - Logger.log("w", "Could not get access token.") - return None - try: - backup_upload_request = requests.put( - self.BACKUP_URL, - json = {"data": {"backup_size": backup_size, - "metadata": backup_metadata - } - }, - headers = { - "Authorization": "Bearer {}".format(access_token) - }) - except requests.exceptions.ConnectionError: - Logger.logException("e", "Unable to connect with the server") - return None - - # Any status code of 300 or above indicates an error. - if backup_upload_request.status_code >= 300: - Logger.log("w", "Could not request backup upload: %s", backup_upload_request.text) - return None - - return backup_upload_request.json()["data"]["upload_url"] + def _onDeleteRequestCompleted(reply: QNetworkReply, callable: Callable[[bool], None], error: Optional["QNetworkReply.NetworkError"] = None) -> None: + callable(HttpRequestManager.replyIndicatesSuccess(reply, error)) |