RemotePackageList.py 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  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, pyqtSignal, pyqtSlot
  4. from PyQt5.QtNetwork import QNetworkReply
  5. from typing import Optional, Set, TYPE_CHECKING
  6. from UM.i18n import i18nCatalog
  7. from UM.Logger import Logger
  8. from UM.TaskManagement.HttpRequestManager import HttpRequestManager # To request the package list from the API.
  9. from .Constants import PACKAGES_URL # To get the list of packages. Imported this way to prevent circular imports.
  10. from .PackageList import PackageList
  11. from .PackageModel import PackageModel # The contents of this list.
  12. if TYPE_CHECKING:
  13. from PyQt5.QtCore import QObject
  14. catalog = i18nCatalog("cura")
  15. class RemotePackageList(PackageList):
  16. ITEMS_PER_PAGE = 20 # Pagination of number of elements to download at once.
  17. def __init__(self, parent: Optional["QObject"] = None) -> None:
  18. super().__init__(parent)
  19. self._package_type_filter = ""
  20. self._requested_search_string = ""
  21. self._current_search_string = ""
  22. self._request_url = self._initialRequestUrl()
  23. self.isLoadingChanged.connect(self._onLoadingChanged)
  24. self.isLoadingChanged.emit()
  25. self._local_packages: Set[str] = { p["package_id"] for p in self._manager.local_packages }
  26. def __del__(self) -> None:
  27. """
  28. When deleting this object, abort the request so that we don't get a callback from it later on a deleted C++
  29. object.
  30. """
  31. self.abortUpdating()
  32. @pyqtSlot()
  33. def updatePackages(self) -> None:
  34. """
  35. Make a request for the first paginated page of packages.
  36. When the request is done, the list will get updated with the new package models.
  37. """
  38. self.setErrorMessage("") # Clear any previous errors.
  39. self.setIsLoading(True)
  40. self._ongoing_request = HttpRequestManager.getInstance().get(
  41. self._request_url,
  42. scope = self._scope,
  43. callback = self._parseResponse,
  44. error_callback = self._onError
  45. )
  46. @pyqtSlot()
  47. def abortUpdating(self) -> None:
  48. HttpRequestManager.getInstance().abortRequest(self._ongoing_request)
  49. self._ongoing_request = None
  50. def reset(self) -> None:
  51. self.clear()
  52. self._request_url = self._initialRequestUrl()
  53. packageTypeFilterChanged = pyqtSignal()
  54. searchStringChanged = pyqtSignal()
  55. def setPackageTypeFilter(self, new_filter: str) -> None:
  56. if new_filter != self._package_type_filter:
  57. self._package_type_filter = new_filter
  58. self.reset()
  59. self.packageTypeFilterChanged.emit()
  60. def setSearchString(self, new_search: str) -> None:
  61. self._requested_search_string = new_search
  62. self._onLoadingChanged()
  63. @pyqtProperty(str, fset = setPackageTypeFilter, notify = packageTypeFilterChanged)
  64. def packageTypeFilter(self) -> str:
  65. """
  66. Get the package type this package list is filtering on, like ``plugin`` or ``material``.
  67. :return: The package type this list is filtering on.
  68. """
  69. return self._package_type_filter
  70. @pyqtProperty(str, fset = setSearchString, notify = searchStringChanged)
  71. def searchString(self) -> str:
  72. """
  73. Get the string the user is currently searching for (as in: the list is updating) within the packages,
  74. or an empty string if no extra search filter has to be applied. Does not override package-type filter!
  75. :return: String the user is searching for. Empty denotes 'no search filter'.
  76. """
  77. return self._current_search_string
  78. def _onLoadingChanged(self) -> None:
  79. if self._requested_search_string != self._current_search_string and not self._is_loading:
  80. self._current_search_string = self._requested_search_string
  81. self.reset()
  82. self.updatePackages()
  83. self.searchStringChanged.emit()
  84. def _initialRequestUrl(self) -> str:
  85. """
  86. Get the URL to request the first paginated page with.
  87. :return: A URL to request.
  88. """
  89. request_url = f"{PACKAGES_URL}?limit={self.ITEMS_PER_PAGE}"
  90. if self._package_type_filter != "":
  91. request_url += f"&package_type={self._package_type_filter}"
  92. if self._current_search_string != "":
  93. request_url += f"&search={self._current_search_string}"
  94. return request_url
  95. def _parseResponse(self, reply: "QNetworkReply") -> None:
  96. """
  97. Parse the response from the package list API request.
  98. This converts that response into PackageModels, and triggers the ListModel to update.
  99. :param reply: A reply containing information about a number of packages.
  100. """
  101. response_data = HttpRequestManager.readJSON(reply)
  102. if "data" not in response_data or "links" not in response_data:
  103. Logger.error(f"Could not interpret the server's response. Missing 'data' or 'links' from response data. Keys in response: {response_data.keys()}")
  104. self.setErrorMessage(catalog.i18nc("@info:error", "Could not interpret the server's response."))
  105. return
  106. for package_data in response_data["data"]:
  107. if package_data["package_id"] in self._local_packages:
  108. continue # We should only show packages which are not already installed
  109. try:
  110. package = PackageModel(package_data, parent = self)
  111. self._connectManageButtonSignals(package)
  112. self.appendItem({"package": package}) # Add it to this list model.
  113. except RuntimeError:
  114. # Setting the ownership of this object to not qml can still result in a RuntimeError. Which can occur when quickly toggling
  115. # between de-/constructing RemotePackageLists. This try-except is here to prevent a hard crash when the wrapped C++ object
  116. # was deleted when it was still parsing the response
  117. return
  118. self._request_url = response_data["links"].get("next", "") # Use empty string to signify that there is no next page.
  119. self._ongoing_request = None
  120. self.setIsLoading(False)
  121. self.setHasMore(self._request_url != "")
  122. def _onError(self, reply: "QNetworkReply", error: Optional[QNetworkReply.NetworkError]) -> None:
  123. """
  124. Handles networking and server errors when requesting the list of packages.
  125. :param reply: The reply with packages. This will most likely be incomplete and should be ignored.
  126. :param error: The error status of the request.
  127. """
  128. if error == QNetworkReply.NetworkError.OperationCanceledError:
  129. Logger.debug("Cancelled request for packages.")
  130. self._ongoing_request = None
  131. return # Don't show an error about this to the user.
  132. Logger.error("Could not reach Marketplace server.")
  133. self.setErrorMessage(catalog.i18nc("@info:error", "Could not reach Marketplace."))
  134. self._ongoing_request = None
  135. self.setIsLoading(False)