PackageList.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319
  1. # Copyright (c) 2021 Ultimaker B.V.
  2. # Cura is released under the terms of the LGPLv3 or higher.
  3. import tempfile
  4. import json
  5. import os.path
  6. from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, Qt
  7. from typing import cast, Dict, List, Optional, Set, TYPE_CHECKING
  8. from UM.i18n import i18nCatalog
  9. from UM.Qt.ListModel import ListModel
  10. from UM.TaskManagement.HttpRequestScope import JsonDecoratorScope
  11. from UM.TaskManagement.HttpRequestManager import HttpRequestData, HttpRequestManager
  12. from UM.Logger import Logger
  13. from UM import PluginRegistry
  14. from cura.CuraApplication import CuraApplication
  15. from cura.CuraPackageManager import CuraPackageManager
  16. from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope # To make requests to the Ultimaker API with correct authorization.
  17. from .PackageModel import PackageModel, ManageState
  18. from .Constants import USER_PACKAGES_URL
  19. if TYPE_CHECKING:
  20. from PyQt5.QtCore import QObject
  21. from PyQt5.QtNetwork import QNetworkReply
  22. catalog = i18nCatalog("cura")
  23. class PackageList(ListModel):
  24. """ A List model for Packages, this class serves as parent class for more detailed implementations.
  25. such as Packages obtained from Remote or Local source
  26. """
  27. PackageRole = Qt.UserRole + 1
  28. DISK_WRITE_BUFFER_SIZE = 256 * 1024 # 256 KB
  29. def __init__(self, parent: Optional["QObject"] = None) -> None:
  30. super().__init__(parent)
  31. self._manager: CuraPackageManager = cast(CuraPackageManager, CuraApplication.getInstance().getPackageManager())
  32. self._plugin_registry: PluginRegistry = CuraApplication.getInstance().getPluginRegistry()
  33. self._account = CuraApplication.getInstance().getCuraAPI().account
  34. self._error_message = ""
  35. self.addRoleName(self.PackageRole, "package")
  36. self._is_loading = False
  37. self._has_more = False
  38. self._has_footer = True
  39. self._to_install: Dict[str, str] = {}
  40. self.canInstallChanged.connect(self._requestInstall)
  41. self._local_packages: Set[str] = {p["package_id"] for p in self._manager.local_packages}
  42. self._ongoing_requests: Dict[str, Optional[HttpRequestData]] = {"download_package": None}
  43. self._scope = JsonDecoratorScope(UltimakerCloudScope(CuraApplication.getInstance()))
  44. self._license_dialogs: Dict[str, QObject] = {}
  45. @pyqtSlot()
  46. def updatePackages(self) -> None:
  47. """ A Qt slot which will update the List from a source. Actual implementation should be done in the child class"""
  48. pass
  49. @pyqtSlot()
  50. def abortUpdating(self) -> None:
  51. """ A Qt slot which allows the update process to be aborted. Override this for child classes with async/callback
  52. updatePackges methods"""
  53. pass
  54. def reset(self) -> None:
  55. """ Resets and clears the list"""
  56. self.clear()
  57. isLoadingChanged = pyqtSignal()
  58. def setIsLoading(self, value: bool) -> None:
  59. if self._is_loading != value:
  60. self._is_loading = value
  61. self.isLoadingChanged.emit()
  62. @pyqtProperty(bool, fset = setIsLoading, notify = isLoadingChanged)
  63. def isLoading(self) -> bool:
  64. """ Indicating if the the packages are loading
  65. :return" ``True`` if the list is being obtained, otherwise ``False``
  66. """
  67. return self._is_loading
  68. hasMoreChanged = pyqtSignal()
  69. def setHasMore(self, value: bool) -> None:
  70. if self._has_more != value:
  71. self._has_more = value
  72. self.hasMoreChanged.emit()
  73. @pyqtProperty(bool, fset = setHasMore, notify = hasMoreChanged)
  74. def hasMore(self) -> bool:
  75. """ Indicating if there are more packages available to load.
  76. :return: ``True`` if there are more packages to load, or ``False``.
  77. """
  78. return self._has_more
  79. errorMessageChanged = pyqtSignal()
  80. def setErrorMessage(self, error_message: str) -> None:
  81. if self._error_message != error_message:
  82. self._error_message = error_message
  83. self.errorMessageChanged.emit()
  84. @pyqtProperty(str, notify = errorMessageChanged, fset = setErrorMessage)
  85. def errorMessage(self) -> str:
  86. """ If an error occurred getting the list of packages, an error message will be held here.
  87. If no error occurred (yet), this will be an empty string.
  88. :return: An error message, if any, or an empty string if everything went okay.
  89. """
  90. return self._error_message
  91. @pyqtProperty(bool, constant = True)
  92. def hasFooter(self) -> bool:
  93. """ Indicating if the PackageList should have a Footer visible. For paginated PackageLists
  94. :return: ``True`` if a Footer should be displayed in the ListView, e.q.: paginated lists, ``False`` Otherwise"""
  95. return self._has_footer
  96. def getPackageModel(self, package_id: str) -> PackageModel:
  97. index = self.find("package", package_id)
  98. return self.getItem(index)["package"]
  99. canInstallChanged = pyqtSignal(str, bool)
  100. def _openLicenseDialog(self, package_id: str, license_content: str) -> None:
  101. plugin_path = self._plugin_registry.getPluginPath("Marketplace")
  102. if plugin_path is None:
  103. plugin_path = os.path.dirname(__file__)
  104. # create a QML component for the license dialog
  105. license_dialog_component_path = os.path.join(plugin_path, "resources", "qml", "LicenseDialog.qml")
  106. dialog = CuraApplication.getInstance().createQmlComponent(license_dialog_component_path, {
  107. "licenseContent": license_content,
  108. "packageId": package_id,
  109. "handler": self
  110. })
  111. dialog.show()
  112. # place dialog in class such that it does not get remove by garbage collector
  113. self._license_dialogs[package_id] = dialog
  114. @pyqtSlot(str)
  115. def onLicenseAccepted(self, package_id: str) -> None:
  116. # close dialog
  117. dialog = self._license_dialogs.pop(package_id)
  118. if dialog is not None:
  119. dialog.deleteLater()
  120. # install relevant package
  121. self._install(package_id)
  122. @pyqtSlot(str)
  123. def onLicenseDeclined(self, package_id: str) -> None:
  124. # close dialog
  125. dialog = self._license_dialogs.pop(package_id)
  126. if dialog is not None:
  127. dialog.deleteLater()
  128. # reset package card
  129. package = self.getPackageModel(package_id)
  130. package.is_installing = ManageState.FAILED
  131. def _requestInstall(self, package_id: str, update: bool = False) -> None:
  132. package_path = self._to_install[package_id]
  133. license_content = self._manager.getPackageLicense(package_path)
  134. if not update and license_content is not None:
  135. # If installation is not and update, and the packages contains a license then
  136. # open dialog, prompting the using to accept the plugin license
  137. self._openLicenseDialog(package_id, license_content)
  138. else:
  139. # Otherwise continue the installation
  140. self._install(package_id, update)
  141. def _install(self, package_id: str, update: bool = False) -> None:
  142. package_path = self._to_install.pop(package_id)
  143. to_be_installed = self._manager.installPackage(package_path) is not None
  144. package = self.getPackageModel(package_id)
  145. if package.can_update and to_be_installed:
  146. package.can_update = False
  147. if update:
  148. package.is_updating = ManageState.HALTED
  149. else:
  150. package.is_installing = ManageState.HALTED
  151. self.subscribeUserToPackage(package_id, str(package.sdk_version))
  152. def download(self, package_id: str, url: str, update: bool = False) -> None:
  153. """Initiate the download request
  154. :param package_id: the package identification string
  155. :param url: the URL from which the package needs to be obtained
  156. :param update: A flag if this is download request is an update process
  157. """
  158. def downloadFinished(reply: "QNetworkReply") -> None:
  159. self._downloadFinished(package_id, reply, update)
  160. def downloadError(reply: "QNetworkReply", error: "QNetworkReply.NetworkError") -> None:
  161. self._downloadError(package_id, update, reply, error)
  162. self._ongoing_requests["download_package"] = HttpRequestManager.getInstance().get(
  163. url,
  164. scope = self._scope,
  165. callback = downloadFinished,
  166. error_callback = downloadError
  167. )
  168. def _downloadFinished(self, package_id: str, reply: "QNetworkReply", update: bool = False) -> None:
  169. try:
  170. with tempfile.NamedTemporaryFile(mode = "wb+", suffix = ".curapackage", delete = False) as temp_file:
  171. bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE)
  172. while bytes_read:
  173. temp_file.write(bytes_read)
  174. bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE)
  175. self._to_install[package_id] = temp_file.name
  176. self._ongoing_requests["download_package"] = None
  177. self.canInstallChanged.emit(package_id, update)
  178. except IOError as e:
  179. Logger.error(f"Failed to write downloaded package to temp file {e}")
  180. temp_file.close()
  181. self._downloadError(package_id, update)
  182. def _downloadError(self, package_id: str, update: bool = False, reply: Optional["QNetworkReply"] = None, error: Optional["QNetworkReply.NetworkError"] = None) -> None:
  183. if reply:
  184. reply_string = bytes(reply.readAll()).decode()
  185. Logger.error(f"Failed to download package: {package_id} due to {reply_string}")
  186. package = self.getPackageModel(package_id)
  187. if update:
  188. package.is_updating = ManageState.FAILED
  189. else:
  190. package.is_installing = ManageState.FAILED
  191. def subscribeUserToPackage(self, package_id: str, sdk_version: str) -> None:
  192. """Subscribe the user (if logged in) to the package for a given SDK
  193. :param package_id: the package identification string
  194. :param sdk_version: the SDK version
  195. """
  196. if self._account.isLoggedIn:
  197. HttpRequestManager.getInstance().put(
  198. url = USER_PACKAGES_URL,
  199. data = json.dumps({"data": {"package_id": package_id, "sdk_version": sdk_version}}).encode(),
  200. scope = self._scope
  201. )
  202. def unsunscribeUserFromPackage(self, package_id: str) -> None:
  203. """Unsubscribe the user (if logged in) from the package
  204. :param package_id: the package identification string
  205. """
  206. if self._account.isLoggedIn:
  207. HttpRequestManager.getInstance().delete(url = f"{USER_PACKAGES_URL}/{package_id}", scope = self._scope)
  208. # --- Handle the manage package buttons ---
  209. def _connectManageButtonSignals(self, package: PackageModel) -> None:
  210. package.installPackageTriggered.connect(self.installPackage)
  211. package.uninstallPackageTriggered.connect(self.uninstallPackage)
  212. package.updatePackageTriggered.connect(self.updatePackage)
  213. package.enablePackageTriggered.connect(self.enablePackage)
  214. package.disablePackageTriggered.connect(self.disablePackage)
  215. @pyqtSlot(str)
  216. def installPackage(self, package_id: str) -> None:
  217. """Install a package from the Marketplace
  218. :param package_id: the package identification string
  219. """
  220. package = self.getPackageModel(package_id)
  221. package.is_installing = ManageState.PROCESSING
  222. url = package.download_url
  223. self.download(package_id, url, False)
  224. @pyqtSlot(str)
  225. def uninstallPackage(self, package_id: str) -> None:
  226. """Uninstall a package from the Marketplace
  227. :param package_id: the package identification string
  228. """
  229. package = self.getPackageModel(package_id)
  230. package.is_installing = ManageState.PROCESSING
  231. self._manager.removePackage(package_id)
  232. self.unsunscribeUserFromPackage(package_id)
  233. package.is_installing = ManageState.HALTED
  234. @pyqtSlot(str)
  235. def updatePackage(self, package_id: str) -> None:
  236. """Update a package from the Marketplace
  237. :param package_id: the package identification string
  238. """
  239. package = self.getPackageModel(package_id)
  240. package.is_updating = ManageState.PROCESSING
  241. self._manager.removePackage(package_id, force_add = True)
  242. url = package.download_url
  243. self.download(package_id, url, True)
  244. @pyqtSlot(str)
  245. def enablePackage(self, package_id: str) -> None:
  246. """Enable a package in the plugin registry
  247. :param package_id: the package identification string
  248. """
  249. package = self.getPackageModel(package_id)
  250. package.is_enabling = ManageState.PROCESSING
  251. self._plugin_registry.enablePlugin(package_id)
  252. package.is_active = True
  253. package.is_enabling = ManageState.HALTED
  254. @pyqtSlot(str)
  255. def disablePackage(self, package_id: str) -> None:
  256. """Disable a package in the plugin registry
  257. :param package_id: the package identification string
  258. """
  259. package = self.getPackageModel(package_id)
  260. package.is_enabling = ManageState.PROCESSING
  261. self._plugin_registry.disablePlugin(package_id)
  262. package.is_active = False
  263. package.is_enabling = ManageState.HALTED