Browse Source

Merge pull request #7211 from Ultimaker/CURA-7150_proper_http_request_headers

CURA-7150_proper_http_request_headers
Remco Burema 5 years ago
parent
commit
c20b2c6ee0

+ 2 - 3
cura/API/Account.py

@@ -4,12 +4,11 @@ from typing import Optional, Dict, TYPE_CHECKING
 
 
 from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot, pyqtProperty
 from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot, pyqtProperty
 
 
-from UM.i18n import i18nCatalog
 from UM.Message import Message
 from UM.Message import Message
-from cura import UltimakerCloudAuthentication
-
+from UM.i18n import i18nCatalog
 from cura.OAuth2.AuthorizationService import AuthorizationService
 from cura.OAuth2.AuthorizationService import AuthorizationService
 from cura.OAuth2.Models import OAuth2Settings
 from cura.OAuth2.Models import OAuth2Settings
+from cura.UltimakerCloud import UltimakerCloudAuthentication
 
 
 if TYPE_CHECKING:
 if TYPE_CHECKING:
     from cura.CuraApplication import CuraApplication
     from cura.CuraApplication import CuraApplication

+ 32 - 53
cura/CuraApplication.py

@@ -7,71 +7,52 @@ import time
 from typing import cast, TYPE_CHECKING, Optional, Callable, List, Any
 from typing import cast, TYPE_CHECKING, Optional, Callable, List, Any
 
 
 import numpy
 import numpy
-
 from PyQt5.QtCore import QObject, QTimer, QUrl, pyqtSignal, pyqtProperty, QEvent, Q_ENUMS
 from PyQt5.QtCore import QObject, QTimer, QUrl, pyqtSignal, pyqtProperty, QEvent, Q_ENUMS
 from PyQt5.QtGui import QColor, QIcon
 from PyQt5.QtGui import QColor, QIcon
-from PyQt5.QtWidgets import QMessageBox
 from PyQt5.QtQml import qmlRegisterUncreatableType, qmlRegisterSingletonType, qmlRegisterType
 from PyQt5.QtQml import qmlRegisterUncreatableType, qmlRegisterSingletonType, qmlRegisterType
+from PyQt5.QtWidgets import QMessageBox
 
 
-from UM.i18n import i18nCatalog
+import UM.Util
+import cura.Settings.cura_empty_instance_containers
 from UM.Application import Application
 from UM.Application import Application
 from UM.Decorators import override
 from UM.Decorators import override
 from UM.FlameProfiler import pyqtSlot
 from UM.FlameProfiler import pyqtSlot
 from UM.Logger import Logger
 from UM.Logger import Logger
-from UM.Message import Message
-from UM.Platform import Platform
-from UM.PluginError import PluginNotFoundError
-from UM.Resources import Resources
-from UM.Preferences import Preferences
-from UM.Qt.QtApplication import QtApplication  # The class we're inheriting from.
-import UM.Util
-from UM.View.SelectionPass import SelectionPass  # For typing.
-
 from UM.Math.AxisAlignedBox import AxisAlignedBox
 from UM.Math.AxisAlignedBox import AxisAlignedBox
 from UM.Math.Matrix import Matrix
 from UM.Math.Matrix import Matrix
 from UM.Math.Quaternion import Quaternion
 from UM.Math.Quaternion import Quaternion
 from UM.Math.Vector import Vector
 from UM.Math.Vector import Vector
-
 from UM.Mesh.ReadMeshJob import ReadMeshJob
 from UM.Mesh.ReadMeshJob import ReadMeshJob
-
+from UM.Message import Message
 from UM.Operations.AddSceneNodeOperation import AddSceneNodeOperation
 from UM.Operations.AddSceneNodeOperation import AddSceneNodeOperation
 from UM.Operations.GroupedOperation import GroupedOperation
 from UM.Operations.GroupedOperation import GroupedOperation
 from UM.Operations.SetTransformOperation import SetTransformOperation
 from UM.Operations.SetTransformOperation import SetTransformOperation
