mirror of
https://github.com/Ultimaker/Cura.git
synced 2025-07-08 23:46:22 -06:00
Initial commit for the DL
This commit is contained in:
parent
e19d5545bc
commit
d972c505d0
38 changed files with 3512 additions and 0 deletions
74
plugins/DigitalLibrary/src/BaseModel.py
Normal file
74
plugins/DigitalLibrary/src/BaseModel.py
Normal file
|
@ -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)
|
31
plugins/DigitalLibrary/src/CloudError.py
Normal file
31
plugins/DigitalLibrary/src/CloudError.py
Normal file
|
@ -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)
|
361
plugins/DigitalLibrary/src/DFFileExportAndUploadManager.py
Normal file
361
plugins/DigitalLibrary/src/DFFileExportAndUploadManager.py
Normal file
|
@ -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
|
147
plugins/DigitalLibrary/src/DFFileUploader.py
Normal file
147
plugins/DigitalLibrary/src/DFFileUploader.py
Normal file
|
@ -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()
|
16
plugins/DigitalLibrary/src/DFLibraryFileUploadRequest.py
Normal file
16
plugins/DigitalLibrary/src/DFLibraryFileUploadRequest.py
Normal file
|
@ -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)
|
49
plugins/DigitalLibrary/src/DFLibraryFileUploadResponse.py
Normal file
49
plugins/DigitalLibrary/src/DFLibraryFileUploadResponse.py
Normal file
|
@ -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)
|
21
plugins/DigitalLibrary/src/DFPrintJobUploadRequest.py
Normal file
21
plugins/DigitalLibrary/src/DFPrintJobUploadRequest.py
Normal file
|
@ -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)
|
35
plugins/DigitalLibrary/src/DFPrintJobUploadResponse.py
Normal file
35
plugins/DigitalLibrary/src/DFPrintJobUploadResponse.py
Normal file
|
@ -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)
|
317
plugins/DigitalLibrary/src/DigitalFactoryApiClient.py
Normal file
317
plugins/DigitalLibrary/src/DigitalFactoryApiClient.py
Normal file
|
@ -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()
|
563
plugins/DigitalLibrary/src/DigitalFactoryController.py
Normal file
563
plugins/DigitalLibrary/src/DigitalFactoryController.py
Normal file
|
@ -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
|
115
plugins/DigitalLibrary/src/DigitalFactoryFileModel.py
Normal file
115
plugins/DigitalLibrary/src/DigitalFactoryFileModel.py
Normal file
|
@ -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
|
58
plugins/DigitalLibrary/src/DigitalFactoryFileProvider.py
Normal file
58
plugins/DigitalLibrary/src/DigitalFactoryFileProvider.py
Normal file
|
@ -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()
|
57
plugins/DigitalLibrary/src/DigitalFactoryFileResponse.py
Normal file
57
plugins/DigitalLibrary/src/DigitalFactoryFileResponse.py
Normal file
|
@ -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")
|
114
plugins/DigitalLibrary/src/DigitalFactoryOutputDevice.py
Normal file
114
plugins/DigitalLibrary/src/DigitalFactoryOutputDevice.py
Normal file
|
@ -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)
|
|
@ -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")
|
71
plugins/DigitalLibrary/src/DigitalFactoryProjectModel.py
Normal file
71
plugins/DigitalLibrary/src/DigitalFactoryProjectModel.py
Normal file
|
@ -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()
|
65
plugins/DigitalLibrary/src/DigitalFactoryProjectResponse.py
Normal file
65
plugins/DigitalLibrary/src/DigitalFactoryProjectResponse.py
Normal file
|
@ -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")
|
55
plugins/DigitalLibrary/src/ExportFileJob.py
Normal file
55
plugins/DigitalLibrary/src/ExportFileJob.py
Normal file
|
@ -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()
|
30
plugins/DigitalLibrary/src/PaginationLinks.py
Normal file
30
plugins/DigitalLibrary/src/PaginationLinks.py
Normal file
|
@ -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)
|
43
plugins/DigitalLibrary/src/PaginationManager.py
Normal file
43
plugins/DigitalLibrary/src/PaginationManager.py
Normal file
|
@ -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
|
25
plugins/DigitalLibrary/src/PaginationMetadata.py
Normal file
25
plugins/DigitalLibrary/src/PaginationMetadata.py
Normal file
|
@ -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)
|
24
plugins/DigitalLibrary/src/ResponseMeta.py
Normal file
24
plugins/DigitalLibrary/src/ResponseMeta.py
Normal file
|
@ -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)
|
0
plugins/DigitalLibrary/src/__init__.py
Normal file
0
plugins/DigitalLibrary/src/__init__.py
Normal file
Loading…
Add table
Add a link
Reference in a new issue