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
This commit is contained in:
Remco Burema 2026-01-20 14:27:16 +01:00
parent 2495cfe464
commit 902f2b1ebe
5 changed files with 78 additions and 12 deletions

View file

@ -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.

View file

@ -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."""

View file

@ -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,

View file

@ -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."""

View file

@ -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."""