diff options
author | Kostas Karmas <konskarm@gmail.com> | 2021-04-20 17:09:54 +0300 |
---|---|---|
committer | Kostas Karmas <konskarm@gmail.com> | 2021-04-20 17:09:54 +0300 |
commit | 5b74090dce6c4d2f268f869b03a7a39c3c4f20f2 (patch) | |
tree | 6f0b6e357219dfff30a11c7f5c8d4e2a56197258 /plugins/DigitalLibrary/src | |
parent | 196c8913311a8c6a2d033620df0b6d70d4d132b4 (diff) | |
parent | 0db033a6907b83e92645e4ac7b8f7532388745ca (diff) |
Merge branch 'df49' into 4.94.9.0
Diffstat (limited to 'plugins/DigitalLibrary/src')
23 files changed, 2289 insertions, 0 deletions
diff --git a/plugins/DigitalLibrary/src/BaseModel.py b/plugins/DigitalLibrary/src/BaseModel.py new file mode 100644 index 0000000000..5bfd14feba --- /dev/null +++ b/plugins/DigitalLibrary/src/BaseModel.py @@ -0,0 +1,74 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. + +from datetime import datetime, timezone +from typing import TypeVar, Dict, List, Any, Type, Union + + +# Type variable used in the parse methods below, which should be a subclass of BaseModel. +T = TypeVar("T", bound="BaseModel") + + +class BaseModel: + + def __init__(self, **kwargs) -> None: + self.__dict__.update(kwargs) + self.validate() + + # Validates the model, raising an exception if the model is invalid. + def validate(self) -> None: + pass + + def __eq__(self, other): + """Checks whether the two models are equal. + + :param other: The other model. + :return: True if they are equal, False if they are different. + """ + return type(self) == type(other) and self.toDict() == other.toDict() + + def __ne__(self, other) -> bool: + """Checks whether the two models are different. + + :param other: The other model. + :return: True if they are different, False if they are the same. + """ + return type(self) != type(other) or self.toDict() != other.toDict() + + def toDict(self) -> Dict[str, Any]: + """Converts the model into a serializable dictionary""" + + return self.__dict__ + + @staticmethod + def parseModel(model_class: Type[T], values: Union[T, Dict[str, Any]]) -> T: + """Parses a single model. + + :param model_class: The model class. + :param values: The value of the model, which is usually a dictionary, but may also be already parsed. + :return: An instance of the model_class given. + """ + if isinstance(values, dict): + return model_class(**values) + return values + + @classmethod + def parseModels(cls, model_class: Type[T], values: List[Union[T, Dict[str, Any]]]) -> List[T]: + """Parses a list of models. + + :param model_class: The model class. + :param values: The value of the list. Each value is usually a dictionary, but may also be already parsed. + :return: A list of instances of the model_class given. + """ + return [cls.parseModel(model_class, value) for value in values] + + @staticmethod + def parseDate(date: Union[str, datetime]) -> datetime: + """Parses the given date string. + + :param date: The date to parse. + :return: The parsed date. + """ + if isinstance(date, datetime): + return date + return datetime.strptime(date, "%Y-%m-%dT%H:%M:%S.%fZ").replace(tzinfo=timezone.utc) diff --git a/plugins/DigitalLibrary/src/CloudError.py b/plugins/DigitalLibrary/src/CloudError.py new file mode 100644 index 0000000000..3c3f5eece2 --- /dev/null +++ b/plugins/DigitalLibrary/src/CloudError.py @@ -0,0 +1,31 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from typing import Dict, Optional, Any + +from .BaseModel import BaseModel + + +class CloudError(BaseModel): + """Class representing errors generated by the servers, according to the JSON-API standard.""" + + def __init__(self, id: str, code: str, title: str, http_status: str, detail: Optional[str] = None, + meta: Optional[Dict[str, Any]] = None, **kwargs) -> None: + """Creates a new error object. + + :param id: Unique identifier for this particular occurrence of the problem. + :param title: A short, human-readable summary of the problem that SHOULD NOT change from occurrence to occurrence + of the problem, except for purposes of localization. + :param code: An application-specific error code, expressed as a string value. + :param detail: A human-readable explanation specific to this occurrence of the problem. Like title, this field's + value can be localized. + :param http_status: The HTTP status code applicable to this problem, converted to string. + :param meta: Non-standard meta-information about the error, depending on the error code. + """ + + self.id = id + self.code = code + self.http_status = http_status + self.title = title + self.detail = detail + self.meta = meta + super().__init__(**kwargs) diff --git a/plugins/DigitalLibrary/src/DFFileExportAndUploadManager.py b/plugins/DigitalLibrary/src/DFFileExportAndUploadManager.py new file mode 100644 index 0000000000..c991f96633 --- /dev/null +++ b/plugins/DigitalLibrary/src/DFFileExportAndUploadManager.py @@ -0,0 +1,361 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +import json +import threading +from typing import List, Dict, Any, Callable, Union, Optional + +from PyQt5.QtCore import QUrl +from PyQt5.QtGui import QDesktopServices +from PyQt5.QtNetwork import QNetworkReply + +from UM.FileHandler.FileHandler import FileHandler +from UM.Logger import Logger +from UM.Message import Message +from UM.Scene.SceneNode import SceneNode +from cura.CuraApplication import CuraApplication +from .DFLibraryFileUploadRequest import DFLibraryFileUploadRequest +from .DFLibraryFileUploadResponse import DFLibraryFileUploadResponse +from .DFPrintJobUploadRequest import DFPrintJobUploadRequest +from .DFPrintJobUploadResponse import DFPrintJobUploadResponse +from .DigitalFactoryApiClient import DigitalFactoryApiClient +from .ExportFileJob import ExportFileJob + + +class DFFileExportAndUploadManager: + """ + Class responsible for exporting the scene and uploading the exported data to the Digital Factory Library. Since 3mf + and UFP files may need to be uploaded at the same time, this class keeps a single progress and success message for + both files and updates those messages according to the progress of both the file job uploads. + """ + def __init__(self, file_handlers: Dict[str, FileHandler], + nodes: List[SceneNode], + library_project_id: str, + library_project_name: str, + file_name: str, + formats: List[str], + on_upload_error: Callable[[], Any], + on_upload_success: Callable[[], Any], + on_upload_finished: Callable[[], Any] , + on_upload_progress: Callable[[int], Any]) -> None: + + self._file_handlers = file_handlers # type: Dict[str, FileHandler] + self._nodes = nodes # type: List[SceneNode] + self._library_project_id = library_project_id # type: str + self._library_project_name = library_project_name # type: str + self._file_name = file_name # type: str + + self._formats = formats # type: List[str] + self._api = DigitalFactoryApiClient(application = CuraApplication.getInstance(), on_error = lambda error: Logger.log("e", str(error))) + + # Functions of the parent class that should be called based on the upload process output + self._on_upload_error = on_upload_error + self._on_upload_success = on_upload_success + self._on_upload_finished = on_upload_finished + self._on_upload_progress = on_upload_progress + + # Lock used for updating the progress message (since the progress is changed by two parallel upload jobs) or + # show the success message (once both upload jobs are done) + self._message_lock = threading.Lock() + + self._file_upload_job_metadata = self.initializeFileUploadJobMetadata() # type: Dict[str, Dict[str, Any]] + + self.progress_message = Message( + title = "Uploading...", + text = "Uploading files to '{}'".format(self._library_project_name), + progress = -1, + lifetime = 0, + dismissable = False, + use_inactivity_timer = False + ) + + self._generic_success_message = Message( + text = "Your {} uploaded to '{}'.".format("file was" if len(self._file_upload_job_metadata) <= 1 else "files were", self._library_project_name), + title = "Upload successful", + lifetime = 0, + ) + self._generic_success_message.addAction( + "open_df_project", + "Open project", + "open-folder", "Open the project containing the file in Digital Library" + ) + self._generic_success_message.actionTriggered.connect(self._onMessageActionTriggered) + + def _onCuraProjectFileExported(self, job: ExportFileJob) -> None: + """Handler for when the DF Library workspace file (3MF) has been created locally. + + It can now be sent over the Digital Factory API. + """ + if not job.getOutput(): + self._onJobExportError(job.getFileName()) + return + self._file_upload_job_metadata[job.getFileName()]["export_job_output"] = job.getOutput() + request = DFLibraryFileUploadRequest( + content_type = job.getMimeType(), + file_name = job.getFileName(), + file_size = len(job.getOutput()), + library_project_id = self._library_project_id + ) + self._api.requestUpload3MF(request, on_finished = self._uploadFileData, on_error = self._onRequestUploadCuraProjectFileFailed) + + def _onPrintFileExported(self, job: ExportFileJob) -> None: + """Handler for when the DF Library print job file (UFP) has been created locally. + + It can now be sent over the Digital Factory API. + """ + if not job.getOutput(): + self._onJobExportError(job.getFileName()) + return + self._file_upload_job_metadata[job.getFileName()]["export_job_output"] = job.getOutput() + request = DFPrintJobUploadRequest( + content_type = job.getMimeType(), + job_name = job.getFileName(), + file_size = len(job.getOutput()), + library_project_id = self._library_project_id + ) + self._api.requestUploadUFP(request, on_finished = self._uploadFileData, on_error = self._onRequestUploadPrintFileFailed) + + def _uploadFileData(self, file_upload_response: Union[DFLibraryFileUploadResponse, DFPrintJobUploadResponse]) -> None: + """Uploads the exported file data after the file or print job upload has been registered at the Digital Factory + Library API. + + :param file_upload_response: The response received from the Digital Factory Library API. + """ + if isinstance(file_upload_response, DFLibraryFileUploadResponse): + file_name = file_upload_response.file_name + elif isinstance(file_upload_response, DFPrintJobUploadResponse): + file_name = file_upload_response.job_name + else: + Logger.log("e", "Wrong response type received. Aborting uploading file to the Digital Library") + return + with self._message_lock: + self.progress_message.show() + self._file_upload_job_metadata[file_name]["file_upload_response"] = file_upload_response + job_output = self._file_upload_job_metadata[file_name]["export_job_output"] + + with self._message_lock: + self._file_upload_job_metadata[file_name]["upload_status"] = "uploading" + + self._api.uploadExportedFileData(file_upload_response, + job_output, + on_finished = self._onFileUploadFinished, + on_success = self._onUploadSuccess, + on_progress = self._onUploadProgress, + on_error = self._onUploadError) + + def _onUploadProgress(self, filename: str, progress: int) -> None: + """ + Updates the progress message according to the total progress of the two files and displays it to the user. It is + made thread-safe with a lock, since the progress can be updated by two separate upload jobs + + :param filename: The name of the file for which we have progress (including the extension). + :param progress: The progress percentage + """ + with self._message_lock: + self._file_upload_job_metadata[filename]["upload_progress"] = progress + self._file_upload_job_metadata[filename]["upload_status"] = "uploading" + total_progress = self.getTotalProgress() + self.progress_message.setProgress(total_progress) + self.progress_message.show() + self._on_upload_progress(progress) + + def _onUploadSuccess(self, filename: str) -> None: + """ + Sets the upload status to success and the progress of the file with the given filename to 100%. This function is + should be called only if the file has uploaded all of its data successfully (i.e. no error occurred during the + upload process). + + :param filename: The name of the file that was uploaded successfully (including the extension). + """ + with self._message_lock: + self._file_upload_job_metadata[filename]["upload_status"] = "success" + self._file_upload_job_metadata[filename]["upload_progress"] = 100 + self._on_upload_success() + + def _onFileUploadFinished(self, filename: str) -> None: + """ + Callback that makes sure the correct messages are displayed according to the statuses of the individual jobs. + + This function is called whenever an upload job has finished, regardless if it had errors or was successful. + Both jobs have to have finished for the messages to show. + + :param filename: The name of the file that has finished uploading (including the extension). + """ + with self._message_lock: + + # All files have finished their uploading process + if all([(file_upload_job["upload_progress"] == 100 and file_upload_job["upload_status"] != "uploading") for file_upload_job in self._file_upload_job_metadata.values()]): + + # Reset and hide the progress message + self.progress_message.setProgress(-1) + self.progress_message.hide() + + # All files were successfully uploaded. + if all([(file_upload_job["upload_status"] == "success") for file_upload_job in self._file_upload_job_metadata.values()]): + # Show a single generic success message for all files + self._generic_success_message.show() + else: # One or more files failed to upload. + # Show individual messages for each file, according to their statuses + for filename, upload_job_metadata in self._file_upload_job_metadata.items(): + if upload_job_metadata["upload_status"] == "success": + upload_job_metadata["file_upload_success_message"].show() + else: + upload_job_metadata["file_upload_failed_message"].show() + + # Call the parent's finished function + self._on_upload_finished() + + def _onJobExportError(self, filename: str) -> None: + """ + Displays an appropriate message when the process to export a file fails. + + :param filename: The name of the file that failed to be exported (including the extension). + """ + Logger.log("d", "Error while exporting file '{}'".format(filename)) + with self._message_lock: + # Set the progress to 100% when the upload job fails, to avoid having the progress message stuck + self._file_upload_job_metadata[filename]["upload_status"] = "failed" + self._file_upload_job_metadata[filename]["upload_progress"] = 100 + self._file_upload_job_metadata[filename]["file_upload_failed_message"] = Message( + text = "Failed to export the file '{}'. The upload process is aborted.".format(filename), + title = "Export error", + lifetime = 0 + ) + self._on_upload_error() + self._onFileUploadFinished(filename) + + def _onRequestUploadCuraProjectFileFailed(self, reply: "QNetworkReply", network_error: "QNetworkReply.NetworkError") -> None: + """ + Displays an appropriate message when the request to upload the Cura project file (.3mf) to the Digital Library fails. + This means that something went wrong with the initial request to create a "file" entry in the digital library. + """ + reply_string = bytes(reply.readAll()).decode() + filename_3mf = self._file_name + ".3mf" + Logger.log("d", "An error occurred while uploading the Cura project file '{}' to the Digital Library project '{}': {}".format(filename_3mf, self._library_project_id, reply_string)) + with self._message_lock: + # Set the progress to 100% when the upload job fails, to avoid having the progress message stuck + self._file_upload_job_metadata[filename_3mf]["upload_status"] = "failed" + self._file_upload_job_metadata[filename_3mf]["upload_progress"] = 100 + + human_readable_error = self.extractErrorTitle(reply_string) + self._file_upload_job_metadata[filename_3mf]["file_upload_failed_message"] = Message( + text = "Failed to upload the file '{}' to '{}'. {}".format(filename_3mf, self._library_project_name, human_readable_error), + title = "File upload error", + lifetime = 0 + ) + self._on_upload_error() + self._onFileUploadFinished(filename_3mf) + + def _onRequestUploadPrintFileFailed(self, reply: "QNetworkReply", network_error: "QNetworkReply.NetworkError") -> None: + """ + Displays an appropriate message when the request to upload the print file (.ufp) to the Digital Library fails. + This means that something went wrong with the initial request to create a "file" entry in the digital library. + """ + reply_string = bytes(reply.readAll()).decode() + filename_ufp = self._file_name + ".ufp" + Logger.log("d", "An error occurred while uploading the print job file '{}' to the Digital Library project '{}': {}".format(filename_ufp, self._library_project_id, reply_string)) + with self._message_lock: + # Set the progress to 100% when the upload job fails, to avoid having the progress message stuck + self._file_upload_job_metadata[filename_ufp]["upload_status"] = "failed" + self._file_upload_job_metadata[filename_ufp]["upload_progress"] = 100 + + human_readable_error = self.extractErrorTitle(reply_string) + self._file_upload_job_metadata[filename_ufp]["file_upload_failed_message"] = Message( + title = "File upload error", + text = "Failed to upload the file '{}' to '{}'. {}".format(filename_ufp, self._library_project_name, human_readable_error), + lifetime = 0 + ) + self._on_upload_error() + self._onFileUploadFinished(filename_ufp) + + @staticmethod + def extractErrorTitle(reply_body: Optional[str]) -> str: + error_title = "" + if reply_body: + reply_dict = json.loads(reply_body) + if "errors" in reply_dict and len(reply_dict["errors"]) >= 1 and "title" in reply_dict["errors"][0]: + error_title = reply_dict["errors"][0]["title"] # type: str + return error_title + + def _onUploadError(self, filename: str, reply: "QNetworkReply", error: "QNetworkReply.NetworkError") -> None: + """ + Displays the given message if uploading the mesh has failed due to a generic error (i.e. lost connection). + If one of the two files fail, this error function will set its progress as finished, to make sure that the + progress message doesn't get stuck. + + :param filename: The name of the file that failed to upload (including the extension). + """ + reply_string = bytes(reply.readAll()).decode() + Logger.log("d", "Error while uploading '{}' to the Digital Library project '{}'. Reply: {}".format(filename, self._library_project_id, reply_string)) + with self._message_lock: + # Set the progress to 100% when the upload job fails, to avoid having the progress message stuck + self._file_upload_job_metadata[filename]["upload_status"] = "failed" + self._file_upload_job_metadata[filename]["upload_progress"] = 100 + human_readable_error = self.extractErrorTitle(reply_string) + self._file_upload_job_metadata[filename]["file_upload_failed_message"] = Message( + title = "File upload error", + text = "Failed to upload the file '{}' to '{}'. {}".format(self._file_name, self._library_project_name, human_readable_error), + lifetime = 0 + ) + + self._on_upload_error() + + def getTotalProgress(self) -> int: + """ + Returns the total upload progress of all the upload jobs + + :return: The average progress percentage + """ + return int(sum([file_upload_job["upload_progress"] for file_upload_job in self._file_upload_job_metadata.values()]) / len(self._file_upload_job_metadata.values())) + + def _onMessageActionTriggered(self, message, action): + if action == "open_df_project": + project_url = "{}/app/library/project/{}?wait_for_new_files=true".format(CuraApplication.getInstance().ultimakerDigitalFactoryUrl, self._library_project_id) + QDesktopServices.openUrl(QUrl(project_url)) + message.hide() + + def initializeFileUploadJobMetadata(self) -> Dict[str, Any]: + metadata = {} + if "3mf" in self._formats and "3mf" in self._file_handlers and self._file_handlers["3mf"]: + filename_3mf = self._file_name + ".3mf" + metadata[filename_3mf] = { + "export_job_output" : None, + "upload_progress" : -1, + "upload_status" : "", + "file_upload_response": None, + "file_upload_success_message": Message( + text = "'{}' was uploaded to '{}'.".format(filename_3mf, self._library_project_name), + title = "Upload successful", + lifetime = 0, + ), + "file_upload_failed_message": Message( + text = "Failed to upload the file '{}' to '{}'.".format(filename_3mf, self._library_project_name), + title = "File upload error", + lifetime = 0 + ) + } + job_3mf = ExportFileJob(self._file_handlers["3mf"], self._nodes, self._file_name, "3mf") + job_3mf.finished.connect(self._onCuraProjectFileExported) + job_3mf.start() + + if "ufp" in self._formats and "ufp" in self._file_handlers and self._file_handlers["ufp"]: + filename_ufp = self._file_name + ".ufp" + metadata[filename_ufp] = { + "export_job_output" : None, + "upload_progress" : -1, + "upload_status" : "", + "file_upload_response": None, + "file_upload_success_message": Message( + text = "'{}' was uploaded to '{}'.".format(filename_ufp, self._library_project_name), + title = "Upload successful", + lifetime = 0, + ), + "file_upload_failed_message": Message( + text = "Failed to upload the file '{}' to '{}'.".format(filename_ufp, self._library_project_name), + title = "File upload error", + lifetime = 0 + ) + } + job_ufp = ExportFileJob(self._file_handlers["ufp"], self._nodes, self._file_name, "ufp") + job_ufp.finished.connect(self._onPrintFileExported) + job_ufp.start() + return metadata diff --git a/plugins/DigitalLibrary/src/DFFileUploader.py b/plugins/DigitalLibrary/src/DFFileUploader.py new file mode 100644 index 0000000000..9c5356255e --- /dev/null +++ b/plugins/DigitalLibrary/src/DFFileUploader.py @@ -0,0 +1,147 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. + +from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply +from typing import Callable, Any, cast, Optional, Union + +from UM.Logger import Logger +from UM.TaskManagement.HttpRequestManager import HttpRequestManager +from .DFLibraryFileUploadResponse import DFLibraryFileUploadResponse +from .DFPrintJobUploadResponse import DFPrintJobUploadResponse + + +class DFFileUploader: + """Class responsible for uploading meshes to the the digital factory library in separate requests.""" + + # The maximum amount of times to retry if the server returns one of the RETRY_HTTP_CODES + MAX_RETRIES = 10 + + # The HTTP codes that should trigger a retry. + RETRY_HTTP_CODES = {500, 502, 503, 504} + + def __init__(self, + http: HttpRequestManager, + df_file: Union[DFLibraryFileUploadResponse, DFPrintJobUploadResponse], + data: bytes, + on_finished: Callable[[str], Any], + on_success: Callable[[str], Any], + on_progress: Callable[[str, int], Any], + on_error: Callable[[str, "QNetworkReply", "QNetworkReply.NetworkError"], Any] + ) -> None: + """Creates a mesh upload object. + + :param http: The network access manager that will handle the HTTP requests. + :param df_file: The file response that was received by the Digital Factory after registering the upload. + :param data: The mesh bytes to be uploaded. + :param on_finished: The method to be called when done. + :param on_success: The method to be called when the upload is successful. + :param on_progress: The method to be called when the progress changes (receives a percentage 0-100). + :param on_error: The method to be called when an error occurs. + """ + + self._http = http # type: HttpRequestManager + self._df_file = df_file # type: Union[DFLibraryFileUploadResponse, DFPrintJobUploadResponse] + self._file_name = "" + if isinstance(self._df_file, DFLibraryFileUploadResponse): + self._file_name = self._df_file.file_name + elif isinstance(self._df_file, DFPrintJobUploadResponse): + if self._df_file.job_name is not None: + self._file_name = self._df_file.job_name + else: + self._file_name = "" + else: + raise TypeError("Incorrect input type") + self._data = data # type: bytes + + self._on_finished = on_finished + self._on_success = on_success + self._on_progress = on_progress + self._on_error = on_error + + self._retries = 0 + self._finished = False + + def start(self) -> None: + """Starts uploading the mesh.""" + + if self._finished: + # reset state. + self._retries = 0 + self._finished = False + self._upload() + + def stop(self): + """Stops uploading the mesh, marking it as finished.""" + + Logger.log("i", "Finished uploading") + self._finished = True # Signal to any ongoing retries that we should stop retrying. + self._on_finished(self._file_name) + + def _upload(self) -> None: + """ + Uploads the file to the Digital Factory Library project + """ + if self._finished: + raise ValueError("The upload is already finished") + + Logger.log("i", "Uploading DF file to project '{library_project_id}' via link '{upload_url}'".format(library_project_id = self._df_file.library_project_id, upload_url = self._df_file.upload_url)) + self._http.put( + url = cast(str, self._df_file.upload_url), + headers_dict = {"Content-Type": cast(str, self._df_file.content_type)}, + data = self._data, + callback = self._onUploadFinished, + error_callback = self._onUploadError, + upload_progress_callback = self._onUploadProgressChanged + ) + + def _onUploadProgressChanged(self, bytes_sent: int, bytes_total: int) -> None: + """Handles an update to the upload progress + + :param bytes_sent: The amount of bytes sent in the current request. + :param bytes_total: The amount of bytes to send in the current request. + """ + Logger.debug("Cloud upload progress %s / %s", bytes_sent, bytes_total) + if bytes_total: + self._on_progress(self._file_name, int(bytes_sent / len(self._data) * 100)) + + def _onUploadError(self, reply: QNetworkReply, error: QNetworkReply.NetworkError) -> None: + """Handles an error uploading.""" + + body = bytes(reply.peek(reply.bytesAvailable())).decode() + Logger.log("e", "Received error while uploading: %s", body) + self._on_error(self._file_name, reply, error) + self.stop() + + def _onUploadFinished(self, reply: QNetworkReply) -> None: + """ + Checks whether a chunk of data was uploaded successfully, starting the next chunk if needed. + """ + + Logger.log("i", "Finished callback %s %s", + reply.attribute(QNetworkRequest.HttpStatusCodeAttribute), reply.url().toString()) + + status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) # type: Optional[int] + if not status_code: + Logger.log("e", "Reply contained no status code.") + self._onUploadError(reply, None) + return + + # check if we should retry the last chunk + if self._retries < self.MAX_RETRIES and status_code in self.RETRY_HTTP_CODES: + self._retries += 1 + Logger.log("i", "Retrying %s/%s request %s", self._retries, self.MAX_RETRIES, reply.url().toString()) + try: + self._upload() + except ValueError: # Asynchronously it could have completed in the meanwhile. + pass + return + + # Http codes that are not to be retried are assumed to be errors. + if status_code > 308: + self._onUploadError(reply, None) + return + + Logger.log("d", "status_code: %s, Headers: %s, body: %s", status_code, + [bytes(header).decode() for header in reply.rawHeaderList()], bytes(reply.readAll()).decode()) + self._on_success(self._file_name) + self.stop() diff --git a/plugins/DigitalLibrary/src/DFLibraryFileUploadRequest.py b/plugins/DigitalLibrary/src/DFLibraryFileUploadRequest.py new file mode 100644 index 0000000000..d9f1af1490 --- /dev/null +++ b/plugins/DigitalLibrary/src/DFLibraryFileUploadRequest.py @@ -0,0 +1,16 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. + +# Model that represents the request to upload a file to a DF Library project +from .BaseModel import BaseModel + + +class DFLibraryFileUploadRequest(BaseModel): + + def __init__(self, content_type: str, file_name: str, file_size: int, library_project_id: str, **kwargs) -> None: + + self.content_type = content_type + self.file_name = file_name + self.file_size = file_size + self.library_project_id = library_project_id + super().__init__(**kwargs) diff --git a/plugins/DigitalLibrary/src/DFLibraryFileUploadResponse.py b/plugins/DigitalLibrary/src/DFLibraryFileUploadResponse.py new file mode 100644 index 0000000000..3093c39076 --- /dev/null +++ b/plugins/DigitalLibrary/src/DFLibraryFileUploadResponse.py @@ -0,0 +1,49 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from datetime import datetime +from typing import Optional + +from .BaseModel import BaseModel + + +class DFLibraryFileUploadResponse(BaseModel): + """ + Model that represents the response received from the Digital Factory after requesting to upload a file in a Library project + """ + + def __init__(self, client_id: str, content_type: str, file_id: str, file_name: str, library_project_id: str, + status: str, uploaded_at: str, user_id: str, username: str, download_url: Optional[str] = None, + file_size: Optional[int] = None, status_description: Optional[str] = None, + upload_url: Optional[str] = None, **kwargs) -> None: + + """ + :param client_id: The ID of the OAuth2 client that uploaded this file + :param content_type: The content type of the Digital Library project file + :param file_id: The ID of the library project file + :param file_name: The name of the file + :param library_project_id: The ID of the library project, in which the file will be uploaded + :param status: The status of the Digital Library project file + :param uploaded_at: The time on which the file was uploaded + :param user_id: The ID of the user that uploaded this file + :param username: The user's unique username + :param download_url: A signed URL to download the resulting file. Only available when the job is finished + :param file_size: The size of the uploaded file (in bytes) + :param status_description: Contains more details about the status, e.g. the cause of failures + :param upload_url: The one-time use URL where the file must be uploaded to (only if status is uploading) + :param kwargs: Other keyword arguments that may be included in the response + """ + + self.client_id = client_id # type: str + self.content_type = content_type # type: str + self.file_id = file_id # type: str + self.file_name = file_name # type: str + self.library_project_id = library_project_id # type: str + self.status = status # type: str + self.uploaded_at = self.parseDate(uploaded_at) # type: datetime + self.user_id = user_id # type: str + self.username = username # type: str + self.download_url = download_url # type: Optional[str] + self.file_size = file_size # type: Optional[int] + self.status_description = status_description # type: Optional[str] + self.upload_url = upload_url # type: Optional[str] + super().__init__(**kwargs) diff --git a/plugins/DigitalLibrary/src/DFPrintJobUploadRequest.py b/plugins/DigitalLibrary/src/DFPrintJobUploadRequest.py new file mode 100644 index 0000000000..ab434e3f04 --- /dev/null +++ b/plugins/DigitalLibrary/src/DFPrintJobUploadRequest.py @@ -0,0 +1,21 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from .BaseModel import BaseModel + + +# Model that represents the request to upload a print job to the cloud +class DFPrintJobUploadRequest(BaseModel): + + def __init__(self, job_name: str, file_size: int, content_type: str, library_project_id: str, **kwargs) -> None: + """Creates a new print job upload request. + + :param job_name: The name of the print job. + :param file_size: The size of the file in bytes. + :param content_type: The content type of the print job (e.g. text/plain or application/gzip) + """ + + self.job_name = job_name + self.file_size = file_size + self.content_type = content_type + self.library_project_id = library_project_id + super().__init__(**kwargs) diff --git a/plugins/DigitalLibrary/src/DFPrintJobUploadResponse.py b/plugins/DigitalLibrary/src/DFPrintJobUploadResponse.py new file mode 100644 index 0000000000..35819645de --- /dev/null +++ b/plugins/DigitalLibrary/src/DFPrintJobUploadResponse.py @@ -0,0 +1,35 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from typing import Optional + +from .BaseModel import BaseModel + + +# Model that represents the response received from the cloud after requesting to upload a print job +class DFPrintJobUploadResponse(BaseModel): + + def __init__(self, job_id: str, status: str, download_url: Optional[str] = None, job_name: Optional[str] = None, + upload_url: Optional[str] = None, content_type: Optional[str] = None, + status_description: Optional[str] = None, slicing_details: Optional[dict] = None, **kwargs) -> None: + """Creates a new print job response model. + + :param job_id: The job unique ID, e.g. 'kBEeZWEifXbrXviO8mRYLx45P8k5lHVGs43XKvRniPg='. + :param status: The status of the print job. + :param status_description: Contains more details about the status, e.g. the cause of failures. + :param download_url: A signed URL to download the resulting status. Only available when the job is finished. + :param job_name: The name of the print job. + :param slicing_details: Model for slice information. + :param upload_url: The one-time use URL where the toolpath must be uploaded to (only if status is uploading). + :param content_type: The content type of the print job (e.g. text/plain or application/gzip) + :param generated_time: The datetime when the object was generated on the server-side. + """ + + self.job_id = job_id + self.status = status + self.download_url = download_url + self.job_name = job_name + self.upload_url = upload_url + self.content_type = content_type + self.status_description = status_description + self.slicing_details = slicing_details + super().__init__(**kwargs) diff --git a/plugins/DigitalLibrary/src/DigitalFactoryApiClient.py b/plugins/DigitalLibrary/src/DigitalFactoryApiClient.py new file mode 100644 index 0000000000..4342d2623e --- /dev/null +++ b/plugins/DigitalLibrary/src/DigitalFactoryApiClient.py @@ -0,0 +1,317 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. + +import json +from json import JSONDecodeError +import re +from time import time +from typing import List, Any, Optional, Union, Type, Tuple, Dict, cast, TypeVar, Callable + +from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest + +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 import UltimakerCloudConstants +from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope +from .DFPrintJobUploadResponse import DFPrintJobUploadResponse +from .BaseModel import BaseModel +from .CloudError import CloudError +from .DFFileUploader import DFFileUploader +from .DFLibraryFileUploadRequest import DFLibraryFileUploadRequest +from .DFLibraryFileUploadResponse import DFLibraryFileUploadResponse +from .DFPrintJobUploadRequest import DFPrintJobUploadRequest +from .DigitalFactoryFileResponse import DigitalFactoryFileResponse +from .DigitalFactoryProjectResponse import DigitalFactoryProjectResponse +from .PaginationManager import PaginationManager + +CloudApiClientModel = TypeVar("CloudApiClientModel", bound=BaseModel) +"""The generic type variable used to document the methods below.""" + + +class DigitalFactoryApiClient: + # The URL to access the digital factory. + ROOT_PATH = UltimakerCloudConstants.CuraCloudAPIRoot + CURA_API_ROOT = "{}/cura/v1".format(ROOT_PATH) + + DEFAULT_REQUEST_TIMEOUT = 10 # seconds + + # In order to avoid garbage collection we keep the callbacks in this list. + _anti_gc_callbacks = [] # type: List[Callable[[Any], None]] + + def __init__(self, application: CuraApplication, on_error: Callable[[List[CloudError]], None], projects_limit_per_page: Optional[int] = None) -> None: + """Initializes a new digital factory API client. + + :param application: + :param on_error: The callback to be called whenever we receive errors from the server. + """ + super().__init__() + self._application = application + self._account = application.getCuraAPI().account + self._scope = JsonDecoratorScope(UltimakerCloudScope(application)) + self._http = HttpRequestManager.getInstance() + self._on_error = on_error + self._file_uploader = None # type: Optional[DFFileUploader] + + self._projects_pagination_mgr = PaginationManager(limit = projects_limit_per_page) if projects_limit_per_page else None # type: Optional[PaginationManager] + + def getProject(self, library_project_id: str, on_finished: Callable[[DigitalFactoryProjectResponse], Any], failed: Callable) -> None: + """ + Retrieves a digital factory project by its library project id. + + :param library_project_id: The id of the library project + :param on_finished: The function to be called after the result is parsed. + :param failed: The function to be called if the request fails. + """ + url = "{}/projects/{}".format(self.CURA_API_ROOT, library_project_id) + + self._http.get(url, + scope = self._scope, + callback = self._parseCallback(on_finished, DigitalFactoryProjectResponse, failed), + error_callback = failed, + timeout = self.DEFAULT_REQUEST_TIMEOUT) + + def getProjectsFirstPage(self, on_finished: Callable[[List[DigitalFactoryProjectResponse]], Any], failed: Callable) -> None: + """ + Retrieves digital factory projects for the user that is currently logged in. + + If a projects pagination manager exists, then it attempts to get the first page of the paginated projects list, + according to the limit set in the pagination manager. If there is no projects pagination manager, this function + leaves the project limit to the default set on the server side (999999). + + :param on_finished: The function to be called after the result is parsed. + :param failed: The function to be called if the request fails. + """ + url = "{}/projects".format(self.CURA_API_ROOT) + if self._projects_pagination_mgr: + self._projects_pagination_mgr.reset() # reset to clear all the links and response metadata + url += "?limit={}".format(self._projects_pagination_mgr.limit) + + self._http.get(url, + scope = self._scope, + callback = self._parseCallback(on_finished, DigitalFactoryProjectResponse, failed, pagination_manager = self._projects_pagination_mgr), + error_callback = failed, + timeout = self.DEFAULT_REQUEST_TIMEOUT) + + def getMoreProjects(self, + on_finished: Callable[[List[DigitalFactoryProjectResponse]], Any], + failed: Callable) -> None: + """Retrieves the next page of the paginated projects list from the API, provided that there is any. + + :param on_finished: The function to be called after the result is parsed. + :param failed: The function to be called if the request fails. + """ + + if self.hasMoreProjectsToLoad(): + url = self._projects_pagination_mgr.links.next_page + self._http.get(url, + scope = self._scope, + callback = self._parseCallback(on_finished, DigitalFactoryProjectResponse, failed, pagination_manager = self._projects_pagination_mgr), + error_callback = failed, + timeout = self.DEFAULT_REQUEST_TIMEOUT) + else: + Logger.log("d", "There are no more projects to load.") + + def hasMoreProjectsToLoad(self) -> bool: + """ + Determines whether the client can get more pages of projects list from the API. + + :return: Whether there are more pages in the projects list available to be retrieved from the API. + """ + return self._projects_pagination_mgr and self._projects_pagination_mgr.links and self._projects_pagination_mgr.links.next_page is not None + + def getListOfFilesInProject(self, library_project_id: str, on_finished: Callable[[List[DigitalFactoryFileResponse]], Any], failed: Callable) -> None: + """Retrieves the list of files contained in the project with library_project_id from the Digital Factory Library. + + :param library_project_id: The id of the digital factory library project in which the files are included + :param on_finished: The function to be called after the result is parsed. + :param failed: The function to be called if the request fails. + """ + + url = "{}/projects/{}/files".format(self.CURA_API_ROOT, library_project_id) + self._http.get(url, + scope = self._scope, + callback = self._parseCallback(on_finished, DigitalFactoryFileResponse, failed), + error_callback = failed, + timeout = self.DEFAULT_REQUEST_TIMEOUT) + + def _parseCallback(self, + on_finished: Union[Callable[[CloudApiClientModel], Any], + Callable[[List[CloudApiClientModel]], Any]], + model: Type[CloudApiClientModel], + on_error: Optional[Callable] = None, + pagination_manager: Optional[PaginationManager] = None) -> Callable[[QNetworkReply], None]: + + """ + Creates a callback function so that it includes the parsing of the response into the correct model. + The callback is added to the 'finished' signal of the reply. If a paginated request was made and a pagination + manager is given, the pagination metadata will be held there. + + :param on_finished: The callback in case the response is successful. Depending on the endpoint it will be either + a list or a single item. + :param model: The type of the model to convert the response to. + :param on_error: The callback in case the response is ... less successful. + :param pagination_manager: Holds the pagination links and metadata contained in paginated responses. + If no pagination manager is provided, the pagination metadata is ignored. + """ + + def parse(reply: QNetworkReply) -> None: + + self._anti_gc_callbacks.remove(parse) + + # Don't try to parse the reply if we didn't get one + if reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) is None: + if on_error is not None: + on_error() + return + + status_code, response = self._parseReply(reply) + if status_code >= 300 and on_error is not None: + on_error() + else: + self._parseModels(response, on_finished, model, pagination_manager = pagination_manager) + + self._anti_gc_callbacks.append(parse) + return parse + + @staticmethod + def _parseReply(reply: QNetworkReply) -> Tuple[int, Dict[str, Any]]: + """Parses the given JSON network reply into a status code and a dictionary, handling unexpected errors as well. + + :param reply: The reply from the server. + :return: A tuple with a status code and a dictionary. + """ + + status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) + try: + response = bytes(reply.readAll()).decode() + return status_code, json.loads(response) + except (UnicodeDecodeError, JSONDecodeError, ValueError) as err: + error = CloudError(code = type(err).__name__, title = str(err), http_code = str(status_code), + id = str(time()), http_status = "500") + Logger.logException("e", "Could not parse the stardust response: %s", error.toDict()) + return status_code, {"errors": [error.toDict()]} + + def _parseModels(self, + response: Dict[str, Any], + on_finished: Union[Callable[[CloudApiClientModel], Any], + Callable[[List[CloudApiClientModel]], Any]], + model_class: Type[CloudApiClientModel], + pagination_manager: Optional[PaginationManager] = None) -> None: + """Parses the given models and calls the correct callback depending on the result. + + :param response: The response from the server, after being converted to a dict. + :param on_finished: The callback in case the response is successful. + :param model_class: The type of the model to convert the response to. It may either be a single record or a list. + :param pagination_manager: Holds the pagination links and metadata contained in paginated responses. + If no pagination manager is provided, the pagination metadata is ignored. + """ + + if "data" in response: + data = response["data"] + if "meta" in response and pagination_manager: + pagination_manager.setResponseMeta(response["meta"]) + if "links" in response and pagination_manager: + pagination_manager.setLinks(response["links"]) + if isinstance(data, list): + results = [model_class(**c) for c in data] # type: List[CloudApiClientModel] + on_finished_list = cast(Callable[[List[CloudApiClientModel]], Any], on_finished) + on_finished_list(results) + else: + result = model_class(**data) # type: CloudApiClientModel + on_finished_item = cast(Callable[[CloudApiClientModel], Any], on_finished) + on_finished_item(result) + elif "errors" in response: + self._on_error([CloudError(**error) for error in response["errors"]]) + else: + Logger.log("e", "Cannot find data or errors in the cloud response: %s", response) + + def requestUpload3MF(self, request: DFLibraryFileUploadRequest, + on_finished: Callable[[DFLibraryFileUploadResponse], Any], + on_error: Optional[Callable[["QNetworkReply", "QNetworkReply.NetworkError"], None]] = None) -> None: + + """Requests the Digital Factory to register the upload of a file in a library project. + + :param request: The request object. + :param on_finished: The function to be called after the result is parsed. + :param on_error: The callback in case the request fails. + """ + + url = "{}/files/upload".format(self.CURA_API_ROOT) + data = json.dumps({"data": request.toDict()}).encode() + + self._http.put(url, + scope = self._scope, + data = data, + callback = self._parseCallback(on_finished, DFLibraryFileUploadResponse), + error_callback = on_error, + timeout = self.DEFAULT_REQUEST_TIMEOUT) + + def requestUploadUFP(self, request: DFPrintJobUploadRequest, + on_finished: Callable[[DFPrintJobUploadResponse], Any], + on_error: Optional[Callable[["QNetworkReply", "QNetworkReply.NetworkError"], None]] = None) -> None: + """Requests the Digital Factory to register the upload of a file in a library project. + + :param request: The request object. + :param on_finished: The function to be called after the result is parsed. + :param on_error: The callback in case the request fails. + """ + + url = "{}/jobs/upload".format(self.CURA_API_ROOT) + data = json.dumps({"data": request.toDict()}).encode() + + self._http.put(url, + scope = self._scope, + data = data, + callback = self._parseCallback(on_finished, DFPrintJobUploadResponse), + error_callback = on_error, + timeout = self.DEFAULT_REQUEST_TIMEOUT) + + def uploadExportedFileData(self, + df_file_upload_response: Union[DFLibraryFileUploadResponse, DFPrintJobUploadResponse], + mesh: bytes, + on_finished: Callable[[str], Any], + on_success: Callable[[str], Any], + on_progress: Callable[[str, int], Any], + on_error: Callable[[str, "QNetworkReply", "QNetworkReply.NetworkError"], Any]) -> None: + + """Uploads an exported file (in bytes) to the Digital Factory Library. + + :param df_file_upload_response: The response received after requesting an upload with `self.requestUpload`. + :param mesh: The mesh data (in bytes) to be uploaded. + :param on_finished: The function to be called after the upload has finished. Called both after on_success and on_error. + It receives the name of the file that has finished uploading. + :param on_success: The function to be called if the upload was successful. + It receives the name of the file that was uploaded successfully. + :param on_progress: A function to be called during upload progress. It receives a percentage (0-100). + It receives the name of the file for which the upload progress should be updated. + :param on_error: A function to be called if the upload fails. + It receives the name of the file that produced errors during the upload process. + """ + + self._file_uploader = DFFileUploader(self._http, df_file_upload_response, mesh, on_finished, on_success, on_progress, on_error) + self._file_uploader.start() + + def createNewProject(self, project_name: str, on_finished: Callable[[CloudApiClientModel], Any], on_error: Callable) -> None: + """ Create a new project in the Digital Factory. + + :param project_name: Name of the new to be created project. + :param on_finished: The function to be called after the result is parsed. + :param on_error: The function to be called if anything goes wrong. + """ + + display_name = re.sub(r"[^a-zA-Z0-9- ./™®ö+']", " ", project_name) + Logger.log("i", "Attempt to create new DF project '{}'.".format(display_name)) + + url = "{}/projects".format(self.CURA_API_ROOT) + data = json.dumps({"data": {"display_name": display_name}}).encode() + self._http.put(url, + scope = self._scope, + data = data, + callback = self._parseCallback(on_finished, DigitalFactoryProjectResponse), + error_callback = on_error, + timeout = self.DEFAULT_REQUEST_TIMEOUT) + + def clear(self) -> None: + self._projects_pagination_mgr.reset() diff --git a/plugins/DigitalLibrary/src/DigitalFactoryController.py b/plugins/DigitalLibrary/src/DigitalFactoryController.py new file mode 100644 index 0000000000..cd20479611 --- /dev/null +++ b/plugins/DigitalLibrary/src/DigitalFactoryController.py @@ -0,0 +1,563 @@ +# Copyright (c) 2021 Ultimaker B.V. +import json +import math +import os +import tempfile +import threading +from enum import IntEnum +from pathlib import Path +from typing import Optional, List, Dict, Any + +from PyQt5.QtCore import pyqtSignal, QObject, pyqtSlot, pyqtProperty, Q_ENUMS, QUrl +from PyQt5.QtNetwork import QNetworkReply +from PyQt5.QtQml import qmlRegisterType, qmlRegisterUncreatableType + +from UM.FileHandler.FileHandler import FileHandler +from UM.Logger import Logger +from UM.Message import Message +from UM.Scene.SceneNode import SceneNode +from UM.Signal import Signal +from UM.TaskManagement.HttpRequestManager import HttpRequestManager +from cura.API import Account +from cura.CuraApplication import CuraApplication +from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope +from .DFFileExportAndUploadManager import DFFileExportAndUploadManager +from .DigitalFactoryApiClient import DigitalFactoryApiClient +from .DigitalFactoryFileModel import DigitalFactoryFileModel +from .DigitalFactoryFileResponse import DigitalFactoryFileResponse +from .DigitalFactoryProjectModel import DigitalFactoryProjectModel +from .DigitalFactoryProjectResponse import DigitalFactoryProjectResponse + + +class RetrievalStatus(IntEnum): + """ + The status of an http get request. + + This is not an enum, because we want to use it in QML and QML doesn't recognize Python enums. + """ + Idle = 0 + InProgress = 1 + Success = 2 + Failed = 3 + + +class DFRetrievalStatus(QObject): + """ + Used as an intermediate QObject that registers the RetrievalStatus as a recognizable enum in QML, so that it can + be used within QML objects as DigitalFactory.RetrievalStatus.<status> + """ + + Q_ENUMS(RetrievalStatus) + + +class DigitalFactoryController(QObject): + + DISK_WRITE_BUFFER_SIZE = 256 * 1024 # 256 KB + + selectedProjectIndexChanged = pyqtSignal(int, arguments = ["newProjectIndex"]) + """Signal emitted whenever the selected project is changed in the projects dropdown menu""" + + selectedFileIndicesChanged = pyqtSignal("QList<int>", arguments = ["newFileIndices"]) + """Signal emitted whenever the selected file is changed in the files table""" + + retrievingProjectsStatusChanged = pyqtSignal(int, arguments = ["status"]) + """Signal emitted whenever the status of the 'retrieving projects' http get request is changed""" + + retrievingFilesStatusChanged = pyqtSignal(int, arguments = ["status"]) + """Signal emitted whenever the status of the 'retrieving files in project' http get request is changed""" + + creatingNewProjectStatusChanged = pyqtSignal(int, arguments = ["status"]) + """Signal emitted whenever the status of the 'create new library project' http get request is changed""" + + hasMoreProjectsToLoadChanged = pyqtSignal() + """Signal emitted whenever the variable hasMoreProjectsToLoad is changed. This variable is used to determine if + the paginated list of projects has more pages to show""" + + preselectedProjectChanged = pyqtSignal() + """Signal emitted whenever a preselected project is set. Whenever there is a preselected project, it means that it is + the only project in the ProjectModel. When the preselected project is invalidated, the ProjectsModel needs to be + retrieved again.""" + + projectCreationErrorTextChanged = pyqtSignal() + """Signal emitted whenever the creation of a new project fails and a specific error message is returned from the + server.""" + + """Signals to inform about the process of the file upload""" + uploadStarted = Signal() + uploadFileProgress = Signal() + uploadFileSuccess = Signal() + uploadFileError = Signal() + uploadFileFinished = Signal() + + def __init__(self, application: CuraApplication) -> None: + super().__init__(parent = None) + + self._application = application + self._dialog = None # type: Optional["QObject"] + + self.file_handlers = {} # type: Dict[str, FileHandler] + self.nodes = None # type: Optional[List[SceneNode]] + self.file_upload_manager = None + self._has_preselected_project = False # type: bool + + self._api = DigitalFactoryApiClient(self._application, on_error = lambda error: Logger.log("e", str(error)), projects_limit_per_page = 20) + + # Indicates whether there are more pages of projects that can be loaded from the API + self._has_more_projects_to_load = False + + self._account = self._application.getInstance().getCuraAPI().account # type: Account + self._current_workspace_information = CuraApplication.getInstance().getCurrentWorkspaceInformation() + + # Initialize the project model + self._project_model = DigitalFactoryProjectModel() + self._selected_project_idx = -1 + self._project_creation_error_text = "Something went wrong while creating a new project. Please try again." + + # Initialize the file model + self._file_model = DigitalFactoryFileModel() + self._selected_file_indices = [] # type: List[int] + + # Filled after the application has been initialized + self._supported_file_types = {} # type: Dict[str, str] + + # For cleaning up the files afterwards: + self._erase_temp_files_lock = threading.Lock() + + # The statuses which indicate whether Cura is waiting for a response from the DigitalFactory API + self.retrieving_files_status = RetrievalStatus.Idle + self.retrieving_projects_status = RetrievalStatus.Idle + self.creating_new_project_status = RetrievalStatus.Idle + + self._application.engineCreatedSignal.connect(self._onEngineCreated) + self._application.initializationFinished.connect(self._applicationInitializationFinished) + + def clear(self) -> None: + self._project_model.clearProjects() + self._api.clear() + self._has_preselected_project = False + self.preselectedProjectChanged.emit() + + self.setRetrievingFilesStatus(RetrievalStatus.Idle) + self.setRetrievingProjectsStatus(RetrievalStatus.Idle) + self.setCreatingNewProjectStatus(RetrievalStatus.Idle) + + self.setSelectedProjectIndex(-1) + + def userAccountHasLibraryAccess(self) -> bool: + """ + Checks whether the currently logged in user account has access to the Digital Library + + :return: True if the user account has Digital Library access, else False + """ + subscriptions = [] # type: Optional[List[Dict[str, Any]]] + if self._account.userProfile: + subscriptions = self._account.userProfile.get("subscriptions", []) + return len(subscriptions) > 0 + + def initialize(self, preselected_project_id: Optional[str] = None) -> None: + self.clear() + + if self._account.isLoggedIn and self.userAccountHasLibraryAccess(): + self.setRetrievingProjectsStatus(RetrievalStatus.InProgress) + if preselected_project_id: + self._api.getProject(preselected_project_id, on_finished = self.setProjectAsPreselected, failed = self._onGetProjectFailed) + else: + self._api.getProjectsFirstPage(on_finished = self._onGetProjectsFirstPageFinished, failed = self._onGetProjectsFailed) + + def setProjectAsPreselected(self, df_project: DigitalFactoryProjectResponse) -> None: + """ + Sets the received df_project as the preselected one. When a project is preselected, it should be the only + project inside the model, so this function first makes sure to clear the projects model. + + :param df_project: The library project intended to be set as preselected + """ + self._project_model.clearProjects() + self._project_model.setProjects([df_project]) + self.setSelectedProjectIndex(0) + self.setHasPreselectedProject(True) + self.setRetrievingProjectsStatus(RetrievalStatus.Success) + self.setCreatingNewProjectStatus(RetrievalStatus.Success) + + def _onGetProjectFailed(self, reply: QNetworkReply, error: QNetworkReply.NetworkError) -> None: + reply_string = bytes(reply.readAll()).decode() + self.setHasPreselectedProject(False) + Logger.log("w", "Something went wrong while trying to retrieve a the preselected Digital Library project. Error: {}".format(reply_string)) + + def _onGetProjectsFirstPageFinished(self, df_projects: List[DigitalFactoryProjectResponse]) -> None: + """ + Set the first page of projects received from the digital factory library in the project model. Called whenever + the retrieval of the first page of projects is successful. + + :param df_projects: A list of all the Digital Factory Library projects linked to the user's account + """ + self.setHasMoreProjectsToLoad(self._api.hasMoreProjectsToLoad()) + self._project_model.setProjects(df_projects) + self.setRetrievingProjectsStatus(RetrievalStatus.Success) + + @pyqtSlot() + def loadMoreProjects(self) -> None: + """ + Initiates the process of retrieving the next page of the projects list from the API. + """ + self._api.getMoreProjects(on_finished = self.loadMoreProjectsFinished, failed = self._onGetProjectsFailed) + self.setRetrievingProjectsStatus(RetrievalStatus.InProgress) + + def loadMoreProjectsFinished(self, df_projects: List[DigitalFactoryProjectResponse]) -> None: + """ + Set the projects received from the digital factory library in the project model. Called whenever the retrieval + of the projects is successful. + + :param df_projects: A list of all the Digital Factory Library projects linked to the user's account + """ + self.setHasMoreProjectsToLoad(self._api.hasMoreProjectsToLoad()) + self._project_model.extendProjects(df_projects) + self.setRetrievingProjectsStatus(RetrievalStatus.Success) + + def _onGetProjectsFailed(self, reply: QNetworkReply, error: QNetworkReply.NetworkError) -> None: + """ + Error function, called whenever the retrieval of projects fails. + """ + self.setRetrievingProjectsStatus(RetrievalStatus.Failed) + Logger.log("w", "Failed to retrieve the list of projects from the Digital Library. Error encountered: {}".format(error)) + + def getProjectFilesFinished(self, df_files_in_project: List[DigitalFactoryFileResponse]) -> None: + """ + Set the files received from the digital factory library in the file model. The files are filtered to only + contain the files which can be opened by Cura. + Called whenever the retrieval of the files is successful. + + :param df_files_in_project: A list of all the Digital Factory Library files that exist in a library project + """ + # Filter to show only the files that can be opened in Cura + self._file_model.setFilters({"file_name": lambda x: Path(x).suffix[1:].lower() in self._supported_file_types}) # the suffix is in format '.xyz', so omit the dot at the start + self._file_model.setFiles(df_files_in_project) + self.setRetrievingFilesStatus(RetrievalStatus.Success) + + def getProjectFilesFailed(self, reply: QNetworkReply, error: QNetworkReply.NetworkError) -> None: + """ + Error function, called whenever the retrieval of the files in a library project fails. + """ + Logger.log("w", "Failed to retrieve the list of files in project '{}' from the Digital Library".format(self._project_model._projects[self._selected_project_idx])) + self.setRetrievingFilesStatus(RetrievalStatus.Failed) + + @pyqtSlot() + def clearProjectSelection(self) -> None: + """ + Clear the selected project. + """ + if self._has_preselected_project: + self.setHasPreselectedProject(False) + else: + self.setSelectedProjectIndex(-1) + + @pyqtSlot(int) + def setSelectedProjectIndex(self, project_idx: int) -> None: + """ + Sets the index of the project which is currently selected in the dropdown menu. Then, it uses the project_id of + that project to retrieve the list of files included in that project and display it in the interface. + + :param project_idx: The index of the currently selected project + """ + if project_idx < -1 or project_idx >= len(self._project_model.items): + Logger.log("w", "The selected project index is invalid.") + project_idx = -1 # -1 is a valid index for the combobox and it is handled as "nothing is selected" + self._selected_project_idx = project_idx + self.selectedProjectIndexChanged.emit(project_idx) + + # Clear the files from the previously-selected project and refresh the files model with the newly-selected- + # project's files + self._file_model.clearFiles() + self.selectedFileIndicesChanged.emit([]) + if 0 <= project_idx < len(self._project_model.items): + library_project_id = self._project_model.items[project_idx]["libraryProjectId"] + self.setRetrievingFilesStatus(RetrievalStatus.InProgress) + self._api.getListOfFilesInProject(library_project_id, on_finished = self.getProjectFilesFinished, failed = self.getProjectFilesFailed) + + @pyqtProperty(int, fset = setSelectedProjectIndex, notify = selectedProjectIndexChanged) + def selectedProjectIndex(self) -> int: + return self._selected_project_idx + + @pyqtSlot("QList<int>") + def setSelectedFileIndices(self, file_indices: List[int]) -> None: + """ + Sets the index of the file which is currently selected in the list of files. + + :param file_indices: The index of the currently selected file + """ + if file_indices != self._selected_file_indices: + self._selected_file_indices = file_indices + self.selectedFileIndicesChanged.emit(file_indices) + + @pyqtProperty(QObject, constant = True) + def digitalFactoryProjectModel(self) -> "DigitalFactoryProjectModel": + return self._project_model + + @pyqtProperty(QObject, constant = True) + def digitalFactoryFileModel(self) -> "DigitalFactoryFileModel": + return self._file_model + + def setHasMoreProjectsToLoad(self, has_more_projects_to_load: bool) -> None: + """ + Set the value that indicates whether there are more pages of projects that can be loaded from the API + + :param has_more_projects_to_load: Whether there are more pages of projects + """ + if has_more_projects_to_load != self._has_more_projects_to_load: + self._has_more_projects_to_load = has_more_projects_to_load + self.hasMoreProjectsToLoadChanged.emit() + + @pyqtProperty(bool, fset = setHasMoreProjectsToLoad, notify = hasMoreProjectsToLoadChanged) + def hasMoreProjectsToLoad(self) -> bool: + """ + :return: whether there are more pages for projects that can be loaded from the API + """ + return self._has_more_projects_to_load + + @pyqtSlot(str) + def createLibraryProjectAndSetAsPreselected(self, project_name: Optional[str]) -> None: + """ + Creates a new project with the given name in the Digital Library. + + :param project_name: The name that will be used for the new project + """ + if project_name: + self._api.createNewProject(project_name, self.setProjectAsPreselected, self._createNewLibraryProjectFailed) + self.setCreatingNewProjectStatus(RetrievalStatus.InProgress) + else: + Logger.log("w", "No project name provided while attempting to create a new project. Aborting the project creation.") + + def _createNewLibraryProjectFailed(self, reply: QNetworkReply, error: QNetworkReply.NetworkError) -> None: + reply_string = bytes(reply.readAll()).decode() + + self._project_creation_error_text = "Something went wrong while creating the new project. Please try again." + if reply_string: + reply_dict = json.loads(reply_string) + if "errors" in reply_dict and len(reply_dict["errors"]) >= 1 and "title" in reply_dict["errors"][0]: + self._project_creation_error_text = "Error while creating the new project: {}".format(reply_dict["errors"][0]["title"]) + self.projectCreationErrorTextChanged.emit() + + self.setCreatingNewProjectStatus(RetrievalStatus.Failed) + Logger.log("e", "Something went wrong while trying to create a new a project. Error: {}".format(reply_string)) + + # The new_status type is actually "RetrievalStatus" but since the RetrievalStatus cannot be an enum, we leave it as int + def setRetrievingProjectsStatus(self, new_status: int) -> None: + """ + Sets the status of the "retrieving library projects" http call. + + :param new_status: The new status + """ + self.retrieving_projects_status = new_status + self.retrievingProjectsStatusChanged.emit(new_status) + + @pyqtProperty(int, fset = setRetrievingProjectsStatus, notify = retrievingProjectsStatusChanged) + def retrievingProjectsStatus(self) -> int: + return self.retrieving_projects_status + + # The new_status type is actually "RetrievalStatus" but since the RetrievalStatus cannot be an enum, we leave it as int + def setRetrievingFilesStatus(self, new_status: int) -> None: + """ + Sets the status of the "retrieving files list in the selected library project" http call. + + :param new_status: The new status + """ + self.retrieving_files_status = new_status + self.retrievingFilesStatusChanged.emit(new_status) + + @pyqtProperty(int, fset = setRetrievingFilesStatus, notify = retrievingFilesStatusChanged) + def retrievingFilesStatus(self) -> int: + return self.retrieving_files_status + + # The new_status type is actually "RetrievalStatus" but since the RetrievalStatus cannot be an enum, we leave it as int + def setCreatingNewProjectStatus(self, new_status: int) -> None: + """ + Sets the status of the "creating new library project" http call. + + :param new_status: The new status + """ + self.creating_new_project_status = new_status + self.creatingNewProjectStatusChanged.emit(new_status) + + @pyqtProperty(int, fset = setCreatingNewProjectStatus, notify = creatingNewProjectStatusChanged) + def creatingNewProjectStatus(self) -> int: + return self.creating_new_project_status + + @staticmethod + def _onEngineCreated() -> None: + qmlRegisterUncreatableType(DFRetrievalStatus, "DigitalFactory", 1, 0, "RetrievalStatus", "Could not create RetrievalStatus enum type") + + def _applicationInitializationFinished(self) -> None: + self._supported_file_types = self._application.getInstance().getMeshFileHandler().getSupportedFileTypesRead() + + @pyqtSlot("QList<int>") + def setSelectedFileIndices(self, file_indices: List[int]) -> None: + """ + Sets the index of the file which is currently selected in the list of files. + + :param file_indices: A list of the indices of the currently selected files + """ + self._selected_file_indices = file_indices + self.selectedFileIndicesChanged.emit(file_indices) + + @pyqtSlot() + def openSelectedFiles(self) -> None: + """ Downloads, then opens all files selected in the Qt frontend open dialog. + """ + + temp_dir = tempfile.mkdtemp() + if temp_dir is None or temp_dir == "": + Logger.error("Digital Library: Couldn't create temporary directory to store to-be downloaded files.") + return + + if self._selected_project_idx < 0 or len(self._selected_file_indices) < 1: + Logger.error("Digital Library: No project or no file selected on open action.") + return + + to_erase_on_done_set = { + os.path.join(temp_dir, self._file_model.getItem(i)["fileName"]).replace('\\', '/') + for i in self._selected_file_indices} + + def onLoadedCallback(filename_done: str) -> None: + filename_done = os.path.join(temp_dir, filename_done).replace('\\', '/') + with self._erase_temp_files_lock: + if filename_done in to_erase_on_done_set: + try: + os.remove(filename_done) + to_erase_on_done_set.remove(filename_done) + if len(to_erase_on_done_set) < 1 and os.path.exists(temp_dir): + os.rmdir(temp_dir) + except (IOError, OSError) as ex: + Logger.error("Can't erase temporary (in) {0} because {1}.", temp_dir, str(ex)) + + # Save the project id to make sure it will be preselected the next time the user opens the save dialog + CuraApplication.getInstance().getCurrentWorkspaceInformation().setEntryToStore("digital_factory", "library_project_id", library_project_id) + + # Disconnect the signals so that they are not fired every time another (project) file is loaded + app.fileLoaded.disconnect(onLoadedCallback) + app.workspaceLoaded.disconnect(onLoadedCallback) + + app = CuraApplication.getInstance() + app.fileLoaded.connect(onLoadedCallback) # fired when non-project files are loaded + app.workspaceLoaded.connect(onLoadedCallback) # fired when project files are loaded + + project_name = self._project_model.getItem(self._selected_project_idx)["displayName"] + for file_index in self._selected_file_indices: + file_item = self._file_model.getItem(file_index) + file_name = file_item["fileName"] + download_url = file_item["downloadUrl"] + library_project_id = file_item["libraryProjectId"] + self._openSelectedFile(temp_dir, project_name, file_name, download_url) + + def _openSelectedFile(self, temp_dir: str, project_name: str, file_name: str, download_url: str) -> None: + """ Downloads, then opens, the single specified file. + + :param temp_dir: The already created temporary directory where the files will be stored. + :param project_name: Name of the project the file belongs to (used for error reporting). + :param file_name: Name of the file to be downloaded and opened (used for error reporting). + :param download_url: This url will be downloaded, then the downloaded file will be opened in Cura. + """ + if not download_url: + Logger.log("e", "No download url for file '{}'".format(file_name)) + return + + progress_message = Message(text = "{0}/{1}".format(project_name, file_name), dismissable = False, lifetime = 0, + progress = 0, title = "Downloading...") + progress_message.setProgress(0) + progress_message.show() + + def progressCallback(rx: int, rt: int) -> None: + progress_message.setProgress(math.floor(rx * 100.0 / rt)) + + def finishedCallback(reply: QNetworkReply) -> None: + progress_message.hide() + try: + with open(os.path.join(temp_dir, file_name), "wb+") 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) + CuraApplication.getInstance().processEvents() + temp_file_name = temp_file.name + except IOError as ex: + Logger.logException("e", "Can't write Digital Library file {0}/{1} download to temp-directory {2}.", + ex, project_name, file_name, temp_dir) + Message( + text = "Failed to write to temporary file for '{}'.".format(file_name), + title = "File-system error", + lifetime = 10 + ).show() + return + + CuraApplication.getInstance().readLocalFile( + QUrl.fromLocalFile(temp_file_name), add_to_recent_files = False) + + def errorCallback(reply: QNetworkReply, error: QNetworkReply.NetworkError, p = project_name, + f = file_name) -> None: + progress_message.hide() + Logger.error("An error {0} {1} occurred while downloading {2}/{3}".format(str(error), str(reply), p, f)) + Message( + text = "Failed Digital Library download for '{}'.".format(f), + title = "Network error {}".format(error), + lifetime = 10 + ).show() + + download_manager = HttpRequestManager.getInstance() + download_manager.get(download_url, callback = finishedCallback, download_progress_callback = progressCallback, + error_callback = errorCallback, scope = UltimakerCloudScope(CuraApplication.getInstance())) + + def setHasPreselectedProject(self, new_has_preselected_project: bool) -> None: + if not new_has_preselected_project: + # The preselected project was the only one in the model, at index 0, so when we set the has_preselected_project to + # false, we also need to clean it from the projects model + self._project_model.clearProjects() + self.setSelectedProjectIndex(-1) + self._api.getProjectsFirstPage(on_finished = self._onGetProjectsFirstPageFinished, failed = self._onGetProjectsFailed) + self.setRetrievingProjectsStatus(RetrievalStatus.InProgress) + self._has_preselected_project = new_has_preselected_project + self.preselectedProjectChanged.emit() + + @pyqtProperty(bool, fset = setHasPreselectedProject, notify = preselectedProjectChanged) + def hasPreselectedProject(self) -> bool: + return self._has_preselected_project + + @pyqtSlot(str, "QStringList") + def saveFileToSelectedProject(self, filename: str, formats: List[str]) -> None: + """ + Function triggered whenever the Save button is pressed. + + :param filename: The name (without the extension) that will be used for the files + :param formats: List of the formats the scene will be exported to. Can include 3mf, ufp, or both + """ + if self._selected_project_idx == -1: + Logger.log("e", "No DF Library project is selected.") + return + + if filename == "": + Logger.log("w", "The file name cannot be empty.") + Message(text = "Cannot upload file with an empty name to the Digital Library", title = "Empty file name provided", lifetime = 0).show() + return + + self._saveFileToSelectedProjectHelper(filename, formats) + + def _saveFileToSelectedProjectHelper(self, filename: str, formats: List[str]) -> None: + # Indicate we have started sending a job. + self.uploadStarted.emit() + + library_project_id = self._project_model.items[self._selected_project_idx]["libraryProjectId"] + library_project_name = self._project_model.items[self._selected_project_idx]["displayName"] + + # Use the file upload manager to export and upload the 3mf and/or ufp files to the DF Library project + self.file_upload_manager = DFFileExportAndUploadManager(file_handlers = self.file_handlers, nodes = self.nodes, + library_project_id = library_project_id, + library_project_name = library_project_name, + file_name = filename, formats = formats, + on_upload_error = self.uploadFileError.emit, + on_upload_success = self.uploadFileSuccess.emit, + on_upload_finished = self.uploadFileFinished.emit, + on_upload_progress = self.uploadFileProgress.emit) + + # Save the project id to make sure it will be preselected the next time the user opens the save dialog + self._current_workspace_information.setEntryToStore("digital_factory", "library_project_id", library_project_id) + + @pyqtProperty(str, notify = projectCreationErrorTextChanged) + def projectCreationErrorText(self) -> str: + return self._project_creation_error_text diff --git a/plugins/DigitalLibrary/src/DigitalFactoryFileModel.py b/plugins/DigitalLibrary/src/DigitalFactoryFileModel.py new file mode 100644 index 0000000000..718bd11cd2 --- /dev/null +++ b/plugins/DigitalLibrary/src/DigitalFactoryFileModel.py @@ -0,0 +1,115 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from typing import List, Dict, Callable + +from PyQt5.QtCore import Qt, pyqtSignal + +from UM.Logger import Logger +from UM.Qt.ListModel import ListModel +from .DigitalFactoryFileResponse import DigitalFactoryFileResponse + + +DIGITAL_FACTORY_DISPLAY_DATETIME_FORMAT = "%d-%m-%Y %H:%M" + + +class DigitalFactoryFileModel(ListModel): + FileNameRole = Qt.UserRole + 1 + FileIdRole = Qt.UserRole + 2 + FileSizeRole = Qt.UserRole + 3 + LibraryProjectIdRole = Qt.UserRole + 4 + DownloadUrlRole = Qt.UserRole + 5 + UsernameRole = Qt.UserRole + 6 + UploadedAtRole = Qt.UserRole + 7 + + dfFileModelChanged = pyqtSignal() + + def __init__(self, parent = None): + super().__init__(parent) + + self.addRoleName(self.FileNameRole, "fileName") + self.addRoleName(self.FileIdRole, "fileId") + self.addRoleName(self.FileSizeRole, "fileSize") + self.addRoleName(self.LibraryProjectIdRole, "libraryProjectId") + self.addRoleName(self.DownloadUrlRole, "downloadUrl") + self.addRoleName(self.UsernameRole, "username") + self.addRoleName(self.UploadedAtRole, "uploadedAt") + + self._files = [] # type: List[DigitalFactoryFileResponse] + self._filters = {} # type: Dict[str, Callable] + + def setFiles(self, df_files_in_project: List[DigitalFactoryFileResponse]) -> None: + if self._files == df_files_in_project: + return + self._files = df_files_in_project + self._update() + + def clearFiles(self) -> None: + self.clear() + self._files.clear() + self.dfFileModelChanged.emit() + + def _update(self) -> None: + filtered_files_list = self.getFilteredFilesList() + + for file in filtered_files_list: + self.appendItem({ + "fileName" : file.file_name, + "fileId" : file.file_id, + "fileSize": file.file_size, + "libraryProjectId": file.library_project_id, + "downloadUrl": file.download_url, + "username": file.username, + "uploadedAt": file.uploaded_at.strftime(DIGITAL_FACTORY_DISPLAY_DATETIME_FORMAT) + }) + + self.dfFileModelChanged.emit() + + def setFilters(self, filters: Dict[str, Callable]) -> None: + """ + Sets the filters and updates the files model to contain only the files that meet all of the filters. + + :param filters: The filters to be applied + example: + { + "attribute_name1": function_to_be_applied_on_DigitalFactoryFileResponse_attribute1, + "attribute_name2": function_to_be_applied_on_DigitalFactoryFileResponse_attribute2 + } + """ + self.clear() + self._filters = filters + self._update() + + def clearFilters(self) -> None: + """ + Clears all the model filters + """ + self.setFilters({}) + + def getFilteredFilesList(self) -> List[DigitalFactoryFileResponse]: + """ + Lists the files that meet all the filters specified in the self._filters. This is achieved by applying each + filter function on the corresponding attribute for all the filters in the self._filters. If all of them are + true, the file is added to the filtered files list. + In order for this to work, the self._filters should be in the format: + { + "attribute_name": function_to_be_applied_on_the_DigitalFactoryFileResponse_attribute + } + + :return: The list of files that meet all the specified filters + """ + if not self._filters: + return self._files + + filtered_files_list = [] + for file in self._files: + filter_results = [] + for attribute, filter_func in self._filters.items(): + try: + filter_results.append(filter_func(getattr(file, attribute))) + except AttributeError: + Logger.log("w", "Attribute '{}' doesn't exist in objects of type '{}'".format(attribute, type(file))) + all_filters_met = all(filter_results) + if all_filters_met: + filtered_files_list.append(file) + + return filtered_files_list diff --git a/plugins/DigitalLibrary/src/DigitalFactoryFileProvider.py b/plugins/DigitalLibrary/src/DigitalFactoryFileProvider.py new file mode 100644 index 0000000000..c2fe6b969c --- /dev/null +++ b/plugins/DigitalLibrary/src/DigitalFactoryFileProvider.py @@ -0,0 +1,58 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +import os + +from UM.FileProvider import FileProvider +from UM.Logger import Logger +from cura.API import Account +from cura.CuraApplication import CuraApplication +from .DigitalFactoryController import DigitalFactoryController + + +class DigitalFactoryFileProvider(FileProvider): + + def __init__(self, df_controller: DigitalFactoryController) -> None: + super().__init__() + self._controller = df_controller + + self.menu_item_display_text = "From Digital Library" + self.shortcut = "Ctrl+Shift+O" + plugin_path = os.path.dirname(os.path.dirname(__file__)) + self._dialog_path = os.path.join(plugin_path, "resources", "qml", "DigitalFactoryOpenDialog.qml") + self._dialog = None + + self._account = CuraApplication.getInstance().getCuraAPI().account # type: Account + self._account.loginStateChanged.connect(self._onLoginStateChanged) + self.enabled = self._account.isLoggedIn and self._controller.userAccountHasLibraryAccess() + self.priority = 10 + + def run(self) -> None: + """ + Function called every time the 'From Digital Factory' option of the 'Open File(s)' submenu is triggered + """ + self.loadWindow() + + if self._account.isLoggedIn and self._controller.userAccountHasLibraryAccess(): + self._controller.initialize() + self._dialog.show() + + def loadWindow(self) -> None: + """ + Create the GUI window for the Digital Library Open dialog. If the window is already open, bring the focus on it. + """ + + if self._dialog: # Dialogue is already open. + self._dialog.requestActivate() # Bring the focus on the dialogue. + return + + self._dialog = CuraApplication.getInstance().createQmlComponent(self._dialog_path, {"manager": self._controller}) + if not self._dialog: + Logger.log("e", "Unable to create the Digital Library Open dialog.") + + def _onLoginStateChanged(self, logged_in: bool) -> None: + """ + Sets the enabled status of the DigitalFactoryFileProvider according to the account's login status + :param logged_in: The new login status + """ + self.enabled = logged_in and self._controller.userAccountHasLibraryAccess() + self.enabledChanged.emit() diff --git a/plugins/DigitalLibrary/src/DigitalFactoryFileResponse.py b/plugins/DigitalLibrary/src/DigitalFactoryFileResponse.py new file mode 100644 index 0000000000..eb7e71fbb6 --- /dev/null +++ b/plugins/DigitalLibrary/src/DigitalFactoryFileResponse.py @@ -0,0 +1,57 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from datetime import datetime +from typing import Optional + +from .BaseModel import BaseModel + +DIGITAL_FACTORY_RESPONSE_DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S.%fZ" + + +class DigitalFactoryFileResponse(BaseModel): + """Class representing a file in a digital factory project.""" + + def __init__(self, client_id: str, content_type: str, file_id: str, file_name: str, library_project_id: str, + status: str, user_id: str, username: str, uploaded_at: str, download_url: Optional[str] = "", status_description: Optional[str] = "", + file_size: Optional[int] = 0, upload_url: Optional[str] = "", **kwargs) -> None: + """ + Creates a new DF file response object + + :param client_id: + :param content_type: + :param file_id: + :param file_name: + :param library_project_id: + :param status: + :param user_id: + :param username: + :param download_url: + :param status_description: + :param file_size: + :param upload_url: + :param kwargs: + """ + + self.client_id = client_id + self.content_type = content_type + self.download_url = download_url + self.file_id = file_id + self.file_name = file_name + self.file_size = file_size + self.library_project_id = library_project_id + self.status = status + self.status_description = status_description + self.upload_url = upload_url + self.user_id = user_id + self.username = username + self.uploaded_at = datetime.strptime(uploaded_at, DIGITAL_FACTORY_RESPONSE_DATETIME_FORMAT) + super().__init__(**kwargs) + + def __repr__(self) -> str: + return "File: {}, from: {}, File ID: {}, Project ID: {}, Download URL: {}".format(self.file_name, self.username, self.file_id, self.library_project_id, self.download_url) + + # Validates the model, raising an exception if the model is invalid. + def validate(self) -> None: + super().validate() + if not self.file_id: + raise ValueError("file_id is required in Digital Library file") diff --git a/plugins/DigitalLibrary/src/DigitalFactoryOutputDevice.py b/plugins/DigitalLibrary/src/DigitalFactoryOutputDevice.py new file mode 100644 index 0000000000..852c565b5e --- /dev/null +++ b/plugins/DigitalLibrary/src/DigitalFactoryOutputDevice.py @@ -0,0 +1,114 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Uranium is released under the terms of the LGPLv3 or higher. +import os +from typing import Optional, List + +from UM.FileHandler.FileHandler import FileHandler +from UM.Logger import Logger +from UM.OutputDevice import OutputDeviceError +from UM.OutputDevice.ProjectOutputDevice import ProjectOutputDevice +from UM.Scene.SceneNode import SceneNode +from cura.API import Account +from cura.CuraApplication import CuraApplication +from .DigitalFactoryController import DigitalFactoryController + + +class DigitalFactoryOutputDevice(ProjectOutputDevice): + """Implements an OutputDevice that supports saving to the digital factory library.""" + + def __init__(self, plugin_id, df_controller: DigitalFactoryController, add_to_output_devices: bool = False, parent = None) -> None: + super().__init__(device_id = "digital_factory", add_to_output_devices = add_to_output_devices, parent = parent) + + self.setName("Digital Library") # Doesn't need to be translated + self.setShortDescription("Save to Library") + self.setDescription("Save to Library") + self.setIconName("save") + self.menu_entry_text = "To Digital Library" + self.shortcut = "Ctrl+Shift+S" + self._plugin_id = plugin_id + self._controller = df_controller + + plugin_path = os.path.dirname(os.path.dirname(__file__)) + self._dialog_path = os.path.join(plugin_path, "resources", "qml", "DigitalFactorySaveDialog.qml") + self._dialog = None + + # Connect the write signals + self._controller.uploadStarted.connect(self._onWriteStarted) + self._controller.uploadFileProgress.connect(self.writeProgress.emit) + self._controller.uploadFileError.connect(self._onWriteError) + self._controller.uploadFileSuccess.connect(self.writeSuccess.emit) + self._controller.uploadFileFinished.connect(self._onWriteFinished) + + self._priority = -1 # Negative value to ensure that it will have less priority than the LocalFileOutputDevice (which has 0) + self._application = CuraApplication.getInstance() + + self._writing = False + + self._account = CuraApplication.getInstance().getCuraAPI().account # type: Account + self._account.loginStateChanged.connect(self._onLoginStateChanged) + self.enabled = self._account.isLoggedIn and self._controller.userAccountHasLibraryAccess() + + self._current_workspace_information = CuraApplication.getInstance().getCurrentWorkspaceInformation() + + def requestWrite(self, nodes: List[SceneNode], file_name: Optional[str] = None, limit_mimetypes: bool = False, file_handler: Optional[FileHandler] = None, **kwargs) -> None: + """Request the specified nodes to be written. + + Function called every time the 'To Digital Factory' option of the 'Save Project' submenu is triggered or when the + "Save to Library" action button is pressed (upon slicing). + + :param nodes: A collection of scene nodes that should be written to the file. + :param file_name: A suggestion for the file name to write to. + :param limit_mimetypes: Limit the possible mimetypes to use for writing to these types. + :param file_handler: The handler responsible for reading and writing mesh files. + :param kwargs: Keyword arguments. + """ + + if self._writing: + raise OutputDeviceError.DeviceBusyError() + self.loadWindow() + + if self._account.isLoggedIn and self._controller.userAccountHasLibraryAccess(): + self._controller.nodes = nodes + + df_workspace_information = self._current_workspace_information.getPluginMetadata("digital_factory") + self._controller.initialize(preselected_project_id = df_workspace_information.get("library_project_id")) + self._dialog.show() + + def loadWindow(self) -> None: + """ + Create the GUI window for the Digital Library Save dialog. If the window is already open, bring the focus on it. + """ + + if self._dialog: # Dialogue is already open. + self._dialog.requestActivate() # Bring the focus on the dialogue. + return + + if not self._controller.file_handlers: + self._controller.file_handlers = { + "3mf": CuraApplication.getInstance().getWorkspaceFileHandler(), + "ufp": CuraApplication.getInstance().getMeshFileHandler() + } + + self._dialog = CuraApplication.getInstance().createQmlComponent(self._dialog_path, {"manager": self._controller}) + if not self._dialog: + Logger.log("e", "Unable to create the Digital Library Save dialog.") + + def _onLoginStateChanged(self, logged_in: bool) -> None: + """ + Sets the enabled status of the DigitalFactoryOutputDevice according to the account's login status + :param logged_in: The new login status + """ + self.enabled = logged_in and self._controller.userAccountHasLibraryAccess() + self.enabledChanged.emit() + + def _onWriteStarted(self) -> None: + self._writing = True + self.writeStarted.emit(self) + + def _onWriteFinished(self) -> None: + self._writing = False + self.writeFinished.emit(self) + + def _onWriteError(self) -> None: + self._writing = False + self.writeError.emit(self) diff --git a/plugins/DigitalLibrary/src/DigitalFactoryOutputDevicePlugin.py b/plugins/DigitalLibrary/src/DigitalFactoryOutputDevicePlugin.py new file mode 100644 index 0000000000..1a0e4f2772 --- /dev/null +++ b/plugins/DigitalLibrary/src/DigitalFactoryOutputDevicePlugin.py @@ -0,0 +1,18 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Uranium is released under the terms of the LGPLv3 or higher. + +from UM.OutputDevice.OutputDevicePlugin import OutputDevicePlugin +from .DigitalFactoryOutputDevice import DigitalFactoryOutputDevice +from .DigitalFactoryController import DigitalFactoryController + + +class DigitalFactoryOutputDevicePlugin(OutputDevicePlugin): + def __init__(self, df_controller: DigitalFactoryController) -> None: + super().__init__() + self.df_controller = df_controller + + def start(self) -> None: + self.getOutputDeviceManager().addProjectOutputDevice(DigitalFactoryOutputDevice(plugin_id = self.getPluginId(), df_controller = self.df_controller, add_to_output_devices = True)) + + def stop(self) -> None: + self.getOutputDeviceManager().removeProjectOutputDevice("digital_factory") diff --git a/plugins/DigitalLibrary/src/DigitalFactoryProjectModel.py b/plugins/DigitalLibrary/src/DigitalFactoryProjectModel.py new file mode 100644 index 0000000000..b35e760998 --- /dev/null +++ b/plugins/DigitalLibrary/src/DigitalFactoryProjectModel.py @@ -0,0 +1,71 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from typing import List, Optional + +from PyQt5.QtCore import Qt, pyqtSignal + +from UM.Logger import Logger +from UM.Qt.ListModel import ListModel +from .DigitalFactoryProjectResponse import DigitalFactoryProjectResponse + +PROJECT_UPDATED_AT_DATETIME_FORMAT = "%d-%m-%Y" + + +class DigitalFactoryProjectModel(ListModel): + DisplayNameRole = Qt.UserRole + 1 + LibraryProjectIdRole = Qt.UserRole + 2 + DescriptionRole = Qt.UserRole + 3 + ThumbnailUrlRole = Qt.UserRole + 5 + UsernameRole = Qt.UserRole + 6 + LastUpdatedRole = Qt.UserRole + 7 + + dfProjectModelChanged = pyqtSignal() + + def __init__(self, parent = None): + super().__init__(parent) + self.addRoleName(self.DisplayNameRole, "displayName") + self.addRoleName(self.LibraryProjectIdRole, "libraryProjectId") + self.addRoleName(self.DescriptionRole, "description") + self.addRoleName(self.ThumbnailUrlRole, "thumbnailUrl") + self.addRoleName(self.UsernameRole, "username") + self.addRoleName(self.LastUpdatedRole, "lastUpdated") + self._projects = [] # type: List[DigitalFactoryProjectResponse] + + def setProjects(self, df_projects: List[DigitalFactoryProjectResponse]) -> None: + if self._projects == df_projects: + return + self._items.clear() + self._projects = df_projects + # self.sortProjectsBy("display_name") + self._update(df_projects) + + def extendProjects(self, df_projects: List[DigitalFactoryProjectResponse]) -> None: + if not df_projects: + return + self._projects.extend(df_projects) + # self.sortProjectsBy("display_name") + self._update(df_projects) + + def sortProjectsBy(self, sort_by: Optional[str]): + if sort_by: + try: + self._projects.sort(key = lambda p: getattr(p, sort_by)) + except AttributeError: + Logger.log("e", "The projects cannot be sorted by '{}'. No such attribute exists.".format(sort_by)) + + def clearProjects(self) -> None: + self.clear() + self._projects.clear() + self.dfProjectModelChanged.emit() + + def _update(self, df_projects: List[DigitalFactoryProjectResponse]) -> None: + for project in df_projects: + self.appendItem({ + "displayName" : project.display_name, + "libraryProjectId" : project.library_project_id, + "description": project.description, + "thumbnailUrl": project.thumbnail_url, + "username": project.username, + "lastUpdated": project.last_updated.strftime(PROJECT_UPDATED_AT_DATETIME_FORMAT) if project.last_updated else "", + }) + self.dfProjectModelChanged.emit() diff --git a/plugins/DigitalLibrary/src/DigitalFactoryProjectResponse.py b/plugins/DigitalLibrary/src/DigitalFactoryProjectResponse.py new file mode 100644 index 0000000000..a511a11bd5 --- /dev/null +++ b/plugins/DigitalLibrary/src/DigitalFactoryProjectResponse.py @@ -0,0 +1,65 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +from datetime import datetime +from typing import Optional, List, Dict, Any + +from .BaseModel import BaseModel +from .DigitalFactoryFileResponse import DIGITAL_FACTORY_RESPONSE_DATETIME_FORMAT + + +class DigitalFactoryProjectResponse(BaseModel): + """Class representing a cloud project.""" + + def __init__(self, + library_project_id: str, + display_name: str, + username: str, + organization_shared: bool, + last_updated: Optional[str] = None, + created_at: Optional[str] = None, + thumbnail_url: Optional[str] = None, + organization_id: Optional[str] = None, + created_by_user_id: Optional[str] = None, + description: Optional[str] = "", + tags: Optional[List[str]] = None, + team_ids: Optional[List[str]] = None, + status: Optional[str] = None, + technical_requirements: Optional[Dict[str, Any]] = None, + **kwargs) -> None: + """ + Creates a new digital factory project response object + :param library_project_id: + :param display_name: + :param username: + :param organization_shared: + :param thumbnail_url: + :param created_by_user_id: + :param description: + :param tags: + :param kwargs: + """ + + self.library_project_id = library_project_id + self.display_name = display_name + self.description = description + self.username = username + self.organization_shared = organization_shared + self.organization_id = organization_id + self.created_by_user_id = created_by_user_id + self.thumbnail_url = thumbnail_url + self.tags = tags + self.team_ids = team_ids + self.created_at = datetime.strptime(created_at, DIGITAL_FACTORY_RESPONSE_DATETIME_FORMAT) if created_at else None + self.last_updated = datetime.strptime(last_updated, DIGITAL_FACTORY_RESPONSE_DATETIME_FORMAT) if last_updated else None + self.status = status + self.technical_requirements = technical_requirements + super().__init__(**kwargs) + + def __str__(self) -> str: + return "Project: {}, Id: {}, from: {}".format(self.display_name, self.library_project_id, self.username) + + # Validates the model, raising an exception if the model is invalid. + def validate(self) -> None: + super().validate() + if not self.library_project_id: + raise ValueError("library_project_id is required on cloud project") diff --git a/plugins/DigitalLibrary/src/ExportFileJob.py b/plugins/DigitalLibrary/src/ExportFileJob.py new file mode 100644 index 0000000000..3e4c6dfea2 --- /dev/null +++ b/plugins/DigitalLibrary/src/ExportFileJob.py @@ -0,0 +1,55 @@ +# Copyright (c) 2021 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. + +import io +from typing import List, Optional, Union + +from UM.FileHandler.FileHandler import FileHandler +from UM.FileHandler.FileWriter import FileWriter +from UM.FileHandler.WriteFileJob import WriteFileJob +from UM.Logger import Logger +from UM.MimeTypeDatabase import MimeTypeDatabase +from UM.OutputDevice import OutputDeviceError +from UM.Scene.SceneNode import SceneNode + + +class ExportFileJob(WriteFileJob): + """Job that exports the build plate to the correct file format for the Digital Factory Library project.""" + + def __init__(self, file_handler: FileHandler, nodes: List[SceneNode], job_name: str, extension: str) -> None: + file_types = file_handler.getSupportedFileTypesWrite() + if len(file_types) == 0: + Logger.log("e", "There are no file types available to write with!") + raise OutputDeviceError.WriteRequestFailedError("There are no file types available to write with!") + + mode = None + file_writer = None + for file_type in file_types: + if file_type["extension"] == extension: + file_writer = file_handler.getWriter(file_type["id"]) + mode = file_type.get("mode") + super().__init__(file_writer, self.createStream(mode = mode), nodes, mode) + + # Determine the filename. + self.setFileName("{}.{}".format(job_name, extension)) + + def getOutput(self) -> bytes: + """Get the job result as bytes as that is what we need to upload to the Digital Factory Library.""" + + output = self.getStream().getvalue() + if isinstance(output, str): + output = output.encode("utf-8") + return output + + def getMimeType(self) -> str: + """Get the mime type of the selected export file type.""" + return MimeTypeDatabase.getMimeTypeForFile(self.getFileName()).name + + @staticmethod + def createStream(mode) -> Union[io.BytesIO, io.StringIO]: + """Creates the right kind of stream based on the preferred format.""" + + if mode == FileWriter.OutputMode.TextMode: + return io.StringIO() + else: + return io.BytesIO() diff --git a/plugins/DigitalLibrary/src/PaginationLinks.py b/plugins/DigitalLibrary/src/PaginationLinks.py new file mode 100644 index 0000000000..06ed183944 --- /dev/null +++ b/plugins/DigitalLibrary/src/PaginationLinks.py @@ -0,0 +1,30 @@ +# Copyright (c) 2021 Ultimaker B.V. + +from typing import Optional + + +class PaginationLinks: + """Model containing pagination links.""" + + def __init__(self, + first: Optional[str] = None, + last: Optional[str] = None, + next: Optional[str] = None, + prev: Optional[str] = None, + **kwargs) -> None: + """ + Creates a new digital factory project response object + :param first: The URL for the first page. + :param last: The URL for the last page. + :param next: The URL for the next page. + :param prev: The URL for the prev page. + :param kwargs: + """ + + self.first_page = first + self.last_page = last + self.next_page = next + self.prev_page = prev + + def __str__(self) -> str: + return "Pagination Links | First: {}, Last: {}, Next: {}, Prev: {}".format(self.first_page, self.last_page, self.next_page, self.prev_page) diff --git a/plugins/DigitalLibrary/src/PaginationManager.py b/plugins/DigitalLibrary/src/PaginationManager.py new file mode 100644 index 0000000000..f2b7c8f5bd --- /dev/null +++ b/plugins/DigitalLibrary/src/PaginationManager.py @@ -0,0 +1,43 @@ +# Copyright (c) 2021 Ultimaker B.V. + +from typing import Optional, Dict, Any + +from .PaginationLinks import PaginationLinks +from .PaginationMetadata import PaginationMetadata +from .ResponseMeta import ResponseMeta + + +class PaginationManager: + + def __init__(self, limit: int) -> None: + self.limit = limit # The limit of items per page + self.meta = None # type: Optional[ResponseMeta] # The metadata of the paginated response + self.links = None # type: Optional[PaginationLinks] # The pagination-related links + + def setResponseMeta(self, meta: Optional[Dict[str, Any]]) -> None: + self.meta = None + + if meta: + page = None + if "page" in meta: + page = PaginationMetadata(**meta["page"]) + self.meta = ResponseMeta(page) + + def setLinks(self, links: Optional[Dict[str, str]]) -> None: + self.links = PaginationLinks(**links) if links else None + + def setLimit(self, new_limit: int) -> None: + """ + Sets the limit of items per page. + + :param new_limit: The new limit of items per page + """ + self.limit = new_limit + self.reset() + + def reset(self) -> None: + """ + Sets the metadata and links to None. + """ + self.meta = None + self.links = None diff --git a/plugins/DigitalLibrary/src/PaginationMetadata.py b/plugins/DigitalLibrary/src/PaginationMetadata.py new file mode 100644 index 0000000000..7f11e43d30 --- /dev/null +++ b/plugins/DigitalLibrary/src/PaginationMetadata.py @@ -0,0 +1,25 @@ +# Copyright (c) 2021 Ultimaker B.V. + +from typing import Optional + + +class PaginationMetadata: + """Class representing the metadata related to pagination.""" + + def __init__(self, + total_count: Optional[int] = None, + total_pages: Optional[int] = None, + **kwargs) -> None: + """ + Creates a new digital factory project response object + :param total_count: The total count of items. + :param total_pages: The total number of pages when pagination is applied. + :param kwargs: + """ + + self.total_count = total_count + self.total_pages = total_pages + self.__dict__.update(kwargs) + + def __str__(self) -> str: + return "PaginationMetadata | Total Count: {}, Total Pages: {}".format(self.total_count, self.total_pages) diff --git a/plugins/DigitalLibrary/src/ResponseMeta.py b/plugins/DigitalLibrary/src/ResponseMeta.py new file mode 100644 index 0000000000..a1dbc949db --- /dev/null +++ b/plugins/DigitalLibrary/src/ResponseMeta.py @@ -0,0 +1,24 @@ +# Copyright (c) 2021 Ultimaker B.V. + +from typing import Optional + +from .PaginationMetadata import PaginationMetadata + + +class ResponseMeta: + """Class representing the metadata included in a Digital Library response (if any)""" + + def __init__(self, + page: Optional[PaginationMetadata] = None, + **kwargs) -> None: + """ + Creates a new digital factory project response object + :param page: Metadata related to pagination + :param kwargs: + """ + + self.page = page + self.__dict__.update(kwargs) + + def __str__(self) -> str: + return "Response Meta | {}".format(self.page) diff --git a/plugins/DigitalLibrary/src/__init__.py b/plugins/DigitalLibrary/src/__init__.py new file mode 100644 index 0000000000..e69de29bb2 --- /dev/null +++ b/plugins/DigitalLibrary/src/__init__.py |