-
+from UM.Platform import Platform
+from UM.PluginError import PluginNotFoundError
+from UM.Preferences import Preferences
+from UM.Qt.QtApplication import QtApplication  # The class we're inheriting from.
+from UM.Resources import Resources
 from UM.Scene.Camera import Camera
 from UM.Scene.Camera import Camera
 from UM.Scene.GroupDecorator import GroupDecorator
 from UM.Scene.GroupDecorator import GroupDecorator
 from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator
 from UM.Scene.Iterator.DepthFirstIterator import DepthFirstIterator
 from UM.Scene.SceneNode import SceneNode
 from UM.Scene.SceneNode import SceneNode
 from UM.Scene.Selection import Selection
 from UM.Scene.Selection import Selection
 from UM.Scene.ToolHandle import ToolHandle
 from UM.Scene.ToolHandle import ToolHandle
-
 from UM.Settings.ContainerRegistry import ContainerRegistry
 from UM.Settings.ContainerRegistry import ContainerRegistry
 from UM.Settings.InstanceContainer import InstanceContainer
 from UM.Settings.InstanceContainer import InstanceContainer
 from UM.Settings.SettingDefinition import SettingDefinition, DefinitionPropertyType
 from UM.Settings.SettingDefinition import SettingDefinition, DefinitionPropertyType
 from UM.Settings.SettingFunction import SettingFunction
 from UM.Settings.SettingFunction import SettingFunction
 from UM.Settings.Validator import Validator
 from UM.Settings.Validator import Validator
-
+from UM.View.SelectionPass import SelectionPass  # For typing.
 from UM.Workspace.WorkspaceReader import WorkspaceReader
 from UM.Workspace.WorkspaceReader import WorkspaceReader
-
+from UM.i18n import i18nCatalog
+from cura import ApplicationMetadata
 from cura.API import CuraAPI
 from cura.API import CuraAPI
-
 from cura.Arranging.Arrange import Arrange
 from cura.Arranging.Arrange import Arrange
-from cura.Arranging.ArrangeObjectsJob import ArrangeObjectsJob
 from cura.Arranging.ArrangeObjectsAllBuildPlatesJob import ArrangeObjectsAllBuildPlatesJob
 from cura.Arranging.ArrangeObjectsAllBuildPlatesJob import ArrangeObjectsAllBuildPlatesJob
+from cura.Arranging.ArrangeObjectsJob import ArrangeObjectsJob
 from cura.Arranging.ShapeArray import ShapeArray
 from cura.Arranging.ShapeArray import ShapeArray
-
-from cura.Operations.SetParentOperation import SetParentOperation
-
-from cura.Scene.BlockSlicingDecorator import BlockSlicingDecorator
-from cura.Scene.BuildPlateDecorator import BuildPlateDecorator
-from cura.Scene.ConvexHullDecorator import ConvexHullDecorator
-from cura.Scene.CuraSceneController import CuraSceneController
-from cura.Scene.CuraSceneNode import CuraSceneNode
-
-from cura.Scene.SliceableObjectDecorator import SliceableObjectDecorator
-from cura.Scene import ZOffsetDecorator
 from cura.Machines.MachineErrorChecker import MachineErrorChecker
 from cura.Machines.MachineErrorChecker import MachineErrorChecker
-
 from cura.Machines.Models.BuildPlateModel import BuildPlateModel
 from cura.Machines.Models.BuildPlateModel import BuildPlateModel
 from cura.Machines.Models.CustomQualityProfilesDropDownMenuModel import CustomQualityProfilesDropDownMenuModel
 from cura.Machines.Models.CustomQualityProfilesDropDownMenuModel import CustomQualityProfilesDropDownMenuModel
 from cura.Machines.Models.DiscoveredPrintersModel import DiscoveredPrintersModel
 from cura.Machines.Models.DiscoveredPrintersModel import DiscoveredPrintersModel
@@ -80,6 +61,8 @@ from cura.Machines.Models.FavoriteMaterialsModel import FavoriteMaterialsModel
 from cura.Machines.Models.FirstStartMachineActionsModel import FirstStartMachineActionsModel
 from cura.Machines.Models.FirstStartMachineActionsModel import FirstStartMachineActionsModel
 from cura.Machines.Models.GenericMaterialsModel import GenericMaterialsModel
 from cura.Machines.Models.GenericMaterialsModel import GenericMaterialsModel
 from cura.Machines.Models.GlobalStacksModel import GlobalStacksModel
 from cura.Machines.Models.GlobalStacksModel import GlobalStacksModel
