# 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. """ 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", 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") 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") 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