Browse Source

Added CuraDirve plugin to Cura build
CURA-6005

Aleksei S 6 years ago
parent
commit
c62cb84c75

+ 0 - 1
.gitignore

@@ -42,7 +42,6 @@ plugins/cura-siemensnx-plugin
 plugins/CuraBlenderPlugin
 plugins/CuraCloudPlugin
 plugins/CuraDrivePlugin
-plugins/CuraDrive
 plugins/CuraLiveScriptingPlugin
 plugins/CuraOpenSCADPlugin
 plugins/CuraPrintProfileCreator

+ 14 - 0
plugins/CuraDrive/__init__.py

@@ -0,0 +1,14 @@
+# Copyright (c) 2017 Ultimaker B.V.
+import os
+
+is_testing = os.getenv('ENV_NAME', "development") == "testing"
+
+# Only load the whole plugin when not running tests as __init__.py is automatically loaded by PyTest
+if not is_testing:
+    from .src.DrivePluginExtension import DrivePluginExtension
+
+    def getMetaData():
+        return {}
+
+    def register(app):
+        return {"extension": DrivePluginExtension(app)}

+ 8 - 0
plugins/CuraDrive/plugin.json

@@ -0,0 +1,8 @@
+{
+    "name": "Cura Backups",
+    "author": "Ultimaker B.V.",
+    "description": "Backup and restore your configuration.",
+    "version": "1.2.1",
+    "api": 5,
+    "i18n-catalog": "cura_drive"
+}

+ 185 - 0
plugins/CuraDrive/src/DriveApiService.py