+from cura.Machines.Models.IntentCategoryModel import IntentCategoryModel
+from cura.Machines.Models.IntentModel import IntentModel
 from cura.Machines.Models.MaterialBrandsModel import MaterialBrandsModel
 from cura.Machines.Models.MaterialBrandsModel import MaterialBrandsModel
 from cura.Machines.Models.MaterialManagementModel import MaterialManagementModel
 from cura.Machines.Models.MaterialManagementModel import MaterialManagementModel
 from cura.Machines.Models.MultiBuildPlateModel import MultiBuildPlateModel
 from cura.Machines.Models.MultiBuildPlateModel import MultiBuildPlateModel
@@ -89,51 +72,47 @@ from cura.Machines.Models.QualityProfilesDropDownMenuModel import QualityProfile
 from cura.Machines.Models.QualitySettingsModel import QualitySettingsModel
 from cura.Machines.Models.QualitySettingsModel import QualitySettingsModel
 from cura.Machines.Models.SettingVisibilityPresetsModel import SettingVisibilityPresetsModel
 from cura.Machines.Models.SettingVisibilityPresetsModel import SettingVisibilityPresetsModel
 from cura.Machines.Models.UserChangesModel import UserChangesModel
 from cura.Machines.Models.UserChangesModel import UserChangesModel
-from cura.Machines.Models.IntentModel import IntentModel
-from cura.Machines.Models.IntentCategoryModel import IntentCategoryModel
-
-from cura.PrinterOutput.PrinterOutputDevice import PrinterOutputDevice
+from cura.Operations.SetParentOperation import SetParentOperation
 from cura.PrinterOutput.NetworkMJPGImage import NetworkMJPGImage
 from cura.PrinterOutput.NetworkMJPGImage import NetworkMJPGImage
-
-import cura.Settings.cura_empty_instance_containers
+from cura.PrinterOutput.PrinterOutputDevice import PrinterOutputDevice
+from cura.Scene import ZOffsetDecorator
+from cura.Scene.BlockSlicingDecorator import BlockSlicingDecorator
+from cura.Scene.BuildPlateDecorator import BuildPlateDecorator
+from cura.Scene.ConvexHullDecorator import ConvexHullDecorator
+from cura.Scene.CuraSceneController import CuraSceneController
+from cura.Scene.CuraSceneNode import CuraSceneNode
+from cura.Scene.SliceableObjectDecorator import SliceableObjectDecorator
 from cura.Settings.ContainerManager import ContainerManager
 from cura.Settings.ContainerManager import ContainerManager
 from cura.Settings.CuraContainerRegistry import CuraContainerRegistry
 from cura.Settings.CuraContainerRegistry import CuraContainerRegistry
 from cura.Settings.CuraFormulaFunctions import CuraFormulaFunctions
 from cura.Settings.CuraFormulaFunctions import CuraFormulaFunctions
 from cura.Settings.ExtruderManager import ExtruderManager
 from cura.Settings.ExtruderManager import ExtruderManager
 from cura.Settings.ExtruderStack import ExtruderStack
 from cura.Settings.ExtruderStack import ExtruderStack
+from cura.Settings.GlobalStack import GlobalStack
+from cura.Settings.IntentManager import IntentManager
 from cura.Settings.MachineManager import MachineManager
 from cura.Settings.MachineManager import MachineManager
 from cura.Settings.MachineNameValidator import MachineNameValidator
 from cura.Settings.MachineNameValidator import MachineNameValidator
-from cura.Settings.IntentManager import IntentManager
 from cura.Settings.MaterialSettingsVisibilityHandler import MaterialSettingsVisibilityHandler
 from cura.Settings.MaterialSettingsVisibilityHandler import MaterialSettingsVisibilityHandler
 from cura.Settings.SettingInheritanceManager import SettingInheritanceManager
 from cura.Settings.SettingInheritanceManager import SettingInheritanceManager
 from cura.Settings.SidebarCustomMenuItemsModel import SidebarCustomMenuItemsModel
 from cura.Settings.SidebarCustomMenuItemsModel import SidebarCustomMenuItemsModel
 from cura.Settings.SimpleModeSettingsManager import SimpleModeSettingsManager
 from cura.Settings.SimpleModeSettingsManager import SimpleModeSettingsManager
-
 from cura.TaskManagement.OnExitCallbackManager import OnExitCallbackManager
 from cura.TaskManagement.OnExitCallbackManager import OnExitCallbackManager
