From 902f2b1ebe879ca97276c18d86e29b279656fa9e Mon Sep 17 00:00:00 2001 From: Remco Burema Date: Tue, 20 Jan 2026 14:27:16 +0100 Subject: [PATCH] Add functionality to cancel print-job-upload in progress. Sometimes this got stuck, and there was no way for users to get rid of (or even retry). CURA-11196 --- .../src/Cloud/CloudApiClient.py | 10 +++-- .../src/Cloud/CloudOutputDevice.py | 37 +++++++++++++++++-- .../src/Cloud/ToolPathUploader.py | 15 +++++++- .../Messages/PrintJobUploadProgressMessage.py | 8 +++- .../src/Network/LocalClusterOutputDevice.py | 20 +++++++++- 5 files changed, 78 insertions(+), 12 deletions(-) diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py index 0831ceebd3..3a7a8e3378 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudApiClient.py @@ -1,4 +1,4 @@ -# Copyright (c) 2019 Ultimaker B.V. +# Copyright (c) 2026 UltiMaker # Cura is released under the terms of the LGPLv3 or higher. import json import urllib.parse @@ -11,6 +11,7 @@ from PyQt6.QtCore import QUrl from PyQt6.QtNetwork import QNetworkRequest, QNetworkReply from UM.Logger import Logger +from UM.TaskManagement.HttpRequestData import HttpRequestData from UM.TaskManagement.HttpRequestManager import HttpRequestManager from UM.TaskManagement.HttpRequestScope import JsonDecoratorScope from cura.API import Account @@ -119,7 +120,7 @@ class CloudApiClient: timeout=self.DEFAULT_REQUEST_TIMEOUT) def requestUpload(self, request: CloudPrintJobUploadRequest, - on_finished: Callable[[CloudPrintJobResponse], Any]) -> None: + on_finished: Callable[[CloudPrintJobResponse], Any]) -> HttpRequestData: """Requests the cloud to register the upload of a print job mesh. @@ -130,14 +131,14 @@ class CloudApiClient: url = f"{self.CURA_API_ROOT}/jobs/upload" data = json.dumps({"data": request.toDict()}).encode() - self._http.put(url, + return self._http.put(url, scope=self._scope, data=data, callback=self._parseCallback(on_finished, CloudPrintJobResponse), timeout=self.DEFAULT_REQUEST_TIMEOUT) def uploadToolPath(self, print_job: CloudPrintJobResponse, mesh: bytes, on_finished: Callable[[], Any], - on_progress: Callable[[int], Any], on_error: Callable[[], Any]): + on_progress: Callable[[int], Any], on_error: Callable[[], Any]) -> ToolPathUploader: """Uploads a print job tool path to the cloud. :param print_job: The object received after requesting an upload with `self.requestUpload`. @@ -149,6 +150,7 @@ class CloudApiClient: self._upload = ToolPathUploader(self._http, print_job, mesh, on_finished, on_progress, on_error) self._upload.start() + return self._upload # Requests a cluster to print the given print job. # \param cluster_id: The ID of the cluster. diff --git a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py index 010ef93fbd..9ecf9dc10f 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/CloudOutputDevice.py @@ -1,4 +1,4 @@ -# Copyright (c) 2022 UltiMaker +# Copyright (c) 2026 UltiMaker # Cura is released under the terms of the LGPLv3 or higher. from time import time @@ -13,6 +13,7 @@ from UM import i18nCatalog from UM.FileHandler.FileHandler import FileHandler from UM.Logger import Logger from UM.Scene.SceneNode import SceneNode +from UM.TaskManagement.HttpRequestData import HttpRequestData from UM.Version import Version from cura.CuraApplication import CuraApplication from cura.PrinterOutput.NetworkedPrinterOutputDevice import AuthState @@ -21,6 +22,7 @@ from cura.Scene.GCodeListDecorator import GCodeListDecorator from cura.Scene.SliceableObjectDecorator import SliceableObjectDecorator from .CloudApiClient import CloudApiClient +from .ToolPathUploader import ToolPathUploader from ..ExportFileJob import ExportFileJob from ..Messages.PrintJobAwaitingApprovalMessage import PrintJobPendingApprovalMessage from ..UltimakerNetworkedPrinterOutputDevice import UltimakerNetworkedPrinterOutputDevice @@ -117,6 +119,10 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice): CuraApplication.getInstance().getBackend().backendDone.connect(self._resetPrintJob) CuraApplication.getInstance().getController().getScene().sceneChanged.connect(self._onSceneChanged) + self._pre_uploader_handle: Optional[HttpRequestData] = None + self._uploader_handle: Optional[ToolPathUploader] = None + self._progress.actionTriggered.connect(self._onProgressMessageActionTriggered) + def connect(self) -> None: """Connects this device.""" @@ -139,11 +145,22 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice): if node.getDecorator(GCodeListDecorator) or node.getDecorator(SliceableObjectDecorator): self._resetPrintJob() + def _cancelInProgressJobs(self): + if self._pre_uploader_handle is not None: + if self._pre_uploader_handle.reply is not None: + self._pre_uploader_handle.reply.abort() + self._pre_uploader_handle.setDone() + self._pre_uploader_handle = None + if self._uploader_handle is not None: + self._uploader_handle.cancel() + self._uploader_handle = None + def _resetPrintJob(self) -> None: """Resets the print job that was uploaded to force a new upload, runs whenever slice finishes.""" self._tool_path = None self._pre_upload_print_job = None self._uploaded_print_job = None + self._cancelInProgressJobs() def matchesNetworkKey(self, network_key: str) -> bool: """Checks whether the given network key is found in the cloud's host name""" @@ -240,7 +257,7 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice): file_size=len(output), content_type=job.getMimeType(), ) - self._api.requestUpload(request, self._uploadPrintJob) + self._pre_uploader_handle = self._api.requestUpload(request, self._uploadPrintJob) def _uploadPrintJob(self, job_response: CloudPrintJobResponse) -> None: """Uploads the mesh when the print job was registered with the cloud API. @@ -250,9 +267,10 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice): if not self._tool_path: return self._onUploadError() + self._pre_uploader_handle = None self._pre_upload_print_job = job_response # store the last uploaded job to prevent re-upload of the same file - self._api.uploadToolPath(job_response, self._tool_path, self._onPrintJobUploaded, self._progress.update, - self._onUploadError) + self._uploader_handle = self._api.uploadToolPath(job_response, self._tool_path, self._onPrintJobUploaded, + self._progress.update, self._onUploadError) def _onPrintJobUploaded(self) -> None: """ @@ -267,6 +285,8 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice): # error, which sets self._pre_uploaded_print_job to `None`. self._pre_upload_print_job = None self._uploaded_print_job = None + self._pre_uploader_handle = None + self._uploader_handle = None Logger.log("w", "Interference from another job uploaded at roughly the same time, not uploading print!") return # Prevent a crash. self._api.requestPrint(self.key, print_job.job_id, self._onPrintUploadCompleted, @@ -315,6 +335,8 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice): self._progress.hide() self._pre_upload_print_job = None self._uploaded_print_job = None + self._pre_uploader_handle = None + self._uploader_handle = None self.writeError.emit() def _onUploadError(self, message: str = None) -> None: @@ -327,9 +349,16 @@ class CloudOutputDevice(UltimakerNetworkedPrinterOutputDevice): self._progress.hide() self._pre_upload_print_job = None self._uploaded_print_job = None + self._cancelInProgressJobs() PrintJobUploadErrorMessage(message).show() self.writeError.emit() + def _onProgressMessageActionTriggered(self, message: "Message", action: str): + if action == "abort_upload": + self._onUploadError(I18N_CATALOG.i18nc("@info:message", "The send-print-job process was aborted. Please try again.")) + else: + Logger.warning(f"Unknown action {action} triggered on print-job upload progress message.") + @pyqtProperty(bool, notify=_cloudClusterPrintersChanged) def isMethod(self) -> bool: """Whether the printer that this output device represents is a Method series printer.""" diff --git a/plugins/UM3NetworkPrinting/src/Cloud/ToolPathUploader.py b/plugins/UM3NetworkPrinting/src/Cloud/ToolPathUploader.py index 1881d90923..ab3679325b 100644 --- a/plugins/UM3NetworkPrinting/src/Cloud/ToolPathUploader.py +++ b/plugins/UM3NetworkPrinting/src/Cloud/ToolPathUploader.py @@ -5,6 +5,7 @@ from PyQt6.QtNetwork import QNetworkRequest, QNetworkReply from typing import Callable, Any, Tuple, cast, Dict, Optional from UM.Logger import Logger +from UM.TaskManagement.HttpRequestData import HttpRequestData from UM.TaskManagement.HttpRequestManager import HttpRequestManager from ..Models.Http.CloudPrintJobResponse import CloudPrintJobResponse @@ -44,6 +45,8 @@ class ToolPathUploader: self._retries = 0 self._finished = False + self._uploader_handle: Optional[HttpRequestData] = None + @property def printJob(self): """Returns the print job for which this object was created.""" @@ -63,9 +66,19 @@ class ToolPathUploader: """Stops uploading the mesh, marking it as finished.""" Logger.log("i", "Finished uploading") + self._uploader_handle = None self._finished = True # Signal to any ongoing retries that we should stop retrying. self._on_finished() + def cancel(self): + """Cancels the upload, marking it as finished.""" + if self._uploader_handle is not None: + if self._uploader_handle.reply is not None: + self._uploader_handle.reply.abort() + self._uploader_handle.setDone() + self.stop() + self._on_error() + def _upload(self) -> None: """ Uploads the print job to the cloud printer. @@ -74,7 +87,7 @@ class ToolPathUploader: raise ValueError("The upload is already finished") Logger.log("i", "Uploading print to {upload_url}".format(upload_url = self._print_job.upload_url)) - self._http.put( + self._uploader_handle = self._http.put( url = cast(str, self._print_job.upload_url), headers_dict = {"Content-Type": cast(str, self._print_job.content_type)}, data = self._data, diff --git a/plugins/UM3NetworkPrinting/src/Messages/PrintJobUploadProgressMessage.py b/plugins/UM3NetworkPrinting/src/Messages/PrintJobUploadProgressMessage.py index 63fa037890..db93769bc1 100644 --- a/plugins/UM3NetworkPrinting/src/Messages/PrintJobUploadProgressMessage.py +++ b/plugins/UM3NetworkPrinting/src/Messages/PrintJobUploadProgressMessage.py @@ -1,4 +1,4 @@ -# Copyright (c) 2019 Ultimaker B.V. +# Copyright (c) 2026 UltiMaker # Cura is released under the terms of the LGPLv3 or higher. from UM import i18nCatalog from UM.Message import Message @@ -19,6 +19,12 @@ class PrintJobUploadProgressMessage(Message): dismissable = False, use_inactivity_timer = False ) + self.addAction( + "abort_upload", + I18N_CATALOG.i18nc("@action:button", "Abort"), + "", + I18N_CATALOG.i18nc("@action:label", "Abort upload"), + ) def show(self): """Shows the progress message.""" diff --git a/plugins/UM3NetworkPrinting/src/Network/LocalClusterOutputDevice.py b/plugins/UM3NetworkPrinting/src/Network/LocalClusterOutputDevice.py index 75a34ee7e2..2809d5631e 100644 --- a/plugins/UM3NetworkPrinting/src/Network/LocalClusterOutputDevice.py +++ b/plugins/UM3NetworkPrinting/src/Network/LocalClusterOutputDevice.py @@ -1,4 +1,4 @@ -# Copyright (c) 2025 UltiMaker +# Copyright (c) 2026 UltiMaker # Cura is released under the terms of the LGPLv3 or higher. import os import platform @@ -11,6 +11,7 @@ from PyQt6.QtCore import pyqtSlot, QUrl, pyqtSignal, pyqtProperty, QObject from PyQt6.QtNetwork import QNetworkReply from UM.FileHandler.FileHandler import FileHandler +from UM.TaskManagement.HttpRequestData import HttpRequestData from UM.Version import Version from UM.i18n import i18nCatalog from UM.Logger import Logger @@ -57,6 +58,9 @@ class LocalClusterOutputDevice(UltimakerNetworkedPrinterOutputDevice): self._setInterfaceElements() self._active_camera_url = QUrl() # type: QUrl + self._uploader_handle: Optional[QNetworkReply] = None + self._progress.actionTriggered.connect(self._onProgressMessageActionTriggered) + def _setInterfaceElements(self) -> None: """Set all the interface elements and texts for this output device.""" @@ -145,6 +149,8 @@ class LocalClusterOutputDevice(UltimakerNetworkedPrinterOutputDevice): PrintJobUploadBlockedMessage().show() return + self._uploader_handle = None + self.writeStarted.emit(self) # Export the scene to the correct file type. @@ -207,7 +213,7 @@ class LocalClusterOutputDevice(UltimakerNetworkedPrinterOutputDevice): if unique_name is not None: parts.append(self._createFormPart("name=require_printer_name", bytes(unique_name, "utf-8"), "text/plain")) # FIXME: move form posting to API client - self.postFormWithParts( + self._uploader_handle = self.postFormWithParts( "/cluster-api/v1/print_jobs/", parts, on_finished=self._onPrintUploadCompleted, @@ -228,6 +234,7 @@ class LocalClusterOutputDevice(UltimakerNetworkedPrinterOutputDevice): def _onPrintUploadCompleted(self, reply: QNetworkReply) -> None: """Handler for when the print job was fully uploaded to the cluster.""" + self._uploader_handle = None self._progress.hide() if reply.error() == QNetworkReply.NetworkError.NoError: PrintJobUploadSuccessMessage().show() @@ -241,10 +248,19 @@ class LocalClusterOutputDevice(UltimakerNetworkedPrinterOutputDevice): :param message: The message to display. """ + self._uploader_handle = None self._progress.hide() PrintJobUploadErrorMessage(message).show() self.writeError.emit() + def _onProgressMessageActionTriggered(self, message: "Message", action: str): + if action == "abort_upload": + if self._uploader_handle is not None: + self._uploader_handle.abort() + self._onUploadError(I18N_CATALOG.i18nc("@info:message", "The send-print-job process was aborted. Please try again.")) + else: + Logger.warning(f"Unknown action {action} triggered on print-job upload progress message.") + def _updatePrintJobPreviewImages(self): """Download all the images from the cluster and load their data in the print job models."""