Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/Ultimaker/Cura.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'plugins/DigitalLibrary/src/DigitalFactoryApiClient.py')
-rw-r--r--plugins/DigitalLibrary/src/DigitalFactoryApiClient.py317
1 files changed, 317 insertions, 0 deletions
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()