-
 from cura.UI import CuraSplashScreen, MachineActionManager, PrintInformation
 from cura.UI import CuraSplashScreen, MachineActionManager, PrintInformation
+from cura.UI.AddPrinterPagesModel import AddPrinterPagesModel
 from cura.UI.MachineSettingsManager import MachineSettingsManager
 from cura.UI.MachineSettingsManager import MachineSettingsManager
 from cura.UI.ObjectsModel import ObjectsModel
 from cura.UI.ObjectsModel import ObjectsModel
-from cura.UI.TextManager import TextManager
-from cura.UI.AddPrinterPagesModel import AddPrinterPagesModel
 from cura.UI.RecommendedMode import RecommendedMode
 from cura.UI.RecommendedMode import RecommendedMode
+from cura.UI.TextManager import TextManager
 from cura.UI.WelcomePagesModel import WelcomePagesModel
 from cura.UI.WelcomePagesModel import WelcomePagesModel
 from cura.UI.WhatsNewPagesModel import WhatsNewPagesModel
 from cura.UI.WhatsNewPagesModel import WhatsNewPagesModel
-
+from cura.UltimakerCloud import UltimakerCloudAuthentication
 from cura.Utils.NetworkingUtil import NetworkingUtil
 from cura.Utils.NetworkingUtil import NetworkingUtil
-
-from .SingleInstance import SingleInstance
-from .AutoSave import AutoSave
-from . import PlatformPhysics
 from . import BuildVolume
 from . import BuildVolume
 from . import CameraAnimation
 from . import CameraAnimation
 from . import CuraActions
 from . import CuraActions
+from . import PlatformPhysics
 from . import PrintJobPreviewImageProvider
 from . import PrintJobPreviewImageProvider
-
-from cura.TaskManagement.OnExitCallbackManager import OnExitCallbackManager
-
-from cura import ApplicationMetadata, UltimakerCloudAuthentication
-from cura.Settings.GlobalStack import GlobalStack
+from .AutoSave import AutoSave
+from .SingleInstance import SingleInstance
 
 
 if TYPE_CHECKING:
 if TYPE_CHECKING:
     from UM.Settings.EmptyInstanceContainer import EmptyInstanceContainer
     from UM.Settings.EmptyInstanceContainer import EmptyInstanceContainer

+ 0 - 0
cura/UltimakerCloudAuthentication.py → cura/UltimakerCloud/UltimakerCloudAuthentication.py


+ 9 - 6
plugins/Toolbox/src/UltimakerCloudScope.py → cura/UltimakerCloud/UltimakerCloudScope.py

@@ -6,17 +6,20 @@ from cura.API import Account
 from cura.CuraApplication import CuraApplication
 from cura.CuraApplication import CuraApplication
 
 
 
 
-## Add a Authorization header to the request for Ultimaker Cloud Api requests.
-# When the user is not logged in or a token is not available, a warning will be logged
-# Also add the user agent headers (see DefaultUserAgentScope)
 class UltimakerCloudScope(DefaultUserAgentScope):
 class UltimakerCloudScope(DefaultUserAgentScope):
+    """Add an Authorization header to the request for Ultimaker Cloud Api requests.
+
+    When the user is not logged in or a token is not available, a warning will be logged
+    Also add the user agent headers (see DefaultUserAgentScope)
+    """
+
     def __init__(self, application: CuraApplication):
     def __init__(self, application: CuraApplication):
         super().__init__(application)
         super().__init__(application)
         api = application.getCuraAPI()
         api = application.getCuraAPI()
         self._account = api.account  # type: Account
         self._account = api.account  # type: Account
 
 
-    def request_hook(self, request: QNetworkRequest):
-        super().request_hook(request)
+    def requestHook(self, request: QNetworkRequest):
+        super().requestHook(request)
         token = self._account.accessToken
         token = self._account.accessToken
         if not self._account.isLoggedIn or token is None:
         if not self._account.isLoggedIn or token is None:
             Logger.warning("Cannot add authorization to Cloud Api request")
             Logger.warning("Cannot add authorization to Cloud Api request")
@@ -25,4 +28,4 @@ class UltimakerCloudScope(DefaultUserAgentScope):
         header_dict = {
         header_dict = {
             "Authorization": "Bearer {}".format(token)
             "Authorization": "Bearer {}".format(token)
         }
         }
-        self.add_headers(request, header_dict)
+        self.addHeaders(request, header_dict)