@@ -0,0 +1,185 @@
+# Copyright (c) 2017 Ultimaker B.V.
+import base64
+import hashlib
+from datetime import datetime
+from tempfile import NamedTemporaryFile
+from typing import Optional, List, Dict
+
+import requests
+
+from UM.Logger import Logger
+from UM.Message import Message
+from UM.Signal import Signal
+
+from .UploadBackupJob import UploadBackupJob
+from .Settings import Settings
+
+
+class DriveApiService:
+    """
+    The DriveApiService is responsible for interacting with the CuraDrive API and Cura's backup handling.
+    """
+
+    GET_BACKUPS_URL = "{}/backups".format(Settings.DRIVE_API_URL)
+    PUT_BACKUP_URL = "{}/backups".format(Settings.DRIVE_API_URL)
+    DELETE_BACKUP_URL = "{}/backups".format(Settings.DRIVE_API_URL)
+
+    # Emit signal when restoring backup started or finished.
+    onRestoringStateChanged = Signal()
+
+    # Emit signal when creating backup started or finished.
+    onCreatingStateChanged = Signal()
+
+    def __init__(self, cura_api) -> None:
+        """Create a new instance of the Drive API service and set the cura_api object."""
+        self._cura_api = cura_api
+
+    def getBackups(self) -> List[Dict[str, any]]:
+        """Get all backups from the API."""
+        access_token = self._cura_api.account.accessToken
+        if not access_token:
+            Logger.log("w", "Could not get access token.")
+            return []
+
+        backup_list_request = requests.get(self.GET_BACKUPS_URL, headers={
+            "Authorization": "Bearer {}".format(access_token)
+        })
+        if backup_list_request.status_code > 299:
+            Logger.log("w", "Could not get backups list from remote: %s", backup_list_request.text)
+            Message(Settings.translatable_messages["get_backups_error"], title = Settings.MESSAGE_TITLE,
+                    lifetime = 10).show()
+            return []
+        return backup_list_request.json()["data"]
+
+    def createBackup(self) -> None:
+        """Create a backup and upload it to CuraDrive cloud storage."""
+        self.onCreatingStateChanged.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.onCreatingStateChanged.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.onCreatingStateChanged.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.finished.connect(self._onUploadFinished)
+        upload_backup_job.start()
+
+    def _onUploadFinished(self, job: "UploadBackupJob") -> None:
+        """
+        Callback handler for the upload job.
+        :param job: The executed job.
+        """
+        if job.backup_upload_error_message != "":
+            # If the job contains an error message we pass it along so the UI can display it.
+            self.onCreatingStateChanged.emit(is_creating=False, error_message=job.backup_upload_error_message)
+        else:
+            self.onCreatingStateChanged.emit(is_creating=False)
+
+    def restoreBackup(self, backup: Dict[str, any]) -> None:
+        """
+        Restore a previously exported backup from cloud storage.
+        :param backup: A dict containing an entry from the API list response.
+        """
+        self.onRestoringStateChanged.emit(is_restoring=True)
+        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()
+
+        download_package = requests.get(download_url, stream=True)
+        if download_package.status_code != 200:
+            # 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("data"))
+            self.onRestoringStateChanged.emit(is_restoring=False)
+
+    def _emitRestoreError(self, error_message: str = Settings.translatable_messages["backup_restore_error_message"]):
+        """Helper method for emitting a signal when restoring failed."""
+        self.onRestoringStateChanged.emit(
+            is_restoring=False,
+            error_message=error_message
+        )
+
+    @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
+
+    def deleteBackup(self, backup_id: str) -> bool:
+        """
+        Delete a backup from the server by ID.
+        :param backup_id: The ID of the backup to delete.
+        :return: Success bool.
+        """
+        access_token = self._cura_api.account.accessToken
+        if not access_token:
+            Logger.log("w", "Could not get access token.")
+            return False
+
+        delete_backup = requests.delete("{}/{}".format(self.DELETE_BACKUP_URL, backup_id), headers = {
+            "Authorization": "Bearer {}".format(access_token)
+        })
+        if delete_backup.status_code > 299:
+            Logger.log("w", "Could not delete backup: %s", delete_backup.text)
+            return False
+        return True
+
+    def _requestBackupUpload(self, backup_metadata: Dict[str, any], backup_size: int) -> Optional[str]:
+        """
+        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.
+        """
+        access_token = self._cura_api.account.accessToken
+        if not access_token:
+            Logger.log("w", "Could not get access token.")
+            return None
+        
+        backup_upload_request = requests.put(self.PUT_BACKUP_URL, json={
+            "data": {
+                "backup_size": backup_size,
+                "metadata": backup_metadata
+            }
+        }, headers={
+            "Authorization": "Bearer {}".format(access_token)
+        })
+        
+        if backup_upload_request.status_code > 299:
+            Logger.log("w", "Could not request backup upload: %s", backup_upload_request.text)
+            return None
+        
+        return backup_upload_request.json()["data"]["upload_url"]

+ 200 - 0
plugins/CuraDrive/src/DrivePluginExtension.py

