DigitalFactoryController.py 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624
  1. # Copyright (c) 2021 Ultimaker B.V.
  2. # Cura is released under the terms of the LGPLv3 or higher.
  3. import json
  4. import math
  5. import os
  6. import tempfile
  7. import threading
  8. from enum import IntEnum
  9. from pathlib import Path
  10. from typing import Optional, List, Dict, Any, cast
  11. from PyQt6.QtCore import pyqtSignal, QObject, pyqtSlot, pyqtProperty, pyqtEnum, QTimer, QUrl, QMetaObject
  12. from PyQt6.QtNetwork import QNetworkReply
  13. from PyQt6.QtQml import qmlRegisterType, qmlRegisterUncreatableMetaObject
  14. from UM.FileHandler.FileHandler import FileHandler
  15. from UM.Logger import Logger
  16. from UM.Message import Message
  17. from UM.Scene.SceneNode import SceneNode
  18. from UM.Signal import Signal
  19. from UM.TaskManagement.HttpRequestManager import HttpRequestManager
  20. from cura.API import Account
  21. from cura.CuraApplication import CuraApplication
  22. from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope
  23. from .BackwardsCompatibleMessage import getBackwardsCompatibleMessage
  24. from .DFFileExportAndUploadManager import DFFileExportAndUploadManager
  25. from .DigitalFactoryApiClient import DigitalFactoryApiClient
  26. from .DigitalFactoryFileModel import DigitalFactoryFileModel
  27. from .DigitalFactoryFileResponse import DigitalFactoryFileResponse
  28. from .DigitalFactoryProjectModel import DigitalFactoryProjectModel
  29. from .DigitalFactoryProjectResponse import DigitalFactoryProjectResponse
  30. class DigitalFactoryController(QObject):
  31. DISK_WRITE_BUFFER_SIZE = 256 * 1024 # 256 KB
  32. selectedProjectIndexChanged = pyqtSignal(int, arguments = ["newProjectIndex"])
  33. """Signal emitted whenever the selected project is changed in the projects dropdown menu"""
  34. selectedFileIndicesChanged = pyqtSignal("QList<int>", arguments = ["newFileIndices"])
  35. """Signal emitted whenever the selected file is changed in the files table"""
  36. retrievingProjectsStatusChanged = pyqtSignal(int, arguments = ["status"])
  37. """Signal emitted whenever the status of the 'retrieving projects' http get request is changed"""
  38. retrievingFilesStatusChanged = pyqtSignal(int, arguments = ["status"])
  39. """Signal emitted whenever the status of the 'retrieving files in project' http get request is changed"""
  40. creatingNewProjectStatusChanged = pyqtSignal(int, arguments = ["status"])
  41. """Signal emitted whenever the status of the 'create new library project' http get request is changed"""
  42. hasMoreProjectsToLoadChanged = pyqtSignal()
  43. """Signal emitted whenever the variable hasMoreProjectsToLoad is changed. This variable is used to determine if
  44. the paginated list of projects has more pages to show"""
  45. preselectedProjectChanged = pyqtSignal()
  46. """Signal emitted whenever a preselected project is set. Whenever there is a preselected project, it means that it is
  47. the only project in the ProjectModel. When the preselected project is invalidated, the ProjectsModel needs to be
  48. retrieved again."""
  49. projectCreationErrorTextChanged = pyqtSignal()
  50. """Signal emitted whenever the creation of a new project fails and a specific error message is returned from the
  51. server."""
  52. """Signals to inform about the process of the file upload"""
  53. uploadStarted = Signal()
  54. uploadFileProgress = Signal()
  55. uploadFileSuccess = Signal()
  56. uploadFileError = Signal()
  57. uploadFileFinished = Signal()
  58. """Signal to inform about the state of user access."""
  59. userAccessStateChanged = pyqtSignal(bool)
  60. """Signal to inform whether the user is allowed to create more Library projects."""
  61. userCanCreateNewLibraryProjectChanged = pyqtSignal(bool)
  62. class RetrievalStatus(IntEnum):
  63. """
  64. The status of an http get request.
  65. This is not an enum, because we want to use it in QML and QML doesn't recognize Python enums.
  66. """
  67. Idle = 0
  68. InProgress = 1
  69. Success = 2
  70. Failed = 3
  71. pyqtEnum(RetrievalStatus)
  72. def __init__(self, application: CuraApplication) -> None:
  73. super().__init__(parent = None)
  74. self._application = application
  75. self._dialog = None # type: Optional["QObject"]
  76. self.file_handlers = {} # type: Dict[str, FileHandler]
  77. self.nodes = None # type: Optional[List[SceneNode]]
  78. self.file_upload_manager = None # type: Optional[DFFileExportAndUploadManager]
  79. self._has_preselected_project = False # type: bool
  80. self._api = DigitalFactoryApiClient(self._application, on_error = lambda error: Logger.log("e", str(error)), projects_limit_per_page = 20)
  81. # Indicates whether there are more pages of projects that can be loaded from the API
  82. self._has_more_projects_to_load = False
  83. self._account = self._application.getInstance().getCuraAPI().account # type: Account
  84. self._account.loginStateChanged.connect(self._onLoginStateChanged)
  85. self._current_workspace_information = CuraApplication.getInstance().getCurrentWorkspaceInformation()
  86. # Initialize the project model
  87. self._project_model = DigitalFactoryProjectModel()
  88. self._selected_project_idx = -1
  89. self._project_creation_error_text = "Something went wrong while creating a new project. Please try again."
  90. self._project_filter = ""
  91. self._project_filter_change_timer = QTimer()
  92. self._project_filter_change_timer.setInterval(200)
  93. self._project_filter_change_timer.setSingleShot(True)
  94. self._project_filter_change_timer.timeout.connect(self._applyProjectFilter)
  95. # Initialize the file model
  96. self._file_model = DigitalFactoryFileModel()
  97. self._selected_file_indices = [] # type: List[int]
  98. # Filled after the application has been initialized
  99. self._supported_file_types = {} # type: Dict[str, str]
  100. # For cleaning up the files afterwards:
  101. self._erase_temp_files_lock = threading.Lock()
  102. # The statuses which indicate whether Cura is waiting for a response from the DigitalFactory API
  103. self.retrieving_files_status = self.RetrievalStatus.Idle
  104. self.retrieving_projects_status = self.RetrievalStatus.Idle
  105. self.creating_new_project_status = self.RetrievalStatus.Idle
  106. self._application.engineCreatedSignal.connect(self._onEngineCreated)
  107. self._application.initializationFinished.connect(self._applicationInitializationFinished)
  108. self._user_has_access = False
  109. self._user_account_can_create_new_project = False
  110. def clear(self) -> None:
  111. self._project_model.clearProjects()
  112. self._api.clear()
  113. self._has_preselected_project = False
  114. self.preselectedProjectChanged.emit()
  115. self.setRetrievingFilesStatus(self.RetrievalStatus.Idle)
  116. self.setRetrievingProjectsStatus(self.RetrievalStatus.Idle)
  117. self.setCreatingNewProjectStatus(self.RetrievalStatus.Idle)
  118. self.setSelectedProjectIndex(-1)
  119. def _onLoginStateChanged(self, logged_in: bool) -> None:
  120. def callback(has_access, **kwargs):
  121. self._user_has_access = has_access
  122. self.userAccessStateChanged.emit(logged_in)
  123. self._api.checkUserHasAccess(callback)
  124. def userAccountHasLibraryAccess(self) -> bool:
  125. """
  126. Checks whether the currently logged in user account has access to the Digital Library
  127. :return: True if the user account has Digital Library access, else False
  128. """
  129. if self._user_has_access:
  130. self._api.checkUserCanCreateNewLibraryProject(callback = self.setCanCreateNewLibraryProject)
  131. return self._user_has_access
  132. def initialize(self, preselected_project_id: Optional[str] = None) -> None:
  133. self.clear()
  134. if self._account.isLoggedIn and self.userAccountHasLibraryAccess():
  135. self.setRetrievingProjectsStatus(self.RetrievalStatus.InProgress)
  136. if preselected_project_id:
  137. self._api.getProject(preselected_project_id, on_finished = self.setProjectAsPreselected, failed = self._onGetProjectFailed)
  138. else:
  139. self._api.getProjectsFirstPage(search_filter = self._project_filter, on_finished = self._onGetProjectsFirstPageFinished, failed = self._onGetProjectsFailed)
  140. def setProjectAsPreselected(self, df_project: DigitalFactoryProjectResponse) -> None:
  141. """
  142. Sets the received df_project as the preselected one. When a project is preselected, it should be the only
  143. project inside the model, so this function first makes sure to clear the projects model.
  144. :param df_project: The library project intended to be set as preselected
  145. """
  146. self._project_model.clearProjects()
  147. self._project_model.setProjects([df_project])
  148. self.setSelectedProjectIndex(0)
  149. self.setHasPreselectedProject(True)
  150. self.setRetrievingProjectsStatus(self.RetrievalStatus.Success)
  151. self.setCreatingNewProjectStatus(self.RetrievalStatus.Success)
  152. def _onGetProjectFailed(self, reply: QNetworkReply, error: QNetworkReply.NetworkError) -> None:
  153. reply_string = bytes(reply.readAll()).decode()
  154. self.setHasPreselectedProject(False)
  155. Logger.log("w", "Something went wrong while trying to retrieve a the preselected Digital Library project. Error: {}".format(reply_string))
  156. def _onGetProjectsFirstPageFinished(self, df_projects: List[DigitalFactoryProjectResponse]) -> None:
  157. """
  158. Set the first page of projects received from the digital factory library in the project model. Called whenever
  159. the retrieval of the first page of projects is successful.
  160. :param df_projects: A list of all the Digital Factory Library projects linked to the user's account
  161. """
  162. self.setHasMoreProjectsToLoad(self._api.hasMoreProjectsToLoad())
  163. self._project_model.setProjects(df_projects)
  164. self.setRetrievingProjectsStatus(self.RetrievalStatus.Success)
  165. @pyqtSlot()
  166. def loadMoreProjects(self) -> None:
  167. """
  168. Initiates the process of retrieving the next page of the projects list from the API.
  169. """
  170. self._api.getMoreProjects(on_finished = self.loadMoreProjectsFinished, failed = self._onGetProjectsFailed)
  171. self.setRetrievingProjectsStatus(self.RetrievalStatus.InProgress)
  172. def loadMoreProjectsFinished(self, df_projects: List[DigitalFactoryProjectResponse]) -> None:
  173. """
  174. Set the projects received from the digital factory library in the project model. Called whenever the retrieval
  175. of the projects is successful.
  176. :param df_projects: A list of all the Digital Factory Library projects linked to the user's account
  177. """
  178. self.setHasMoreProjectsToLoad(self._api.hasMoreProjectsToLoad())
  179. self._project_model.extendProjects(df_projects)
  180. self.setRetrievingProjectsStatus(self.RetrievalStatus.Success)
  181. def _onGetProjectsFailed(self, reply: QNetworkReply, error: QNetworkReply.NetworkError) -> None:
  182. """
  183. Error function, called whenever the retrieval of projects fails.
  184. """
  185. self.setRetrievingProjectsStatus(self.RetrievalStatus.Failed)
  186. Logger.log("w", "Failed to retrieve the list of projects from the Digital Library. Error encountered: {}".format(error))
  187. def getProjectFilesFinished(self, df_files_in_project: List[DigitalFactoryFileResponse]) -> None:
  188. """
  189. Set the files received from the digital factory library in the file model. The files are filtered to only
  190. contain the files which can be opened by Cura.
  191. Called whenever the retrieval of the files is successful.
  192. :param df_files_in_project: A list of all the Digital Factory Library files that exist in a library project
  193. """
  194. # Filter to show only the files that can be opened in Cura
  195. 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
  196. self._file_model.setFiles(df_files_in_project)
  197. self.setRetrievingFilesStatus(self.RetrievalStatus.Success)
  198. def getProjectFilesFailed(self, reply: QNetworkReply, error: QNetworkReply.NetworkError) -> None:
  199. """
  200. Error function, called whenever the retrieval of the files in a library project fails.
  201. """
  202. try:
  203. Logger.warning(f"Failed to retrieve the list of files in project '{self._project_model._projects[self._selected_project_idx]}' from the Digital Library")
  204. except IndexError:
  205. Logger.warning(f"Failed to retrieve the list of files in a project from the Digital Library. And failed to get the project too.")
  206. self.setRetrievingFilesStatus(self.RetrievalStatus.Failed)
  207. @pyqtSlot()
  208. def clearProjectSelection(self) -> None:
  209. """
  210. Clear the selected project.
  211. """
  212. if self._has_preselected_project:
  213. self.setHasPreselectedProject(False)
  214. else:
  215. self.setSelectedProjectIndex(-1)
  216. @pyqtSlot(int)
  217. def setSelectedProjectIndex(self, project_idx: int) -> None:
  218. """
  219. Sets the index of the project which is currently selected in the dropdown menu. Then, it uses the project_id of
  220. that project to retrieve the list of files included in that project and display it in the interface.
  221. :param project_idx: The index of the currently selected project
  222. """
  223. if project_idx < -1 or project_idx >= len(self._project_model.items):
  224. Logger.log("w", "The selected project index is invalid.")
  225. project_idx = -1 # -1 is a valid index for the combobox and it is handled as "nothing is selected"
  226. self._selected_project_idx = project_idx
  227. self.selectedProjectIndexChanged.emit(project_idx)
  228. # Clear the files from the previously-selected project and refresh the files model with the newly-selected-
  229. # project's files
  230. self._file_model.clearFiles()
  231. self.selectedFileIndicesChanged.emit([])
  232. if 0 <= project_idx < len(self._project_model.items):
  233. library_project_id = self._project_model.items[project_idx]["libraryProjectId"]
  234. self.setRetrievingFilesStatus(self.RetrievalStatus.InProgress)
  235. self._api.getListOfFilesInProject(library_project_id, on_finished = self.getProjectFilesFinished, failed = self.getProjectFilesFailed)
  236. @pyqtProperty(int, fset = setSelectedProjectIndex, notify = selectedProjectIndexChanged)
  237. def selectedProjectIndex(self) -> int:
  238. return self._selected_project_idx
  239. @pyqtSlot("QList<int>")
  240. def setSelectedFileIndices(self, file_indices: List[int]) -> None:
  241. """
  242. Sets the index of the file which is currently selected in the list of files.
  243. :param file_indices: The index of the currently selected file
  244. """
  245. if file_indices != self._selected_file_indices:
  246. self._selected_file_indices = file_indices
  247. self.selectedFileIndicesChanged.emit(file_indices)
  248. def setProjectFilter(self, new_filter: str) -> None:
  249. """
  250. Called when the user wants to change the search filter for projects.
  251. The filter is not immediately applied. There is some delay to allow the user to finish typing.
  252. :param new_filter: The new filter that the user wants to apply.
  253. """
  254. self._project_filter = new_filter
  255. self._project_filter_change_timer.start()
  256. """
  257. Signal to notify Qt that the applied filter has changed.
  258. """
  259. projectFilterChanged = pyqtSignal()
  260. @pyqtProperty(str, notify = projectFilterChanged, fset = setProjectFilter)
  261. def projectFilter(self) -> str:
  262. """
  263. The current search filter being applied to the project list.
  264. :return: The current search filter being applied to the project list.
  265. """
  266. return self._project_filter
  267. def _applyProjectFilter(self) -> None:
  268. """
  269. Actually apply the current filter to search for projects with the user-defined search string.
  270. :return:
  271. """
  272. self.clear()
  273. self.projectFilterChanged.emit()
  274. self._api.getProjectsFirstPage(search_filter = self._project_filter, on_finished = self._onGetProjectsFirstPageFinished, failed = self._onGetProjectsFailed)
  275. @pyqtProperty(QObject, constant = True)
  276. def digitalFactoryProjectModel(self) -> "DigitalFactoryProjectModel":
  277. return self._project_model
  278. @pyqtProperty(QObject, constant = True)
  279. def digitalFactoryFileModel(self) -> "DigitalFactoryFileModel":
  280. return self._file_model
  281. def setHasMoreProjectsToLoad(self, has_more_projects_to_load: bool) -> None:
  282. """
  283. Set the value that indicates whether there are more pages of projects that can be loaded from the API
  284. :param has_more_projects_to_load: Whether there are more pages of projects
  285. """
  286. if has_more_projects_to_load != self._has_more_projects_to_load:
  287. self._has_more_projects_to_load = has_more_projects_to_load
  288. self.hasMoreProjectsToLoadChanged.emit()
  289. @pyqtProperty(bool, fset = setHasMoreProjectsToLoad, notify = hasMoreProjectsToLoadChanged)
  290. def hasMoreProjectsToLoad(self) -> bool:
  291. """
  292. :return: whether there are more pages for projects that can be loaded from the API
  293. """
  294. return self._has_more_projects_to_load
  295. @pyqtSlot(str)
  296. def createLibraryProjectAndSetAsPreselected(self, project_name: Optional[str]) -> None:
  297. """
  298. Creates a new project with the given name in the Digital Library.
  299. :param project_name: The name that will be used for the new project
  300. """
  301. if project_name:
  302. self._api.createNewProject(project_name, self.setProjectAsPreselected, self._createNewLibraryProjectFailed)
  303. self.setCreatingNewProjectStatus(self.RetrievalStatus.InProgress)
  304. else:
  305. Logger.log("w", "No project name provided while attempting to create a new project. Aborting the project creation.")
  306. def _createNewLibraryProjectFailed(self, reply: QNetworkReply, error: QNetworkReply.NetworkError) -> None:
  307. reply_string = bytes(reply.readAll()).decode()
  308. self._project_creation_error_text = "Something went wrong while creating the new project. Please try again."
  309. if reply_string:
  310. reply_dict = json.loads(reply_string)
  311. if "errors" in reply_dict and len(reply_dict["errors"]) >= 1 and "title" in reply_dict["errors"][0]:
  312. self._project_creation_error_text = "Error while creating the new project: {}".format(reply_dict["errors"][0]["title"])
  313. self.projectCreationErrorTextChanged.emit()
  314. self.setCreatingNewProjectStatus(self.RetrievalStatus.Failed)
  315. Logger.log("e", "Something went wrong while trying to create a new a project. Error: {}".format(reply_string))
  316. def setRetrievingProjectsStatus(self, new_status: RetrievalStatus) -> None:
  317. """
  318. Sets the status of the "retrieving library projects" http call.
  319. :param new_status: The new status
  320. """
  321. self.retrieving_projects_status = new_status
  322. self.retrievingProjectsStatusChanged.emit(int(new_status))
  323. @pyqtProperty(int, fset = setRetrievingProjectsStatus, notify = retrievingProjectsStatusChanged)
  324. def retrievingProjectsStatus(self) -> int:
  325. return int(self.retrieving_projects_status)
  326. def setRetrievingFilesStatus(self, new_status: RetrievalStatus) -> None:
  327. """
  328. Sets the status of the "retrieving files list in the selected library project" http call.
  329. :param new_status: The new status
  330. """
  331. self.retrieving_files_status = new_status
  332. self.retrievingFilesStatusChanged.emit(int(new_status))
  333. @pyqtProperty(int, fset = setRetrievingFilesStatus, notify = retrievingFilesStatusChanged)
  334. def retrievingFilesStatus(self) -> int:
  335. return int(self.retrieving_files_status)
  336. def setCreatingNewProjectStatus(self, new_status: RetrievalStatus) -> None:
  337. """
  338. Sets the status of the "creating new library project" http call.
  339. :param new_status: The new status
  340. """
  341. self.creating_new_project_status = new_status
  342. self.creatingNewProjectStatusChanged.emit(int(new_status))
  343. @pyqtProperty(int, fset = setCreatingNewProjectStatus, notify = creatingNewProjectStatusChanged)
  344. def creatingNewProjectStatus(self) -> int:
  345. return int(self.creating_new_project_status)
  346. @staticmethod
  347. def _onEngineCreated() -> None:
  348. qmlRegisterUncreatableMetaObject(DigitalFactoryController.staticMetaObject, "DigitalFactory", 1, 0, "RetrievalStatus", "RetrievalStatus is an Enum-only type")
  349. def _applicationInitializationFinished(self) -> None:
  350. self._supported_file_types = self._application.getInstance().getMeshFileHandler().getSupportedFileTypesRead()
  351. # Although Cura supports these, it's super confusing in this context to show them.
  352. for extension in ["jpg", "jpeg", "png", "bmp", "gif"]:
  353. if extension in self._supported_file_types:
  354. del self._supported_file_types[extension]
  355. @pyqtSlot()
  356. def openSelectedFiles(self) -> None:
  357. """ Downloads, then opens all files selected in the Qt frontend open dialog.
  358. """
  359. temp_dir = tempfile.mkdtemp()
  360. if temp_dir is None or temp_dir == "":
  361. Logger.error("Digital Library: Couldn't create temporary directory to store to-be downloaded files.")
  362. return
  363. if self._selected_project_idx < 0 or len(self._selected_file_indices) < 1:
  364. Logger.error("Digital Library: No project or no file selected on open action.")
  365. return
  366. to_erase_on_done_set = {
  367. os.path.join(temp_dir, self._file_model.getItem(i)["fileName"]).replace('\\', '/')
  368. for i in self._selected_file_indices}
  369. def onLoadedCallback(filename_done: str) -> None:
  370. filename_done = os.path.join(temp_dir, filename_done).replace('\\', '/')
  371. with self._erase_temp_files_lock:
  372. if filename_done in to_erase_on_done_set:
  373. try:
  374. os.remove(filename_done)
  375. to_erase_on_done_set.remove(filename_done)
  376. if len(to_erase_on_done_set) < 1 and os.path.exists(temp_dir):
  377. os.rmdir(temp_dir)
  378. except (IOError, OSError) as ex:
  379. Logger.error("Can't erase temporary (in) {0} because {1}.", temp_dir, str(ex))
  380. # Save the project id to make sure it will be preselected the next time the user opens the save dialog
  381. CuraApplication.getInstance().getCurrentWorkspaceInformation().setEntryToStore("digital_factory", "library_project_id", library_project_id)
  382. # Disconnect the signals so that they are not fired every time another (project) file is loaded
  383. app.fileLoaded.disconnect(onLoadedCallback)
  384. app.workspaceLoaded.disconnect(onLoadedCallback)
  385. app = CuraApplication.getInstance()
  386. app.fileLoaded.connect(onLoadedCallback) # fired when non-project files are loaded
  387. app.workspaceLoaded.connect(onLoadedCallback) # fired when project files are loaded
  388. project_name = self._project_model.getItem(self._selected_project_idx)["displayName"]
  389. for file_index in self._selected_file_indices:
  390. file_item = self._file_model.getItem(file_index)
  391. file_name = file_item["fileName"]
  392. download_url = file_item["downloadUrl"]
  393. library_project_id = file_item["libraryProjectId"]
  394. self._openSelectedFile(temp_dir, project_name, file_name, download_url)
  395. def _openSelectedFile(self, temp_dir: str, project_name: str, file_name: str, download_url: str) -> None:
  396. """ Downloads, then opens, the single specified file.
  397. :param temp_dir: The already created temporary directory where the files will be stored.
  398. :param project_name: Name of the project the file belongs to (used for error reporting).
  399. :param file_name: Name of the file to be downloaded and opened (used for error reporting).
  400. :param download_url: This url will be downloaded, then the downloaded file will be opened in Cura.
  401. """
  402. if not download_url:
  403. Logger.log("e", "No download url for file '{}'".format(file_name))
  404. return
  405. progress_message = Message(text = "{0}/{1}".format(project_name, file_name), dismissable = False, lifetime = 0,
  406. progress = 0, title = "Downloading...")
  407. progress_message.setProgress(0)
  408. progress_message.show()
  409. def progressCallback(rx: int, rt: int) -> None:
  410. progress_message.setProgress(math.floor(rx * 100.0 / rt))
  411. def finishedCallback(reply: QNetworkReply) -> None:
  412. progress_message.hide()
  413. try:
  414. with open(os.path.join(temp_dir, file_name), "wb+") as temp_file:
  415. bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE)
  416. while bytes_read:
  417. temp_file.write(bytes_read)
  418. bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE)
  419. CuraApplication.getInstance().processEvents()
  420. temp_file_name = temp_file.name
  421. except IOError as ex:
  422. Logger.logException("e", "Can't write Digital Library file {0}/{1} download to temp-directory {2}.",
  423. ex, project_name, file_name, temp_dir)
  424. getBackwardsCompatibleMessage(
  425. text = "Failed to write to temporary file for '{}'.".format(file_name),
  426. title = "File-system error",
  427. message_type_str="ERROR",
  428. lifetime = 10
  429. ).show()
  430. return
  431. CuraApplication.getInstance().readLocalFile(
  432. QUrl.fromLocalFile(temp_file_name), add_to_recent_files = False)
  433. def errorCallback(reply: QNetworkReply, error: QNetworkReply.NetworkError, p = project_name,
  434. f = file_name) -> None:
  435. progress_message.hide()
  436. Logger.error("An error {0} {1} occurred while downloading {2}/{3}".format(str(error), str(reply), p, f))
  437. getBackwardsCompatibleMessage(
  438. text = "Failed Digital Library download for '{}'.".format(f),
  439. title = "Network error {}".format(error),
  440. message_type_str="ERROR",
  441. lifetime = 10
  442. ).show()
  443. download_manager = HttpRequestManager.getInstance()
  444. download_manager.get(download_url, callback = finishedCallback, download_progress_callback = progressCallback,
  445. error_callback = errorCallback, scope = UltimakerCloudScope(CuraApplication.getInstance()))
  446. def setHasPreselectedProject(self, new_has_preselected_project: bool) -> None:
  447. if not new_has_preselected_project:
  448. # The preselected project was the only one in the model, at index 0, so when we set the has_preselected_project to
  449. # false, we also need to clean it from the projects model
  450. self._project_model.clearProjects()
  451. self.setSelectedProjectIndex(-1)
  452. self._api.getProjectsFirstPage(search_filter = self._project_filter, on_finished = self._onGetProjectsFirstPageFinished, failed = self._onGetProjectsFailed)
  453. self._api.checkUserCanCreateNewLibraryProject(callback = self.setCanCreateNewLibraryProject)
  454. self.setRetrievingProjectsStatus(self.RetrievalStatus.InProgress)
  455. self._has_preselected_project = new_has_preselected_project
  456. self.preselectedProjectChanged.emit()
  457. @pyqtProperty(bool, fset = setHasPreselectedProject, notify = preselectedProjectChanged)
  458. def hasPreselectedProject(self) -> bool:
  459. return self._has_preselected_project
  460. def setCanCreateNewLibraryProject(self, can_create_new_library_project: bool) -> None:
  461. self._user_account_can_create_new_project = can_create_new_library_project
  462. self.userCanCreateNewLibraryProjectChanged.emit(self._user_account_can_create_new_project)
  463. @pyqtProperty(bool, fset = setCanCreateNewLibraryProject, notify = userCanCreateNewLibraryProjectChanged)
  464. def userAccountCanCreateNewLibraryProject(self) -> bool:
  465. return self._user_account_can_create_new_project
  466. @pyqtSlot(str, "QStringList")
  467. def saveFileToSelectedProject(self, filename: str, formats: List[str]) -> None:
  468. """
  469. Function triggered whenever the Save button is pressed.
  470. :param filename: The name (without the extension) that will be used for the files
  471. :param formats: List of the formats the scene will be exported to. Can include 3mf, ufp, or both
  472. """
  473. if self._selected_project_idx == -1:
  474. Logger.log("e", "No DF Library project is selected.")
  475. return
  476. if filename == "":
  477. Logger.log("w", "The file name cannot be empty.")
  478. getBackwardsCompatibleMessage(
  479. text = "Cannot upload file with an empty name to the Digital Library",
  480. title = "Empty file name provided",
  481. message_type_str = "ERROR",
  482. lifetime = 0
  483. ).show()
  484. return
  485. self._saveFileToSelectedProjectHelper(filename, formats)
  486. def _saveFileToSelectedProjectHelper(self, filename: str, formats: List[str]) -> None:
  487. # Indicate we have started sending a job (and propagate any user file name changes back to the open project)
  488. self.uploadStarted.emit(filename if "3mf" in formats else None)
  489. library_project_id = self._project_model.items[self._selected_project_idx]["libraryProjectId"]
  490. library_project_name = self._project_model.items[self._selected_project_idx]["displayName"]
  491. # Use the file upload manager to export and upload the 3mf and/or ufp files to the DF Library project
  492. self.file_upload_manager = DFFileExportAndUploadManager(file_handlers = self.file_handlers, nodes = cast(List[SceneNode], self.nodes),
  493. library_project_id = library_project_id,
  494. library_project_name = library_project_name,
  495. file_name = filename, formats = formats,
  496. on_upload_error = self.uploadFileError.emit,
  497. on_upload_success = self.uploadFileSuccess.emit,
  498. on_upload_finished = self.uploadFileFinished.emit,
  499. on_upload_progress = self.uploadFileProgress.emit)
  500. self.file_upload_manager.start()
  501. # Save the project id to make sure it will be preselected the next time the user opens the save dialog
  502. self._current_workspace_information.setEntryToStore("digital_factory", "library_project_id", library_project_id)
  503. @pyqtProperty(str, notify = projectCreationErrorTextChanged)
  504. def projectCreationErrorText(self) -> str:
  505. return self._project_creation_error_text