+ 0 - 0
cura/UltimakerCloud/__init__.py


+ 7 - 0
cura_app.py

@@ -23,6 +23,8 @@ import os
 import Arcus  # @UnusedImport
 import Arcus  # @UnusedImport
 import Savitar  # @UnusedImport
 import Savitar  # @UnusedImport
 
 
+from PyQt5.QtNetwork import QSslConfiguration, QSslSocket
+
 from UM.Platform import Platform
 from UM.Platform import Platform
 from cura import ApplicationMetadata
 from cura import ApplicationMetadata
 from cura.ApplicationMetadata import CuraAppName
 from cura.ApplicationMetadata import CuraAppName
@@ -220,5 +222,10 @@ if Platform.isLinux() and getattr(sys, "frozen", False):
     import trimesh.exchange.load
     import trimesh.exchange.load
     os.environ["LD_LIBRARY_PATH"] = old_env
     os.environ["LD_LIBRARY_PATH"] = old_env
 
 
+if ApplicationMetadata.CuraDebugMode:
+    ssl_conf = QSslConfiguration.defaultConfiguration()
+    ssl_conf.setPeerVerifyMode(QSslSocket.VerifyNone)
+    QSslConfiguration.setDefaultConfiguration(ssl_conf)
+
 app = CuraApplication()
 app = CuraApplication()
 app.run()
 app.run()

+ 119 - 0
plugins/CuraDrive/src/CreateBackupJob.py

@@ -0,0 +1,119 @@
+# Copyright (c) 2020 Ultimaker B.V.
+# Cura is released under the terms of the LGPLv3 or higher.
+import json
+import threading
+from datetime import datetime
+from typing import Any, Dict, Optional
+
+from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest
+
+from UM.Job import Job
+from UM.Logger import Logger
+from UM.Message import Message
+from UM.TaskManagement.HttpRequestManager import HttpRequestManager
+from UM.TaskManagement.HttpRequestScope import JsonDecoratorScope
+from UM.i18n import i18nCatalog
+from cura.CuraApplication import CuraApplication
+from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope
+
+catalog = i18nCatalog("cura")
+
+
+class CreateBackupJob(Job):
+    """Creates backup zip, requests upload url and uploads the backup file to cloud storage."""
+
+    MESSAGE_TITLE = catalog.i18nc("@info:title", "Backups")
+    DEFAULT_UPLOAD_ERROR_MESSAGE = catalog.i18nc("@info:backup_status", "There was an error while uploading your backup.")
+
+    def __init__(self, api_backup_url: str) -> None:
+        """ Create a new backup Job. start the job by calling start()
+
+        :param api_backup_url: The url of the 'backups' endpoint of the Cura Drive Api
+        """
+
+        super().__init__()
+
+        self._api_backup_url = api_backup_url
+        self._json_cloud_scope = JsonDecoratorScope(UltimakerCloudScope(CuraApplication.getInstance()))
+
+        self._backup_zip = None  # type: Optional[bytes]
+        self._job_done = threading.Event()
+        """Set when the job completes. Does not indicate success."""
+        self.backup_upload_error_message = ""
+        """After the job completes, an empty string indicates success. Othrerwise, the value is a translated message."""
+
+    def run(self) -> None:
+        upload_message = Message(catalog.i18nc("@info:backup_status", "Creating your backup..."), title = self.MESSAGE_TITLE, progress = -1)
+        upload_message.show()
+        CuraApplication.getInstance().processEvents()
+        cura_api = CuraApplication.getInstance().getCuraAPI()
+        self._backup_zip, backup_meta_data = cura_api.backups.createBackup()
+
+        if not self._backup_zip or not backup_meta_data:
+            self.backup_upload_error_message = catalog.i18nc("@info:backup_status", "There was an error while creating your backup.")
+            upload_message.hide()
+            return
+
+        upload_message.setText(catalog.i18nc("@info:backup_status", "Uploading your backup..."))
+        CuraApplication.getInstance().processEvents()
+
+        # 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"])
+        self._requestUploadSlot(backup_meta_data, len(self._backup_zip))
+
+        self._job_done.wait()
+        if self.backup_upload_error_message == "":
+            upload_message.setText(catalog.i18nc("@info:backup_status", "Your backup has finished uploading."))
+            upload_message.setProgress(None)  # Hide progress bar
+        else:
+            # some error occurred. This error is presented to the user by DrivePluginExtension
+            upload_message.hide()
+
+    def _requestUploadSlot(self, backup_metadata: Dict[str, Any], backup_size: int) -> None:
+        """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.
+        """
+
+        payload = json.dumps({"data": {"backup_size": backup_size,
+                                       "metadata": backup_metadata
+                                       }
+                              }).encode()
+
+        HttpRequestManager.getInstance().put(
+            self._api_backup_url,
+            data = payload,
+            callback = self._onUploadSlotCompleted,
+            error_callback = self._onUploadSlotCompleted,
+            scope = self._json_cloud_scope)
+
+    def _onUploadSlotCompleted(self, reply: QNetworkReply, error: Optional["QNetworkReply.NetworkError"] = None) -> None:
+        if error is not None:
+            Logger.warning(str(error))
+            self.backup_upload_error_message = self.DEFAULT_UPLOAD_ERROR_MESSAGE
+            self._job_done.set()
+            return
+        if reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) >= 300:
+            Logger.warning("Could not request backup upload: %s", HttpRequestManager.readText(reply))
+            self.backup_upload_error_message = self.DEFAULT_UPLOAD_ERROR_MESSAGE
+            self._job_done.set()
+            return
+
+        backup_upload_url = HttpRequestManager.readJSON(reply)["data"]["upload_url"]
+
+        # Upload the backup to storage.
+        HttpRequestManager.getInstance().put(
+            backup_upload_url,
+            data=self._backup_zip,
+            callback=self._uploadFinishedCallback,
+            error_callback=self._uploadFinishedCallback
+        )
+
+    def _uploadFinishedCallback(self, reply: QNetworkReply, error: QNetworkReply.NetworkError = None):
+        if not HttpRequestManager.replyIndicatesSuccess(reply, error):
+            Logger.log("w", "Could not upload backup file: %s", HttpRequestManager.readText(reply))
+            self.backup_upload_error_message = self.DEFAULT_UPLOAD_ERROR_MESSAGE
+
+        self._job_done.set()