@@ -0,0 +1,200 @@
+# Copyright (c) 2017 Ultimaker B.V.
+import os
+from datetime import datetime
+from typing import Optional
+
+from PyQt5.QtCore import QObject, pyqtSlot, pyqtProperty, pyqtSignal
+
+from UM.Extension import Extension
+from UM.Message import Message
+
+from .Settings import Settings
+from .DriveApiService import DriveApiService
+from .models.BackupListModel import BackupListModel
+
+
+class DrivePluginExtension(QObject, Extension):
+    """
+    The DivePluginExtension provides functionality to backup and restore your Cura configuration to Ultimaker's cloud.
+    """
+
+    # Signal emitted when the list of backups changed.
+    backupsChanged = pyqtSignal()
+
+    # Signal emitted when restoring has started. Needed to prevent parallel restoring.
+    restoringStateChanged = pyqtSignal()
+
+    # Signal emitted when creating has started. Needed to prevent parallel creation of backups.
+    creatingStateChanged = pyqtSignal()
+
+    # Signal emitted when preferences changed (like auto-backup).
+    preferencesChanged = pyqtSignal()
+    
+    DATE_FORMAT = "%d/%m/%Y %H:%M:%S"
+
+    def __init__(self, application):
+        super(DrivePluginExtension, self).__init__()
+        
+        # Re-usable instance of application.
+        self._application = application
+
+        # Local data caching for the UI.
+        self._drive_window = None  # type: Optional[QObject]
+        self._backups_list_model = BackupListModel()
+        self._is_restoring_backup = False
+        self._is_creating_backup = False
+
+        # Initialize services.
+        self._preferences = self._application.getPreferences()
+        self._cura_api = self._application.getCuraAPI()
+        self._drive_api_service = DriveApiService(self._cura_api)
+
+        # Attach signals.
+        self._cura_api.account.loginStateChanged.connect(self._onLoginStateChanged)
+        self._drive_api_service.onRestoringStateChanged.connect(self._onRestoringStateChanged)
+        self._drive_api_service.onCreatingStateChanged.connect(self._onCreatingStateChanged)
+
+        # Register preferences.
+        self._preferences.addPreference(Settings.AUTO_BACKUP_ENABLED_PREFERENCE_KEY, False)
+        self._preferences.addPreference(Settings.AUTO_BACKUP_LAST_DATE_PREFERENCE_KEY, datetime.now()
+                                        .strftime(self.DATE_FORMAT))
+        
+        # Register menu items.
+        self._updateMenuItems()
+
+        # Make auto-backup on boot if required.
+        self._application.engineCreatedSignal.connect(self._autoBackup)
+
+    def showDriveWindow(self) -> None:
+        """Show the Drive UI popup window."""
+        if not self._drive_window:
+            self._drive_window = self.createDriveWindow()
+        self.refreshBackups()
+        self._drive_window.show()
+
+    def createDriveWindow(self) -> Optional["QObject"]:
+        """
+        Create an instance of the Drive UI popup window.
+        :return: The popup window object.
+        """
+        path = os.path.join(os.path.dirname(__file__), "qml", "main.qml")
+        return self._application.createQmlComponent(path, {"CuraDrive": self})
+    
+    def _updateMenuItems(self) -> None:
+        """Update the menu items."""
+        self.addMenuItem(Settings.translatable_messages["extension_menu_entry"], self.showDriveWindow)
+
+    def _autoBackup(self) -> None:
+        """Automatically make a backup on boot if enabled."""
+        if self._preferences.getValue(Settings.AUTO_BACKUP_ENABLED_PREFERENCE_KEY) and self._lastBackupTooLongAgo():
+            self.createBackup()
+            
+    def _lastBackupTooLongAgo(self) -> bool:
+        """Check if the last backup was longer than 1 day ago."""
+        current_date = datetime.now()
+        last_backup_date = self._getLastBackupDate()
+        date_diff = current_date - last_backup_date
+        return date_diff.days > 1
+
+    def _getLastBackupDate(self) -> "datetime":
+        """Get the last backup date as datetime object."""
+        last_backup_date = self._preferences.getValue(Settings.AUTO_BACKUP_LAST_DATE_PREFERENCE_KEY)
+        return datetime.strptime(last_backup_date, self.DATE_FORMAT)
+
+    def _storeBackupDate(self) -> None:
+        """Store the current date as last backup date."""
+        backup_date = datetime.now().strftime(self.DATE_FORMAT)
+        self._preferences.setValue(Settings.AUTO_BACKUP_LAST_DATE_PREFERENCE_KEY, backup_date)
+
+    def _onLoginStateChanged(self, logged_in: bool = False) -> None:
+        """Callback handler for changes in the login state."""
+        if logged_in:
+            self.refreshBackups()
+
+    def _onRestoringStateChanged(self, is_restoring: bool = False, error_message: str = None) -> None:
+        """Callback handler for changes in the restoring state."""
+        self._is_restoring_backup = is_restoring
+        self.restoringStateChanged.emit()
+        if error_message:
+            Message(error_message, title = Settings.MESSAGE_TITLE, lifetime = 5).show()
+
+    def _onCreatingStateChanged(self, is_creating: bool = False, error_message: str = None) -> None:
+        """Callback handler for changes in the creation state."""
+        self._is_creating_backup = is_creating
+        self.creatingStateChanged.emit()
+        if error_message:
+            Message(error_message, title = Settings.MESSAGE_TITLE, lifetime = 5).show()
+        else:
+            self._storeBackupDate()
+        if not is_creating:
+            # We've finished creating a new backup, to the list has to be updated.
+            self.refreshBackups()
+
+    @pyqtSlot(bool, name = "toggleAutoBackup")
+    def toggleAutoBackup(self, enabled: bool) -> None:
+        """Enable or disable the auto-backup feature."""
+        self._preferences.setValue(Settings.AUTO_BACKUP_ENABLED_PREFERENCE_KEY, enabled)
+        self.preferencesChanged.emit()
+
+    @pyqtProperty(bool, notify = preferencesChanged)
+    def autoBackupEnabled(self) -> bool:
+        """Check if auto-backup is enabled or not."""
+        return bool(self._preferences.getValue(Settings.AUTO_BACKUP_ENABLED_PREFERENCE_KEY))
+
+    @pyqtProperty(QObject, notify = backupsChanged)
+    def backups(self) -> BackupListModel:
+        """
+        Get a list of the backups.
+        :return: The backups as Qt List Model.
+        """
+        return self._backups_list_model
+
+    @pyqtSlot(name = "refreshBackups")
+    def refreshBackups(self) -> None:
+        """
+        Forcefully refresh the backups list.
+        """
+        self._backups_list_model.loadBackups(self._drive_api_service.getBackups())
+        self.backupsChanged.emit()
+
+    @pyqtProperty(bool, notify = restoringStateChanged)
+    def isRestoringBackup(self) -> bool:
+        """
+        Get the current restoring state.
+        :return: Boolean if we are restoring or not.
+        """
+        return self._is_restoring_backup
+
+    @pyqtProperty(bool, notify = creatingStateChanged)
+    def isCreatingBackup(self) -> bool:
+        """
+        Get the current creating state.
+        :return: Boolean if we are creating or not.
+        """
+        return self._is_creating_backup
+
+    @pyqtSlot(str, name = "restoreBackup")
+    def restoreBackup(self, backup_id: str) -> None:
+        """
+        Download and restore a backup by ID.
+        :param backup_id: The ID of the backup.
+        """
+        index = self._backups_list_model.find("backup_id", backup_id)
+        backup = self._backups_list_model.getItem(index)
+        self._drive_api_service.restoreBackup(backup)
+
+    @pyqtSlot(name = "createBackup")
+    def createBackup(self) -> None:
+        """
+        Create a new backup.
+        """
+        self._drive_api_service.createBackup()
+
+    @pyqtSlot(str, name = "deleteBackup")
+    def deleteBackup(self, backup_id: str) -> None:
+        """
+        Delete a backup by ID.
+        :param backup_id: The ID of the backup.
+        """
+        self._drive_api_service.deleteBackup(backup_id)
+        self.refreshBackups()

