PackageList.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245
  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. from PyQt5.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, Qt
  6. from typing import Dict, Optional, Set, TYPE_CHECKING
  7. from UM.i18n import i18nCatalog
  8. from UM.Qt.ListModel import ListModel
  9. from UM.TaskManagement.HttpRequestScope import JsonDecoratorScope
  10. from UM.TaskManagement.HttpRequestManager import HttpRequestData, HttpRequestManager
  11. from UM.Logger import Logger
  12. from UM import PluginRegistry
  13. from cura.CuraApplication import CuraApplication
  14. from cura import CuraPackageManager
  15. from cura.UltimakerCloud.UltimakerCloudScope import UltimakerCloudScope # To make requests to the Ultimaker API with correct authorization.
  16. from .PackageModel import PackageModel
  17. from .Constants import USER_PACKAGES_URL
  18. if TYPE_CHECKING:
  19. from PyQt5.QtCore import QObject
  20. catalog = i18nCatalog("cura")
  21. class PackageList(ListModel):
  22. """ A List model for Packages, this class serves as parent class for more detailed implementations.
  23. such as Packages obtained from Remote or Local source
  24. """
  25. PackageRole = Qt.UserRole + 1
  26. DISK_WRITE_BUFFER_SIZE = 256 * 1024 # 256 KB
  27. def __init__(self, parent: Optional["QObject"] = None) -> None:
  28. super().__init__(parent)
  29. self._manager: CuraPackageManager = CuraApplication.getInstance().getPackageManager()
  30. self._plugin_registry: PluginRegistry = CuraApplication.getInstance().getPluginRegistry()
  31. self._account = CuraApplication.getInstance().getCuraAPI().account
  32. self._error_message = ""
  33. self.addRoleName(self.PackageRole, "package")
  34. self._is_loading = False
  35. self._has_more = False
  36. self._has_footer = True
  37. self._to_install: Dict[str, str] = {}
  38. self.canInstallChanged.connect(self._install)
  39. self._local_packages: Set[str] = {p["package_id"] for p in self._manager.local_packages}
  40. self._ongoing_request: Optional[HttpRequestData] = None
  41. self._scope = JsonDecoratorScope(UltimakerCloudScope(CuraApplication.getInstance()))
  42. @pyqtSlot()
  43. def updatePackages(self) -> None:
  44. """ A Qt slot which will update the List from a source. Actual implementation should be done in the child class"""
  45. pass
  46. @pyqtSlot()
  47. def abortUpdating(self) -> None:
  48. """ A Qt slot which allows the update process to be aborted. Override this for child classes with async/callback
  49. updatePackges methods"""
  50. pass
  51. def reset(self) -> None:
  52. """ Resets and clears the list"""
  53. self.clear()
  54. isLoadingChanged = pyqtSignal()
  55. def setIsLoading(self, value: bool) -> None:
  56. if self._is_loading != value:
  57. self._is_loading = value
  58. self.isLoadingChanged.emit()
  59. @pyqtProperty(bool, fset = setIsLoading, notify = isLoadingChanged)
  60. def isLoading(self) -> bool:
  61. """ Indicating if the the packages are loading
  62. :return" ``True`` if the list is being obtained, otherwise ``False``
  63. """
  64. return self._is_loading
  65. hasMoreChanged = pyqtSignal()
  66. def setHasMore(self, value: bool) -> None:
  67. if self._has_more != value:
  68. self._has_more = value
  69. self.hasMoreChanged.emit()
  70. @pyqtProperty(bool, fset = setHasMore, notify = hasMoreChanged)
  71. def hasMore(self) -> bool:
  72. """ Indicating if there are more packages available to load.
  73. :return: ``True`` if there are more packages to load, or ``False``.
  74. """
  75. return self._has_more
  76. errorMessageChanged = pyqtSignal()
  77. def setErrorMessage(self, error_message: str) -> None:
  78. if self._error_message != error_message:
  79. self._error_message = error_message
  80. self.errorMessageChanged.emit()
  81. @pyqtProperty(str, notify = errorMessageChanged, fset = setErrorMessage)
  82. def errorMessage(self) -> str:
  83. """ If an error occurred getting the list of packages, an error message will be held here.
  84. If no error occurred (yet), this will be an empty string.
  85. :return: An error message, if any, or an empty string if everything went okay.
  86. """
  87. return self._error_message
  88. @pyqtProperty(bool, constant = True)
  89. def hasFooter(self) -> bool:
  90. """ Indicating if the PackageList should have a Footer visible. For paginated PackageLists
  91. :return: ``True`` if a Footer should be displayed in the ListView, e.q.: paginated lists, ``False`` Otherwise"""
  92. return self._has_footer
  93. def getPackageModel(self, package_id: str) -> PackageModel:
  94. index = self.find("package", package_id)
  95. return self.getItem(index)["package"]
  96. canInstallChanged = pyqtSignal(str, bool)
  97. def _install(self, package_id: str, update: bool = False) -> None:
  98. package_path = self._to_install.pop(package_id)
  99. Logger.debug(f"Installing {package_id}")
  100. to_be_installed = self._manager.installPackage(package_path) is not None
  101. package = self.getPackageModel(package_id)
  102. if package.can_update and to_be_installed:
  103. package.can_update = False
  104. if update:
  105. package.is_updating = False
  106. else:
  107. Logger.debug(f"Setting recently installed for package: {package_id}")
  108. package.is_recently_managed = True
  109. package.is_installing = False
  110. self.subscribeUserToPackage(package_id, str(package.sdk_version))
  111. def download(self, package_id: str, url: str, update: bool = False) -> None:
  112. def downloadFinished(reply: "QNetworkReply") -> None:
  113. self._downloadFinished(package_id, reply, update)
  114. def downloadError(reply: "QNetworkReply", error: "QNetworkReply.NetworkError") -> None:
  115. self._downloadError(package_id, update, reply, error)
  116. HttpRequestManager.getInstance().get(
  117. url,
  118. scope = self._scope,
  119. callback = downloadFinished,
  120. error_callback = downloadError
  121. )
  122. def _downloadFinished(self, package_id: str, reply: "QNetworkReply", update: bool = False) -> None:
  123. try:
  124. with tempfile.NamedTemporaryFile(mode = "wb+", suffix = ".curapackage", delete = False) as temp_file:
  125. bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE)
  126. while bytes_read:
  127. temp_file.write(bytes_read)
  128. bytes_read = reply.read(self.DISK_WRITE_BUFFER_SIZE)
  129. Logger.debug(f"Finished downloading {package_id} and stored it as {temp_file.name}")
  130. self._to_install[package_id] = temp_file.name
  131. self.canInstallChanged.emit(package_id, update)
  132. except IOError as e:
  133. Logger.error(f"Failed to write downloaded package to temp file {e}")
  134. temp_file.close()
  135. self._downloadError(package_id, update)
  136. def _downloadError(self, package_id: str, update: bool = False, reply: Optional["QNetworkReply"] = None, error: Optional["QNetworkReply.NetworkError"] = None) -> None:
  137. if reply:
  138. reply_string = bytes(reply.readAll()).decode()
  139. Logger.error(f"Failed to download package: {package_id} due to {reply_string}")
  140. package = self.getPackageModel(package_id)
  141. if update:
  142. package.is_updating = False
  143. else:
  144. package.is_installing = False
  145. def subscribeUserToPackage(self, package_id: str, sdk_version: str) -> None:
  146. if self._account.isLoggedIn:
  147. Logger.debug(f"Subscribing the user for package: {package_id}")
  148. HttpRequestManager.getInstance().put(
  149. url = USER_PACKAGES_URL,
  150. data = json.dumps({"data": {"package_id": package_id, "sdk_version": sdk_version}}).encode(),
  151. scope = self._scope
  152. )
  153. def unsunscribeUserFromPackage(self, package_id: str) -> None:
  154. if self._account.isLoggedIn:
  155. Logger.debug(f"Unsubscribing the user for package: {package_id}")
  156. HttpRequestManager.getInstance().delete(url = f"{USER_PACKAGES_URL}/{package_id}", scope = self._scope)
  157. # --- Handle the manage package buttons ---
  158. def _connectManageButtonSignals(self, package: PackageModel) -> None:
  159. package.installPackageTriggered.connect(self.installPackage)
  160. package.uninstallPackageTriggered.connect(self.uninstallPackage)
  161. package.updatePackageTriggered.connect(self.installPackage)
  162. package.enablePackageTriggered.connect(self.enablePackage)
  163. package.disablePackageTriggered.connect(self.disablePackage)
  164. @pyqtSlot(str)
  165. def installPackage(self, package_id: str) -> None:
  166. package = self.getPackageModel(package_id)
  167. package.is_installing = True
  168. url = package.download_url
  169. Logger.debug(f"Trying to download and install {package_id} from {url}")
  170. self.download(package_id, url, False)
  171. @pyqtSlot(str)
  172. def uninstallPackage(self, package_id: str) -> None:
  173. Logger.debug(f"Uninstalling {package_id}")
  174. package = self.getPackageModel(package_id)
  175. package.is_installing = True
  176. self._manager.removePackage(package_id)
  177. self.unsunscribeUserFromPackage(package_id)
  178. package.is_installing = False
  179. package.is_recently_managed = True
  180. @pyqtSlot(str)
  181. def updatePackage(self, package_id: str) -> None:
  182. package = self.getPackageModel(package_id)
  183. package.is_updating = True
  184. self._manager.removePackage(package_id, force_add = True)
  185. url = package.download_url
  186. Logger.debug(f"Trying to download and update {package_id} from {url}")
  187. self.download(package_id, url, True)
  188. @pyqtSlot(str)
  189. def enablePackage(self, package_id: str) -> None:
  190. package = self.getPackageModel(package_id)
  191. package.is_enabling = True
  192. Logger.debug(f"Enabling {package_id}")
  193. self._plugin_registry.enablePlugin(package_id)
  194. package.is_active = True
  195. package.is_enabling = False
  196. @pyqtSlot(str)
  197. def disablePackage(self, package_id: str) -> None:
  198. package = self.getPackageModel(package_id)
  199. package.is_enabling = True
  200. Logger.debug(f"Disabling {package_id}")
  201. self._plugin_registry.disablePlugin(package_id)
  202. package.is_active = False
  203. package.is_enabling = False