PackageModel.py 16 KB

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