+ 36 - 0
plugins/CuraDrive/src/Settings.py

@@ -0,0 +1,36 @@
+# Copyright (c) 2018 Ultimaker B.V.
+from UM import i18nCatalog
+
+
+class Settings:
+    """
+    Keeps the application settings.
+    """
+    UM_CLOUD_API_ROOT = "https://api.ultimaker.com"
+    DRIVE_API_VERSION = 1
+    DRIVE_API_URL = "{}/cura-drive/v{}".format(UM_CLOUD_API_ROOT, str(DRIVE_API_VERSION))
+    
+    AUTO_BACKUP_ENABLED_PREFERENCE_KEY = "cura_drive/auto_backup_enabled"
+    AUTO_BACKUP_LAST_DATE_PREFERENCE_KEY = "cura_drive/auto_backup_date"
+
+    I18N_CATALOG_ID = "cura_drive"
+    I18N_CATALOG = i18nCatalog(I18N_CATALOG_ID)
+    
+    MESSAGE_TITLE = I18N_CATALOG.i18nc("@info:title", "Backups"),
+
+    # Translatable messages for the entire plugin.
+    translatable_messages = {
+        
+        # Menu items.
+        "extension_menu_entry": I18N_CATALOG.i18nc("@item:inmenu", "Manage backups"),
+        
+        # Notification messages.
+        "backup_failed": I18N_CATALOG.i18nc("@info:backup_status", "There was an error while creating your backup."),
+        "uploading_backup": I18N_CATALOG.i18nc("@info:backup_status", "Uploading your backup..."),
+        "uploading_backup_success": I18N_CATALOG.i18nc("@info:backup_status", "Your backup has finished uploading."),
+        "uploading_backup_error": I18N_CATALOG.i18nc("@info:backup_status",
+                                                     "There was an error while uploading your backup."),
+        "get_backups_error": I18N_CATALOG.i18nc("@info:backup_status", "There was an error listing your backups."),
+        "backup_restore_error_message": I18N_CATALOG.i18nc("@info:backup_status",
+                                                           "There was an error trying to restore your backup.")
+    }

