diff --git a/cura/API/Account.py b/cura/API/Account.py index d78c7e8826..70881000a3 100644 --- a/cura/API/Account.py +++ b/cura/API/Account.py @@ -61,6 +61,11 @@ class Account(QObject): self._authorization_service.onAuthenticationError.connect(self._onLoginStateChanged) self._authorization_service.loadAuthDataFromPreferences() + ## Returns a boolean indicating whether the given authentication is applied against staging or not. + @property + def is_staging(self) -> bool: + return "staging" in self._oauth_root + @pyqtProperty(bool, notify=loginStateChanged) def isLoggedIn(self) -> bool: return self._logged_in diff --git a/cura/NetworkClient.py b/cura/NetworkClient.py index fbe0c63c36..8a321b6af4 100644 --- a/cura/NetworkClient.py +++ b/cura/NetworkClient.py @@ -1,7 +1,7 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. from time import time -from typing import Optional, Dict, Callable, List +from typing import Optional, Dict, Callable, List, Union from PyQt5.QtCore import QUrl from PyQt5.QtNetwork import QNetworkAccessManager, QNetworkReply, QHttpMultiPart, QNetworkRequest, QHttpPart, \ @@ -49,6 +49,8 @@ class NetworkClient: ## Create a new empty network request. # Automatically adds the required HTTP headers. + # \param url: The URL to request + # \param content_type: The type of the body contents. def _createEmptyRequest(self, url: str, content_type: Optional[str] = "application/json") -> QNetworkRequest: request = QNetworkRequest(QUrl(url)) if content_type: @@ -120,67 +122,82 @@ class NetworkClient: def createFormPart(self, content_header: str, data: bytes, content_type: Optional[str] = None) -> QHttpPart: return self._createFormPart(content_header, data, content_type) - ## Does a PUT request to the given URL. - def put(self, url: str, data: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None: + ## Sends a put request to the given path. + # url: The path after the API prefix. + # data: The data to be sent in the body + # content_type: The content type of the body data. + # on_finished: The function to call when the response is received. + # on_progress: The function to call when the progress changes. Parameters are bytes_sent / bytes_total. + def put(self, url: str, data: Union[str, bytes], content_type: Optional[str] = None, + on_finished: Optional[Callable[[QNetworkReply], None]] = None, + on_progress: Optional[Callable[[int, int], None]] = None) -> None: self._validateManager() - - request = self._createEmptyRequest(url) - self._last_request_time = time() - - if not self._manager: - Logger.log("e", "No network manager was created to execute the PUT call with.") - return - reply = self._manager.put(request, data.encode()) + request = self._createEmptyRequest(url, content_type = content_type) + self._last_request_time = time() + + if not self._manager: + return Logger.log("e", "No network manager was created to execute the PUT call with.") + + body = data if isinstance(data, bytes) else data.encode() # type: bytes + reply = self._manager.put(request, body) self._registerOnFinishedCallback(reply, on_finished) - ## Does a DELETE request to the given URL. + if on_progress is not None: + reply.uploadProgress.connect(on_progress) + + ## Sends a delete request to the given path. + # url: The path after the API prefix. + # on_finished: The function to be call when the response is received. def delete(self, url: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None: self._validateManager() - + request = self._createEmptyRequest(url) self._last_request_time = time() - + if not self._manager: - Logger.log("e", "No network manager was created to execute the DELETE call with.") - return - + return Logger.log("e", "No network manager was created to execute the DELETE call with.") + reply = self._manager.deleteResource(request) self._registerOnFinishedCallback(reply, on_finished) - ## Does a GET request to the given URL. + ## Sends a get request to the given path. + # \param url: The path after the API prefix. + # \param on_finished: The function to be call when the response is received. def get(self, url: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None: self._validateManager() - + request = self._createEmptyRequest(url) self._last_request_time = time() - + if not self._manager: - Logger.log("e", "No network manager was created to execute the GET call with.") - return - + return Logger.log("e", "No network manager was created to execute the GET call with.") + reply = self._manager.get(request) self._registerOnFinishedCallback(reply, on_finished) - ## Does a POST request to the given URL. - def post(self, url: str, data: str, on_finished: Optional[Callable[[QNetworkReply], None]], - on_progress: Callable = None) -> None: + ## Sends a post request to the given path. + # \param url: The path after the API prefix. + # \param data: The data to be sent in the body + # \param on_finished: The function to call when the response is received. + # \param on_progress: The function to call when the progress changes. Parameters are bytes_sent / bytes_total. + def post(self, url: str, data: Union[str, bytes], + on_finished: Optional[Callable[[QNetworkReply], None]], + on_progress: Optional[Callable[[int, int], None]] = None) -> None: self._validateManager() - + request = self._createEmptyRequest(url) self._last_request_time = time() - + if not self._manager: - Logger.log("e", "No network manager was created to execute the GET call with.") - return - - reply = self._manager.post(request, data.encode()) - + return Logger.log("e", "Could not find manager.") + + body = data if isinstance(data, bytes) else data.encode() # type: bytes + reply = self._manager.post(request, body) if on_progress is not None: reply.uploadProgress.connect(on_progress) - self._registerOnFinishedCallback(reply, on_finished) - + ## Does a POST request with form data to the given URL. def postForm(self, url: str, header_data: str, body_data: bytes, on_finished: Optional[Callable[[QNetworkReply], None]], diff --git a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py index 72b6319020..300ed5194d 100644 --- a/cura/PrinterOutput/NetworkedPrinterOutputDevice.py +++ b/cura/PrinterOutput/NetworkedPrinterOutputDevice.py @@ -145,7 +145,7 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice): url = QUrl("http://" + self._address + self._api_prefix + target) request = QNetworkRequest(url) if content_type is not None: - request.setHeader(QNetworkRequest.ContentTypeHeader, "application/json") + request.setHeader(QNetworkRequest.ContentTypeHeader, content_type) request.setHeader(QNetworkRequest.UserAgentHeader, self._user_agent) return request @@ -180,54 +180,85 @@ class NetworkedPrinterOutputDevice(PrinterOutputDevice): self._createNetworkManager() assert (self._manager is not None) - def put(self, target: str, data: Union[str, bytes], content_type: str = None, + ## Sends a put request to the given path. + # url: The path after the API prefix. + # data: The data to be sent in the body + # content_type: The content type of the body data. + # on_finished: The function to call when the response is received. + # on_progress: The function to call when the progress changes. Parameters are bytes_sent / bytes_total. + def put(self, url: str, data: Union[str, bytes], content_type: Optional[str] = None, on_finished: Optional[Callable[[QNetworkReply], None]] = None, - on_progress: Optional[Callable] = None) -> None: + on_progress: Optional[Callable[[int, int], None]] = None) -> None: self._validateManager() - request = self._createEmptyRequest(target, content_type = content_type) - self._last_request_time = time() - if self._manager is not None: - reply = self._manager.put(request, data if isinstance(data, bytes) else data.encode()) - self._registerOnFinishedCallback(reply, on_finished) - if on_progress is not None: - reply.uploadProgress.connect(on_progress) - else: - Logger.log("e", "Could not find manager.") - def delete(self, target: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None: + request = self._createEmptyRequest(url, content_type = content_type) + self._last_request_time = time() + + if not self._manager: + return Logger.log("e", "No network manager was created to execute the PUT call with.") + + body = data if isinstance(data, bytes) else data.encode() # type: bytes + reply = self._manager.put(request, body) + self._registerOnFinishedCallback(reply, on_finished) + + if on_progress is not None: + reply.uploadProgress.connect(on_progress) + + ## Sends a delete request to the given path. + # url: The path after the API prefix. + # on_finished: The function to be call when the response is received. + def delete(self, url: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None: self._validateManager() - request = self._createEmptyRequest(target) - self._last_request_time = time() - if self._manager is not None: - reply = self._manager.deleteResource(request) - self._registerOnFinishedCallback(reply, on_finished) - else: - Logger.log("e", "Could not find manager.") - def get(self, target: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None: + request = self._createEmptyRequest(url) + self._last_request_time = time() + + if not self._manager: + return Logger.log("e", "No network manager was created to execute the DELETE call with.") + + reply = self._manager.deleteResource(request) + self._registerOnFinishedCallback(reply, on_finished) + + ## Sends a get request to the given path. + # \param url: The path after the API prefix. + # \param on_finished: The function to be call when the response is received. + def get(self, url: str, on_finished: Optional[Callable[[QNetworkReply], None]]) -> None: self._validateManager() - request = self._createEmptyRequest(target) - self._last_request_time = time() - if self._manager is not None: - reply = self._manager.get(request) - self._registerOnFinishedCallback(reply, on_finished) - else: - Logger.log("e", "Could not find manager.") - def post(self, target: str, data: Union[str, bytes], on_finished: Optional[Callable[[QNetworkReply], None]], - on_progress: Callable = None) -> None: + request = self._createEmptyRequest(url) + self._last_request_time = time() + + if not self._manager: + return Logger.log("e", "No network manager was created to execute the GET call with.") + + reply = self._manager.get(request) + self._registerOnFinishedCallback(reply, on_finished) + + ## Sends a post request to the given path. + # \param url: The path after the API prefix. + # \param data: The data to be sent in the body + # \param on_finished: The function to call when the response is received. + # \param on_progress: The function to call when the progress changes. Parameters are bytes_sent / bytes_total. + def post(self, url: str, data: Union[str, bytes], + on_finished: Optional[Callable[[QNetworkReply], None]], + on_progress: Optional[Callable[[int, int], None]] = None) -> None: self._validateManager() - request = self._createEmptyRequest(target) - self._last_request_time = time() - if self._manager is not None: - reply = self._manager.post(request, data if isinstance(data, bytes) else data.encode()) - if on_progress is not None: - reply.uploadProgress.connect(on_progress) - self._registerOnFinishedCallback(reply, on_finished) - else: - Logger.log("e", "Could not find manager.") - def postFormWithParts(self, target: str, parts: List[QHttpPart], on_finished: Optional[Callable[[QNetworkReply], None]], on_progress: Callable = None) -> QNetworkReply: + request = self._createEmptyRequest(url) + self._last_request_time = time() + + if not self._manager: + return Logger.log("e", "Could not find manager.") + + body = data if isinstance(data, bytes) else data.encode() # type: bytes + reply = self._manager.post(request, body) + if on_progress is not None: + reply.uploadProgress.connect(on_progress) + self._registerOnFinishedCallback(reply, on_finished) + + def postFormWithParts(self, target: str, parts: List[QHttpPart], + on_finished: Optional[Callable[[QNetworkReply], None]], + on_progress: Callable = None) -> QNetworkReply: self._validateManager() request = self._createEmptyRequest(target, content_type=None) multi_post_part = QHttpMultiPart(QHttpMultiPart.FormDataType) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py new file mode 100644 index 0000000000..1d2de1d9bf --- /dev/null +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py @@ -0,0 +1,155 @@ +# Copyright (c) 2018 Ultimaker B.V. +# Cura is released under the terms of the LGPLv3 or higher. +import json +from json import JSONDecodeError +from typing import Callable, List, Type, TypeVar, Union, Optional, Tuple, Dict + +from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply + +from UM.Logger import Logger +from cura.API import Account +from cura.NetworkClient import NetworkClient +from plugins.UM3NetworkPrinting.src.Models import BaseModel +from plugins.UM3NetworkPrinting.src.Cloud.Models import ( + CloudCluster, CloudErrorObject, CloudClusterStatus, CloudJobUploadRequest, + CloudJobResponse, + CloudPrintResponse +) + + +## The cloud API client is responsible for handling the requests and responses from the cloud. +# Each method should only handle models instead of exposing any HTTP details. +class CloudApiClient(NetworkClient): + + # The cloud URL to use for this remote cluster. + # TODO: Make sure that this URL goes to the live api before release + ROOT_PATH = "https://api-staging.ultimaker.com" + CLUSTER_API_ROOT = "{}/connect/v1/".format(ROOT_PATH) + CURA_API_ROOT = "{}/cura/v1/".format(ROOT_PATH) + + ## Initializes a new cloud API client. + # \param account: The user's account object + # \param on_error: The callback to be called whenever we receive errors from the server. + def __init__(self, account: Account, on_error: Callable[[List[CloudErrorObject]], None]): + super().__init__() + self._account = account + self._on_error = on_error + + ## Retrieves all the clusters for the user that is currently logged in. + # \param on_finished: The function to be called after the result is parsed. + def getClusters(self, on_finished: Callable[[List[CloudCluster]], any]) -> None: + url = "/clusters" + self.get(url, on_finished=self._createCallback(on_finished, CloudCluster)) + + ## Retrieves the status of the given cluster. + # \param cluster_id: The ID of the cluster. + # \param on_finished: The function to be called after the result is parsed. + def getClusterStatus(self, cluster_id: str, on_finished: Callable[[CloudClusterStatus], any]) -> None: + url = "{}/cluster/{}/status".format(self.CLUSTER_API_ROOT, cluster_id) + self.get(url, on_finished=self._createCallback(on_finished, CloudClusterStatus)) + + ## Requests the cloud to register the upload of a print job mesh. + # \param request: The request object. + # \param on_finished: The function to be called after the result is parsed. + def requestUpload(self, request: CloudJobUploadRequest, on_finished: Callable[[CloudJobResponse], any]) -> None: + url = "{}/jobs/upload".format(self.CURA_API_ROOT) + body = json.dumps({"data": request.__dict__}) + self.put(url, body, on_finished=self._createCallback(on_finished, CloudJobResponse)) + + ## Requests the cloud to register the upload of a print job mesh. + # \param upload_response: The object received after requesting an upload with `self.requestUpload`. + # \param on_finished: The function to be called after the result is parsed. It receives the print job ID. + def uploadMesh(self, upload_response: CloudJobResponse, mesh: bytes, on_finished: Callable[[str], any], + on_progress: Callable[[int], any]): + + def progressCallback(bytes_sent: int, bytes_total: int) -> None: + if bytes_total: + on_progress(int((bytes_sent / bytes_total) * 100)) + + def finishedCallback(reply: QNetworkReply): + status_code, response = self._parseReply(reply) + if status_code < 300: + on_finished(upload_response.job_id) + else: + self._uploadMeshError(status_code, response) + + # TODO: Multipart upload + self.put(upload_response.upload_url, data = mesh, content_type = upload_response.content_type, + on_finished = finishedCallback, on_progress = progressCallback) + + # Requests a cluster to print the given print job. + # \param cluster_id: The ID of the cluster. + # \param job_id: The ID of the print job. + # \param on_finished: The function to be called after the result is parsed. + def requestPrint(self, cluster_id: str, job_id: str, on_finished: Callable[[], any]) -> None: + url = "{}/cluster/{}/print/{}".format(self.CLUSTER_API_ROOT, cluster_id, job_id) + self.post(url, data = "", on_finished=self._createCallback(on_finished, CloudPrintResponse)) + + ## We override _createEmptyRequest in order to add the user credentials. + # \param url: The URL to request + # \param content_type: The type of the body contents. + def _createEmptyRequest(self, path: str, content_type: Optional[str] = "application/json") -> QNetworkRequest: + request = super()._createEmptyRequest(path, content_type) + if self._account.isLoggedIn: + request.setRawHeader(b"Authorization", "Bearer {}".format(self._account.accessToken).encode()) + return request + + ## 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. + @staticmethod + def _parseReply(reply: QNetworkReply) -> Tuple[int, Dict[str, any]]: + status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) + try: + response = bytes(reply.readAll()).decode() + Logger.log("i", "Received an HTTP %s from %s with %s", status_code, reply.url, response) + return status_code, json.loads(response) + except (UnicodeDecodeError, JSONDecodeError, ValueError) as err: + error = {"code": type(err).__name__, "title": str(err), "http_code": str(status_code)} + Logger.logException("e", "Could not parse the stardust response: %s", error) + return status_code, {"errors": [error]} + + ## Calls the error handler that is responsible for handling errors uploading meshes. + # \param http_status - The status of the HTTP request. + # \param response - The response received from the upload endpoint. This is not formatted according to the standard + # JSON-api response. + def _uploadMeshError(self, http_status: int, response: Dict[str, any]) -> None: + error = CloudErrorObject( + code = "uploadError", + http_status = str(http_status), + title = "Could not upload the mesh", + meta = response + ) + self._on_error([error]) + + ## The generic type variable used to document the methods below. + Model = TypeVar("Model", bound=BaseModel) + + ## 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: The type of the model to convert the response to. It may either be a single record or a list. + def _parseModels(self, response: Dict[str, any], + on_finished: Callable[[Union[Model, List[Model]]], any], + model: Type[Model]) -> None: + if "data" in response: + data = response["data"] + result = [model(**c) for c in data] if isinstance(data, list) else model(**data) + on_finished(result) + elif "error" in response: + self._on_error([CloudErrorObject(**error) for error in response["errors"]]) + else: + Logger.log("e", "Cannot find data or errors in the cloud response: %s", response) + + ## Creates a callback function that includes the parsing of the response into the correct model. + # \param on_finished: The callback in case the response is successful. + # \param model: The type of the model to convert the response to. It may either be a single record or a list. + # \return: A function that can be passed to the + def _createCallback(self, + on_finished: Callable[[Union[Model, List[Model]]], any], + model: Type[Model], + ) -> Callable[[QNetworkReply], None]: + def parse(reply: QNetworkReply) -> None: + status_code, response = self._parseReply(reply) + return self._parseModels(response, on_finished, model) + return parse diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index d17728f513..adc670ad1e 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -1,13 +1,10 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. import io -import json import os -from json import JSONDecodeError -from typing import List, Optional, Dict, cast, Union, Tuple +from typing import List, Optional, Dict, cast, Union from PyQt5.QtCore import QObject, pyqtSignal, QUrl, pyqtProperty, pyqtSlot -from PyQt5.QtNetwork import QNetworkReply, QNetworkRequest from UM import i18nCatalog from UM.FileHandler.FileWriter import FileWriter @@ -22,10 +19,11 @@ from cura.PrinterOutput.PrinterOutputController import PrinterOutputController from cura.PrinterOutput.MaterialOutputModel import MaterialOutputModel from cura.PrinterOutput.NetworkedPrinterOutputDevice import NetworkedPrinterOutputDevice, AuthState from cura.PrinterOutput.PrinterOutputModel import PrinterOutputModel +from plugins.UM3NetworkPrinting.src.Cloud.CloudApiClient import CloudApiClient from plugins.UM3NetworkPrinting.src.UM3PrintJobOutputModel import UM3PrintJobOutputModel from .Models import ( - CloudClusterPrinter, CloudClusterPrintJob, JobUploadRequest, JobUploadResponse, PrintResponse, CloudClusterStatus, - CloudClusterPrinterConfigurationMaterial + CloudClusterPrinter, CloudClusterPrintJob, CloudJobUploadRequest, CloudJobResponse, CloudClusterStatus, + CloudClusterPrinterConfigurationMaterial, CloudErrorObject ) @@ -40,20 +38,16 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): # The translation catalog for this device. I18N_CATALOG = i18nCatalog("cura") - # The cloud URL to use for this remote cluster. - # TODO: Make sure that this URL goes to the live api before release - ROOT_PATH = "https://api-staging.ultimaker.com" - CLUSTER_API_ROOT = "{}/connect/v1/".format(ROOT_PATH) - CURA_API_ROOT = "{}/cura/v1/".format(ROOT_PATH) - # Signal triggered when the printers in the remote cluster were changed. printersChanged = pyqtSignal() # Signal triggered when the print jobs in the queue were changed. printJobsChanged = pyqtSignal() - def __init__(self, device_id: str, parent: QObject = None): + def __init__(self, api_client: CloudApiClient, device_id: str, parent: QObject = None): super().__init__(device_id = device_id, address = "", properties = {}, parent = parent) + self._api = api_client + self._setInterfaceElements() self._device_id = device_id @@ -76,40 +70,6 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): self._sending_job = False self._progress_message = None # type: Optional[Message] - @staticmethod - def _parseReply(reply: QNetworkReply) -> Tuple[int, Union[None, str, bytes]]: - """ - Parses a reply from the stardust server. - :param reply: The reply received from the server. - :return: The status code and the response dict. - """ - status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) - response = None - try: - response = bytes(reply.readAll()).decode("utf-8") - response = json.loads(response) - except JSONDecodeError: - Logger.logException("w", "Unable to decode JSON from reply.") - return status_code, response - - ## We need to override _createEmptyRequest to work for the cloud. - def _createEmptyRequest(self, path: str, content_type: Optional[str] = "application/json") -> QNetworkRequest: - # noinspection PyArgumentList - url = QUrl(path) - request = QNetworkRequest(url) - request.setHeader(QNetworkRequest.ContentTypeHeader, content_type) - request.setHeader(QNetworkRequest.UserAgentHeader, self._user_agent) - - if not self._account.isLoggedIn: - # TODO: show message to user to sign in - self.setAuthenticationState(AuthState.NotAuthenticated) - else: - # TODO: not execute call at all when not signed in? - self.setAuthenticationState(AuthState.Authenticated) - request.setRawHeader(b"Authorization", "Bearer {}".format(self._account.accessToken).encode()) - - return request - ## Set all the interface elements and texts for this output device. def _setInterfaceElements(self): self.setPriority(2) # make sure we end up below the local networking and above 'save to file' @@ -223,22 +183,16 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): def _update(self) -> None: super()._update() Logger.log("i", "Calling the cloud cluster") - self.get("{root}/cluster/{cluster_id}/status".format(root = self.CLUSTER_API_ROOT, - cluster_id = self._device_id), - on_finished = self._onStatusCallFinished) + if self._account.isLoggedIn: + self.setAuthenticationState(AuthState.Authenticated) + self._api.getClusterStatus(self._device_id, self._onStatusCallFinished) + else: + self.setAuthenticationState(AuthState.NotAuthenticated) ## Method called when HTTP request to status endpoint is finished. # Contains both printers and print jobs statuses in a single response. - def _onStatusCallFinished(self, reply: QNetworkReply) -> None: - status_code, response = self._parseReply(reply) - if status_code > 204 or not isinstance(response, dict) or "data" not in response: - Logger.log("w", "Got unexpected response while trying to get cloud cluster data: %s, %s", - status_code, response) - return - - Logger.log("d", "Got response form the cloud cluster %s, %s", status_code, response) - status = CloudClusterStatus(**response["data"]) - + def _onStatusCallFinished(self, status: CloudClusterStatus) -> None: + Logger.log("d", "Got response form the cloud cluster: %s", status.__dict__) # Update all data from the cluster. self._updatePrinters(status.printers) self._updatePrintJobs(status.print_jobs) @@ -325,18 +279,14 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): remote_jobs = {j.uuid: j for j in jobs} # type: Dict[str, CloudClusterPrintJob] current_jobs = {j.key: j for j in self._print_jobs} # type: Dict[str, UM3PrintJobOutputModel] - removed_job_ids = set(current_jobs).difference(set(remote_jobs)) - new_job_ids = set(remote_jobs.keys()).difference(set(current_jobs)) - updated_job_ids = set(current_jobs).intersection(set(remote_jobs)) + for removed_job_id in set(current_jobs).difference(remote_jobs): + self._print_jobs.remove(current_jobs[removed_job_id]) - for job_id in removed_job_ids: - self._print_jobs.remove(current_jobs[job_id]) + for new_job_id in set(remote_jobs.keys()).difference(current_jobs): + self._addPrintJob(remote_jobs[new_job_id]) - for job_id in new_job_ids: - self._addPrintJob(remote_jobs[job_id]) - - for job_id in updated_job_ids: - self._updateUM3PrintJobOutputModel(current_jobs[job_id], remote_jobs[job_id]) + for updated_job_id in set(current_jobs).intersection(remote_jobs): + self._updateUM3PrintJobOutputModel(current_jobs[updated_job_id], remote_jobs[updated_job_id]) # TODO: properly handle removed and updated printers self.printJobsChanged.emit() @@ -362,56 +312,25 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): def _sendPrintJob(self, file_name: str, content_type: str, stream: Union[io.StringIO, io.BytesIO]) -> None: mesh = stream.getvalue() - request = JobUploadRequest() + request = CloudJobUploadRequest() request.job_name = file_name request.file_size = len(mesh) request.content_type = content_type Logger.log("i", "Creating new cloud print job: %s", request.__dict__) - self.put("{}/jobs/upload".format(self.CURA_API_ROOT), data = json.dumps({"data": request.__dict__}), - on_finished = lambda reply: self._onPrintJobCreated(mesh, reply)) + self._api.requestUpload(request, lambda response: self._onPrintJobCreated(mesh, response)) - def _onPrintJobCreated(self, mesh: bytes, reply: QNetworkReply) -> None: - status_code, response = self._parseReply(reply) - if status_code > 204 or not isinstance(response, dict) or "data" not in response: - Logger.log("w", "Unexpected response while adding to queue: {}, {}".format(status_code, response)) - self._onUploadError(self.I18N_CATALOG.i18nc("@info:status", "Could not add print job to queue.")) - return - - # TODO: Multipart upload - job_response = JobUploadResponse(**response.get("data")) + def _onPrintJobCreated(self, mesh: bytes, job_response: CloudJobResponse) -> None: Logger.log("i", "Print job created successfully: %s", job_response.__dict__) - self.put(job_response.upload_url, data = mesh, content_type = job_response.content_type, - on_finished = lambda r: self._onPrintJobUploaded(job_response.job_id, r), - on_progress = self._onUploadPrintJobProgress) + self._api.uploadMesh(job_response, mesh, self._onPrintJobUploaded, self._onUploadPrintJobProgress) + + def _onPrintJobUploaded(self, job_id: str) -> None: + self._api.requestPrint(self._device_id, job_id, self._onUploadSuccess) def _onUploadPrintJobProgress(self, bytes_sent: int, bytes_total: int) -> None: if bytes_total > 0: self._updateUploadProgress(int((bytes_sent / bytes_total) * 100)) - def _onPrintJobUploaded(self, job_id: str, reply: QNetworkReply) -> None: - status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) - if status_code > 204: - Logger.log("w", "Received unexpected response from the job upload: %s, %s.", status_code, - bytes(reply.readAll()).decode()) - self._onUploadError(self.I18N_CATALOG.i18nc("@info:status", "Could not add print job to queue.")) - return - - Logger.log("i", "Print job uploaded successfully: %s", reply.readAll()) - url = "{}/cluster/{}/print/{}".format(self.CLUSTER_API_ROOT, self._device_id, job_id) - self.post(url, data = "", on_finished = self._onPrintJobRequested) - - def _onPrintJobRequested(self, reply: QNetworkReply) -> None: - status_code, response = self._parseReply(reply) - if status_code > 204 or not isinstance(response, dict) or "data" not in response: - Logger.log("w", "Got unexpected response while trying to request printing: %s, %s", status_code, response) - self._onUploadError(self.I18N_CATALOG.i18nc("@info:status", "Could not add print job to queue.")) - return - - print_response = PrintResponse(**response["data"]) - Logger.log("i", "Print job requested successfully: %s", print_response.__dict__) - self._onUploadSuccess() - def _updateUploadProgress(self, progress: int): if not self._progress_message: self._progress_message = Message( @@ -479,3 +398,6 @@ class CloudOutputDevice(NetworkedPrinterOutputDevice): @pyqtProperty(bool, notify = printJobsChanged) def receivedPrintJobs(self) -> bool: return True + + def _onApiError(self, errors: List[CloudErrorObject]) -> None: + pass # TODO: Show errors... diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py index 85e734f7a3..22e2d57b05 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDeviceManager.py @@ -1,19 +1,17 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -import json -from time import sleep from threading import Timer -from typing import Dict, Optional - -from PyQt5.QtNetwork import QNetworkRequest, QNetworkReply +from typing import Dict, List +from UM import i18nCatalog from UM.Logger import Logger +from UM.Message import Message from UM.Signal import Signal from cura.CuraApplication import CuraApplication -from cura.NetworkClient import NetworkClient +from plugins.UM3NetworkPrinting.src.Cloud.CloudApiClient import CloudApiClient from .CloudOutputDevice import CloudOutputDevice -from .Models import CloudCluster +from .Models import CloudCluster, CloudErrorObject ## The cloud output device manager is responsible for using the Ultimaker Cloud APIs to manage remote clusters. @@ -21,14 +19,14 @@ from .Models import CloudCluster # # API spec is available on https://api.ultimaker.com/docs/connect/spec/. # -class CloudOutputDeviceManager(NetworkClient): - - # The cloud URL to use for remote clusters. - API_ROOT_PATH = "https://api-staging.ultimaker.com/connect/v1" +class CloudOutputDeviceManager: # The interval with which the remote clusters are checked CHECK_CLUSTER_INTERVAL = 5 # seconds - + + # The translation catalog for this device. + I18N_CATALOG = i18nCatalog("cura") + def __init__(self): super().__init__() @@ -37,8 +35,10 @@ class CloudOutputDeviceManager(NetworkClient): application = CuraApplication.getInstance() self._output_device_manager = application.getOutputDeviceManager() + self._account = application.getCuraAPI().account self._account.loginStateChanged.connect(self._getRemoteClusters) + self._api = CloudApiClient(self._account, self._onApiError) # When switching machines we check if we have to activate a remote cluster. application.globalContainerStackChanged.connect(self._connectToActiveMachine) @@ -46,40 +46,21 @@ class CloudOutputDeviceManager(NetworkClient): self._on_cluster_received = Signal() self._on_cluster_received.connect(self._getRemoteClusters) - - ## Override _createEmptyRequest to add the needed authentication header for talking to the Ultimaker Cloud API. - def _createEmptyRequest(self, path: str, content_type: Optional[str] = "application/json") -> QNetworkRequest: - request = super()._createEmptyRequest(self.API_ROOT_PATH + path, content_type = content_type) - if self._account.isLoggedIn: - # TODO: add correct scopes to OAuth2 client to use remote connect API. - # TODO: don't create the client when not signed in? - request.setRawHeader(b"Authorization", "Bearer {}".format(self._account.accessToken).encode()) - return request - ## Gets all remote clusters from the API. def _getRemoteClusters(self) -> None: Logger.log("i", "Retrieving remote clusters") if self._account.isLoggedIn: - self.get("/clusters", on_finished = self._onGetRemoteClustersFinished) + self._api.getClusters(self._onGetRemoteClustersFinished) # Only start the polling thread after the user is authenticated # The first call to _getRemoteClusters comes from self._account.loginStateChanged timer = Timer(5.0, self._on_cluster_received.emit) timer.start() - ## Callback for when the request for getting the clusters. is finished. - def _onGetRemoteClustersFinished(self, reply: QNetworkReply) -> None: - Logger.log("i", "Received remote clusters") + def _onGetRemoteClustersFinished(self, clusters: List[CloudCluster]) -> None: + found_clusters = {c.cluster_id: c for c in clusters} - status_code = reply.attribute(QNetworkRequest.HttpStatusCodeAttribute) - if status_code > 204: - Logger.log("w", "Got unexpected response while trying to get cloud cluster data: {}, {}" - .format(status_code, reply.readAll())) - return - - # Parse the response (returns the "data" field from the body). - found_clusters = self._parseStatusResponse(reply) Logger.log("i", "Parsed remote clusters to %s", found_clusters) if not found_clusters: return @@ -97,28 +78,17 @@ class CloudOutputDeviceManager(NetworkClient): for cluster_id in known_cluster_ids.difference(found_cluster_ids): self._removeCloudOutputDevice(found_clusters[cluster_id]) - @staticmethod - def _parseStatusResponse(reply: QNetworkReply) -> Dict[str, CloudCluster]: - try: - response = bytes(reply.readAll()).decode() - return {c["cluster_id"]: CloudCluster(**c) for c in json.loads(response)["data"]} - except UnicodeDecodeError: - Logger.log("w", "Unable to read server response") - except json.decoder.JSONDecodeError: - Logger.logException("w", "Unable to decode JSON from reply.") - except ValueError: - Logger.logException("w", "Response was missing values.") - return {} - ## Adds a CloudOutputDevice for each entry in the remote cluster list from the API. + # \param cluster: The cluster that was added. def _addCloudOutputDevice(self, cluster: CloudCluster): - device = CloudOutputDevice(cluster.cluster_id) + device = CloudOutputDevice(self._api, cluster.cluster_id) self._output_device_manager.addOutputDevice(device) self._remote_clusters[cluster.cluster_id] = device device.connect() # TODO: remove this self._connectToActiveMachine() ## Remove a CloudOutputDevice + # \param cluster: The cluster that was removed def _removeCloudOutputDevice(self, cluster: CloudCluster): self._output_device_manager.removeOutputDevice(cluster.cluster_id) del self._remote_clusters[cluster.cluster_id] @@ -141,3 +111,15 @@ class CloudOutputDeviceManager(NetworkClient): # TODO: If so, we can also immediate connect to it. # active_machine.setMetaDataEntry("um_cloud_cluster_id", "") # self._remote_clusters.get(stored_cluster_id).connect() + + ## Handles an API error received from the cloud. + # \param errors: The errors received + def _onApiError(self, errors: List[CloudErrorObject]) -> None: + message = ". ".join(e.title for e in errors) # TODO: translate errors + message = Message( + text = message, + title = self.I18N_CATALOG.i18nc("@info:title", "Error"), + lifetime = 10, + dismissable = True + ) + message.show() diff --git a/plugins/UM3NetworkPrinting/src/Cloud/Models.py b/plugins/UM3NetworkPrinting/src/Cloud/Models.py index d7cb68e5d3..27ff7df604 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/Models.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/Models.py @@ -1,10 +1,22 @@ # Copyright (c) 2018 Ultimaker B.V. # Cura is released under the terms of the LGPLv3 or higher. -from typing import List +from typing import List, Dict from ..Models import BaseModel +## Class representing errors generated by the cloud servers, according to the json-api standard. +class CloudErrorObject(BaseModel): + def __init__(self, **kwargs): + self.id = None # type: str + self.code = None # type: str + self.http_status = None # type: str + self.title = None # type: str + self.detail = None # type: str + self.meta = None # type: Dict[str, any] + super().__init__(**kwargs) + + ## Class representing a cloud connected cluster. class CloudCluster(BaseModel): def __init__(self, **kwargs): @@ -95,17 +107,23 @@ class CloudClusterPrintJob(BaseModel): for p in self.constraints] +# Model that represents the status of the cluster for the cloud class CloudClusterStatus(BaseModel): def __init__(self, **kwargs): + # a list of the printers self.printers = [] # type: List[CloudClusterPrinter] + # a list of the print jobs self.print_jobs = [] # type: List[CloudClusterPrintJob] + super().__init__(**kwargs) + # converting any dictionaries into models self.printers = [CloudClusterPrinter(**p) if isinstance(p, dict) else p for p in self.printers] self.print_jobs = [CloudClusterPrintJob(**j) if isinstance(j, dict) else j for j in self.print_jobs] -class JobUploadRequest(BaseModel): +# Model that represents the request to upload a print job to the cloud +class CloudJobUploadRequest(BaseModel): def __init__(self, **kwargs): self.file_size = None # type: int self.job_name = None # type: str @@ -113,7 +131,8 @@ class JobUploadRequest(BaseModel): super().__init__(**kwargs) -class JobUploadResponse(BaseModel): +# Model that represents the response received from the cloud after requesting to upload a print job +class CloudJobResponse(BaseModel): def __init__(self, **kwargs): self.download_url = None # type: str self.job_id = None # type: str @@ -125,7 +144,8 @@ class JobUploadResponse(BaseModel): super().__init__(**kwargs) -class PrintResponse(BaseModel): +# Model that represents the responses received from the cloud after requesting a job to be printed. +class CloudPrintResponse(BaseModel): def __init__(self, **kwargs): self.cluster_job_id = None # type: str self.job_id = None # type: str