+ 72 - 150
plugins/CuraDrive/src/DriveApiService.py

@@ -1,90 +1,70 @@
 # Copyright (c) 2018 Ultimaker B.V.
 # Copyright (c) 2018 Ultimaker B.V.
 # Cura is released under the terms of the LGPLv3 or higher.
 # 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.Logger import Logger
-from UM.Message import Message
 from UM.Signal import Signal, signalemitter
 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 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 .Settings import Settings
 
 
-from UM.i18n import i18nCatalog
 catalog = i18nCatalog("cura")
 catalog = i18nCatalog("cura")
 
 
 
 
-## The DriveApiService is responsible for interacting with the CuraDrive API and Cura's backup handling.
 @signalemitter
 @signalemitter
 class DriveApiService:
 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)
     BACKUP_URL = "{}/backups".format(Settings.DRIVE_API_URL)
 
 
-    # Emit signal when restoring backup started or finished.
     restoringStateChanged = Signal()
     restoringStateChanged = Signal()
+    """Emits signal when restoring backup started or finished."""
 
 
-    # Emit signal when creating backup started or finished.
     creatingStateChanged = Signal()
     creatingStateChanged = Signal()
+    """Emits signal when creating backup started or finished."""
 
 
     def __init__(self) -> None:
     def __init__(self) -> None:
         self._cura_api = CuraApplication.getInstance().getCuraAPI()
         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:
     def createBackup(self) -> None:
         self.creatingStateChanged.emit(is_creating = True)
         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.finished.connect(self._onUploadFinished)
         upload_backup_job.start()
         upload_backup_job.start()
 
 
-    def _onUploadFinished(self, job: "UploadBackupJob") -> None:
+    def _onUploadFinished(self, job: "CreateBackupJob") -> None:
         if job.backup_upload_error_message != "":
         if job.backup_upload_error_message != "":
             # If the job contains an error message we pass it along so the UI can display it.
             # 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)
             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")
         download_url = backup.get("download_url")
         if not download_url:
         if not download_url:
             # If there is no download URL, we can't restore the backup.
             # 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
     @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))

+ 9 - 3
plugins/CuraDrive/src/DrivePluginExtension.py

