PackageList.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  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, 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
  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. def __del__(self) -> None:
  46. """ When this object is deleted it will loop through all registered API requests and aborts them """
  47. self.cleanUpAPIRequest()
  48. def abortRequest(self, request_id: str) -> None:
  49. """Aborts a single request"""
  50. if request_id in self._ongoing_requests and self._ongoing_requests[request_id]:
  51. HttpRequestManager.getInstance().abortRequest(self._ongoing_requests[request_id])
  52. self._ongoing_requests[request_id] = None
  53. @pyqtSlot()
  54. def cleanUpAPIRequest(self) -> None:
  55. for request_id in self._ongoing_requests:
  56. self.abortRequest(request_id)
  57. @pyqtSlot()
  58. def updatePackages(self) -> None:
  59. """ A Qt slot which will update the List from a source. Actual implementation should be done in the child class"""
  60. pass
  61. def reset(self) -> None:
  62. """ Resets and clears the list"""
  63. self.clear()
  64. isLoadingChanged = pyqtSignal()
  65. def setIsLoading(self, value: bool) -> None:
  66. if self._is_loading != value:
  67. self._is_loading = value
  68. self.isLoadingChanged.emit()
  69. @pyqtProperty(bool, fset = setIsLoading, notify = isLoadingChanged)
  70. def isLoading(self) -> bool:
  71. """ Indicating if the the packages are loading
  72. :return" ``True`` if the list is being obtained, otherwise ``False``
  73. """
  74. return self._is_loading
  75. hasMoreChanged = pyqtSignal()
  76. def setHasMore(self, value: bool) -> None:
  77. if self._has_more != value:
  78. self._has_more = value
  79. self.hasMoreChanged.emit()
  80. @pyqtProperty(bool, fset = setHasMore, notify = hasMoreChanged)
  81. def hasMore(self) -> bool:
  82. """ Indicating if there are more packages available to load.
  83. :return: ``True`` if there are more packages to load, or ``False``.
  84. """
  85. return self._has_more
  86. errorMessageChanged = pyqtSignal()
  87. def setErrorMessage(self, error_message: str) -> None:
  88. if self._error_message != error_message:
  89. self._error_message = error_message
  90. self.errorMessageChanged.emit()
  91. @pyqtProperty(str, notify = errorMessageChanged, fset = setErrorMessage)
  92. def errorMessage(self) -> str:
  93. """ If an error occurred getting the list of packages, an error message will be held here.
  94. If no error occurred (yet), this will be an empty string.
  95. :return: An error message, if any, or an empty string if everything went okay.
  96. """
  97. return self._error_message
  98. @pyqtProperty(bool, constant = True)
  99. def hasFooter(self) -> bool:
  100. """ Indicating if the PackageList should have a Footer visible. For paginated PackageLists
  101. :return: ``True`` if a Footer should be displayed in the ListView, e.q.: paginated lists, ``False`` Otherwise"""
  102. return self._has_footer
  103. def getPackageModel(self, package_id: str) -> PackageModel:
  104. index = self.find("package", package_id)
  105. return self.getItem(index)["package"]
  106. canInstallChanged = pyqtSignal(str, bool)
  107. def _openLicenseDialog(self, package_id: str, license_content: str) -> None:
  108. plugin_path = self._plugin_registry.getPluginPath("Marketplace")
  109. if plugin_path is None:
  110. plugin_path = os.path.dirname(__file__)
  111. # create a QML component for the license dialog
  112. license_dialog_component_path = os.path.join(plugin_path, "resources", "qml", "LicenseDialog.qml")
  113. dialog = CuraApplication.getInstance().createQmlComponent(license_dialog_component_path, {
  114. "licenseContent": license_content,
  115. "packageId": package_id,
  116. "handler": self
  117. })
  118. dialog.show()
  119. # place dialog in class such that it does not get remove by garbage collector
  120. self._license_dialogs[package_id] = dialog
  121. @pyqtSlot(str)
  122. def onLicenseAccepted(self, package_id: str) -> None:
  123. # close dialog
  124. dialog = self._license_dialogs.pop(package_id)
  125. if dialog is not None:
  126. dialog.deleteLater()
  127. # install relevant package
  128. self._install(package_id)
  129. @pyqtSlot(str)
  130. def onLicenseDeclined(self, package_id: str) -> None:
  131. # close dialog
  132. dialog = self._license_dialogs.pop(package_id)
  133. if dialog is not None:
  134. dialog.deleteLater()
  135. # reset package card
  136. package = self.getPackageModel(package_id)
  137. def _requestInstall(self, package_id: str, update: bool = False) -> None:
  138. package_path = self._to_install[package_id]
  139. license_content = self._manager.getPackageLicense(package_path)
  140. if not update and license_content is not None:
  141. # If installation is not and update, and the packages contains a license then
  142. # open dialog, prompting the using to accept the plugin license
  143. self._openLicenseDialog(package_id, license_content)
  144. else:
  145. # Otherwise continue the installation
  146. self._install(package_id, update)
  147. def _install(self, package_id: str, update: bool = False) -> None:
  148. package_path = self._to_install.pop(package_id)
  149. to_be_installed = self._manager.installPackage(package_path) is not None
  150. package = self.getPackageModel(package_id)
  151. # TODO handle failure
  152. self.subscribeUserToPackage(package_id, str(package.sdk_version))
  153. def download(self, package_id: str, url: str, update: bool = False) -> None:
  154. """Initiate the download request
  155. :param package_id: the package identification string
  156. :param url: the URL from which the package needs to be obtained
  157. :param update: A flag if this is download request is an update process
  158. """
  159. def downloadFinished(reply: "QNetworkReply") -> None:
  160. self._downloadFinished(package_id, reply, update)
  161. def downloadError(reply: "QNetworkReply", error: "QNetworkReply.NetworkError") -> None:
  162. self._downloadError(package_id, update, reply, error)
  163. self._ongoing_requests["download_package"] = HttpRequestManager.getInstance().get(
  164. url,
  165. scope = self._scope,
  166. callback = downloadFinished,
  167. error_callback = downloadError
  168. )
  169. def _downloadFinished(self, package_id: str, reply: "QNetworkReply", update: bool = False) -> None:
  170. try:
  171. with tempfile.NamedTemporaryFile(mode = "wb+", suffix = ".curapackage", delete = False) as temp_file:
  172. bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE)
  173. while bytes_read:
  174. temp_file.write(bytes_read)
  175. bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE)
  176. self._to_install[package_id] = temp_file.name
  177. self._ongoing_requests["download_package"] = None
  178. self.canInstallChanged.emit(package_id, update)
  179. except IOError as e:
  180. Logger.error(f"Failed to write downloaded package to temp file {e}")
  181. temp_file.close()
  182. self._downloadError(package_id, update)
  183. except RuntimeError:
  184. # Setting the ownership of this object to not qml can still result in a RuntimeError. Which can occur when quickly toggling
  185. # between de-/constructing Remote or Local PackageLists. This try-except is here to prevent a hard crash when the wrapped C++ object
  186. # was deleted when it was still parsing the response
  187. return
  188. def _downloadError(self, package_id: str, update: bool = False, reply: Optional["QNetworkReply"] = None, error: Optional["QNetworkReply.NetworkError"] = None) -> None:
  189. if reply:
  190. reply_string = bytes(reply.readAll()).decode()
  191. Logger.error(f"Failed to download package: {package_id} due to {reply_string}")
  192. try:
  193. package = self.getPackageModel(package_id)
  194. # TODO: handle error
  195. except RuntimeError:
  196. # Setting the ownership of this object to not qml can still result in a RuntimeError. Which can occur when quickly toggling
  197. # between de-/constructing Remote or Local PackageLists. This try-except is here to prevent a hard crash when the wrapped C++ object
  198. # was deleted when it was still parsing the response
  199. return
  200. def subscribeUserToPackage(self, package_id: str, sdk_version: str) -> None:
  201. """Subscribe the user (if logged in) to the package for a given SDK
  202. :param package_id: the package identification string
  203. :param sdk_version: the SDK version
  204. """
  205. if self._account.isLoggedIn:
  206. HttpRequestManager.getInstance().put(
  207. url = USER_PACKAGES_URL,
  208. data = json.dumps({"data": {"package_id": package_id, "sdk_version": sdk_version}}).encode(),
  209. scope = self._scope
  210. )
  211. def unsunscribeUserFromPackage(self, package_id: str) -> None:
  212. """Unsubscribe the user (if logged in) from the package
  213. :param package_id: the package identification string
  214. """
  215. if self._account.isLoggedIn:
  216. HttpRequestManager.getInstance().delete(url = f"{USER_PACKAGES_URL}/{package_id}", scope = self._scope)
  217. # --- Handle the manage package buttons ---
  218. def _connectManageButtonSignals(self, package: PackageModel) -> None:
  219. package.installPackageTriggered.connect(self.installPackage)
  220. package.uninstallPackageTriggered.connect(self.uninstallPackage)
  221. package.updatePackageTriggered.connect(self.updatePackage)
  222. package.enablePackageTriggered.connect(self._plugin_registry.enablePlugin)
  223. package.disablePackageTriggered.connect(self._plugin_registry.disablePlugin)
  224. def installPackage(self, package_id: str) -> None:
  225. """Install a package from the Marketplace
  226. :param package_id: the package identification string
  227. """
  228. package = self.getPackageModel(package_id)
  229. url = package.download_url
  230. self.download(package_id, url, False)
  231. def uninstallPackage(self, package_id: str) -> None:
  232. """Uninstall a package from the Marketplace
  233. :param package_id: the package identification string
  234. """
  235. package = self.getPackageModel(package_id)
  236. self._manager.removePackage(package_id)
  237. self.unsunscribeUserFromPackage(package_id)
  238. def updatePackage(self, package_id: str) -> None:
  239. """Update a package from the Marketplace
  240. :param package_id: the package identification string
  241. """
  242. package = self.getPackageModel(package_id)
  243. self._manager.removePackage(package_id, force_add = True)
  244. url = package.download_url
  245. self.download(package_id, url, True)