PackageModel.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422
  1. # Copyright (c) 2021 Ultimaker B.V.
  2. # Cura is released under the terms of the LGPLv3 or higher.
  3. import re
  4. from enum import Enum
  5. from typing import Any, cast, Dict, List, Optional
  6. from PyQt6.QtCore import pyqtProperty, QObject, pyqtSignal, pyqtSlot
  7. from PyQt6.QtQml import QQmlEngine
  8. from cura.CuraApplication import CuraApplication
  9. from cura.CuraPackageManager import CuraPackageManager
  10. from cura.Settings.CuraContainerRegistry import CuraContainerRegistry # To get names of materials we're compatible with.
  11. from UM.i18n import i18nCatalog # To translate placeholder names if data is not present.
  12. from UM.Logger import Logger
  13. from UM.PluginRegistry import PluginRegistry
  14. catalog = i18nCatalog("cura")
  15. class PackageModel(QObject):
  16. """
  17. Represents a package, containing all the relevant information to be displayed about a package.
  18. """
  19. def __init__(self, package_data: Dict[str, Any], section_title: Optional[str] = None, parent: Optional[QObject] = None) -> None:
  20. """
  21. Constructs a new model for a single package.
  22. :param package_data: The data received from the Marketplace API about the package to create.
  23. :param section_title: If the packages are to be categorized per section provide the section_title
  24. :param parent: The parent QML object that controls the lifetime of this model (normally a PackageList).
  25. """
  26. super().__init__(parent)
  27. QQmlEngine.setObjectOwnership(self, QQmlEngine.ObjectOwnership.CppOwnership)
  28. self._package_manager: CuraPackageManager = cast(CuraPackageManager, CuraApplication.getInstance().getPackageManager())
  29. self._plugin_registry: PluginRegistry = CuraApplication.getInstance().getPluginRegistry()
  30. self._package_id = package_data.get("package_id", "UnknownPackageId")
  31. self._package_type = package_data.get("package_type", "")
  32. self._is_bundled = package_data.get("is_bundled", False)
  33. self._icon_url = package_data.get("icon_url", "")
  34. self._marketplace_url = package_data.get("marketplace_url", "")
  35. self._display_name = package_data.get("display_name", catalog.i18nc("@label:property", "Unknown Package"))
  36. tags = package_data.get("tags", [])
  37. self._is_checked_by_ultimaker = (self._package_type == "plugin" and "verified" in tags) or (
  38. self._package_type == "material" and "certified" in tags)
  39. self._package_version = package_data.get("package_version", "") # Display purpose, no need for 'UM.Version'.
  40. self._package_info_url = package_data.get("website", "") # Not to be confused with 'download_url'.
  41. self._download_count = package_data.get("download_count", 0)
  42. self._description = package_data.get("description", "")
  43. self._formatted_description = self._format(self._description)
  44. self._download_url = package_data.get("download_url", "")
  45. self._release_notes = package_data.get("release_notes", "") # Not used yet, propose to add to description?
  46. subdata = package_data.get("data", {})
  47. self._technical_data_sheet = self._findLink(subdata, "technical_data_sheet")
  48. self._safety_data_sheet = self._findLink(subdata, "safety_data_sheet")
  49. self._where_to_buy = self._findLink(subdata, "where_to_buy")
  50. self._compatible_printers = self._getCompatiblePrinters(subdata)
  51. self._compatible_support_materials = self._getCompatibleSupportMaterials(subdata)
  52. self._is_compatible_material_station = self._isCompatibleMaterialStation(subdata)
  53. self._is_compatible_air_manager = self._isCompatibleAirManager(subdata)
  54. author_data = package_data.get("author", {})
  55. self._author_name = author_data.get("display_name", catalog.i18nc("@label:property", "Unknown Author"))
  56. self._author_info_url = author_data.get("website", "")
  57. if not self._icon_url or self._icon_url == "":
  58. self._icon_url = author_data.get("icon_url", "")
  59. self._can_update = False
  60. self._section_title = section_title
  61. self.sdk_version = package_data.get("sdk_version_semver", "")
  62. # Note that there's a lot more info in the package_data than just these specified here.
  63. self.enablePackageTriggered.connect(self._plugin_registry.enablePlugin)
  64. self.disablePackageTriggered.connect(self._plugin_registry.disablePlugin)
  65. self._plugin_registry.pluginsEnabledOrDisabledChanged.connect(self.stateManageButtonChanged)
  66. self._package_manager.packageInstalled.connect(lambda pkg_id: self._packageInstalled(pkg_id))
  67. self._package_manager.packageUninstalled.connect(lambda pkg_id: self._packageInstalled(pkg_id))
  68. self._package_manager.packageInstallingFailed.connect(lambda pkg_id: self._packageInstalled(pkg_id))
  69. self._package_manager.packagesWithUpdateChanged.connect(self._processUpdatedPackages)
  70. self._is_busy = False
  71. self._is_missing_package_information = False
  72. @classmethod
  73. def fromIncompletePackageInformation(cls, display_name: str, package_version: str,
  74. package_type: str) -> "PackageModel":
  75. description = ""
  76. match package_type:
  77. case "material":
  78. description = catalog.i18nc("@label:label Ultimaker Marketplace is a brand name, don't translate",
  79. "The material package associated with the Cura project could not be found on the Ultimaker Marketplace. Use the partial material profile definition stored in the Cura project file at your own risk.")
  80. case "plugin":
  81. description = catalog.i18nc("@label:label Ultimaker Marketplace is a brand name, don't translate",
  82. "The plugin associated with the Cura project could not be found on the Ultimaker Marketplace. As the plugin may be required to slice the project it might not be possible to correctly slice the file.")
  83. package_data = {
  84. "display_name": display_name,
  85. "package_version": package_version,
  86. "package_type": package_type,
  87. "description": description,
  88. }
  89. package_model = cls(package_data)
  90. package_model.setIsMissingPackageInformation(True)
  91. return package_model
  92. @pyqtSlot()
  93. def _processUpdatedPackages(self):
  94. self.setCanUpdate(self._package_manager.checkIfPackageCanUpdate(self._package_id))
  95. def __del__(self):
  96. self._package_manager.packagesWithUpdateChanged.disconnect(self._processUpdatedPackages)
  97. def __eq__(self, other: object) -> bool:
  98. if isinstance(other, PackageModel):
  99. return other == self
  100. elif isinstance(other, str):
  101. return other == self._package_id
  102. else:
  103. return False
  104. def __repr__(self) -> str:
  105. return f"<{self._package_id} : {self._package_version} : {self._section_title}>"
  106. def _findLink(self, subdata: Dict[str, Any], link_type: str) -> str:
  107. """
  108. Searches the package data for a link of a certain type.
  109. The links are not in a fixed path in the package data. We need to iterate over the available links to find them.
  110. :param subdata: The "data" element in the package data, which should contain links.
  111. :param link_type: The type of link to find.
  112. :return: A URL of where the link leads, or an empty string if there is no link of that type in the package data.
  113. """
  114. links = subdata.get("links", [])
  115. for link in links:
  116. if link.get("type", "") == link_type:
  117. return link.get("url", "")
  118. else:
  119. return "" # No link with the correct type was found.
  120. def _format(self, text: str) -> str:
  121. """
  122. Formats a user-readable block of text for display.
  123. :return: A block of rich text with formatting embedded.
  124. """
  125. # Turn all in-line hyperlinks into actual links.
  126. url_regex = re.compile(r"(((http|https)://)[a-zA-Z0-9@:%.\-_+~#?&/=]{2,256}\.[a-z]{2,12}(/[a-zA-Z0-9@:%.\-_+~#?&/=]*)?)")
  127. text = re.sub(url_regex, r'<a href="\1">\1</a>', text)
  128. # Turn newlines into <br> so that they get displayed as newlines when rendering as rich text.
  129. text = text.replace("\n", "<br>")
  130. return text
  131. def _getCompatiblePrinters(self, subdata: Dict[str, Any]) -> List[str]:
  132. """
  133. Gets the list of printers that this package provides material compatibility with.
  134. Any printer is listed, even if it's only for a single nozzle on a single material in the package.
  135. :param subdata: The "data" element in the package data, which should contain this compatibility information.
  136. :return: A list of printer names that this package provides material compatibility with.
  137. """
  138. result = set()
  139. for material in subdata.get("materials", []):
  140. for compatibility in material.get("compatibility", []):
  141. printer_name = compatibility.get("machine_name")
  142. if printer_name is None:
  143. continue # Missing printer name information. Skip this one.
  144. for subcompatibility in compatibility.get("compatibilities", []):
  145. if subcompatibility.get("hardware_compatible", False):
  146. result.add(printer_name)
  147. break
  148. return list(sorted(result))
  149. def _getCompatibleSupportMaterials(self, subdata: Dict[str, Any]) -> List[str]:
  150. """
  151. Gets the list of support materials that the materials in this package are compatible with.
  152. Since the materials are individually encoded as keys in the API response, only PVA and Breakaway are currently
  153. supported.
  154. :param subdata: The "data" element in the package data, which should contain this compatibility information.
  155. :return: A list of support materials that the materials in this package are compatible with.
  156. """
  157. result = set()
  158. container_registry = CuraContainerRegistry.getInstance()
  159. try:
  160. pva_name = container_registry.findContainersMetadata(id = "ultimaker_pva")[0].get("name", "Ultimaker PVA")
  161. except IndexError:
  162. pva_name = "Ultimaker PVA"
  163. try:
  164. breakaway_name = container_registry.findContainersMetadata(id = "ultimaker_bam")[0].get("name", "Ultimaker Breakaway")
  165. except IndexError:
  166. breakaway_name = "Ultimaker Breakaway"
  167. for material in subdata.get("materials", []):
  168. if material.get("pva_compatible", False):
  169. result.add(pva_name)
  170. if material.get("breakaway_compatible", False):
  171. result.add(breakaway_name)
  172. return list(sorted(result))
  173. def _isCompatibleMaterialStation(self, subdata: Dict[str, Any]) -> bool:
  174. """
  175. Finds out if this package provides any material that is compatible with the material station.
  176. :param subdata: The "data" element in the package data, which should contain this compatibility information.
  177. :return: Whether this package provides any material that is compatible with the material station.
  178. """
  179. for material in subdata.get("materials", []):
  180. for compatibility in material.get("compatibility", []):
  181. if compatibility.get("material_station_optimized", False):
  182. return True
  183. return False
  184. def _isCompatibleAirManager(self, subdata: Dict[str, Any]) -> bool:
  185. """
  186. Finds out if this package provides any material that is compatible with the air manager.
  187. :param subdata: The "data" element in the package data, which should contain this compatibility information.
  188. :return: Whether this package provides any material that is compatible with the air manager.
  189. """
  190. for material in subdata.get("materials", []):
  191. for compatibility in material.get("compatibility", []):
  192. if compatibility.get("air_manager_optimized", False):
  193. return True
  194. return False
  195. @pyqtProperty(str, constant = True)
  196. def packageId(self) -> str:
  197. return self._package_id
  198. @pyqtProperty(str, constant=True)
  199. def marketplaceURL(self)-> str:
  200. return self._marketplace_url
  201. @pyqtProperty(str, constant = True)
  202. def packageType(self) -> str:
  203. return self._package_type
  204. @pyqtProperty(str, constant = True)
  205. def iconUrl(self) -> str:
  206. return self._icon_url
  207. @pyqtProperty(str, constant = True)
  208. def displayName(self) -> str:
  209. return self._display_name
  210. @pyqtProperty(bool, constant = True)
  211. def isCheckedByUltimaker(self):
  212. return self._is_checked_by_ultimaker
  213. @pyqtProperty(str, constant = True)
  214. def packageVersion(self) -> str:
  215. return self._package_version
  216. @pyqtProperty(str, constant = True)
  217. def packageInfoUrl(self) -> str:
  218. return self._package_info_url
  219. @pyqtProperty(int, constant = True)
  220. def downloadCount(self) -> str:
  221. return self._download_count
  222. @pyqtProperty(str, constant = True)
  223. def description(self) -> str:
  224. return self._description
  225. @pyqtProperty(str, constant = True)
  226. def formattedDescription(self) -> str:
  227. return self._formatted_description
  228. @pyqtProperty(str, constant = True)
  229. def authorName(self) -> str:
  230. return self._author_name
  231. @pyqtProperty(str, constant = True)
  232. def authorInfoUrl(self) -> str:
  233. return self._author_info_url
  234. @pyqtProperty(str, constant = True)
  235. def sectionTitle(self) -> Optional[str]:
  236. return self._section_title
  237. @pyqtProperty(str, constant = True)
  238. def technicalDataSheet(self) -> str:
  239. return self._technical_data_sheet
  240. @pyqtProperty(str, constant = True)
  241. def safetyDataSheet(self) -> str:
  242. return self._safety_data_sheet
  243. @pyqtProperty(str, constant = True)
  244. def whereToBuy(self) -> str:
  245. return self._where_to_buy
  246. @pyqtProperty("QStringList", constant = True)
  247. def compatiblePrinters(self) -> List[str]:
  248. return self._compatible_printers
  249. @pyqtProperty("QStringList", constant = True)
  250. def compatibleSupportMaterials(self) -> List[str]:
  251. return self._compatible_support_materials
  252. @pyqtProperty(bool, constant = True)
  253. def isCompatibleMaterialStation(self) -> bool:
  254. return self._is_compatible_material_station
  255. @pyqtProperty(bool, constant = True)
  256. def isCompatibleAirManager(self) -> bool:
  257. return self._is_compatible_air_manager
  258. @pyqtProperty(bool, constant = True)
  259. def isBundled(self) -> bool:
  260. return self._is_bundled
  261. def setDownloadUrl(self, download_url):
  262. self._download_url = download_url
  263. # --- manage buttons signals ---
  264. stateManageButtonChanged = pyqtSignal()
  265. installPackageTriggered = pyqtSignal(str, str)
  266. uninstallPackageTriggered = pyqtSignal(str)
  267. updatePackageTriggered = pyqtSignal(str, str)
  268. enablePackageTriggered = pyqtSignal(str)
  269. disablePackageTriggered = pyqtSignal(str)
  270. busyChanged = pyqtSignal()
  271. @pyqtSlot()
  272. def install(self):
  273. self.setBusy(True)
  274. self.installPackageTriggered.emit(self.packageId, self._download_url)
  275. @pyqtSlot()
  276. def update(self):
  277. self.setBusy(True)
  278. self.updatePackageTriggered.emit(self.packageId, self._download_url)
  279. @pyqtSlot()
  280. def uninstall(self):
  281. self.uninstallPackageTriggered.emit(self.packageId)
  282. @pyqtProperty(bool, notify= busyChanged)
  283. def busy(self):
  284. """
  285. Property indicating that some kind of upgrade is active.
  286. """
  287. return self._is_busy
  288. @pyqtSlot()
  289. def enable(self):
  290. self.enablePackageTriggered.emit(self.packageId)
  291. @pyqtSlot()
  292. def disable(self):
  293. self.disablePackageTriggered.emit(self.packageId)
  294. def setBusy(self, value: bool):
  295. if self._is_busy != value:
  296. self._is_busy = value
  297. try:
  298. self.busyChanged.emit()
  299. except RuntimeError:
  300. pass
  301. def _packageInstalled(self, package_id: str) -> None:
  302. if self._package_id != package_id:
  303. return
  304. self.setBusy(False)
  305. try:
  306. self.stateManageButtonChanged.emit()
  307. except RuntimeError:
  308. pass
  309. @pyqtProperty(bool, notify = stateManageButtonChanged)
  310. def isInstalled(self) -> bool:
  311. return self._package_id in self._package_manager.getAllInstalledPackageIDs()
  312. @pyqtProperty(bool, notify = stateManageButtonChanged)
  313. def isToBeInstalled(self) -> bool:
  314. return self._package_id in self._package_manager.getPackagesToInstall()
  315. @pyqtProperty(bool, notify = stateManageButtonChanged)
  316. def isActive(self) -> bool:
  317. return not self._package_id in self._plugin_registry.getDisabledPlugins()
  318. @pyqtProperty(bool, notify = stateManageButtonChanged)
  319. def canDowngrade(self) -> bool:
  320. """Flag if the installed package can be downgraded to a bundled version"""
  321. return self._package_manager.canDowngrade(self._package_id)
  322. def setCanUpdate(self, value: bool) -> None:
  323. self._can_update = value
  324. self.stateManageButtonChanged.emit()
  325. @pyqtProperty(bool, fset = setCanUpdate, notify = stateManageButtonChanged)
  326. def canUpdate(self) -> bool:
  327. """Flag indicating if the package can be updated"""
  328. return self._can_update
  329. isMissingPackageInformationChanged = pyqtSignal()
  330. def setIsMissingPackageInformation(self, isMissingPackageInformation: bool) -> None:
  331. self._is_missing_package_information = isMissingPackageInformation
  332. self.isMissingPackageInformationChanged.emit()
  333. @pyqtProperty(bool, notify=isMissingPackageInformationChanged)
  334. def isMissingPackageInformation(self) -> bool:
  335. """Flag indicating if the package can be updated"""
  336. return self._is_missing_package_information