@@ -133,7 +133,10 @@ class DrivePluginExtension(QObject, Extension):
 
 
     @pyqtSlot(name = "refreshBackups")
     @pyqtSlot(name = "refreshBackups")
     def refreshBackups(self) -> None:
     def refreshBackups(self) -> None:
-        self._backups = self._drive_api_service.getBackups()
+        self._drive_api_service.getBackups(self._backupsChangedCallback)
+
+    def _backupsChangedCallback(self, backups: List[Dict[str, Any]]) -> None:
+        self._backups = backups
         self.backupsChanged.emit()
         self.backupsChanged.emit()
 
 
     @pyqtProperty(bool, notify = restoringStateChanged)
     @pyqtProperty(bool, notify = restoringStateChanged)
@@ -158,5 +161,8 @@ class DrivePluginExtension(QObject, Extension):
 
 
     @pyqtSlot(str, name = "deleteBackup")
     @pyqtSlot(str, name = "deleteBackup")
     def deleteBackup(self, backup_id: str) -> None:
     def deleteBackup(self, backup_id: str) -> None:
-        self._drive_api_service.deleteBackup(backup_id)
-        self.refreshBackups()
+        self._drive_api_service.deleteBackup(backup_id, self._backupDeletedCallback)
+
+    def _backupDeletedCallback(self, success: bool):
+        if success:
+            self.refreshBackups()

+ 92 - 0
plugins/CuraDrive/src/RestoreBackupJob.py

@@ -0,0 +1,92 @@
+import base64
+import hashlib
+import threading
+from tempfile import NamedTemporaryFile
+from typing import Optional, Any, Dict
+
+from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest
+
+from UM.Job import Job
+from UM.Logger import Logger
+from UM.PackageManager import catalog
+from UM.TaskManagement.HttpRequestManager import HttpRequestManager
+from cura.CuraApplication import CuraApplication
+
+
+class RestoreBackupJob(Job):
+    """Downloads a backup and overwrites local configuration with the backup.
+
+     When `Job.finished` emits, `restore_backup_error_message` will either be `""` (no error) or an error message
+     """
+
+    DISK_WRITE_BUFFER_SIZE = 512 * 1024
+    DEFAULT_ERROR_MESSAGE = catalog.i18nc("@info:backup_status", "There was an error trying to restore your backup.")
+
+    def __init__(self, backup: Dict[str, Any]) -> None:
+        """ Create a new restore Job. start the job by calling start()
+
+        :param backup: A dict containing a backup spec
+        """
+
+        super().__init__()
+        self._job_done = threading.Event()
+
+        self._backup = backup
+        self.restore_backup_error_message = ""
+
+    def run(self) -> None:
+
+        url = self._backup.get("download_url")
+        assert url is not None
+
+        HttpRequestManager.getInstance().get(
+            url = url,
+            callback = self._onRestoreRequestCompleted,
+            error_callback = self._onRestoreRequestCompleted
+        )
+
+        self._job_done.wait()  # A job is considered finished when the run function completes
+
+    def _onRestoreRequestCompleted(self, reply: QNetworkReply, error: Optional["QNetworkReply.NetworkError"] = None) -> None:
+        if not HttpRequestManager.replyIndicatesSuccess(reply, error):
+            Logger.warning("Requesting backup failed, response code %s while trying to connect to %s",
+                           reply.attribute(QNetworkRequest.HttpStatusCodeAttribute), reply.url())
+            self.restore_backup_error_message = self.DEFAULT_ERROR_MESSAGE
+            self._job_done.set()
+            return
+
+        # 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:
+            app = CuraApplication.getInstance()
+            bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE)
+            while bytes_read:
+                write_backup.write(bytes_read)
+                bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE)
+                app.processEvents()
+
+        if not self._verifyMd5Hash(temporary_backup_file.name, self._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.")
+            self.restore_backup_error_message = self.DEFAULT_ERROR_MESSAGE
+
+        # Tell Cura to place the backup back in the user data folder.
+        with open(temporary_backup_file.name, "rb") as read_backup:
+            cura_api = CuraApplication.getInstance().getCuraAPI()
+            cura_api.backups.restoreBackup(read_backup.read(), self._backup.get("metadata", {}))
+
+        self._job_done.set()
+
+    @staticmethod
+    def _verifyMd5Hash(file_path: str, known_hash: str) -> bool:
+        """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.
+        """
+
+        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

Some files were not shown because too many files changed in this diff