diff --git a/src/qgis_geonode/apiclient/__init__.py b/src/qgis_geonode/apiclient/__init__.py
index 6a0e6df8..f05cb44c 100644
--- a/src/qgis_geonode/apiclient/__init__.py
+++ b/src/qgis_geonode/apiclient/__init__.py
@@ -2,8 +2,6 @@
import importlib
import typing
-from .models import UNSUPPORTED_REMOTE
-
def get_geonode_client(
connection_settings: "ConnectionSettings",
diff --git a/src/qgis_geonode/apiclient/base.py b/src/qgis_geonode/apiclient/base.py
index edf2898f..3b75c030 100644
--- a/src/qgis_geonode/apiclient/base.py
+++ b/src/qgis_geonode/apiclient/base.py
@@ -8,7 +8,7 @@
QtXml,
)
-from .. import network
+from .. import network, conf
from . import models
from .models import GeonodeApiSearchFilters
@@ -16,34 +16,40 @@
class BaseGeonodeClient(QtCore.QObject):
auth_config: str
base_url: str
- network_fetcher_task: typing.Optional[network.MultipleNetworkFetcherTask]
+ network_fetcher_task: typing.Optional[network.NetworkRequestTask]
capabilities: typing.List[models.ApiClientCapability]
page_size: int
+ network_requests_timeout: int
dataset_list_received = QtCore.pyqtSignal(list, models.GeonodePaginationInfo)
dataset_detail_received = QtCore.pyqtSignal(object)
style_detail_received = QtCore.pyqtSignal(QtXml.QDomElement)
keyword_list_received = QtCore.pyqtSignal(list)
- error_received = QtCore.pyqtSignal([str], [str, int, str])
+
+ search_error_received = QtCore.pyqtSignal([str], [str, int, str])
+ dataset_detail_error_received = QtCore.pyqtSignal([str], [str, int, str])
def __init__(
self,
base_url: str,
page_size: int,
+ network_requests_timeout: int,
auth_config: typing.Optional[str] = None,
):
super().__init__()
self.auth_config = auth_config or ""
self.base_url = base_url.rstrip("/")
self.page_size = page_size
+ self.network_requests_timeout = network_requests_timeout
self.network_fetcher_task = None
@classmethod
- def from_connection_settings(cls, connection_settings: "ConnectionSettings"):
+ def from_connection_settings(cls, connection_settings: conf.ConnectionSettings):
return cls(
base_url=connection_settings.base_url,
page_size=connection_settings.page_size,
auth_config=connection_settings.auth_config,
+ network_requests_timeout=connection_settings.network_requests_timeout,
)
def get_ordering_fields(self) -> typing.List[typing.Tuple[str, str]]:
@@ -61,11 +67,13 @@ def get_dataset_detail_url(self, dataset_id: int) -> QtCore.QUrl:
raise NotImplementedError
def get_dataset_list(self, search_filters: GeonodeApiSearchFilters):
- self.network_fetcher_task = network.MultipleNetworkFetcherTask(
+ self.network_fetcher_task = network.NetworkRequestTask(
[network.RequestToPerform(url=self.get_dataset_list_url(search_filters))],
self.auth_config,
+ network_task_timeout=self.network_requests_timeout,
+ description="Get dataset list",
)
- self.network_fetcher_task.all_finished.connect(self.handle_dataset_list)
+ self.network_fetcher_task.task_done.connect(self.handle_dataset_list)
qgis.core.QgsApplication.taskManager().addTask(self.network_fetcher_task)
def handle_dataset_list(self, result: bool):
@@ -83,50 +91,16 @@ def get_dataset_detail(self, brief_dataset: models.BriefDataset):
sld_url = QtCore.QUrl(brief_dataset.default_style.sld_url)
requests_to_perform.append(network.RequestToPerform(url=sld_url))
- self.network_fetcher_task = network.MultipleNetworkFetcherTask(
- requests_to_perform, self.auth_config
+ self.network_fetcher_task = network.NetworkRequestTask(
+ requests_to_perform,
+ self.auth_config,
+ network_task_timeout=self.network_requests_timeout,
+ description="Get dataset detail",
)
- self.network_fetcher_task.all_finished.connect(
+ self.network_fetcher_task.task_done.connect(
partial(self.handle_dataset_detail, brief_dataset)
)
qgis.core.QgsApplication.taskManager().addTask(self.network_fetcher_task)
def handle_dataset_detail(self, brief_dataset: models.BriefDataset, result: bool):
raise NotImplementedError
-
- def get_layer_styles(self, layer_id: int):
- request = QtNetwork.QNetworkRequest(
- self.get_layer_styles_url_endpoint(layer_id)
- )
- self.network_fetcher_task = network.NetworkFetcherTask(
- self, request, authcfg=self.auth_config
- )
- self.network_fetcher_task.request_finished.connect(self.handle_layer_style_list)
- qgis.core.QgsApplication.taskManager().addTask(self.network_fetcher_task)
-
- def get_layer_style(
- self, layer: models.Dataset, style_name: typing.Optional[str] = None
- ):
- if style_name is None:
- style_url = layer.default_style.sld_url
- else:
- style_details = [i for i in layer.styles if i.name == style_name][0]
- style_url = style_details.sld_url
- self.network_fetcher_task = network.NetworkFetcherTask(
- self,
- QtNetwork.QNetworkRequest(QtCore.QUrl(style_url)),
- authcfg=self.auth_config,
- )
- self.network_fetcher_task.request_finished.connect(
- self.handle_layer_style_detail
- )
- qgis.core.QgsApplication.taskManager().addTask(self.network_fetcher_task)
-
- def deserialize_sld_style(self, raw_sld: QtCore.QByteArray) -> QtXml.QDomDocument:
- sld_doc = QtXml.QDomDocument()
- # in the line below, `True` means use XML namespaces and it is crucial for
- # QGIS to be able to load the SLD
- sld_loaded = sld_doc.setContent(raw_sld, True)
- if not sld_loaded:
- raise RuntimeError("Could not load downloaded SLD document")
- return sld_doc
diff --git a/src/qgis_geonode/apiclient/csw.py b/src/qgis_geonode/apiclient/csw.py
deleted file mode 100644
index 20a8a045..00000000
--- a/src/qgis_geonode/apiclient/csw.py
+++ /dev/null
@@ -1,1309 +0,0 @@
-import dataclasses
-import datetime as dt
-import enum
-import io
-import json
-import math
-import typing
-import urllib.request
-import urllib.parse
-import uuid
-from functools import partial
-from xml.etree import ElementTree as ET
-
-import qgis.core
-from qgis.PyQt import (
- QtCore,
- QtNetwork,
-)
-
-from . import models
-from . import base
-from .. import network
-from ..utils import (
- log,
-)
-from ..network import parse_network_reply
-
-
-@dataclasses.dataclass()
-class GeoNodeCswLayerDetail:
- parsed_csw_record: typing.Optional[ET.Element]
- parsed_layer_detail: typing.Optional[typing.Dict]
- brief_style: typing.Optional[models.BriefGeonodeStyle]
-
-
-class GeoNodeLegacyAuthenticatedRecordSearcherTask(network.NetworkFetcherTask):
- TIMEOUT: int = 10000
- username: str
- password: str
- base_url: str
- _first_login_reply: typing.Optional[QtNetwork.QNetworkReply]
- _second_login_reply: typing.Optional[QtNetwork.QNetworkReply]
- _final_reply: typing.Optional[QtNetwork.QNetworkReply]
- _logout_reply: typing.Optional[QtNetwork.QNetworkReply]
-
- first_login_parsed = QtCore.pyqtSignal()
- second_login_parsed = QtCore.pyqtSignal()
- logout_parsed = QtCore.pyqtSignal()
-
- def __init__(self, base_url: str, username: str, password: str, *args, **kwargs):
- """Performs authenticated POST requests against a GeoNode's legacy CSW endpoint.
-
- This is mainly usable for perfoming CSW GetRecords operations. In order to
- support a broader range of search filters, GeoNode CSW GetRecords requests
- ought to be sent as HTTP POST requests (why? in brief, pycsw has better
- support for POST when doing GetRecords). However, due to GeoNode having the
- CSW API protected by django's session-based authentication, before being able to
- perform a POST request we need to simulate a browser login. This is achieved
- by:
-
- 1. Issuing a first GET request to the login url. This shall allow retrieving
- the necessary cookies and also the csrf token used by django
-
- 2. Issuing a second POST request ot the login url. If successful, this shall
- complete the login process
-
- 3. Finally perform the POST reequest to interact with the CSW API
-
- """
-
- super().__init__(
- *args,
- redirect_policy=QtNetwork.QNetworkRequest.ManualRedirectPolicy,
- **kwargs,
- )
- self.base_url = base_url
- self.username = username
- self.password = password
- self._first_login_reply = None
- self._second_login_reply = None
- self._final_reply = None
- self._logout_reply = None
-
- @property
- def login_url(self) -> QtCore.QUrl:
- return QtCore.QUrl(f"{self.base_url}/account/login/")
-
- @property
- def logout_url(self) -> QtCore.QUrl:
- return QtCore.QUrl(f"{self.base_url}/account/logout/")
-
- def run(self) -> bool:
- if self._blocking_get_csrf_token():
- logged_in = self._blocking_login()
- log(f"logged_in: {logged_in}")
- if logged_in:
- if self._blocking_get_authenticated_reply():
- self.parsed_reply = parse_network_reply(self._final_reply)
- self.reply_content = self._final_reply.readAll()
- self._blocking_logout()
- result = self.parsed_reply.qt_error is None
- else:
- result = False
- else:
- result = False
- return result
-
- def _request_done(self, qgis_reply: qgis.core.QgsNetworkReplyContent):
- log(f"requested_url: {qgis_reply.request().url().toString()}")
- self.parsed_reply = parse_network_reply(qgis_reply)
- log(f"http_status_code: {self.parsed_reply.http_status_code}")
- log(f"qt_error: {self.parsed_reply.qt_error}")
- found_matched_reply = False
- if self._first_login_reply is not None:
- if network.reply_matches(qgis_reply, self._first_login_reply):
- found_matched_reply = True
- self.first_login_parsed.emit()
- if self._second_login_reply is not None:
- if network.reply_matches(qgis_reply, self._second_login_reply):
- found_matched_reply = True
- self.second_login_parsed.emit()
- if self._final_reply is not None:
- if network.reply_matches(qgis_reply, self._final_reply):
- found_matched_reply = True
- self.request_parsed.emit()
- if self._logout_reply is not None:
- if network.reply_matches(qgis_reply, self._logout_reply):
- found_matched_reply = True
- self.logout_parsed.emit()
- if not found_matched_reply:
- log("Could not match this reply with a previous one, ignoring...")
-
- def _blocking_get_csrf_token(self) -> bool:
- """Perform a first request to login URL to get a csrf token
-
- Logging in to a django-baased website (such as GeoNode) requires obtaining
- a CSRF token first. This token needs to be sent together with the login
- credentials. This function performs a first visit to the login page and gets
- the CSRF token.
-
- """
-
- with network.wait_for_signal(
- self.first_login_parsed, self.TIMEOUT
- ) as loop_result:
- self._first_login_reply = self.network_access_manager.get(
- QtNetwork.QNetworkRequest(self.login_url)
- )
- if loop_result.result:
- result = self._first_login_reply.error() == QtNetwork.QNetworkReply.NoError
- else:
- result = False
- return result
-
- def _blocking_login(self) -> bool:
- """Login to GeoNode using the previously gotten CSRF token
-
- In order to perform a session-based login to a django app (i.e. GeoNode) we need
- to:
-
- - Perform a first GET request to the login page in order to get some relevant
- cookies:
-
- - sessionid
- - csrftoken
-
- - Retrieve the CSRF TOKEN from the cookies, as it also needs to be sent as form
- data
-
- - Perform a second request to the login page, this time using POST method,
- sending:
-
- - the previously gotten cookies
- - form data with the username, password and csrftoken
-
- """
-
- csrf_token = self._get_csrf_token()
- log(f"csrf_token: {csrf_token}")
- if csrf_token is not None:
- form_data = QtCore.QUrlQuery()
- form_data.addQueryItem("login", self.username)
- form_data.addQueryItem("password", self.password)
- form_data.addQueryItem("csrfmiddlewaretoken", csrf_token)
- data_ = form_data.query().encode("utf-8")
- request = QtNetwork.QNetworkRequest(self.login_url)
- request.setRawHeader(b"Referer", self.login_url.toString().encode("utf-8"))
- with network.wait_for_signal(
- self.second_login_parsed, self.TIMEOUT
- ) as loop_result:
- self._second_login_reply = self.network_access_manager.post(
- request, data_
- )
- log(f"loop result: {loop_result.result}")
- if loop_result:
- result = (
- self._second_login_reply.error() == QtNetwork.QNetworkReply.NoError
- )
- else:
- result = False
- else:
- log("Could not retrieve CSRF token")
- result = False
- return result
-
- def _get_csrf_token(self) -> typing.Optional[str]:
- """Retrieves CSRF token from the current cookie jar."""
-
- cookie_jar = self.network_access_manager.cookieJar()
- for cookie in cookie_jar.cookiesForUrl(QtCore.QUrl(self.base_url)):
- if cookie.name() == "csrftoken":
- result = str(cookie.value(), encoding="utf-8")
- break
- else:
- result = None
- return result
-
- def _blocking_get_authenticated_reply(
- self,
- ) -> bool:
- """We are now logged in and can perform the final request"""
- with network.wait_for_signal(self.request_parsed, self.TIMEOUT) as loop_result:
- if self.request_payload is None:
- self._final_reply = self.network_access_manager.get(self.request)
- else:
- self._final_reply = self.network_access_manager.post(
- self.request,
- QtCore.QByteArray(self.request_payload.encode("utf-8")),
- )
- if loop_result.result:
- result = self._final_reply.error() == QtNetwork.QNetworkReply.NoError
- else:
- result = False
- return result
-
- def _blocking_logout(self) -> bool:
- csrf_token = self._get_csrf_token()
- log(f"csrf_token: {csrf_token}")
- if csrf_token is not None:
- form_data = QtCore.QUrlQuery()
- form_data.addQueryItem("csrfmiddlewaretoken", csrf_token)
- data_ = form_data.query().encode("utf-8")
- request = QtNetwork.QNetworkRequest(self.logout_url)
- request.setRawHeader(b"Referer", self.logout_url.toString().encode("utf-8"))
- with network.wait_for_signal(
- self.logout_parsed, self.TIMEOUT
- ) as loop_result:
- self._logout_reply = self.network_access_manager.post(request, data_)
- if loop_result.result:
- result = self._logout_reply.error() == QtNetwork.QNetworkReply.NoError
- else:
- result = False
- else:
- log("Could not retrieve CSRF token")
- result = False
- return result
-
-
-class GeonodeLayerDetailFetcherMixin:
- TIMEOUT: int
- base_url: str
- authcfg: str
- network_access_manager: qgis.core.QgsNetworkAccessManager
-
- layer_detail_api_v1_parsed: QtCore.pyqtSignal
- layer_style_parsed: QtCore.pyqtSignal
-
- def _blocking_get_layer_detail_v1_api(
- self, layer_name: str
- ) -> typing.Optional[typing.Dict]:
- layer_detail_url = "?".join(
- (
- f"{self.base_url}/api/layers/",
- urllib.parse.urlencode({"alternate": layer_name}),
- )
- )
- request = QtNetwork.QNetworkRequest(QtCore.QUrl(layer_detail_url))
- auth_manager = qgis.core.QgsApplication.authManager()
- auth_manager.updateNetworkRequest(request, self.authcfg)
- with network.wait_for_signal(self.layer_detail_api_v1_parsed, self.TIMEOUT):
- self._layer_detail_api_v1_reply = self.network_access_manager.get(request)
- if self._layer_detail_api_v1_reply.error() == QtNetwork.QNetworkReply.NoError:
- raw_layer_detail = self._layer_detail_api_v1_reply.readAll()
- layer_detail_response = json.loads(raw_layer_detail.data().decode())
- try:
- result = layer_detail_response["objects"][0]
- except (KeyError, IndexError):
- raise IOError(f"Received unexpected API response for {layer_name!r}")
- else:
- result = None
- return result
-
- def _blocking_get_style_detail(self, style_uri: str) -> models.BriefGeonodeStyle:
- request = QtNetwork.QNetworkRequest(QtCore.QUrl(f"{self.base_url}{style_uri}"))
- auth_manager = qgis.core.QgsApplication.authManager()
- auth_manager.updateNetworkRequest(request, self.authcfg)
- with network.wait_for_signal(self.layer_style_parsed, self.TIMEOUT):
- self._layer_style_reply = self.network_access_manager.get(request)
- if self._layer_style_reply.error() == QtNetwork.QNetworkReply.NoError:
- raw_style_detail = self._layer_style_reply.readAll()
- style_detail = json.loads(raw_style_detail.data().decode())
- sld_path = urllib.parse.urlparse(style_detail["sld_url"]).path
- result = models.BriefGeonodeStyle(
- name=style_detail["name"],
- sld_url=f"{self.base_url}{sld_path}",
- )
- else:
- parsed_reply = parse_network_reply(self._layer_style_reply)
- msg = (
- f"Received an error retrieving style detail: {parsed_reply.qt_error} - "
- f"{parsed_reply.http_status_code} - {parsed_reply.http_status_reason} "
- f"- {self._layer_style_reply.readAll()}"
- )
- raise RuntimeError(msg)
- return result
-
-
-class GeoNodeLegacyLayerDetailFetcher(
- GeonodeLayerDetailFetcherMixin, network.NetworkFetcherTask
-):
- TIMEOUT: int = 10000
- base_url: str
- reply_content = GeoNodeCswLayerDetail
- _layer_detail_api_v1_reply: typing.Optional[QtNetwork.QNetworkReply]
- _layer_style_reply: typing.Optional[QtNetwork.QNetworkReply]
-
- layer_detail_api_v1_parsed = QtCore.pyqtSignal()
- layer_style_parsed = QtCore.pyqtSignal()
-
- def __init__(self, base_url: str, *args, **kwargs):
- """Fetch layer details from GeoNode using CSW API with anonymous access."""
- super().__init__(*args, **kwargs)
- self.base_url = base_url
- self.reply_content = GeoNodeCswLayerDetail(None, None, None)
- self._layer_detail_api_v1_reply = None
- self._layer_style_reply = None
-
- def run(self):
- record = self._blocking_get_reply()
- if record is not None:
- self.reply_content.parsed_csw_record = record
- layer_name = _extract_layer_name(record)
- layer_detail = self._blocking_get_layer_detail_v1_api(layer_name)
- if layer_detail is not None:
- self.reply_content.parsed_layer_detail = layer_detail
- style_uri = layer_detail["default_style"]
- try:
- brief_style = self._blocking_get_style_detail(style_uri)
- self.reply_content.brief_style = brief_style
- result = brief_style is not None
- except RuntimeError as exc:
- log(str(exc))
- result = False
- else:
- result = False
- else:
- result = False
- return result
-
- def _request_done(self, qgis_reply: qgis.core.QgsNetworkReplyContent):
- self.parsed_reply = parse_network_reply(qgis_reply)
- log(f"requested_url: {qgis_reply.request().url().toString()}")
- log(f"http_status_code: {self.parsed_reply.http_status_code}")
- log(f"qt_error: {self.parsed_reply.qt_error}")
- found_matched_reply = False
- if self._final_reply is not None:
- if network.reply_matches(qgis_reply, self._final_reply):
- found_matched_reply = True
- self.request_parsed.emit()
- if self._layer_detail_api_v1_reply is not None:
- if network.reply_matches(qgis_reply, self._layer_detail_api_v1_reply):
- found_matched_reply = True
- self.layer_detail_api_v1_parsed.emit()
- if self._layer_style_reply is not None:
- if network.reply_matches(qgis_reply, self._layer_style_reply):
- found_matched_reply = True
- self.layer_style_parsed.emit()
- if not found_matched_reply:
- log("Could not match this reply with a previous one, ignoring...")
-
- def _blocking_get_reply(
- self,
- ) -> typing.Optional[ET.Element]:
- with network.wait_for_signal(self.request_parsed, self.TIMEOUT) as loop_result:
- if self.request_payload is None:
- self._final_reply = self.network_access_manager.get(self.request)
- else:
- self._final_reply = self.network_access_manager.post(
- self.request,
- QtCore.QByteArray(self.request_payload.encode("utf-8")),
- )
- if loop_result.result:
- decoded = self._final_reply.readAll().data().decode("utf-8")
- decoded_element = ET.fromstring(decoded)
- record = decoded_element.find(f"{{{Csw202Namespace.GMD.value}}}MD_Metadata")
- else:
- record = None
- return record
-
-
-class GeoNodeLegacyAuthenticatedLayerDetailFetcherTask(
- GeonodeLayerDetailFetcherMixin, GeoNodeLegacyAuthenticatedRecordSearcherTask
-):
- reply_content: GeoNodeCswLayerDetail
-
- _layer_detail_api_v1_reply: typing.Optional[QtNetwork.QNetworkReply]
- _layer_style_reply: typing.Optional[QtNetwork.QNetworkReply]
-
- layer_detail_api_v1_parsed = QtCore.pyqtSignal()
- layer_style_parsed = QtCore.pyqtSignal()
-
- def __init__(self, *args, **kwargs):
- """Fetch a layer's detail when using the GeoNode legacy API
-
- Using the GeoNode legacy API for fetching a layer's details involves making
- more than one network request, since we need to:
-
- - login
- - GetRecordById with the CSW API
- - /api/layer/id with the pre-v1 API
- - get the style detail
- - logout
-
- """
- super().__init__(*args, **kwargs)
- self.reply_content = GeoNodeCswLayerDetail(None, None, None)
- self._layer_detail_api_v1_reply = None
- self._layer_style_reply = None
-
- def run(self):
- if self._blocking_get_csrf_token():
- logged_in = self._blocking_login()
- log(f"logged_in: {logged_in}")
- if logged_in:
- record = self._blocking_get_authenticated_reply()
- if record is not None:
- self.reply_content.parsed_csw_record = record
- layer_name = _extract_layer_name(record)
- layer_detail = self._blocking_get_layer_detail_v1_api(layer_name)
- if layer_detail is not None:
- self.reply_content.parsed_layer_detail = layer_detail
- style_uri = layer_detail["default_style"]
- try:
- brief_style = self._blocking_get_style_detail(style_uri)
- self.reply_content.brief_style = brief_style
- except RuntimeError as exc:
- log(str(exc))
- self._blocking_logout()
- result = self.parsed_reply.qt_error is None
- else:
- result = False
- else:
- result = False
- return result
-
- def _request_done(self, qgis_reply: qgis.core.QgsNetworkReplyContent):
- """Handle finished network requests
-
- This slot is cannected to the network access manager and is used as a handler
- for all HTTP requests.
-
- The logic defined herein is something like:
-
- - test whether the request that has just finished is known to us
- - if it is, emit a signal that causes the relevant event loop to quit. This is
- part of the strategy that this class adopts, which is to block the current
- thread until a network request finishes
-
- """
-
- self.parsed_reply = parse_network_reply(qgis_reply)
- log(f"requested_url: {qgis_reply.request().url().toString()}")
- log(f"http_status_code: {self.parsed_reply.http_status_code}")
- log(f"qt_error: {self.parsed_reply.qt_error}")
- found_matched_reply = False
- if self._first_login_reply is not None:
- if network.reply_matches(qgis_reply, self._first_login_reply):
- found_matched_reply = True
- self.first_login_parsed.emit()
- if self._second_login_reply is not None:
- if network.reply_matches(qgis_reply, self._second_login_reply):
- found_matched_reply = True
- self.second_login_parsed.emit()
- if self._final_reply is not None:
- if network.reply_matches(qgis_reply, self._final_reply):
- found_matched_reply = True
- self.request_parsed.emit()
- if self._layer_detail_api_v1_reply is not None:
- if network.reply_matches(qgis_reply, self._layer_detail_api_v1_reply):
- found_matched_reply = True
- self.layer_detail_api_v1_parsed.emit()
- if self._layer_style_reply is not None:
- if network.reply_matches(qgis_reply, self._layer_style_reply):
- found_matched_reply = True
- self.layer_style_parsed.emit()
- if self._logout_reply is not None:
- if network.reply_matches(qgis_reply, self._logout_reply):
- found_matched_reply = True
- self.logout_parsed.emit()
- if not found_matched_reply:
- log("Could not match this reply with a previous one, ignoring...")
-
- def _blocking_get_authenticated_reply(
- self,
- ) -> typing.Optional[ET.Element]:
- result = super()._blocking_get_authenticated_reply()
- if result:
- decoded = self._final_reply.readAll().data().decode("utf-8")
- decoded_element = ET.fromstring(decoded)
- record = decoded_element.find(f"{{{Csw202Namespace.GMD.value}}}MD_Metadata")
- else:
- record = None
- return record
-
-
-class Csw202Namespace(enum.Enum):
- CSW = "http://www.opengis.net/cat/csw/2.0.2"
- DC = "http://purl.org/dc/elements/1.1/"
- DCT = "http://purl.org/dc/terms/"
- GCO = "http://www.isotc211.org/2005/gco"
- GMD = "http://www.isotc211.org/2005/gmd"
- GML = "http://www.opengis.net/gml"
- OWS = "http://www.opengis.net/ows"
- OGC = "http://www.opengis.net/ogc"
- APISO = "http://www.opengis.net/cat/csw/apiso/1.0"
-
-
-class GeonodeCswClient(base.BaseGeonodeClient):
- """Asynchronous GeoNode API client for pre-v2 API"""
-
- SERVICE = "CSW"
- VERSION = "2.0.2"
- OUTPUT_SCHEMA = Csw202Namespace.GMD.value
- OUTPUT_FORMAT = "application/xml"
- TYPE_NAME = ET.QName(Csw202Namespace.GMD.value, "MD_Metadata")
-
- capabilities = [
- models.ApiClientCapability.FILTER_BY_TITLE,
- models.ApiClientCapability.FILTER_BY_ABSTRACT,
- # models.ApiClientCapability.FILTER_BY_SPATIAL_EXTENT,
- ]
- host: str
- username: typing.Optional[str]
- password: typing.Optional[str]
-
- def __init__(
- self,
- *args,
- username: typing.Optional[str] = None,
- password: typing.Optional[str] = None,
- **kwargs,
- ):
- super().__init__(*args, **kwargs)
- self.username = username
- self.password = password
-
- @classmethod
- def from_connection_settings(cls, connection_settings: "ConnectionSettings"):
- return cls(
- username=connection_settings.api_version_settings.username,
- password=connection_settings.api_version_settings.password,
- base_url=connection_settings.base_url,
- auth_config=connection_settings.auth_config,
- )
-
- @property
- def catalogue_url(self):
- return f"{self.base_url}/catalogue/csw"
-
- @property
- def host(self):
- return urllib.parse.urlparse(self.base_url).netloc
-
- @property
- def login_url(self):
- return f"{self.base_url}/account/login/"
-
- def get_ordering_filter_name(
- self,
- ordering_type: models.OrderingType,
- reverse_sort: typing.Optional[bool] = False,
- ) -> str:
- """Return name of the term that is sent to the CSW API when performing searches.
-
- The CSW specification (and also ISO AP) only define `Title` as a core queryable
- therefore, for the `name` case we search for title instead.
-
- """
-
- name = {
- models.OrderingType.TITLE: "apiso:Title",
- }[ordering_type]
- return f"{name}:{'D' if reverse_sort else 'A'}"
-
- def get_search_result_identifier(
- self, resource: models.BriefGeonodeResource
- ) -> str:
- """Field that should be shown on the QGIS GUI as the layer identifier
-
- In order to be consistent with the search filter, we use the `title` property.
-
- """
-
- return resource.title
-
- def get_layers_url_endpoint(
- self, search_params: models.GeonodeApiSearchFilters
- ) -> QtCore.QUrl:
- return QtCore.QUrl(self.catalogue_url)
-
- def get_layers_request_payload(
- self, search_params: models.GeonodeApiSearchFilters
- ) -> typing.Optional[str]:
- start_position = (self.page_size * search_params.page + 1) - self.page_size
- for member in Csw202Namespace:
- ET.register_namespace(member.name.lower(), member.value)
- get_records_el = ET.Element(
- ET.QName(Csw202Namespace.CSW.value, "GetRecords"),
- attrib={
- "service": self.SERVICE,
- "version": self.VERSION,
- "resultType": "results",
- "startPosition": str(start_position),
- "maxRecords": str(self.page_size),
- "outputFormat": self.OUTPUT_FORMAT,
- "outputSchema": self.OUTPUT_SCHEMA,
- },
- )
- log(f"get_records_el: {ET.tostring(get_records_el, encoding='unicode')}")
- query_el = ET.SubElement(
- get_records_el,
- ET.QName(Csw202Namespace.CSW.value, "Query"),
- attrib={"typeNames": self.TYPE_NAME},
- )
- elementsetname_el = ET.SubElement(
- query_el, ET.QName(Csw202Namespace.CSW.value, "ElementSetName")
- )
- elementsetname_el.text = "full"
- _add_constraints(query_el, search_params)
- _add_ordering(query_el, "dc:title", search_params.reverse_ordering)
- tree = ET.ElementTree(get_records_el)
- buffer = io.StringIO()
- tree.write(buffer, xml_declaration=True, encoding="unicode")
- result = buffer.getvalue()
- buffer.close()
- log(f"result: {result}")
- return result
-
- def get_layer_detail_from_brief_resource(
- self, brief_resource: models.BriefGeonodeResource
- ):
- self.get_layer_detail(brief_resource.uuid)
-
- def get_layer_detail_url_endpoint(self, id_: uuid.UUID) -> QtCore.QUrl:
- url = QtCore.QUrl(f"{self.catalogue_url}")
- query = QtCore.QUrlQuery()
- query.addQueryItem("service", "CSW")
- query.addQueryItem("version", "2.0.2")
- query.addQueryItem("request", "GetRecordById")
- query.addQueryItem("outputschema", self.OUTPUT_SCHEMA)
- query.addQueryItem("elementsetname", "full")
- query.addQueryItem("id", str(id_))
- url.setQuery(query.query())
- return url
-
- def get_layers(
- self, search_params: typing.Optional[models.GeonodeApiSearchFilters] = None
- ):
- url = self.get_layers_url_endpoint(search_params)
- params = search_params or models.GeonodeApiSearchFilters()
- request_payload = self.get_layers_request_payload(params)
- log(f"URL: {url.toString()}")
- request = QtNetwork.QNetworkRequest(url)
- if self.username is not None:
- self.network_fetcher_task = GeoNodeLegacyAuthenticatedRecordSearcherTask(
- self.base_url,
- self.username,
- self.password,
- self,
- request=request,
- request_payload=request_payload,
- authcfg=self.auth_config,
- )
- else:
- self.network_fetcher_task = network.NetworkFetcherTask(
- self, request, request_payload=request_payload, authcfg=self.auth_config
- )
- self.network_fetcher_task.request_finished.connect(
- partial(self.handle_layer_list, params)
- )
- qgis.core.QgsApplication.taskManager().addTask(self.network_fetcher_task)
-
- def get_layer_detail(self, id_: typing.Union[int, uuid.UUID]):
- request = QtNetwork.QNetworkRequest(self.get_layer_detail_url_endpoint(id_))
- if self.username is not None:
- self.network_fetcher_task = (
- GeoNodeLegacyAuthenticatedLayerDetailFetcherTask(
- self,
- self.base_url,
- self.username,
- self.password,
- request,
- authcfg=self.auth_config,
- )
- )
- else:
- self.network_fetcher_task = GeoNodeLegacyLayerDetailFetcher(
- self.base_url, self, request
- )
- self.network_fetcher_task.request_finished.connect(
- partial(self.handle_layer_detail)
- )
- qgis.core.QgsApplication.taskManager().addTask(self.network_fetcher_task)
-
- def deserialize_response_contents(self, contents: QtCore.QByteArray) -> ET.Element:
- decoded_contents: str = contents.data().decode()
- return ET.fromstring(decoded_contents)
-
- def handle_layer_list(
- self,
- original_search_params: models.GeonodeApiSearchFilters,
- ):
- log(f"inside handle_layer_list")
- layers = []
- if self.network_fetcher_task.parsed_reply.qt_error is None:
- deserialized = self.deserialize_response_contents(
- self.network_fetcher_task.reply_content
- )
- search_results = deserialized.find(
- f"{{{Csw202Namespace.CSW.value}}}SearchResults"
- )
- else:
- search_results = None
- if search_results is not None:
- total = int(search_results.attrib["numberOfRecordsMatched"])
- next_record = int(search_results.attrib["nextRecord"])
- if next_record == 0: # reached the last page
- current_page = max(int(math.ceil(total / self.page_size)), 1)
- else:
- current_page = max(int((next_record - 1) / self.page_size), 1)
- items = search_results.findall(
- f"{{{Csw202Namespace.GMD.value}}}MD_Metadata"
- )
- for item in items:
- try:
- brief_resource = get_brief_geonode_resource(
- item, self.base_url, self.auth_config
- )
- except (AttributeError, ValueError):
- log(f"Could not parse {item!r} into a valid item")
- else:
- layers.append(brief_resource)
- pagination_info = models.GeonodePaginationInfo(
- total_records=total,
- current_page=current_page,
- page_size=self.page_size,
- )
- self.layer_list_received.emit(layers, pagination_info)
- else:
- self.layer_list_received.emit(
- layers,
- models.GeonodePaginationInfo(
- total_records=0,
- current_page=1,
- page_size=self.page_size,
- ),
- )
-
- def handle_layer_detail(self):
- """Parse the input payload into a GeonodeResource instance
-
- This method performs additional blocking HTTP requests.
-
- A required property of ``GeonodeResource`` instances is their respective
- default style. Since the GeoNode CSW endpoint does not provide information on a
- layer's style, we need to make additional HTTP requests in order to get this
- from the API v1 endpoints.
-
- With this in mind, this method proceeds to:
-
- 1. Make a GET request to API v1 to get the layer detail page
- 2. Parse the layer detail, retrieve the style uri and build a full URL for it
- 3. Make a GET request to API v1 to get the style detail page
- 4. Parse the style detail, retrieve the style URL and name
-
- """
-
- self.network_fetcher_task: typing.Union[
- GeoNodeLegacyLayerDetailFetcher,
- GeoNodeLegacyAuthenticatedLayerDetailFetcherTask,
- ]
- layer = get_geonode_resource(
- self.network_fetcher_task.reply_content.parsed_csw_record,
- self.base_url,
- self.auth_config,
- default_style=self.network_fetcher_task.reply_content.brief_style,
- )
- self.layer_detail_received.emit(layer)
-
-
-def get_brief_geonode_resource(
- record: ET.Element, geonode_base_url: str, auth_config: str
-) -> models.BriefGeonodeResource:
- return models.BriefGeonodeResource(
- **_get_common_model_fields(record, geonode_base_url, auth_config)
- )
-
-
-def get_geonode_resource(
- record: ET.Element,
- geonode_base_url: str,
- auth_config: str,
- default_style: models.BriefGeonodeStyle,
-) -> models.GeonodeResource:
- common_fields = _get_common_model_fields(record, geonode_base_url, auth_config)
-
- return models.GeonodeResource(
- language=record.find(
- f"{{{Csw202Namespace.GMD.value}}}identificationInfo/"
- f"{{{Csw202Namespace.GMD.value}}}MD_DataIdentification/"
- f"{{{Csw202Namespace.GMD.value}}}language/"
- f"{{{Csw202Namespace.GCO.value}}}CharacterString"
- ).text,
- license=_get_license(record),
- constraints="", # FIXME: get constraints from record
- owner="", # FIXME: extract owner
- metadata_author="", # FIXME: extract metadata author
- default_style=default_style,
- styles=[],
- **common_fields,
- )
-
-
-def _get_common_model_fields(
- record: ET.Element, geonode_base_url: str, auth_config: str
-) -> typing.Dict:
- try:
- topic_category = record.find(
- f"{{{Csw202Namespace.GMD.value}}}identificationInfo/"
- f"{{{Csw202Namespace.GMD.value}}}MD_DataIdentification/"
- f"{{{Csw202Namespace.GMD.value}}}topicCategory/"
- f"{{{Csw202Namespace.GMD.value}}}MD_TopicCategoryCode"
- ).text
- except AttributeError:
- topic_category = None
- crs = _get_crs(
- record.find(
- f"{{{Csw202Namespace.GMD.value}}}referenceSystemInfo/"
- f"{{{Csw202Namespace.GMD.value}}}MD_ReferenceSystem/"
- f"{{{Csw202Namespace.GMD.value}}}referenceSystemIdentifier/"
- f"{{{Csw202Namespace.GMD.value}}}RS_Identifier"
- )
- )
- layer_name = (
- record.find(
- f"{{{Csw202Namespace.GMD.value}}}identificationInfo/"
- f"{{{Csw202Namespace.GMD.value}}}MD_DataIdentification/"
- f"{{{Csw202Namespace.GMD.value}}}citation/"
- f"{{{Csw202Namespace.GMD.value}}}CI_Citation/"
- f"{{{Csw202Namespace.GMD.value}}}name/"
- f"{{{Csw202Namespace.GCO.value}}}CharacterString"
- ).text
- or ""
- )
-
- resource_type = _get_resource_type(record)
- if resource_type == models.GeonodeResourceType.VECTOR_LAYER:
- service_urls = {
- models.GeonodeService.OGC_WMS: _get_wms_uri(
- record, layer_name, crs, auth_config
- ),
- models.GeonodeService.OGC_WFS: _get_wfs_uri(
- record, layer_name, auth_config
- ),
- }
- elif resource_type == models.GeonodeResourceType.RASTER_LAYER:
- service_urls = {
- models.GeonodeService.OGC_WMS: _get_wms_uri(
- record, layer_name, crs, auth_config
- ),
- models.GeonodeService.OGC_WCS: _get_wcs_uri(
- record, layer_name, auth_config
- ),
- }
- elif resource_type == models.GeonodeResourceType.MAP:
- service_urls = {
- models.GeonodeService.OGC_WMS: _get_wms_uri(
- record, layer_name, crs, auth_config
- ),
- }
- else:
- service_urls = None
- reported_thumbnail_url = record.find(
- f"{{{Csw202Namespace.GMD.value}}}identificationInfo/"
- f"{{{Csw202Namespace.GMD.value}}}MD_DataIdentification/"
- f"{{{Csw202Namespace.GMD.value}}}graphicOverview/"
- f"{{{Csw202Namespace.GMD.value}}}MD_BrowseGraphic/"
- f"{{{Csw202Namespace.GMD.value}}}fileName/"
- f"{{{Csw202Namespace.GCO.value}}}CharacterString"
- ).text
- if reported_thumbnail_url.startswith(geonode_base_url):
- thumbnail_url = reported_thumbnail_url
- else:
- # Sometimes GeoNode returns the full thumbnail URL, others it returns a
- # relative URI
- thumbnail_url = f"{geonode_base_url}{reported_thumbnail_url}"
- return {
- "uuid": uuid.UUID(
- record.find(
- f"{{{Csw202Namespace.GMD.value}}}fileIdentifier/"
- f"{{{Csw202Namespace.GCO.value}}}CharacterString"
- ).text
- ),
- "name": layer_name,
- "resource_type": resource_type,
- "title": record.find(
- f"{{{Csw202Namespace.GMD.value}}}identificationInfo/"
- f"{{{Csw202Namespace.GMD.value}}}MD_DataIdentification/"
- f"{{{Csw202Namespace.GMD.value}}}citation/"
- f"{{{Csw202Namespace.GMD.value}}}CI_Citation/"
- f"{{{Csw202Namespace.GMD.value}}}title/"
- f"{{{Csw202Namespace.GCO.value}}}CharacterString"
- ).text,
- "abstract": record.find(
- f"{{{Csw202Namespace.GMD.value}}}identificationInfo/"
- f"{{{Csw202Namespace.GMD.value}}}MD_DataIdentification/"
- f"{{{Csw202Namespace.GMD.value}}}abstract/"
- f"{{{Csw202Namespace.GCO.value}}}CharacterString"
- ).text
- or "",
- "spatial_extent": _get_spatial_extent(
- record.find(
- f"{{{Csw202Namespace.GMD.value}}}identificationInfo/"
- f"{{{Csw202Namespace.GMD.value}}}MD_DataIdentification/"
- f"{{{Csw202Namespace.GMD.value}}}extent/"
- f"{{{Csw202Namespace.GMD.value}}}EX_Extent/"
- f"{{{Csw202Namespace.GMD.value}}}geographicElement/"
- f"{{{Csw202Namespace.GMD.value}}}EX_GeographicBoundingBox"
- )
- ),
- "crs": crs,
- "thumbnail_url": thumbnail_url,
- # FIXME: this XPATH is not unique
- "gui_url": record.find(
- f"{{{Csw202Namespace.GMD.value}}}distributionInfo/"
- f"{{{Csw202Namespace.GMD.value}}}MD_Distribution/"
- f"{{{Csw202Namespace.GMD.value}}}transferOptions/"
- f"{{{Csw202Namespace.GMD.value}}}MD_DigitalTransferOptions/"
- f"{{{Csw202Namespace.GMD.value}}}onLine/"
- f"{{{Csw202Namespace.GMD.value}}}CI_OnlineResource/"
- f"{{{Csw202Namespace.GMD.value}}}linkage/"
- f"{{{Csw202Namespace.GMD.value}}}URL"
- ).text,
- "published_date": _get_published_date(record),
- "temporal_extent": _get_temporal_extent(record),
- "keywords": _get_keywords(record),
- "category": topic_category,
- "service_urls": service_urls,
- }
-
-
-def _get_resource_type(
- record: ET.Element,
-) -> typing.Optional[models.GeonodeResourceType]:
- content_info = record.find(f"{{{Csw202Namespace.GMD.value}}}contentInfo")
- is_raster = content_info.find(
- f"{{{Csw202Namespace.GMD.value}}}MD_CoverageDescription"
- )
- is_vector = content_info.find(
- f"{{{Csw202Namespace.GMD.value}}}MD_FeatureCatalogueDescription"
- )
- if is_raster:
- result = models.GeonodeResourceType.RASTER_LAYER
- elif is_vector:
- result = models.GeonodeResourceType.VECTOR_LAYER
- else:
- result = None
- return result
-
-
-def _get_crs(rs_identifier: ET.Element) -> qgis.core.QgsCoordinateReferenceSystem:
- code = rs_identifier.find(
- f"{{{Csw202Namespace.GMD.value}}}code/"
- f"{{{Csw202Namespace.GCO.value}}}CharacterString"
- ).text
- authority = rs_identifier.find(
- f"{{{Csw202Namespace.GMD.value}}}codeSpace/"
- f"{{{Csw202Namespace.GCO.value}}}CharacterString"
- ).text
- return qgis.core.QgsCoordinateReferenceSystem(f"{authority}:{code}")
-
-
-def _get_spatial_extent(geographic_bounding_box: ET.Element) -> qgis.core.QgsRectangle:
- # sometimes pycsw returns the extent fields with a comma as the decimal separator,
- # so we replace a comma with a dot
- min_x = float(
- geographic_bounding_box.find(
- f"{{{Csw202Namespace.GMD.value}}}westBoundLongitude/"
- f"{{{Csw202Namespace.GCO.value}}}Decimal"
- ).text.replace(",", ".")
- )
- min_y = float(
- geographic_bounding_box.find(
- f"{{{Csw202Namespace.GMD.value}}}southBoundLatitude/"
- f"{{{Csw202Namespace.GCO.value}}}Decimal"
- ).text.replace(",", ".")
- )
- max_x = float(
- geographic_bounding_box.find(
- f"{{{Csw202Namespace.GMD.value}}}eastBoundLongitude/"
- f"{{{Csw202Namespace.GCO.value}}}Decimal"
- ).text.replace(",", ".")
- )
- max_y = float(
- geographic_bounding_box.find(
- f"{{{Csw202Namespace.GMD.value}}}northBoundLatitude/"
- f"{{{Csw202Namespace.GCO.value}}}Decimal"
- ).text.replace(",", ".")
- )
- return qgis.core.QgsRectangle(min_x, min_y, max_x, max_y)
-
-
-def _get_temporal_extent(
- payload: ET.Element,
-) -> typing.Optional[typing.List[typing.Optional[dt.datetime]]]:
- time_period = payload.find(
- f"{{{Csw202Namespace.GMD.value}}}identificationInfo/"
- f"{{{Csw202Namespace.GMD.value}}}MD_DataIdentification/"
- f"{{{Csw202Namespace.GMD.value}}}extent/"
- f"{{{Csw202Namespace.GMD.value}}}EX_Extent/"
- f"{{{Csw202Namespace.GMD.value}}}temporalElement/"
- f"{{{Csw202Namespace.GMD.value}}}EX_TemporalExtent/"
- f"{{{Csw202Namespace.GMD.value}}}extent/"
- f"{{{Csw202Namespace.GML.value}}}TimePeriod"
- )
- if time_period is not None:
- temporal_format = "%Y-%m-%dT%H:%M:%S%z"
- start = _parse_datetime(
- time_period.find(f"{{{Csw202Namespace.GML.value}}}beginPosition").text,
- format_=temporal_format,
- )
- end = _parse_datetime(
- time_period.find(f"{{{Csw202Namespace.GML.value}}}endPosition").text,
- format_=temporal_format,
- )
- result = [start, end]
- else:
- result = None
- return result
-
-
-def _parse_datetime(raw_value: str, format_="%Y-%m-%dT%H:%M:%SZ") -> dt.datetime:
- try:
- result = dt.datetime.strptime(raw_value, format_)
- except ValueError:
- microsecond_format = "%Y-%m-%dT%H:%M:%S.%fZ"
- result = dt.datetime.strptime(raw_value, microsecond_format)
- return result
-
-
-def _get_published_date(record: ET.Element) -> dt.datetime:
- raw_date = record.find(
- f"{{{Csw202Namespace.GMD.value}}}identificationInfo/"
- f"{{{Csw202Namespace.GMD.value}}}MD_DataIdentification/"
- f"{{{Csw202Namespace.GMD.value}}}citation/"
- f"{{{Csw202Namespace.GMD.value}}}CI_Citation/"
- f"{{{Csw202Namespace.GMD.value}}}date/"
- f"{{{Csw202Namespace.GMD.value}}}CI_Date/"
- f"{{{Csw202Namespace.GMD.value}}}date/"
- f"{{{Csw202Namespace.GCO.value}}}DateTime"
- ).text
- result = _parse_datetime(raw_date)
- return result
-
-
-def _get_keywords(payload: ET.Element) -> typing.List[str]:
- keywords = payload.findall(f".//{{{Csw202Namespace.GMD.value}}}keyword")
- result = []
- for keyword in keywords:
- result.append(
- keyword.find(f"{{{Csw202Namespace.GCO.value}}}CharacterString").text
- )
- return result
-
-
-def _get_license(record: ET.Element) -> typing.Optional[str]:
- license_element = record.find(
- f"{{{Csw202Namespace.GMD.value}}}identificationInfo/"
- f"{{{Csw202Namespace.GMD.value}}}MD_DataIdentification/"
- f"{{{Csw202Namespace.GMD.value}}}resourceConstraints/"
- f"{{{Csw202Namespace.GMD.value}}}MD_LegalConstraints/"
- f"{{{Csw202Namespace.GMD.value}}}useConstraints/"
- f"{{{Csw202Namespace.GMD.value}}}MD_RestrictionCode[@codeListValue='license']/"
- f"../../"
- f"{{{Csw202Namespace.GMD.value}}}otherConstraints/"
- f"{{{Csw202Namespace.GCO.value}}}CharacterString"
- )
- return license_element.text if license_element is not None else None
-
-
-def _get_online_elements(record: ET.Element) -> typing.List[ET.Element]:
- return record.findall(
- f"{{{Csw202Namespace.GMD.value}}}distributionInfo/"
- f"{{{Csw202Namespace.GMD.value}}}MD_Distribution/"
- f"{{{Csw202Namespace.GMD.value}}}transferOptions/"
- f"{{{Csw202Namespace.GMD.value}}}MD_DigitalTransferOptions/"
- f"{{{Csw202Namespace.GMD.value}}}onLine/"
- f"{{{Csw202Namespace.GMD.value}}}CI_OnlineResource"
- )
-
-
-def _find_protocol_linkage(record: ET.Element, protocol: str) -> typing.Optional[str]:
- online_elements = record.findall(
- f"{{{Csw202Namespace.GMD.value}}}distributionInfo/"
- f"{{{Csw202Namespace.GMD.value}}}MD_Distribution/"
- f"{{{Csw202Namespace.GMD.value}}}transferOptions/"
- f"{{{Csw202Namespace.GMD.value}}}MD_DigitalTransferOptions/"
- f"{{{Csw202Namespace.GMD.value}}}onLine/"
- f"{{{Csw202Namespace.GMD.value}}}CI_OnlineResource"
- )
- for item in online_elements:
- reported_protocol = item.find(
- f"{{{Csw202Namespace.GMD.value}}}protocol/"
- f"{{{Csw202Namespace.GCO.value}}}CharacterString"
- ).text
- if reported_protocol.lower() == protocol.lower():
- linkage_url = item.find(
- f"{{{Csw202Namespace.GMD.value}}}linkage/"
- f"{{{Csw202Namespace.GMD.value}}}URL"
- ).text
- break
- else:
- linkage_url = None
- return linkage_url
-
-
-def _get_wms_uri(
- record: ET.Element,
- layer_name: str,
- crs: qgis.core.QgsCoordinateReferenceSystem,
- auth_config: typing.Optional[str] = None,
- wms_format: typing.Optional[str] = "image/png",
-) -> str:
- params = {
- "url": _find_protocol_linkage(record, "ogc:wms"),
- "format": wms_format,
- "layers": layer_name,
- "crs": f"EPSG:{crs.postgisSrid()}",
- "styles": "",
- "version": "auto",
- }
- if auth_config is not None:
- params["authcfg"] = auth_config
- return "&".join(f"{k}={v.replace('=', '%3D')}" for k, v in params.items())
-
-
-def _get_wcs_uri(
- record: ET.Element,
- layer_name: str,
- auth_config: typing.Optional[str] = None,
-) -> str:
- params = {
- "identifier": layer_name,
- "url": _find_protocol_linkage(record, "ogc:wcs"),
- }
- if auth_config is not None:
- params["authcfg"] = auth_config
- return "&".join(f"{k}={v.replace('=', '%3D')}" for k, v in params.items())
-
-
-def _get_wfs_uri(
- record: ET.Element,
- layer_name: str,
- auth_config: typing.Optional[str] = None,
-) -> str:
- params = {
- "url": _find_protocol_linkage(record, "ogc:wfs"),
- "typename": layer_name,
- "version": "auto",
- }
- if auth_config is not None:
- params["authcfg"] = auth_config
- return " ".join(f"{k}='{v}'" for k, v in params.items())
-
-
-def _add_constraints(
- parent: ET.Element,
- search_params: models.GeonodeApiSearchFilters,
-):
- if search_params.layer_types is None:
- types = [
- models.GeonodeResourceType.VECTOR_LAYER,
- models.GeonodeResourceType.RASTER_LAYER,
- models.GeonodeResourceType.MAP,
- ]
- else:
- types = list(search_params.layer_types)
- filter_params = (
- search_params.title,
- search_params.abstract,
- # search_params.spatial_extent,
- )
- if any(filter_params):
- constraint_el = ET.SubElement(
- parent,
- ET.QName(Csw202Namespace.CSW.value, "Constraint"),
- attrib={"version": "1.1.0"},
- )
- filter_el = ET.SubElement(
- constraint_el, ET.QName(Csw202Namespace.OGC.value, "Filter")
- )
- multiple_conditions = len([i for i in filter_params if i]) > 1
- filter_root_el = filter_el
- if multiple_conditions:
- and_el = ET.SubElement(
- filter_el, ET.QName(Csw202Namespace.OGC.value, "And")
- )
- filter_root_el = and_el
- if search_params.title is not None:
- _add_property_is_like_element(
- filter_root_el, "dc:title", search_params.title
- )
- if search_params.abstract is not None:
- _add_property_is_like_element(
- filter_root_el, "dc:abstract", search_params.abstract
- )
- # if search_params.selected_keyword is not None:
- # pass
- # if search_params.topic_category is not None:
- # pass
- # if types is not None:
- # pass
- # if search_params.temporal_extent_start is not None:
- # pass
- # if search_params.temporal_extent_end is not None:
- # pass
- # if search_params.publication_date_start is not None:
- # pass
- # if search_params.publication_date_end is not None:
- # pass
- # if search_params.spatial_extent is not None:
- # _add_bbox_operator(filter_root_el, search_params.spatial_extent)
-
-
-def _add_ordering(parent: ET.Element, ordering_field: str, reverse: bool):
- sort_by_el = ET.SubElement(parent, ET.QName(Csw202Namespace.OGC.value, "SortBy"))
- sort_property_el = ET.SubElement(
- sort_by_el, ET.QName(Csw202Namespace.OGC.value, "SortProperty")
- )
- property_name_el = ET.SubElement(
- sort_property_el, ET.QName(Csw202Namespace.OGC.value, "PropertyName")
- )
- property_name_el.text = ordering_field
- sort_order_el = ET.SubElement(
- sort_property_el, ET.QName(Csw202Namespace.OGC.value, "SortOrder")
- )
- sort_order_el.text = "DESC" if reverse else "ASC"
-
-
-def _add_property_is_like_element(parent: ET.Element, name: str, value: str):
- wildcard = "*"
- property_is_like_el = ET.SubElement(
- parent,
- ET.QName(Csw202Namespace.OGC.value, "PropertyIsLike"),
- attrib={
- "wildCard": wildcard,
- "escapeChar": "",
- "singleChar": "?",
- "matchCase": "false",
- },
- )
- property_name_el = ET.SubElement(
- property_is_like_el, ET.QName(Csw202Namespace.OGC.value, "PropertyName")
- )
- property_name_el.text = name
- literal_el = ET.SubElement(
- property_is_like_el, ET.QName(Csw202Namespace.OGC.value, "Literal")
- )
- literal_el.text = f"{wildcard}{value}{wildcard}"
-
-
-def _add_bbox_operator(parent: ET.Element, spatial_extent: qgis.core.QgsRectangle):
- bbox_el = ET.SubElement(parent, ET.QName(Csw202Namespace.OGC.value, "BBOX"))
- property_name_el = ET.SubElement(
- bbox_el, ET.QName(Csw202Namespace.OGC.value, "PropertyName")
- )
- property_name_el.text = "apiso:BoundingBox"
- envelope_el = ET.SubElement(
- bbox_el, ET.QName(Csw202Namespace.GML.value, "Envelope")
- )
- lower_corner_el = ET.SubElement(
- envelope_el, ET.QName(Csw202Namespace.GML.value, "lowerCorner")
- )
- lower_corner_el.text = f"{spatial_extent.yMinimum()} {spatial_extent.xMinimum()}"
- upper_corner_el = ET.SubElement(
- envelope_el, ET.QName(Csw202Namespace.GML.value, "upperCorner")
- )
- upper_corner_el.text = f"{spatial_extent.yMaximum()} {spatial_extent.xMaximum()}"
-
-
-def _extract_layer_name(record: ET.Element):
- return record.find(
- f"{{{Csw202Namespace.GMD.value}}}identificationInfo/"
- f"{{{Csw202Namespace.GMD.value}}}MD_DataIdentification/"
- f"{{{Csw202Namespace.GMD.value}}}citation/"
- f"{{{Csw202Namespace.GMD.value}}}CI_Citation/"
- f"{{{Csw202Namespace.GMD.value}}}name/"
- f"{{{Csw202Namespace.GCO.value}}}CharacterString"
- ).text
diff --git a/src/qgis_geonode/apiclient/models.py b/src/qgis_geonode/apiclient/models.py
index f87b90ef..085aa2d8 100644
--- a/src/qgis_geonode/apiclient/models.py
+++ b/src/qgis_geonode/apiclient/models.py
@@ -17,10 +17,11 @@
QgsRectangle,
)
-from ..utils import IsoTopicCategory
+from .. import styles as qgis_geonode_styles
+from ..utils import log
-UNSUPPORTED_REMOTE = "unsupported"
DATASET_CUSTOM_PROPERTY_KEY = "plugins/qgis_geonode/dataset"
+DATASET_CONNECTION_CUSTOM_PROPERTY_KEY = "plugins/qgis_geonode/dataset_connection"
class ApiClientCapability(enum.Enum):
@@ -37,14 +38,40 @@ class ApiClientCapability(enum.Enum):
FILTER_BY_SPATIAL_EXTENT = enum.auto()
LOAD_LAYER_METADATA = enum.auto()
MODIFY_LAYER_METADATA = enum.auto()
- LOAD_LAYER_STYLE = enum.auto()
- MODIFY_LAYER_STYLE = enum.auto()
+ LOAD_VECTOR_LAYER_STYLE = enum.auto()
+ LOAD_RASTER_LAYER_STYLE = enum.auto()
+ MODIFY_VECTOR_LAYER_STYLE = enum.auto()
+ MODIFY_RASTER_LAYER_STYLE = enum.auto()
LOAD_VECTOR_DATASET_VIA_WMS = enum.auto()
LOAD_VECTOR_DATASET_VIA_WFS = enum.auto()
LOAD_RASTER_DATASET_VIA_WMS = enum.auto()
LOAD_RASTER_DATASET_VIA_WCS = enum.auto()
+# NOTE: for simplicity, this enum's variants are named directly after the GeoNode
+# topic_category ids.
+class IsoTopicCategory(enum.Enum):
+ biota = "Biota"
+ boundaries = "Boundaries"
+ climatologyMeteorologyAtmosphere = "Climatology Meteorology Atmosphere"
+ economy = "Economy"
+ elevation = "Elevation"
+ environment = "Environment"
+ farming = "Farming"
+ geoscientificInformation = "Geoscientific Information"
+ health = "Health"
+ imageryBaseMapsEarthCover = "Imagery Base Maps Earth Cover"
+ inlandWaters = "Inland Waters"
+ intelligenceMilitary = "Intelligence Military"
+ location = "Location"
+ oceans = "Oceans"
+ planningCadastre = "Planning Cadastre"
+ society = "Society"
+ structure = "Structure"
+ transportation = "Transportation"
+ utilitiesCommunication = "Utilities Communication"
+
+
class GeonodeService(enum.Enum):
OGC_WMS = "wms"
OGC_WFS = "wfs"
@@ -76,6 +103,7 @@ def total_pages(self):
class BriefGeonodeStyle:
name: str
sld_url: str
+ sld: typing.Optional[QtXml.QDomElement] = None
@dataclasses.dataclass()
@@ -106,10 +134,21 @@ class Dataset(BriefDataset):
constraints: str
owner: typing.Dict[str, str]
metadata_author: typing.Dict[str, str]
- styles: typing.List[BriefGeonodeStyle]
- default_style: typing.Optional[QtXml.QDomElement]
def to_json(self):
+ if self.temporal_extent is not None:
+ serialized_temporal_extent = []
+ for temporal_extent_item in self.temporal_extent:
+ temporal_extent_item: dt.datetime
+ serialized_temporal_extent.append(temporal_extent_item.isoformat())
+ else:
+ serialized_temporal_extent = None
+ if self.default_style.sld is not None:
+ serialized_sld = qgis_geonode_styles.serialize_sld_named_layer(
+ self.default_style.sld
+ )
+ else:
+ serialized_sld = None
return json.dumps(
{
"pk": self.pk,
@@ -122,7 +161,7 @@ def to_json(self):
if self.published_date
else None,
"spatial_extent": self.spatial_extent.asWktPolygon(),
- "temporal_extent": None, # TODO
+ "temporal_extent": serialized_temporal_extent,
"srid": self.srid.postgisSrid(),
"thumbnail_url": self.thumbnail_url,
"link": self.link,
@@ -137,21 +176,70 @@ def to_json(self):
"constraints": self.constraints,
"owner": self.owner,
"metadata_author": self.metadata_author,
- # TODO: add styles
+ "default_style": {
+ "name": self.default_style.name,
+ "sld_url": self.default_style.sld_url,
+ "sld": serialized_sld,
+ },
}
)
- # @classmethod
- # def from_json(cls, contents: str):
- # parsed = json.loads(contents)
- # return cls(
- # pk=parsed["pk"],
- # uuid=UUID(parsed["uuid"]),
- # name=parsed["name"],
- # dataset_sub_type=GeonodeResourceType(parsed["dataset_sub_type.value"]),
- # title=parsed["title"],
- # abstract=parsed["abstract"],
- # )
+ @classmethod
+ def from_json(cls, contents: str):
+ parsed = json.loads(contents)
+ raw_published = parsed["published_date"]
+ raw_temporal_extent = parsed["temporal_extent"]
+ if raw_temporal_extent is not None:
+ temporal_extent = [
+ dt.datetime.fromisoformat(i) for i in raw_temporal_extent
+ ]
+ else:
+ temporal_extent = None
+ service_urls = {}
+ for service_type, url in parsed["service_urls"].items():
+ type_ = GeonodeService(service_type)
+ service_urls[type_] = url
+ default_sld = parsed.get("default_style", {}).get("sld")
+ if default_sld is not None:
+ sld, sld_error_message = qgis_geonode_styles.deserialize_sld_named_layer(
+ default_sld
+ )
+ if sld is None:
+ log(f"Could not deserialize SLD style: {sld_error_message}")
+ else:
+ sld = None
+ return cls(
+ pk=parsed["pk"],
+ uuid=UUID(parsed["uuid"]),
+ name=parsed["name"],
+ dataset_sub_type=GeonodeResourceType(parsed["dataset_sub_type"]),
+ title=parsed["title"],
+ abstract=parsed["abstract"],
+ published_date=(
+ dt.datetime.fromisoformat(raw_published)
+ if raw_published is not None
+ else None
+ ),
+ spatial_extent=qgis.core.QgsRectangle.fromWkt(parsed["spatial_extent"]),
+ temporal_extent=temporal_extent,
+ srid=qgis.core.QgsCoordinateReferenceSystem.fromEpsgId(parsed["srid"]),
+ thumbnail_url=parsed["thumbnail_url"],
+ link=parsed["link"],
+ detail_url=parsed["detail_url"],
+ keywords=parsed["keywords"],
+ category=parsed["category"],
+ service_urls=service_urls,
+ language=parsed["language"],
+ license=parsed["license"],
+ constraints=parsed["constraints"],
+ owner=parsed["owner"],
+ metadata_author=parsed["metadata_author"],
+ default_style=BriefGeonodeStyle(
+ name=parsed.get("default_style", {}).get("name", ""),
+ sld_url=parsed.get("default_style", {}).get("sld_url"),
+ sld=sld,
+ ),
+ )
@dataclasses.dataclass
@@ -171,3 +259,35 @@ class GeonodeApiSearchFilters:
publication_date_start: typing.Optional[QtCore.QDateTime] = None
publication_date_end: typing.Optional[QtCore.QDateTime] = None
spatial_extent: typing.Optional[qgis.core.QgsRectangle] = None
+
+
+def loading_style_supported(
+ layer_type: qgis.core.QgsMapLayerType,
+ capabilities: typing.List[ApiClientCapability],
+) -> bool:
+ result = False
+ if layer_type == qgis.core.QgsMapLayerType.VectorLayer:
+ if ApiClientCapability.LOAD_VECTOR_LAYER_STYLE in capabilities:
+ result = True
+ elif layer_type == qgis.core.QgsMapLayerType.RasterLayer:
+ if ApiClientCapability.LOAD_RASTER_LAYER_STYLE in capabilities:
+ result = True
+ else:
+ pass
+ return result
+
+
+def modifying_style_supported(
+ layer_type: qgis.core.QgsMapLayerType,
+ capabilities: typing.List[ApiClientCapability],
+) -> bool:
+ result = False
+ if layer_type == qgis.core.QgsMapLayerType.VectorLayer:
+ if ApiClientCapability.MODIFY_VECTOR_LAYER_STYLE in capabilities:
+ result = True
+ elif layer_type == qgis.core.QgsMapLayerType.RasterLayer:
+ if ApiClientCapability.MODIFY_RASTER_LAYER_STYLE in capabilities:
+ result = True
+ else:
+ pass
+ return result
diff --git a/src/qgis_geonode/apiclient/version_legacy.py b/src/qgis_geonode/apiclient/version_legacy.py
index 47506a04..963e6cc0 100644
--- a/src/qgis_geonode/apiclient/version_legacy.py
+++ b/src/qgis_geonode/apiclient/version_legacy.py
@@ -7,9 +7,9 @@
from .. import network
from ..utils import (
- IsoTopicCategory,
log,
)
+from .models import IsoTopicCategory
from . import models
from .base import BaseGeonodeClient
@@ -98,20 +98,18 @@ def get_dataset_list_url(
def get_dataset_detail_url(self, dataset_id: int) -> QtCore.QUrl:
return QtCore.QUrl(f"{self.dataset_list_url}{dataset_id}/")
- def handle_dataset_list(self, result: bool):
- brief_datasets = []
- pagination_info = models.GeonodePaginationInfo(
- total_records=0, current_page=1, page_size=0
- )
+ def handle_dataset_list(self, result: bool) -> None:
if result:
response_content: network.ParsedNetworkReply = (
self.network_fetcher_task.response_contents[0]
)
- if response_content.qt_error is None:
+ emtpy_body = response_content.response_body.isEmpty()
+ if response_content.qt_error is None and not emtpy_body:
deserialized_content = network.deserialize_json_response(
response_content.response_body
)
if deserialized_content is not None:
+ brief_datasets = []
for raw_brief_dataset in deserialized_content.get("objects", []):
try:
parsed_properties = self._get_common_model_properties(
@@ -134,32 +132,69 @@ def handle_dataset_list(self, result: bool):
current_page=current_page,
page_size=page_size,
)
- self.dataset_list_received.emit(brief_datasets, pagination_info)
+ self.dataset_list_received.emit(brief_datasets, pagination_info)
+ else:
+ self.search_error_received[str].emit(
+ "Could not parse dataset list returned from remote GeoNode"
+ )
+ else:
+ self.search_error_received[str, int, str].emit(
+ response_content.qt_error,
+ response_content.http_status_code,
+ response_content.http_status_reason,
+ )
+ else:
+ self.search_error_received[str].emit(
+ "Could not complete request for dataset list"
+ )
- def handle_dataset_detail(self, brief_dataset: models.BriefDataset, result: bool):
- dataset = None
+ def handle_dataset_detail(
+ self, brief_dataset: models.BriefDataset, result: bool
+ ) -> None:
if result:
detail_response_content: network.ParsedNetworkReply = (
self.network_fetcher_task.response_contents[0]
)
- deserialized_response = network.deserialize_json_response(
- detail_response_content.response_body
- )
- if deserialized_response is not None:
- try:
- dataset = self._parse_dataset_detail(deserialized_response)
- except KeyError as exc:
- log(
- f"Could not parse server response into a dataset: {str(exc)}",
- debug=False,
- )
+ empty_body = detail_response_content.response_body.isEmpty()
+ if detail_response_content.qt_error is None and not empty_body:
+ deserialized_response = network.deserialize_json_response(
+ detail_response_content.response_body
+ )
+ if deserialized_response is not None:
+ try:
+ dataset = self._parse_dataset_detail(deserialized_response)
+ except KeyError as exc:
+ log(
+ f"Could not parse server response into a dataset: {str(exc)}",
+ debug=False,
+ )
+ else:
+ self.dataset_detail_received.emit(dataset)
else:
- # TODO: maybe we also have the SLD to parse fetch
- pass
- self.dataset_detail_received.emit(dataset)
+ self.dataset_detail_error_received[str].emit(
+ "Could not parse dataset detail returned from remote GeoNode"
+ )
+ else:
+ self.dataset_detail_error_received[str, int, str].emit(
+ detail_response_content.qt_error,
+ detail_response_content.http_status_code,
+ detail_response_content.http_status_reason,
+ )
+ else:
+ self.dataset_detail_error_received[str].emit(
+ "Could not complete request for dataset detail"
+ )
- def _parse_dataset_detail(self) -> models.Dataset:
- pass
+ def _parse_dataset_detail(self, raw_dataset: typing.Dict) -> models.Dataset:
+ properties = self._get_common_model_properties(raw_dataset)
+ properties.update(
+ language=raw_dataset.get("language"),
+ license=(raw_dataset.get("license") or {}).get("identifier", ""),
+ constraints=raw_dataset.get("raw_constraints_other", ""),
+ owner=raw_dataset.get("owner", {}).get("username", ""),
+ metadata_author=raw_dataset.get("metadata_author", {}).get("username", ""),
+ )
+ return models.Dataset(**properties)
def _get_common_model_properties(self, raw_dataset: typing.Dict) -> typing.Dict:
type_ = _get_resource_type(raw_dataset)
diff --git a/src/qgis_geonode/apiclient/version_postv2.py b/src/qgis_geonode/apiclient/version_postv2.py
index 1519fadd..4d6abd99 100644
--- a/src/qgis_geonode/apiclient/version_postv2.py
+++ b/src/qgis_geonode/apiclient/version_postv2.py
@@ -1,13 +1,14 @@
import datetime as dt
import typing
import uuid
-from functools import partial
import qgis.core
from qgis.PyQt import QtCore
from .. import network
+from .. import styles as geonode_styles
from ..utils import log
+
from . import models
from .base import BaseGeonodeClient
@@ -24,7 +25,11 @@ class GeonodePostV2ApiClient(BaseGeonodeClient):
models.ApiClientCapability.FILTER_BY_PUBLICATION_DATE,
models.ApiClientCapability.FILTER_BY_TEMPORAL_EXTENT,
models.ApiClientCapability.LOAD_LAYER_METADATA,
- models.ApiClientCapability.LOAD_LAYER_STYLE,
+ models.ApiClientCapability.LOAD_VECTOR_LAYER_STYLE,
+ # NOTE: loading raster layer style is not present here
+ # because QGIS does not currently support loading SLD for raster layers
+ models.ApiClientCapability.MODIFY_VECTOR_LAYER_STYLE,
+ models.ApiClientCapability.MODIFY_RASTER_LAYER_STYLE,
models.ApiClientCapability.LOAD_VECTOR_DATASET_VIA_WMS,
models.ApiClientCapability.LOAD_VECTOR_DATASET_VIA_WFS,
models.ApiClientCapability.LOAD_RASTER_DATASET_VIA_WMS,
@@ -100,9 +105,9 @@ def build_search_query(
is_vector = models.GeonodeResourceType.VECTOR_LAYER in types
is_raster = models.GeonodeResourceType.RASTER_LAYER in types
if is_vector:
- query.addQueryItem("filter{subtype}", "vector")
+ query.addQueryItem("filter{subtype.in}", "vector")
if is_raster:
- query.addQueryItem("filter{subtype}", "raster")
+ query.addQueryItem("filter{subtype.in}", "raster")
if search_filters.ordering_field is not None:
query.addQueryItem(
"sort[]", f"{'-' if search_filters.reverse_ordering else ''}name"
@@ -123,100 +128,122 @@ def get_dataset_detail_url(self, dataset_id: int) -> QtCore.QUrl:
def get_layer_style_list_url(self, layer_id: int):
return QtCore.QUrl(f"{self.dataset_list_url}{layer_id}/styles/")
- def handle_dataset_list(self, result: bool):
- brief_datasets = []
- pagination_info = models.GeonodePaginationInfo(
- total_records=0, current_page=1, page_size=0
- )
+ def handle_dataset_list(self, result: bool) -> None:
+ log(f"inside apiclient.handle_dataset_list with a result of {result!r}")
if result:
response_content: network.ParsedNetworkReply = (
self.network_fetcher_task.response_contents[0]
)
if response_content.qt_error is None:
- deserialized_content = network.deserialize_json_response(
- response_content.response_body
- )
- if deserialized_content is not None:
- for raw_brief_dataset in deserialized_content.get("datasets", []):
- try:
- parsed_properties = _get_common_model_properties(
- raw_brief_dataset
- )
- brief_dataset = models.BriefDataset(**parsed_properties)
- except ValueError:
- log(
- f"Could not parse {raw_brief_dataset!r} into "
- f"a valid item",
- debug=False,
- )
- else:
- brief_datasets.append(brief_dataset)
- pagination_info = models.GeonodePaginationInfo(
- total_records=deserialized_content.get("total") or 0,
- current_page=deserialized_content.get("page") or 1,
- page_size=deserialized_content.get("page_size") or 0,
+ if not response_content.response_body.isEmpty():
+ deserialized_content = network.deserialize_json_response(
+ response_content.response_body
)
- # TODO: WIP - handle errors
+ if deserialized_content is not None:
+ brief_datasets = []
+ for raw_brief_dataset in deserialized_content.get(
+ "datasets", []
+ ):
+ try:
+ parsed_properties = _get_common_model_properties(
+ raw_brief_dataset
+ )
+ brief_dataset = models.BriefDataset(**parsed_properties)
+ except ValueError:
+ log(
+ f"Could not parse {raw_brief_dataset!r} into "
+ f"a valid item",
+ debug=False,
+ )
+ else:
+ brief_datasets.append(brief_dataset)
+ pagination_info = models.GeonodePaginationInfo(
+ total_records=deserialized_content.get("total") or 0,
+ current_page=deserialized_content.get("page") or 1,
+ page_size=deserialized_content.get("page_size") or 0,
+ )
+ self.dataset_list_received.emit(brief_datasets, pagination_info)
+ else:
+ self.search_error_received[str].emit(
+ "Could not parse dataset list returned from remote GeoNode"
+ )
+ else:
+ self.search_error_received[str, int, str].emit(
+ response_content.qt_error,
+ response_content.http_status_code,
+ response_content.http_status_reason,
+ )
+ else:
+ self.search_error_received[str, int, str].emit(
+ response_content.qt_error,
+ response_content.http_status_code,
+ response_content.http_status_reason,
+ )
else:
- self.error_received.emit[str, int, str]
- self.dataset_list_received.emit(brief_datasets, pagination_info)
+ self.search_error_received[str].emit(
+ "Could not complete request for dataset list"
+ )
- def handle_dataset_detail(self, brief_dataset: models.BriefDataset, result: bool):
- dataset = None
+ def handle_dataset_detail(
+ self, brief_dataset: models.BriefDataset, result: bool
+ ) -> None:
if result:
detail_response_content: network.ParsedNetworkReply = (
self.network_fetcher_task.response_contents[0]
)
- deserialized_resource = network.deserialize_json_response(
- detail_response_content.response_body
- )
- if deserialized_resource is not None:
- try:
- dataset = parse_dataset_detail(deserialized_resource["dataset"])
- except KeyError as exc:
- log(
- f"Could not parse server response into a dataset: {str(exc)}",
- debug=False,
- )
- else:
- is_vector = (
- brief_dataset.dataset_sub_type
- == models.GeonodeResourceType.VECTOR_LAYER
- )
- if is_vector:
- sld_response_content: network.ParsedNetworkReply = (
- self.network_fetcher_task.response_contents[1]
+ empty_body = detail_response_content.response_body.isEmpty()
+ if detail_response_content.qt_error is None and not empty_body:
+ deserialized_resource = network.deserialize_json_response(
+ detail_response_content.response_body
+ )
+ if deserialized_resource is not None:
+ try:
+ dataset = parse_dataset_detail(deserialized_resource["dataset"])
+ except KeyError as exc:
+ log(
+ f"Could not parse server response into a dataset: {str(exc)}",
+ debug=False,
)
- sld_doc = self.deserialize_sld_style(
- sld_response_content.response_body
+ else:
+ is_vector = (
+ brief_dataset.dataset_sub_type
+ == models.GeonodeResourceType.VECTOR_LAYER
)
- sld_root = sld_doc.documentElement()
- error_message = "Could not parse downloaded SLD document"
- if sld_root.isNull():
- raise RuntimeError(error_message)
- sld_named_layer = sld_root.firstChildElement("NamedLayer")
- if sld_named_layer.isNull():
- raise RuntimeError(error_message)
- dataset.default_style = sld_named_layer
- self.dataset_detail_received.emit(dataset)
+ if is_vector:
+ (
+ sld_named_layer,
+ error_message,
+ ) = geonode_styles.get_usable_sld(
+ self.network_fetcher_task.response_contents[1]
+ )
+ if sld_named_layer is None:
+ raise RuntimeError(error_message)
+ dataset.default_style.sld = sld_named_layer
+ self.dataset_detail_received.emit(dataset)
+ else:
+ self.dataset_detail_error_received[str].emit(
+ "Could not parse dataset detail returned from remote GeoNode"
+ )
+ else:
+ self.dataset_detail_error_received[str, int, str].emit(
+ detail_response_content.qt_error,
+ detail_response_content.http_status_code,
+ detail_response_content.http_status_reason,
+ )
+ else:
+ self.dataset_detail_error_received[str].emit(
+ "Could not complete request for dataset detail"
+ )
def parse_dataset_detail(raw_dataset: typing.Dict) -> models.Dataset:
properties = _get_common_model_properties(raw_dataset)
- styles = []
- for raw_style in raw_dataset.get("styles", []):
- styles.append(
- models.BriefGeonodeStyle(
- name=raw_style.get("name", ""), sld_url=raw_style.get("sld_url")
- )
- )
properties.update(
language=raw_dataset.get("language"),
license=(raw_dataset.get("license") or {}).get("identifier", ""),
constraints=raw_dataset.get("raw_constraints_other", ""),
owner=raw_dataset.get("owner", {}).get("username", ""),
metadata_author=raw_dataset.get("metadata_author", {}).get("username", ""),
- styles=styles,
)
return models.Dataset(**properties)
diff --git a/src/qgis_geonode/conf.py b/src/qgis_geonode/conf.py
index 5c72ace4..14a7b062 100644
--- a/src/qgis_geonode/conf.py
+++ b/src/qgis_geonode/conf.py
@@ -11,8 +11,7 @@
from qgis.core import QgsRectangle, QgsSettings
from .apiclient import models
-from .apiclient.models import GeonodeResourceType
-from .utils import IsoTopicCategory
+from .apiclient.models import GeonodeResourceType, IsoTopicCategory
logger = logging.getLogger(__name__)
@@ -28,6 +27,13 @@ def qgis_settings(group_root: str):
settings.endGroup()
+def _get_network_requests_timeout():
+ settings = QgsSettings()
+ return settings.value(
+ "qgis/networkAndProxy/networkTimeout", type=int, defaultValue=5000
+ )
+
+
@dataclasses.dataclass
class ConnectionSettings:
"""Helper class to manage settings for a Connection"""
@@ -36,6 +42,9 @@ class ConnectionSettings:
name: str
base_url: str
page_size: int
+ network_requests_timeout: int = dataclasses.field(
+ default_factory=_get_network_requests_timeout, init=False
+ )
api_client_class_path: typing.Optional[str] = None
auth_config: typing.Optional[str] = None
diff --git a/src/qgis_geonode/gui/connection_dialog.py b/src/qgis_geonode/gui/connection_dialog.py
index c3d6ef9a..61066adf 100644
--- a/src/qgis_geonode/gui/connection_dialog.py
+++ b/src/qgis_geonode/gui/connection_dialog.py
@@ -13,13 +13,12 @@
)
from qgis.PyQt.uic import loadUiType
+from .. import network, utils
from ..apiclient.base import BaseGeonodeClient
-from ..apiclient import models
from ..conf import (
ConnectionSettings,
settings_manager,
)
-from .. import network
from ..utils import tr
DialogUi, _ = loadUiType(
@@ -28,6 +27,16 @@
class ConnectionDialog(QtWidgets.QDialog, DialogUi):
+ name_le: QtWidgets.QLineEdit
+ url_le: QtWidgets.QLineEdit
+ authcfg_acs: qgis.gui.QgsAuthConfigSelect
+ page_size_sb: QtWidgets.QSpinBox
+ network_timeout_sb: QtWidgets.QSpinBox
+ test_connection_pb: QtWidgets.QPushButton
+ buttonBox: QtWidgets.QDialogButtonBox
+ options_gb: QtWidgets.QGroupBox
+ bar: qgis.gui.QgsMessageBar
+
connection_id: uuid.UUID
api_client_class_path: typing.Optional[str]
discovery_task: typing.Optional[network.ApiClientDiscovererTask]
@@ -37,7 +46,7 @@ def __init__(self, connection_settings: typing.Optional[ConnectionSettings] = No
super().__init__()
self.setupUi(self)
self._widgets_to_toggle_during_connection_test = [
- self.test_connection_btn,
+ self.test_connection_pb,
self.buttonBox,
self.authcfg_acs,
self.options_gb,
@@ -57,7 +66,7 @@ def __init__(self, connection_settings: typing.Optional[ConnectionSettings] = No
self.url_le.setText(connection_settings.base_url)
self.authcfg_acs.setConfigId(connection_settings.auth_config)
self.page_size_sb.setValue(connection_settings.page_size)
- if self.api_client_class_path == models.UNSUPPORTED_REMOTE:
+ if self.api_client_class_path == network.UNSUPPORTED_REMOTE:
self.show_progress(
tr("Invalid configuration. Correct GeoNode URL and/or test again."),
message_level=qgis.core.Qgis.Critical,
@@ -72,7 +81,7 @@ def __init__(self, connection_settings: typing.Optional[ConnectionSettings] = No
]
for signal in ok_signals:
signal.connect(self.update_ok_buttons)
- self.test_connection_btn.clicked.connect(self.test_connection)
+ self.test_connection_pb.clicked.connect(self.test_connection)
# disallow names that have a slash since that is not compatible with how we
# are storing plugin state in QgsSettings
self.name_le.setValidator(
@@ -105,7 +114,7 @@ def test_connection(self):
def handle_discovery_test(self, discovered_api_client_class_path: str):
self.bar.clearWidgets()
self.api_client_class_path = discovered_api_client_class_path
- if self.api_client_class_path != models.UNSUPPORTED_REMOTE:
+ if self.api_client_class_path != network.UNSUPPORTED_REMOTE:
self.bar.pushMessage("Connection is valid", level=qgis.core.Qgis.Info)
else:
self.bar.pushMessage(
@@ -142,7 +151,7 @@ def accept(self):
def update_ok_buttons(self):
enabled_state = self.name_le.text() != "" and self.url_le.text() != ""
self.buttonBox.button(QtWidgets.QDialogButtonBox.Ok).setEnabled(enabled_state)
- self.test_connection_btn.setEnabled(enabled_state)
+ self.test_connection_pb.setEnabled(enabled_state)
def show_progress(
self,
@@ -150,14 +159,9 @@ def show_progress(
message_level: typing.Optional[qgis.core.Qgis] = qgis.core.Qgis.Info,
include_progress_bar: typing.Optional[bool] = False,
):
- message_bar_item = self.bar.createMessage(message)
- if include_progress_bar:
- progress_bar = QtWidgets.QProgressBar()
- progress_bar.setAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter)
- progress_bar.setMinimum(0)
- progress_bar.setMaximum(0)
- message_bar_item.layout().addWidget(progress_bar)
- self.bar.pushWidget(message_bar_item, message_level)
+ return utils.show_message(
+ self.bar, message, message_level, add_loading_widget=include_progress_bar
+ )
def _clear_layout(layout: QtWidgets.QLayout):
diff --git a/src/qgis_geonode/gui/geonode_data_source_widget.py b/src/qgis_geonode/gui/geonode_data_source_widget.py
index 527efea9..d7110c39 100644
--- a/src/qgis_geonode/gui/geonode_data_source_widget.py
+++ b/src/qgis_geonode/gui/geonode_data_source_widget.py
@@ -16,18 +16,20 @@
get_geonode_client,
models,
)
-from .. import conf
-from ..apiclient.models import ApiClientCapability
+from .. import (
+ conf,
+ utils,
+)
+from ..apiclient.models import ApiClientCapability, IsoTopicCategory
from ..gui.connection_dialog import ConnectionDialog
from ..gui.search_result_widget import SearchResultWidget
from .. import network
from ..utils import (
- IsoTopicCategory,
log,
tr,
)
-WidgetUi, _ = loadUiType(Path(__file__).parent / "../ui/geonode_datasource_widget.ui")
+WidgetUi, _ = loadUiType(Path(__file__).parents[1] / "ui/geonode_datasource_widget.ui")
_INVALID_CONNECTION_MESSAGE = (
"Current connection is invalid. Please review connection settings."
@@ -288,7 +290,7 @@ def activate_connection_configuration(self, index: int):
self.toggle_search_buttons(enable=False)
else:
conf.settings_manager.set_current_connection(current_connection.id)
- if current_connection.api_client_class_path == models.UNSUPPORTED_REMOTE:
+ if current_connection.api_client_class_path == network.UNSUPPORTED_REMOTE:
self.show_message(
tr(_INVALID_CONNECTION_MESSAGE), level=qgis.core.Qgis.Critical
)
@@ -299,7 +301,9 @@ def activate_connection_configuration(self, index: int):
self.api_client.dataset_list_received.connect(
self.handle_dataset_list
)
- self.api_client.error_received.connect(self.show_search_error)
+ self.api_client.search_error_received.connect(
+ self.handle_search_error
+ )
else:
# don't know if current config is valid or not yet, need to detect it
pass
@@ -394,18 +398,15 @@ def _confirm_deletion(self, connection_name: str):
)
return confirmation == QtWidgets.QMessageBox.Yes
- def show_progress(self, message):
- message_bar_item = self.message_bar.createMessage(message)
- progress_bar = QtWidgets.QProgressBar()
- progress_bar.setAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter)
- progress_bar.setMinimum(0)
- progress_bar.setMaximum(0)
- message_bar_item.layout().addWidget(progress_bar)
- self.message_bar.pushWidget(message_bar_item, qgis.core.Qgis.Info)
-
- def show_message(self, message: str, level=qgis.core.Qgis.Warning):
- self.message_bar.clearWidgets()
- self.message_bar.pushMessage(message, level=level)
+ def show_message(
+ self, message: str, level=qgis.core.Qgis.Info, add_loading_widget: bool = False
+ ) -> None:
+ utils.show_message(
+ self.message_bar,
+ message,
+ level=level,
+ add_loading_widget=add_loading_widget,
+ )
def request_next_page(self):
self.current_page += 1
@@ -461,7 +462,7 @@ def toggle_search_controls(self, enabled: bool):
def handle_search_start(self):
self.toggle_search_controls(False)
self.clear_search_results()
- self.show_progress(tr("Searching..."))
+ self.show_message(tr("Searching..."), add_loading_widget=True)
def handle_search_end(self, message: str):
self.message_bar.clearWidgets()
@@ -470,17 +471,19 @@ def handle_search_end(self, message: str):
self.toggle_search_controls(True)
self.toggle_search_buttons()
- def show_search_error(
+ def handle_search_error(
self,
qt_error_message: str,
http_status_code: int = 0,
http_status_reason: str = None,
):
- if http_status_code != 0:
- http_error = f"{http_status_code!r} - {http_status_reason!r}"
- else:
- http_error = ""
- error_message = f"Request error: {' '.join((qt_error_message, http_error))}"
+ message_fragments = [
+ "Search ended with error",
+ qt_error_message,
+ f"HTTP {http_status_code}" if http_status_code != 0 else None,
+ http_status_reason,
+ ]
+ error_message = " - ".join(i for i in message_fragments if i)
self.search_finished.emit(error_message)
def handle_dataset_list(
diff --git a/src/qgis_geonode/gui/geonode_map_layer_config_widget.py b/src/qgis_geonode/gui/geonode_map_layer_config_widget.py
new file mode 100644
index 00000000..a86575fa
--- /dev/null
+++ b/src/qgis_geonode/gui/geonode_map_layer_config_widget.py
@@ -0,0 +1,333 @@
+import typing
+import xml.etree.ElementTree as ET
+from pathlib import Path
+from uuid import UUID
+
+import qgis.core
+import qgis.gui
+
+from qgis.PyQt import (
+ QtCore,
+ QtGui,
+ QtWidgets,
+ QtXml,
+)
+from qgis.PyQt.uic import loadUiType
+
+from .. import (
+ conf,
+ network,
+ styles,
+ utils,
+)
+from ..apiclient import (
+ base,
+ get_geonode_client,
+ models,
+)
+from ..utils import (
+ log,
+)
+
+WidgetUi, _ = loadUiType(Path(__file__).parents[1] / "ui/qgis_geonode_layer_dialog.ui")
+
+
+class GeonodeMapLayerConfigWidget(qgis.gui.QgsMapLayerConfigWidget, WidgetUi):
+ download_style_pb: QtWidgets.QPushButton
+ upload_style_pb: QtWidgets.QPushButton
+ open_detail_url_pb: QtWidgets.QPushButton
+ open_link_url_pb: QtWidgets.QPushButton
+ message_bar: qgis.gui.QgsMessageBar
+
+ network_task: typing.Optional[network.NetworkRequestTask]
+ _apply_geonode_style: bool
+
+ @property
+ def connection_settings(self) -> typing.Optional[conf.ConnectionSettings]:
+ connection_settings_id = self.layer.customProperty(
+ models.DATASET_CONNECTION_CUSTOM_PROPERTY_KEY
+ )
+ if connection_settings_id is not None:
+ result = conf.settings_manager.get_connection_settings(
+ UUID(connection_settings_id)
+ )
+ else:
+ result = None
+ return result
+
+ @property
+ def api_client(self) -> typing.Optional[base.BaseGeonodeClient]:
+ connection_settings = self.connection_settings
+ if connection_settings is not None:
+ result = get_geonode_client(connection_settings)
+ else:
+ result = None
+ return result
+
+ def __init__(self, layer, canvas, parent):
+ super().__init__(layer, canvas, parent)
+ self.setupUi(self)
+ self.open_detail_url_pb.setIcon(
+ QtGui.QIcon(":/plugins/qgis_geonode/mIconGeonode.svg")
+ )
+ self.download_style_pb.setIcon(
+ QtGui.QIcon(":/images/themes/default/mActionRefresh.svg")
+ )
+ self.upload_style_pb.setIcon(
+ QtGui.QIcon(":/images/themes/default/mActionFileSave.svg")
+ )
+ self.message_bar = qgis.gui.QgsMessageBar()
+ self.message_bar.setSizePolicy(
+ QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Fixed
+ )
+ self.layout().insertWidget(0, self.message_bar)
+ self.network_task = None
+ self._apply_geonode_style = False
+ self.layer = layer
+ self._toggle_style_controls(enabled=False)
+ self._toggle_link_controls(enabled=False)
+ if self.layer.customProperty(models.DATASET_CUSTOM_PROPERTY_KEY) is not None:
+ # this layer came from GeoNode
+ self.download_style_pb.clicked.connect(self.download_style)
+ self.upload_style_pb.clicked.connect(self.upload_style)
+ self.open_detail_url_pb.clicked.connect(self.open_detail_url)
+ self.open_link_url_pb.clicked.connect(self.open_link_url)
+ self._toggle_style_controls(enabled=True)
+ self._toggle_link_controls(enabled=True)
+ else: # this is not a GeoNode layer
+ pass
+
+ def apply(self):
+ self.message_bar.clearWidgets()
+ if self._apply_geonode_style:
+ self._apply_sld()
+ self._apply_geonode_style = False
+
+ def get_dataset(self) -> typing.Optional[models.Dataset]:
+ serialized_dataset = self.layer.customProperty(
+ models.DATASET_CUSTOM_PROPERTY_KEY
+ )
+ if serialized_dataset is not None:
+ result = models.Dataset.from_json(
+ self.layer.customProperty(models.DATASET_CUSTOM_PROPERTY_KEY)
+ )
+ else:
+ result = None
+ return result
+
+ def update_dataset(self, new_dataset: models.Dataset):
+ serialized = new_dataset.to_json()
+ self.layer.setCustomProperty(models.DATASET_CUSTOM_PROPERTY_KEY, serialized)
+
+ def download_style(self):
+ dataset = self.get_dataset()
+ self.network_task = network.NetworkRequestTask(
+ [network.RequestToPerform(QtCore.QUrl(dataset.default_style.sld_url))],
+ self.connection_settings.auth_config,
+ network_task_timeout=self.api_client.network_requests_timeout,
+ description="Get dataset style",
+ )
+ self.network_task.task_done.connect(self.handle_style_downloaded)
+ self._toggle_style_controls(enabled=False)
+ self._show_message(message="Retrieving style...", add_loading_widget=True)
+ qgis.core.QgsApplication.taskManager().addTask(self.network_task)
+
+ def handle_style_downloaded(self, task_result: bool):
+ self._toggle_style_controls(enabled=True)
+ if task_result:
+ sld_named_layer, error_message = styles.get_usable_sld(
+ self.network_task.response_contents[0]
+ )
+ if sld_named_layer is not None:
+ dataset = self.get_dataset()
+ dataset.default_style.sld = sld_named_layer
+ self.update_dataset(dataset)
+ self._apply_geonode_style = True
+ self.apply()
+ else:
+ self._show_message(
+ message=(
+ f"Unable to download and parse SLD style from remote "
+ f"GeoNode: {error_message}"
+ ),
+ level=qgis.core.Qgis.Warning,
+ )
+ else:
+ self._show_message(
+ "Unable to retrieve GeoNode style", level=qgis.core.Qgis.Warning
+ )
+
+ def upload_style(self):
+ self.apply()
+ sld_data = self._prepare_style_for_upload()
+ if sld_data is not None:
+ serialized_sld, content_type = sld_data
+ dataset = self.get_dataset()
+ self.network_task = network.NetworkRequestTask(
+ [
+ network.RequestToPerform(
+ QtCore.QUrl(dataset.default_style.sld_url),
+ method=network.HttpMethod.PUT,
+ payload=serialized_sld,
+ content_type=content_type,
+ )
+ ],
+ self.connection_settings.auth_config,
+ network_task_timeout=self.api_client.network_requests_timeout,
+ description="Upload dataset style to GeoNode",
+ )
+ qgis.core.QgsApplication.taskManager().addTask(self.network_task)
+ self.network_task.task_done.connect(self.handle_style_uploaded)
+ self._toggle_style_controls(enabled=False)
+ self._show_message(message="Uploading style...", add_loading_widget=True)
+
+ def _prepare_style_for_upload(self) -> typing.Optional[typing.Tuple[str, str]]:
+ doc = QtXml.QDomDocument()
+ error_message = ""
+ self.layer.exportSldStyle(doc, error_message)
+ log(f"exportSldStyle error_message: {error_message!r}")
+ if error_message == "":
+ serialized_sld = doc.toString(0)
+ if self.layer.type() == qgis.core.QgsMapLayerType.VectorLayer:
+ # For vector layers QGIS exports SLD version 1.1.0.
+ #
+ # According to GeoServer docs here:
+ #
+ # https://docs.geoserver.org/stable/en/user/rest/api/styles.html#styles-format
+ #
+ # updating an SLD v1.1.0 requires a content-type of
+ # `application/vnd.ogc.se+xml`. I've not been able to find mention to
+ # this content-type in the OGC standards for Symbology (SE v1.1.0)
+ # nor Styled Layer Descriptor Profile for WMS v1.1.0 though (I
+ # probably missed it). However, it seems to work OK
+ # with GeoNode+GeoServer.
+ result = (serialized_sld, "application/vnd.ogc.se+xml")
+ elif self.layer.type() == qgis.core.QgsMapLayerType.RasterLayer:
+ result = self._prepare_raster_style_for_upload(serialized_sld)
+ else:
+ raise NotImplementedError("Unknown layer type")
+ else:
+ result = None
+ return result
+
+ def _prepare_raster_style_for_upload(
+ self, sld_generated_by_qgis: str
+ ) -> typing.Tuple[str, str]:
+ """Prepare raster SLD for uploading to remote GeoNode.
+
+ For raster layers, QGIS exports SLD version 1.0.0 with an element of
+ `sld:UserLayer`. We modify to `sld:NamedLayer` and adjust the content-type
+ accordingly.
+
+ """
+
+ nsmap = {
+ "sld": "http://www.opengis.net/sld",
+ "ogc": "http://www.opengis.net/ogc",
+ "xlink": "http://www.w3.org/1999/xlink",
+ "se": "http://www.opengis.net/se",
+ }
+ old_root = ET.fromstring(sld_generated_by_qgis)
+ old_user_style_el = old_root.find(f".//{{{nsmap['sld']}}}UserStyle")
+ old_name_el = old_user_style_el.find(f"./{{{nsmap['sld']}}}Name")
+ new_root = ET.Element(f"{{{nsmap['sld']}}}StyledLayerDescriptor")
+ new_root.set("version", "1.0.0")
+ named_layer_el = ET.SubElement(new_root, f"{{{nsmap['sld']}}}NamedLayer")
+ name_el = ET.SubElement(named_layer_el, f"{{{nsmap['sld']}}}Name")
+ name_el.text = old_name_el.text
+ named_layer_el.append(old_user_style_el)
+ new_serialized = ET.tostring(new_root, encoding="unicode", xml_declaration=True)
+ content_type = "application/vnd.ogc.sld+xml"
+ return new_serialized, content_type
+
+ def handle_style_uploaded(self, task_result: bool):
+ self._toggle_style_controls(enabled=True)
+ if task_result:
+ parsed_reply = self.network_task.response_contents[0]
+ if parsed_reply is not None:
+ if parsed_reply.http_status_code == 200:
+ self._show_message("Style uploaded successfully!")
+ else:
+ error_message_parts = [
+ "Could not upload style",
+ parsed_reply.qt_error,
+ f"HTTP {parsed_reply.http_status_code}",
+ parsed_reply.http_status_reason,
+ ]
+ error_message = " - ".join(i for i in error_message_parts if i)
+ self._show_message(error_message, level=qgis.core.Qgis.Warning)
+ else:
+ self._show_message(
+ f"Could not upload style",
+ level=qgis.core.Qgis.Warning,
+ )
+
+ def open_detail_url(self) -> None:
+ dataset = self.get_dataset()
+ QtGui.QDesktopServices.openUrl(QtCore.QUrl(dataset.detail_url))
+
+ def open_link_url(self) -> None:
+ dataset = self.get_dataset()
+ QtGui.QDesktopServices.openUrl(QtCore.QUrl(dataset.link))
+
+ def _apply_sld(self) -> None:
+ dataset = self.get_dataset()
+ sld_load_error_msg = ""
+ sld_load_result = self.layer.readSld(
+ dataset.default_style.sld, sld_load_error_msg
+ )
+ if sld_load_result:
+ layer_properties_dialog = self._get_layer_properties_dialog()
+ layer_properties_dialog.syncToLayer()
+ else:
+ self._show_message(
+ message=f"Could not load GeoNode style: {sld_load_error_msg}",
+ level=qgis.core.Qgis.Warning,
+ )
+
+ def _show_message(
+ self,
+ message: str,
+ level: typing.Optional[qgis.core.Qgis.MessageLevel] = qgis.core.Qgis.Info,
+ add_loading_widget: bool = False,
+ ) -> None:
+ utils.show_message(self.message_bar, message, level, add_loading_widget)
+
+ def _get_layer_properties_dialog(self):
+ # FIXME: This is a very hacky way to get the layer properties dialog, and it
+ # may not even work for layers that are not vector, but I've not been able
+ # to find a more elegant way to retrieve it yet
+ return self.parent().parent().parent().parent()
+
+ def _toggle_link_controls(self, enabled: bool) -> None:
+ widgets = (
+ self.open_detail_url_pb,
+ self.open_link_url_pb,
+ )
+ for widget in widgets:
+ widget.setEnabled(enabled)
+
+ def _toggle_style_controls(self, enabled: bool) -> None:
+ if enabled:
+ widgets = []
+ if self.connection_settings is not None:
+ can_load_style = models.loading_style_supported(
+ self.layer.type(), self.api_client.capabilities
+ )
+ can_modify_style = models.modifying_style_supported(
+ self.layer.type(), self.api_client.capabilities
+ )
+ dataset = self.get_dataset()
+ is_service = self.layer.dataProvider().name().lower() in ("wfs", "wcs")
+ has_style_url = dataset.default_style.sld_url is not None
+ if can_load_style and has_style_url and is_service:
+ widgets.append(self.download_style_pb)
+ if can_modify_style and has_style_url and is_service:
+ widgets.append(self.upload_style_pb)
+ else:
+ widgets = [
+ self.upload_style_pb,
+ self.download_style_pb,
+ ]
+ for widget in widgets:
+ widget.setEnabled(enabled)
diff --git a/src/qgis_geonode/gui/geonode_maplayer_config_widget_factory.py b/src/qgis_geonode/gui/geonode_maplayer_config_widget_factory.py
new file mode 100644
index 00000000..705c5901
--- /dev/null
+++ b/src/qgis_geonode/gui/geonode_maplayer_config_widget_factory.py
@@ -0,0 +1,33 @@
+from pathlib import Path
+
+import qgis.core
+import qgis.gui
+
+from qgis.PyQt import QtGui
+from qgis.PyQt.uic import loadUiType
+
+from ..utils import tr
+
+from .geonode_map_layer_config_widget import GeonodeMapLayerConfigWidget
+
+WidgetUi, _ = loadUiType(Path(__file__).parents[1] / "ui/qgis_geonode_layer_dialog.ui")
+
+
+class GeonodeMapLayerConfigWidgetFactory(qgis.gui.QgsMapLayerConfigWidgetFactory):
+ def createWidget(self, layer, canvas, dock_widget, parent):
+ return GeonodeMapLayerConfigWidget(layer, canvas, parent)
+
+ def supportsLayer(self, layer):
+ return layer.type() in (
+ qgis.core.QgsMapLayerType.VectorLayer,
+ qgis.core.QgsMapLayerType.RasterLayer,
+ )
+
+ def supportLayerPropertiesDialog(self):
+ return True
+
+ def icon(self):
+ return QtGui.QIcon(":/plugins/qgis_geonode/mIconGeonode.svg")
+
+ def title(self):
+ return tr("GeoNode")
diff --git a/src/qgis_geonode/gui/layer_properties_config_widget.py b/src/qgis_geonode/gui/layer_properties_config_widget.py
deleted file mode 100644
index 2da4cd0a..00000000
--- a/src/qgis_geonode/gui/layer_properties_config_widget.py
+++ /dev/null
@@ -1,76 +0,0 @@
-import os
-
-from qgis.core import QgsMapLayerType, QgsProject, Qgis
-
-from qgis.gui import (
- QgsMapLayerConfigWidget,
- QgsMapLayerConfigWidgetFactory,
- QgsMessageBar,
-)
-
-from qgis.PyQt.uic import loadUiType
-from qgis.PyQt.QtCore import QUrl
-from qgis.PyQt.QtWidgets import QSizePolicy
-
-from PyQt5.QtGui import QIcon, QDesktopServices
-
-from ..resources import *
-from ..utils import tr, log
-
-WidgetUi, _ = loadUiType(
- os.path.join(os.path.dirname(__file__), "../ui/qgis_geonode_layer_dialog.ui")
-)
-
-
-class LayerPropertiesConfigWidgetFactory(QgsMapLayerConfigWidgetFactory):
- def createWidget(self, layer, canvas, dock_widget, parent):
- return LayerPropertiesConfigWidget(layer, canvas, parent)
-
- def supportsLayer(self, layer):
- return layer.type() in (
- QgsMapLayerType.VectorLayer,
- QgsMapLayerType.RasterLayer,
- )
-
- def supportLayerPropertiesDialog(self):
- return True
-
- def icon(self):
- return QIcon(":/plugins/qgis_geonode/mIconGeonode.svg")
-
- def title(self):
- return tr("GeoNode")
-
-
-class LayerPropertiesConfigWidget(QgsMapLayerConfigWidget, WidgetUi):
- def __init__(self, layer, canvas, parent):
- super(LayerPropertiesConfigWidget, self).__init__(layer, canvas, parent)
- self.setupUi(self)
- self.project = QgsProject.instance()
- self.layer = layer
- self.message_bar = QgsMessageBar()
- self.message_bar.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed)
- self.layout().insertWidget(0, self.message_bar)
-
- self.open_page_btn.clicked.connect(self.open_layer_page)
-
- def open_layer_page(self):
- for link in self.layer.metadata().links():
- if link.name == "Detail":
- QDesktopServices.openUrl(QUrl(link.url))
- return
-
- log(
- "Couldn't open layer page, the layer metadata "
- "doesn't contain the GeoNode layer page URL"
- )
- self.message_bar.pushMessage(
- tr(
- "Couldn't open layer page, the layer metadata "
- "doesn't contain the GeoNode layer page URL "
- ),
- level=Qgis.Critical,
- )
-
- def apply(self):
- pass
diff --git a/src/qgis_geonode/gui/search_result_widget.py b/src/qgis_geonode/gui/search_result_widget.py
index 6323d91b..60c61397 100644
--- a/src/qgis_geonode/gui/search_result_widget.py
+++ b/src/qgis_geonode/gui/search_result_widget.py
@@ -18,9 +18,10 @@
models,
)
from .. import network
+from ..apiclient.models import ApiClientCapability
+from ..conf import settings_manager
from ..resources import *
from ..utils import log, tr
-from ..apiclient.models import ApiClientCapability
WidgetUi, _ = loadUiType(
os.path.join(os.path.dirname(__file__), "../ui/search_result_widget.ui")
@@ -37,9 +38,9 @@ class SearchResultWidget(QtWidgets.QWidget, WidgetUi):
thumbnail_la: QtWidgets.QLabel
dataset_loader_task: typing.Optional[qgis.core.QgsTask]
- # thumbnail_fetcher_task fetches the thumbnail
+ # thumbnail_fetcher_task fetches the thumbnail over the network
# thumbnail_loader_task then loads the thumbnail
- thumbnail_fetcher_task: typing.Optional[network.MultipleNetworkFetcherTask]
+ thumbnail_fetcher_task: typing.Optional[network.NetworkRequestTask]
thumbnail_loader_task: typing.Optional[qgis.core.QgsTask]
load_layer_started = QtCore.pyqtSignal()
@@ -153,15 +154,17 @@ def toggle_service_url_buttons(self, enabled: bool):
def load_thumbnail(self):
"""Fetch the thumbnail from its remote URL and load it"""
- self.thumbnail_fetcher_task = network.MultipleNetworkFetcherTask(
+ self.thumbnail_fetcher_task = network.NetworkRequestTask(
[
network.RequestToPerform(
url=QtCore.QUrl(self.brief_dataset.thumbnail_url)
)
],
self.api_client.auth_config,
+ network_task_timeout=self.api_client.network_requests_timeout,
+ description=f"Get thumbnail for {self.brief_dataset.title!r}",
)
- self.thumbnail_fetcher_task.all_finished.connect(self.handle_thumbnail_response)
+ self.thumbnail_fetcher_task.task_done.connect(self.handle_thumbnail_response)
qgis.core.QgsApplication.taskManager().addTask(self.thumbnail_fetcher_task)
def handle_thumbnail_response(self, fetch_result: bool):
@@ -176,7 +179,9 @@ def handle_thumbnail_response(self, fetch_result: bool):
def handle_dataset_load_start(self):
self.data_source_widget.toggle_search_controls(False)
- self.data_source_widget.show_progress(tr("Loading layer..."))
+ self.data_source_widget.show_message(
+ tr("Loading layer..."), add_loading_widget=True
+ )
self.toggle_service_url_buttons(False)
def handle_layer_load_end(self, clear_message_bar: typing.Optional[bool] = True):
@@ -186,12 +191,6 @@ def handle_layer_load_end(self, clear_message_bar: typing.Optional[bool] = True)
if clear_message_bar:
self.data_source_widget.message_bar.clearWidgets()
- def handle_loading_error(self):
- log("Inside handle_loading_error")
- message = f"Unable to load layer {self.brief_dataset.title}: {self.dataset_loader_task._exception}"
- self.data_source_widget.show_message(message, level=qgis.core.Qgis.Critical)
- self.handle_layer_load_end(clear_message_bar=False)
-
def load_dataset(self, service_type: models.GeonodeService):
self.handle_dataset_load_start()
self.dataset_loader_task = LayerLoaderTask(
@@ -208,7 +207,7 @@ def prepare_loaded_layer(self):
log(self.dataset_loader_task._exception)
self.layer = self.dataset_loader_task.layer
self.api_client.dataset_detail_received.connect(self.handle_layer_detail)
- self.api_client.error_received.connect(self.handle_loading_error)
+ self.api_client.dataset_detail_error_received.connect(self.handle_loading_error)
self.api_client.get_dataset_detail(self.brief_dataset)
def handle_layer_detail(self, dataset: typing.Optional[models.Dataset]):
@@ -217,20 +216,33 @@ def handle_layer_detail(self, dataset: typing.Optional[models.Dataset]):
models.DATASET_CUSTOM_PROPERTY_KEY,
dataset.to_json() if dataset is not None else None,
)
+ current_connection_settings = settings_manager.get_current_connection_settings()
+ self.layer.setCustomProperty(
+ models.DATASET_CONNECTION_CUSTOM_PROPERTY_KEY,
+ str(current_connection_settings.id),
+ )
if ApiClientCapability.LOAD_LAYER_METADATA in self.api_client.capabilities:
metadata = populate_metadata(self.layer.metadata(), dataset)
self.layer.setMetadata(metadata)
- if ApiClientCapability.LOAD_LAYER_STYLE in self.api_client.capabilities:
- provider_name = self.layer.dataProvider().name()
- if provider_name == "WFS" and dataset.default_style:
- error_message = ""
- loaded_sld = self.layer.readSld(dataset.default_style, error_message)
- if not loaded_sld:
- log(f"Could not apply SLD to layer: {error_message}")
+ can_load_style = models.loading_style_supported(
+ self.layer.type(), self.api_client.capabilities
+ )
+ if can_load_style and dataset.default_style:
+ error_message = ""
+ loaded_sld = self.layer.readSld(dataset.default_style.sld, error_message)
+ if not loaded_sld:
+ log(f"Could not apply SLD to layer: {error_message}")
self.add_layer_to_project()
+ def handle_loading_error(self):
+ message = f"Unable to load layer {self.brief_dataset.title}: {self.dataset_loader_task._exception}"
+ self.data_source_widget.show_message(message, level=qgis.core.Qgis.Critical)
+ self.handle_layer_load_end(clear_message_bar=False)
+
def add_layer_to_project(self):
- self.api_client.error_received.disconnect(self.handle_loading_error)
+ self.api_client.dataset_detail_error_received.disconnect(
+ self.handle_loading_error
+ )
self.project.addMapLayer(self.layer)
self.handle_layer_load_end()
@@ -345,11 +357,14 @@ def finished(self, result: bool):
def _load_wms(self) -> qgis.core.QgsMapLayer:
params = {
"crs": f"EPSG:{self.brief_dataset.srid.postgisSrid()}",
+ "url": self.brief_dataset.service_urls[self.service_type],
"format": "image/png",
"layers": self.brief_dataset.name,
"styles": "",
- "url": self.brief_dataset.service_urls[self.service_type],
+ "version": "auto",
}
+ if self.api_client.auth_config:
+ params["authcfg"] = self.api_client.auth_config
return qgis.core.QgsRasterLayer(
urllib.parse.unquote(urllib.parse.urlencode(params)),
self.brief_dataset.title,
@@ -362,6 +377,8 @@ def _load_wcs(self) -> qgis.core.QgsMapLayer:
"identifier": self.brief_dataset.name,
"crs": f"EPSG:{self.brief_dataset.srid.postgisSrid()}",
}
+ if self.api_client.auth_config:
+ params["authcfg"] = self.api_client.auth_config
return qgis.core.QgsRasterLayer(
urllib.parse.unquote(urllib.parse.urlencode(params)),
self.brief_dataset.title,
@@ -370,18 +387,15 @@ def _load_wcs(self) -> qgis.core.QgsMapLayer:
def _load_wfs(self) -> qgis.core.QgsMapLayer:
params = {
- "service": "WFS",
- "version": "2.0.0",
- "request": "GetFeature",
- "typename": self.brief_dataset.name,
"srsname": f"EPSG:{self.brief_dataset.srid.postgisSrid()}",
+ "typename": self.brief_dataset.name,
+ "url": self.brief_dataset.service_urls[self.service_type].rstrip("/"),
+ "version": "auto",
}
- base_url = self.brief_dataset.service_urls[self.service_type].rstrip("/")
- return qgis.core.QgsVectorLayer(
- f"{base_url}/?{urllib.parse.unquote(urllib.parse.urlencode(params))}",
- self.brief_dataset.title,
- "WFS",
- )
+ if self.api_client.auth_config:
+ params["authcfg"] = self.api_client.auth_config
+ uri = " ".join(f"{key}='{value}'" for key, value in params.items())
+ return qgis.core.QgsVectorLayer(uri, self.brief_dataset.title, "WFS")
def populate_metadata(metadata: qgis.core.QgsLayerMetadata, dataset: models.Dataset):
diff --git a/src/qgis_geonode/main.py b/src/qgis_geonode/main.py
index d35d3bd7..ddf67708 100644
--- a/src/qgis_geonode/main.py
+++ b/src/qgis_geonode/main.py
@@ -20,8 +20,8 @@
from .resources import *
from .gui.geonode_source_select_provider import GeonodeSourceSelectProvider
-from .gui.layer_properties_config_widget import (
- LayerPropertiesConfigWidgetFactory,
+from .gui.geonode_maplayer_config_widget_factory import (
+ GeonodeMapLayerConfigWidgetFactory,
)
@@ -42,7 +42,10 @@ def __init__(self, iface):
self.actions = []
self.menu = self.tr(u"&QGIS GeoNode Plugin")
# TODO: We are going to let the user set this up in a future iteration
- self.layerPropertiesConfigWidgetFactory = LayerPropertiesConfigWidgetFactory()
+ self.layer_properties_config_widget_factory = (
+ GeonodeMapLayerConfigWidgetFactory()
+ )
+
self.pluginIsActive = False
self.geonodeSourceSelectProvider = GeonodeSourceSelectProvider()
@@ -136,7 +139,7 @@ def initGui(self):
)
self.iface.registerMapLayerConfigWidgetFactory(
- self.layerPropertiesConfigWidgetFactory
+ self.layer_properties_config_widget_factory
)
QgsGui.sourceSelectProviderRegistry().addProvider(
self.geonodeSourceSelectProvider
@@ -153,7 +156,7 @@ def unload(self):
self.iface.removeToolBarIcon(action)
self.iface.unregisterMapLayerConfigWidgetFactory(
- self.layerPropertiesConfigWidgetFactory
+ self.layer_properties_config_widget_factory
)
QgsGui.sourceSelectProviderRegistry().removeProvider(
self.geonodeSourceSelectProvider
diff --git a/src/qgis_geonode/network.py b/src/qgis_geonode/network.py
index 7e87fef7..df911bd5 100644
--- a/src/qgis_geonode/network.py
+++ b/src/qgis_geonode/network.py
@@ -1,4 +1,5 @@
import dataclasses
+import enum
import json
import typing
from contextlib import contextmanager
@@ -12,84 +13,55 @@
)
from .utils import log
-from .apiclient.models import UNSUPPORTED_REMOTE
+
+UNSUPPORTED_REMOTE = "unsupported"
+
+
+class HttpMethod(enum.Enum):
+ GET = "get"
+ POST = "post"
+ PUT = "put"
@dataclasses.dataclass()
class ParsedNetworkReply:
http_status_code: int
http_status_reason: str
- qt_error: str
+ qt_error: typing.Optional[str]
response_body: QtCore.QByteArray
@dataclasses.dataclass()
class RequestToPerform:
url: QtCore.QUrl
+ method: typing.Optional[HttpMethod] = HttpMethod.GET
payload: typing.Optional[str] = None
+ content_type: typing.Optional[str] = None
-class MultipleNetworkFetcherTask(qgis.core.QgsTask):
+@dataclasses.dataclass()
+class EventLoopResult:
+ result: typing.Optional[bool]
- response_contents: typing.List[ParsedNetworkReply]
- _exceptions_raised: typing.List[str]
- all_finished = QtCore.pyqtSignal(bool)
+def _get_qt_network_reply_error_mapping() -> typing.Dict:
+ """Workaround for accessing unsubscriptable enum types of QNetworkReply.NetworkError
- def __init__(
- self,
- requests_to_perform: typing.List[RequestToPerform],
- authcfg: typing.Optional[str],
- description: typing.Optional[str] = "MyMultipleNetworkfetcherTask",
- ):
- """QGIS Task that is able to perform network requests
+ adapted from https://stackoverflow.com/a/39677321
- Implementation uses QgsNetworkAccessManager's blocking GET and POST in order
- to perform blocking requests inside the task's run() method.
-
- """
-
- super().__init__(description)
- self.requests_to_perform = requests_to_perform
- self.authcfg = authcfg
- self.response_contents = []
- self._exceptions_raised = []
-
- def run(self) -> bool:
- result = True
- network_manager = qgis.core.QgsNetworkAccessManager()
- for request_params in self.requests_to_perform:
- log(f"Performing request for {request_params.url}...")
- request = QtNetwork.QNetworkRequest(request_params.url)
- if request_params.payload is None:
- reply_content = network_manager.blockingGet(request, self.authcfg)
- else:
- reply_content = network_manager.blockingPost(
- request, request_params.payload, self.authcfg
- )
- try:
- parsed_reply = parse_network_reply(reply_content)
- except AttributeError as exc:
- result = False
- self._exceptions_raised.append(str(exc))
- else:
- if parsed_reply.qt_error is not None:
- result = False
- self.response_contents.append(parsed_reply)
- return result
+ """
- def finished(self, result: bool):
- for index, exception_text in enumerate(self._exceptions_raised):
- log(
- f"There was a problem running request "
- f"{self.requests_to_perform[index]!r}: {exception_text}"
- )
- self.all_finished.emit(result)
+ result = {}
+ for property_name in dir(QtNetwork.QNetworkReply):
+ value = getattr(QtNetwork.QNetworkReply, property_name)
+ if isinstance(value, QtNetwork.QNetworkReply.NetworkError):
+ result[value] = property_name
+ return result
-@dataclasses.dataclass()
-class EventLoopResult:
- result: typing.Optional[bool]
+_Q_NETWORK_REPLY_ERROR_MAP: typing.Final[
+ typing.Dict[QtNetwork.QNetworkReply.NetworkError, str]
+] = _get_qt_network_reply_error_mapping()
@contextmanager
@@ -115,9 +87,7 @@ def wait_for_signal(
loop_result = EventLoopResult(result=None)
yield loop_result
QtCore.QTimer.singleShot(timeout, partial(_forcibly_terminate_loop, loop))
- log(f"About to start custom event loop...")
loop_result.result = not bool(loop.exec_())
- log(f"Custom event loop ended, resuming...")
def _forcibly_terminate_loop(loop: QtCore.QEventLoop):
@@ -125,91 +95,174 @@ def _forcibly_terminate_loop(loop: QtCore.QEventLoop):
loop.exit(1)
-class SimplerNetworkFetcherTask(qgis.core.QgsTask):
+class NetworkRequestTask(qgis.core.QgsTask):
+ authcfg: typing.Optional[str]
+ network_task_timeout: int
+ network_access_manager: qgis.core.QgsNetworkAccessManager
+ requests_to_perform: typing.List[RequestToPerform]
+ response_contents: typing.List[typing.Optional[ParsedNetworkReply]]
+ _num_finished: int
+ _pending_replies: typing.Dict[int, typing.Tuple[int, QtNetwork.QNetworkReply]]
+
+ _all_requests_finished = QtCore.pyqtSignal()
+ task_done = QtCore.pyqtSignal(bool)
+
def __init__(
self,
- request: QtNetwork.QNetworkRequest,
- request_payload: typing.Optional[str] = None,
+ requests_to_perform: typing.List[RequestToPerform],
+ authcfg: typing.Optional[str] = None,
+ description: typing.Optional[str] = "AnotherNetworkRequestTask",
+ network_task_timeout: typing.Optional[int] = 10,
):
- """
- Custom QgsTask that performs network requests
-
- This class is able to perform both GET and POST HTTP requests.
+ """A QGIS task to run a series of network requests in sequence."""
+ super().__init__(description)
+ self.authcfg = authcfg
+ self.network_task_timeout = network_task_timeout
+ self.requests_to_perform = requests_to_perform[:]
+ self.response_contents = [None] * len(requests_to_perform)
+ self._num_finished = 0
+ self._pending_replies = {}
+ self.network_access_manager = qgis.core.QgsNetworkAccessManager.instance()
+ self.network_access_manager.requestTimedOut.connect(
+ self._handle_request_timed_out
+ )
+ self.network_access_manager.finished.connect(self._handle_request_finished)
+ self.network_access_manager.authBrowserAborted.connect(
+ self._handle_auth_browser_aborted
+ )
- It is needed because:
+ def run(self) -> bool:
+ """Run the QGIS task
- - QgsNetworkContentFetcherTask only performs GET requests
- - QgsNetworkAcessManager.blockingPost() does not seem to handle redirects
- correctly
+ This method is called by the QGIS task manager.
- Implementation is based on QgsNetworkContentFetcher. The run() method performs
- a normal async request using QtNetworkAccessManager's get() or post() methods.
- The resulting QNetworkReply instance has its `finished` signal be connected to
- a custom handler. The request is executed in scope of a custom Qt event loop,
- which blocks the current thread while the request is being processed.
+ Implementation uses a custom Qt event loop that waits until
+ all of the task's requests have been performed.
"""
- super().__init__("QgisGeonodeNetworkFetcherTask")
- self.request = request
- self.request_payload = request_payload
- self.reply_content = None
- self.parsed_reply = None
- self.network_access_manager = qgis.core.QgsNetworkAccessManager.instance()
- # self.network_access_manager.setRedirectPolicy(
- # QtNetwork.QNetworkRequest.NoLessSafeRedirectPolicy)
- self.network_access_manager.finished.connect(self._request_done)
- self._reply = None
-
- def run(self):
- with wait_for_signal(self.request_parsed) as loop_result:
- if self.request_payload is None:
- self._reply = self.network_access_manager.get(self.request)
- else:
- self._reply = self.network_access_manager.post(
- self.request,
- QtCore.QByteArray(self.request_payload.encode("utf-8")),
- )
- try:
- if loop_result.result:
- result = self.parsed_reply.qt_error is None
- else:
- result = False
- self._reply.deleteLater()
- self._reply = None
- except AttributeError:
+ if len(self.requests_to_perform) == 0: # there is nothing to do
result = False
+ else:
+ with wait_for_signal(
+ self._all_requests_finished,
+ timeout=self.network_task_timeout * len(self.requests_to_perform),
+ ) as event_loop_result:
+ for index, request_params in enumerate(self.requests_to_perform):
+ request = self._create_request(
+ request_params.url, request_params.content_type
+ )
+ if self.authcfg:
+ auth_manager = qgis.core.QgsApplication.authManager()
+ auth_added, _ = auth_manager.updateNetworkRequest(
+ request, self.authcfg
+ )
+ else:
+ auth_added = True
+ log(f"auth_added: {auth_added}")
+ if auth_added:
+ qt_reply = self._dispatch_request(
+ request, request_params.method, request_params.payload
+ )
+ # QGIS adds a custom `requestId` property to all requests made by
+ # its network access manager - this can be used to keep track of
+ # replies
+ request_id = qt_reply.property("requestId")
+ self._pending_replies[request_id] = (index, qt_reply)
+ else:
+ self._all_requests_finished.emit()
+ loop_forcibly_ended = not bool(event_loop_result.result)
+ if loop_forcibly_ended:
+ result = False
+ else:
+ result = self._num_finished >= len(self.requests_to_perform)
return result
- def finished(self, result: bool):
- self.network_access_manager.finished.disconnect(self._request_done)
- log(f"Inside finished. Result: {result}")
- self.request_finished.emit()
- if not result:
- if self.parsed_reply is not None:
- self.api_client.error_received[str, int, str].emit(
- self.parsed_reply.qt_error,
- self.parsed_reply.http_status_code,
- self.parsed_reply.http_status_reason,
- )
+ def finished(self, result: bool) -> None:
+ """This method is called by the QGIS task manager when this task is finished"""
+ # This class emits the `task_done` signal in order to have a unified way to
+ # deal with the various types of errors that can arise. The alternative would
+ # have been to rely on the base class' `taskCompleted` and `taskTerminated`
+ # signals
+ if result:
+ for index, response in enumerate(self.response_contents):
+ if response is None:
+ final_result = False
+ break
+ elif response.qt_error is not None:
+ final_result = False
+ break
else:
- self.api_client.error_received.emit("Problem parsing network reply")
-
- def _request_done(self, qgis_reply: qgis.core.QgsNetworkReplyContent):
- log(f"requested_url: {qgis_reply.request().url().toString()}")
- if self._reply is None:
- log(
- "Some other request was completed, probably authentication, "
- "ignoring..."
- )
- elif reply_matches(qgis_reply, self._reply):
- self.reply_content = self._reply.readAll()
- self.parsed_reply = parse_network_reply(qgis_reply)
- log(f"http_status_code: {self.parsed_reply.http_status_code}")
- log(f"qt_error: {self.parsed_reply.qt_error}")
- self.request_parsed.emit()
+ final_result = result
+ else:
+ final_result = result
+ for _, qt_reply in self._pending_replies.values():
+ qt_reply.deleteLater()
+ self.task_done.emit(final_result)
+
+ def _create_request(
+ self, url: QtCore.QUrl, content_type: typing.Optional[str] = None
+ ) -> QtNetwork.QNetworkRequest:
+ request = QtNetwork.QNetworkRequest(url)
+ if content_type is not None:
+ request.setHeader(QtNetwork.QNetworkRequest.ContentTypeHeader, content_type)
+ return request
+
+ def _dispatch_request(
+ self,
+ request: QtNetwork.QNetworkRequest,
+ method: HttpMethod,
+ payload: typing.Optional[str],
+ ) -> QtNetwork.QNetworkReply:
+ if method == HttpMethod.GET:
+ reply = self.network_access_manager.get(request)
+ elif method == HttpMethod.PUT:
+ data_ = QtCore.QByteArray(payload.encode())
+ reply = self.network_access_manager.put(request, data_)
+ else:
+ raise NotImplementedError
+ return reply
+
+ def _handle_request_finished(self, qgis_reply: qgis.core.QgsNetworkReplyContent):
+ """Handle the finishing of a network request
+
+ This slot is triggered when the network access manager emits the ``finished``
+ signal. The custom QGIS network access manager provides an instance of
+ ``QgsNetworkContentReply`` as an argument to this method. Note that this is not
+ the same as the vanilla QNetworkReply - notoriously it seems to not be possible
+ to retrieve the HTTP response body from this type of instance. Therefore, this
+ method retrieves the original QNetworkReply (by comparing the reply's id) and
+ then uses that to gain access to the response body.
+
+ """
+
+ try:
+ index, qt_reply = self._pending_replies[qgis_reply.requestId()]
+ except KeyError:
+ pass # we are not managing this request, ignore
+ else:
+ parsed = parse_qt_network_reply(qt_reply)
+ self.response_contents[index] = parsed
+ self._num_finished += 1
+ if self._num_finished >= len(self.requests_to_perform):
+ self._all_requests_finished.emit()
+
+ def _handle_request_timed_out(
+ self, request_params: qgis.core.QgsNetworkRequestParameters
+ ) -> None:
+ log(f"Request with id: {request_params.requestId()} has timed out")
+ try:
+ index, qt_reply = self._pending_replies[request_params.requestId()]
+ except KeyError:
+ pass # we are not managing this request, ignore
else:
- log(f"qgis_reply did not match the original reply id, ignoring...")
+ self.response_contents[index] = None
+ self._num_finished += 1
+ if self._num_finished >= len(self.requests_to_perform):
+ self._all_requests_finished.emit()
+
+ def _handle_auth_browser_aborted(self):
+ log("inside _handle_auth_browser_aborted")
def deserialize_json_response(
@@ -219,128 +272,31 @@ def deserialize_json_response(
try:
contents = json.loads(decoded_contents)
except json.JSONDecodeError as exc:
- log(f"decoded_contents: {decoded_contents}")
+ log(f"JSON decode error - decoded_contents: {decoded_contents}")
log(exc, debug=False)
contents = None
return contents
-class NetworkFetcherTask(qgis.core.QgsTask):
- api_client: "BaseGeonodeClient"
- authcfg: typing.Optional[str]
- description: str
- request: QtNetwork.QNetworkRequest
- request_payload: typing.Optional[str]
- reply_content: typing.Optional[QtCore.QByteArray]
- parsed_reply: typing.Optional[ParsedNetworkReply]
- redirect_policy: QtNetwork.QNetworkRequest.RedirectPolicy
- _reply: typing.Optional[QtNetwork.QNetworkReply]
-
- request_finished = QtCore.pyqtSignal()
- request_parsed = QtCore.pyqtSignal()
-
- def __init__(
- self,
- api_client: "BaseGeonodeClient",
- request: QtNetwork.QNetworkRequest,
- request_payload: typing.Optional[str] = None,
- authcfg: typing.Optional[str] = None,
- description: typing.Optional[str] = "MyNetworkfetcherTask",
- redirect_policy: typing.Optional[QtNetwork.QNetworkRequest.RedirectPolicy] = (
- QtNetwork.QNetworkRequest.NoLessSafeRedirectPolicy
- ),
- ):
- """
- Custom QgsTask that performs network requests
-
- This class is able to perform both GET and POST HTTP requests.
-
- It is needed because:
-
- - QgsNetworkContentFetcherTask only performs GET requests
- - QgsNetworkAcessManager.blockingPost() does not seem to handle redirects
- correctly
-
- Implementation is based on QgsNetworkContentFetcher. The run() method performs
- a normal async request using QtNetworkAccessManager's get() or post() methods.
- The resulting QNetworkReply instance has its `finished` signal be connected to
- a custom handler. The request is executed in scope of a custom Qt event loop,
- which blocks the current thread while the request is being processed.
-
- """
-
- super().__init__(description)
- self.api_client = api_client
- self.authcfg = authcfg
- self.request = request
- self.request_payload = request_payload
- self.reply_content = None
- self.parsed_reply = None
- self.redirect_policy = redirect_policy
- self.network_access_manager = qgis.core.QgsNetworkAccessManager.instance()
- self.network_access_manager.setRedirectPolicy(self.redirect_policy)
- self.network_access_manager.finished.connect(self._request_done)
- self._reply = None
-
- def run(self):
- if self.authcfg is not None:
- auth_manager = qgis.core.QgsApplication.authManager()
- auth_manager.updateNetworkRequest(self.request, self.authcfg)
- with wait_for_signal(self.request_parsed) as loop_result:
- if self.request_payload is None:
- self._reply = self.network_access_manager.get(self.request)
- else:
- self._reply = self.network_access_manager.post(
- self.request,
- QtCore.QByteArray(self.request_payload.encode("utf-8")),
- )
- try:
- if loop_result.result:
- result = self.parsed_reply.qt_error is None
- else:
- result = False
- self._reply.deleteLater()
- self._reply = None
- except AttributeError:
- result = False
- return result
-
- def finished(self, result: bool):
- self.network_access_manager.finished.disconnect(self._request_done)
- log(f"Inside finished. Result: {result}")
- self.request_finished.emit()
- if not result:
- if self.parsed_reply is not None:
- self.api_client.error_received[str, int, str].emit(
- self.parsed_reply.qt_error,
- self.parsed_reply.http_status_code,
- self.parsed_reply.http_status_reason,
- )
- else:
- self.api_client.error_received.emit("Problem parsing network reply")
-
- def _request_done(self, qgis_reply: qgis.core.QgsNetworkReplyContent):
- log(f"requested_url: {qgis_reply.request().url().toString()}")
- if self._reply is None:
- log(
- "Some other request was completed, probably authentication, "
- "ignoring..."
- )
- elif reply_matches(qgis_reply, self._reply):
- self.reply_content = self._reply.readAll()
- self.parsed_reply = parse_network_reply(qgis_reply)
- log(f"http_status_code: {self.parsed_reply.http_status_code}")
- log(f"qt_error: {self.parsed_reply.qt_error}")
- self.request_parsed.emit()
- else:
- log(f"qgis_reply did not match the original reply id, ignoring...")
-
-
-def reply_matches(
- qgis_reply: qgis.core.QgsNetworkReplyContent, qt_reply: QtNetwork.QNetworkReply
-) -> bool:
- reply_id = int(qt_reply.property("requestId"))
- return qgis_reply.requestId() == reply_id
+def parse_qt_network_reply(reply: QtNetwork.QNetworkReply) -> ParsedNetworkReply:
+ http_status_code = reply.attribute(
+ QtNetwork.QNetworkRequest.HttpStatusCodeAttribute
+ )
+ http_status_reason = reply.attribute(
+ QtNetwork.QNetworkRequest.HttpReasonPhraseAttribute
+ )
+ error = reply.error()
+ if error == QtNetwork.QNetworkReply.NoError:
+ qt_error = None
+ else:
+ qt_error = _Q_NETWORK_REPLY_ERROR_MAP[error]
+ body = reply.readAll()
+ return ParsedNetworkReply(
+ http_status_code=http_status_code,
+ http_status_reason=http_status_reason,
+ qt_error=qt_error,
+ response_body=body,
+ )
def parse_network_reply(reply: qgis.core.QgsNetworkReplyContent) -> ParsedNetworkReply:
@@ -354,33 +310,17 @@ def parse_network_reply(reply: qgis.core.QgsNetworkReplyContent) -> ParsedNetwor
if error == QtNetwork.QNetworkReply.NoError:
qt_error = None
else:
- qt_error = _get_qt_error(
- QtNetwork.QNetworkReply, QtNetwork.QNetworkReply.NetworkError, error
- )
+ qt_error = _Q_NETWORK_REPLY_ERROR_MAP[error]
+ body = reply.content()
+ log(f"body: {body.data().decode()}")
return ParsedNetworkReply(
http_status_code=http_status_code,
http_status_reason=http_status_reason,
qt_error=qt_error,
- response_body=reply.content(),
+ response_body=body,
)
-def _get_qt_error(cls, enum, error: QtNetwork.QNetworkReply.NetworkError) -> str:
- """workaround for accessing unsubscriptable sip enum types
-
- from https://stackoverflow.com/a/39677321
-
- """
-
- mapping = {}
- for key in dir(cls):
- value = getattr(cls, key)
- if isinstance(value, enum):
- mapping[key] = value
- mapping[value] = key
- return mapping[error]
-
-
class ApiClientDiscovererTask(qgis.core.QgsTask):
discovery_result: typing.Optional[str]
diff --git a/src/qgis_geonode/styles.py b/src/qgis_geonode/styles.py
new file mode 100644
index 00000000..4c0fa579
--- /dev/null
+++ b/src/qgis_geonode/styles.py
@@ -0,0 +1,57 @@
+import typing
+
+from PyQt5 import QtCore, QtXml
+from qgis.PyQt import QtXml
+
+from . import network
+
+
+def deserialize_sld_doc(
+ raw_sld_doc: QtCore.QByteArray,
+) -> typing.Tuple[typing.Optional[QtXml.QDomElement], str]:
+ """Deserialize SLD document gotten from GeoNode into a usable named layer element"""
+ sld_doc = QtXml.QDomDocument()
+ # in the line below, `True` means use XML namespaces and it is crucial for
+ # QGIS to be able to load the SLD
+ sld_loaded = sld_doc.setContent(raw_sld_doc, True)
+ error_message = "Could not parse SLD document"
+ named_layer_element = None
+ if sld_loaded:
+ root = sld_doc.documentElement()
+ if not root.isNull():
+ sld_named_layer = root.firstChildElement("NamedLayer")
+ if not sld_named_layer.isNull():
+ named_layer_element = sld_named_layer
+ error_message = ""
+ return named_layer_element, error_message
+
+
+def deserialize_sld_named_layer(
+ raw_sld_named_layer: str,
+) -> typing.Tuple[typing.Optional[QtXml.QDomElement], str]:
+ """Deserialize the SLD named layer element which is used to style QGIS layers."""
+ sld_doc = QtXml.QDomDocument()
+ sld_loaded = sld_doc.setContent(
+ QtCore.QByteArray(raw_sld_named_layer.encode()), True
+ )
+ error_message = "Could not parse SLD document"
+ named_layer_element = None
+ if sld_loaded:
+ named_layer_element = sld_doc.documentElement()
+ if not named_layer_element.isNull():
+ error_message = ""
+ return named_layer_element, error_message
+
+
+def serialize_sld_named_layer(sld_named_layer: QtXml.QDomElement) -> str:
+ buffer_ = QtCore.QByteArray()
+ stream = QtCore.QTextStream(buffer_)
+ sld_named_layer.save(stream, 0)
+ return buffer_.data().decode(encoding="utf-8")
+
+
+def get_usable_sld(
+ http_response: network.ParsedNetworkReply,
+) -> typing.Tuple[typing.Optional[QtXml.QDomElement], str]:
+ raw_sld = http_response.response_body
+ return deserialize_sld_doc(raw_sld)
diff --git a/src/qgis_geonode/ui/connection_dialog.ui b/src/qgis_geonode/ui/connection_dialog.ui
index db3cab4b..63837c0c 100644
--- a/src/qgis_geonode/ui/connection_dialog.ui
+++ b/src/qgis_geonode/ui/connection_dialog.ui
@@ -7,13 +7,13 @@
0
0
596
- 268
+ 342
GeoNode Connection Configuration
-
+
-
@@ -83,56 +83,52 @@
Options
-
-
-
-
-
-
-
-
- Page size
-
-
-
- -
-
-
-
- 0
- 0
-
-
-
- 1
-
-
- 50
-
-
- 1
-
-
- 10
-
-
-
- -
-
-
- Qt::Horizontal
-
-
-
- 40
- 20
-
-
-
-
-
+
+ -
+
+
+ Page size
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ 1
+
+
+ 50
+
+
+ 1
+
+
+ 10
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ Test Connection
+
+
+
-
@@ -146,19 +142,6 @@
- -
-
-
-
- 0
- 0
-
-
-
- Test Connection
-
-
-
-
diff --git a/src/qgis_geonode/ui/qgis_geonode_layer_dialog.ui b/src/qgis_geonode/ui/qgis_geonode_layer_dialog.ui
index d72c1f37..b8deee3d 100644
--- a/src/qgis_geonode/ui/qgis_geonode_layer_dialog.ui
+++ b/src/qgis_geonode/ui/qgis_geonode_layer_dialog.ui
@@ -13,112 +13,106 @@
Geonode Layer
-
+
-
-
-
-
-
-
- Geonode Instance
-
-
-
- -
-
-
-
-
- -
-
-
-
-
-
- Save connections to file
-
-
- Sync layer
-
-
-
- -
-
-
- Qt::Horizontal
-
-
-
- 40
- 20
-
-
-
-
-
-
- -
-
-
-
-
-
- Save connections to file
-
-
- Open in Browser
-
-
-
- -
-
-
- Qt::Horizontal
-
-
-
- 40
- 20
-
-
-
-
-
-
- -
-
-
-
-
-
- Save connections to file
-
-
- Remove from Geonode
-
-
-
- -
-
-
- Qt::Horizontal
-
-
-
- 40
- 20
-
-
-
-
-
+
+
+ Links
+
+
+ false
+
+
+ -
+
+
+ View dataset on GeoNode...
+
+
+
+ -
+
+
+ View dataset API details...
+
+
+
+
+
-
-
+
- Layer management controls
+ Layer Style
-
+
+
-
+
+
-
+
+
+ Reload default style from GeoNode
+
+
+
+ -
+
+
+ Save current style to GeoNode
+
+
+
+
+
+ -
+
+
+ Qt::Horizontal
+
+
+
+ -
+
+
+
+ 0
+ 0
+
+
+
+ <html><head/><body><p><span style=" font-weight:600;">Note1</span></p><p>Styles uploaded to GeoNode are converted from native QGIS symbology to SLD (Styled Layer Descriptor). QGIS may not be able to convert the current style to an exact SLD representation, which may lead to discrepancies.</p><p><span style=" font-weight:600;">Note2</span></p><p>QGIS is not currently able to apply SLD styles to WCS (raster) layers.</p></body></html>
+
+
+ true
+
+
+
+
+ -
+
+
+ Qt::Vertical
+
+
+
+ 20
+ 202
+
+
+
+
+
+
+ QgsCollapsibleGroupBox
+ QGroupBox
+
+ 1
+
+
diff --git a/src/qgis_geonode/utils.py b/src/qgis_geonode/utils.py
index 6abe0515..473e1554 100644
--- a/src/qgis_geonode/utils.py
+++ b/src/qgis_geonode/utils.py
@@ -1,40 +1,13 @@
-import enum
import typing
-from PyQt5 import (
- QtCore,
-)
-
+import qgis.gui
+from PyQt5 import QtCore, QtWidgets
from qgis.core import (
Qgis,
QgsMessageLog,
)
-# NOTE: for simplicity, this enum's variants are named directly after the GeoNode
-# topic_category ids.
-class IsoTopicCategory(enum.Enum):
- biota = "Biota"
- boundaries = "Boundaries"
- climatologyMeteorologyAtmosphere = "Climatology Meteorology Atmosphere"
- economy = "Economy"
- elevation = "Elevation"
- environment = "Environment"
- farming = "Farming"
- geoscientificInformation = "Geoscientific Information"
- health = "Health"
- imageryBaseMapsEarthCover = "Imagery Base Maps Earth Cover"
- inlandWaters = "Inland Waters"
- intelligenceMilitary = "Intelligence Military"
- location = "Location"
- oceans = "Oceans"
- planningCadastre = "Planning Cadastre"
- society = "Society"
- structure = "Structure"
- transportation = "Transportation"
- utilitiesCommunication = "Utilities Communication"
-
-
def log(message: typing.Any, name: str = "qgis_geonode", debug: bool = True):
level = Qgis.Info if debug else Qgis.Warning
QgsMessageLog.logMessage(str(message), name, level=level)
@@ -47,3 +20,20 @@ def tr(text):
if type(text) != str:
text = str(text)
return QtCore.QCoreApplication.translate("QgisGeoNode", text)
+
+
+def show_message(
+ message_bar: qgis.gui.QgsMessageBar,
+ message: str,
+ level: typing.Optional[qgis.core.Qgis.MessageLevel] = qgis.core.Qgis.Info,
+ add_loading_widget: bool = False,
+) -> None:
+ message_bar.clearWidgets()
+ message_item = message_bar.createMessage(message)
+ if add_loading_widget:
+ progress_bar = QtWidgets.QProgressBar()
+ progress_bar.setAlignment(QtCore.Qt.AlignLeft | QtCore.Qt.AlignVCenter)
+ progress_bar.setMinimum(0)
+ progress_bar.setMaximum(0)
+ message_item.layout().addWidget(progress_bar)
+ message_bar.pushWidget(message_item, level=level)