+ 39 - 0
plugins/CuraDrive/src/UploadBackupJob.py

@@ -0,0 +1,39 @@
+# Copyright (c) 2018 Ultimaker B.V.
+import requests
+
+from UM.Job import Job
+from UM.Logger import Logger
+from UM.Message import Message
+
+from .Settings import Settings
+
+
+class UploadBackupJob(Job):
+    """
+    This job is responsible for uploading the backup file to cloud storage.
+    As it can take longer than some other tasks, we schedule this using a Cura Job.
+    """
+
+    def __init__(self, signed_upload_url: str, backup_zip: bytes):
+        super().__init__()
+        self._signed_upload_url = signed_upload_url
+        self._backup_zip = backup_zip
+        self._upload_success = False
+        self.backup_upload_error_message = ""
+
+    def run(self):
+        Message(Settings.translatable_messages["uploading_backup"], title = Settings.MESSAGE_TITLE,
+                lifetime = 10).show()
+
+        backup_upload = requests.put(self._signed_upload_url, data = self._backup_zip)
+        if backup_upload.status_code not in (200, 201):
+            self.backup_upload_error_message = backup_upload.text
+            Logger.log("w", "Could not upload backup file: %s", backup_upload.text)
+            Message(Settings.translatable_messages["uploading_backup_error"], title = Settings.MESSAGE_TITLE,
+                    lifetime = 10).show()
+        else:
+            self._upload_success = True
+            Message(Settings.translatable_messages["uploading_backup_success"], title = Settings.MESSAGE_TITLE,
+                    lifetime = 10).show()
+
+        self.finished.emit(self)

+ 0 - 0
plugins/CuraDrive/src/__init__.py


+ 38 - 0
plugins/CuraDrive/src/models/BackupListModel.py

@@ -0,0 +1,38 @@
+# Copyright (c) 2018 Ultimaker B.V.
+from typing import List, Dict
+
+from UM.Qt.ListModel import ListModel
+
+from PyQt5.QtCore import Qt
+
+
+class BackupListModel(ListModel):
+    """
+    The BackupListModel transforms the backups data that came from the server so it can be served to the Qt UI.
+    """
+
+    def __init__(self, parent=None):
+        super().__init__(parent)
+        self.addRoleName(Qt.UserRole + 1, "backup_id")
+        self.addRoleName(Qt.UserRole + 2, "download_url")
+        self.addRoleName(Qt.UserRole + 3, "generated_time")
+        self.addRoleName(Qt.UserRole + 4, "md5_hash")
+        self.addRoleName(Qt.UserRole + 5, "data")
+
+    def loadBackups(self, data: List[Dict[str, any]]) -> None:
+        """
+        Populate the model with server data.
+        :param data:
+        """
+        items = []
+        for backup in data:
+            # We do this loop because we only want to append these specific fields.
+            # Without this, ListModel will break.
+            items.append({
+                "backup_id": backup["backup_id"],
+                "download_url": backup["download_url"],
+                "generated_time": backup["generated_time"],
+                "md5_hash": backup["md5_hash"],
+                "data": backup["metadata"]
+            })
+        self.setItems(items)

+ 0 - 0
plugins/CuraDrive/src/models/__init__.py


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