From 55b3efb96c5ba9cc6518464e87388b6c867e4b67 Mon Sep 17 00:00:00 2001 From: Ricardo Garcia Silva Date: Wed, 9 Feb 2022 17:31:49 +0000 Subject: [PATCH] Add new configuration option to choose WFS version (#226) --- CHANGELOG.md | 3 +- docs/user-guide.md | 9 ++ src/qgis_geonode/apiclient/base.py | 4 + src/qgis_geonode/apiclient/geonode_v3.py | 6 +- src/qgis_geonode/conf.py | 12 ++ src/qgis_geonode/gui/connection_dialog.py | 118 +++++++++++++++++-- src/qgis_geonode/gui/search_result_widget.py | 2 +- src/qgis_geonode/ui/connection_dialog.ui | 21 ++++ test/test_apiclient_geonode_v3.py | 19 ++- 9 files changed, 173 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 33b847c4..9ecbfbc9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Fixed - Remove unsupported f-string formatting on Python3.7 - Assign `UNKNOWN` as dataset type when the remote does not report it +- Add new WFS version config option and default to WFS v1.1.0 + ## [0.9.4] - 2022-02-07 diff --git a/docs/user-guide.md b/docs/user-guide.md index 3f1bc54b..1288f41c 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -28,6 +28,15 @@ In order to add a new GeoNode connection: | GeoNode URL | The base URL of the GeoNode being connected to (_e.g._ ) | | Authentication | Whether to use authentication to connect to GeoNode or not. See the [Configure authentication](#configure-authentication) section below for more details on how to configure authenticated access to GeoNode | | Page size | How many search results per page shall be shown by QGIS. This defaults to `10` | + | WFS version | Which version of the Web Feature Service (WFS) to use for requesting vector layers from the remote GeoNode. Defaults to `v1.1.0`. | + + !!! Note + There is currently a bug in QGIS which prevents using WFS version 2.0.0 for editing a vector layer's geometry, + + + + Therefore, for the time being, we reccomend using WFS version 1.1.0, which works OK. + 5. Optionally you may now click the `Test Connection` button. QGIS will then try to connect to GeoNode in order to discover what version of GeoNode is diff --git a/src/qgis_geonode/apiclient/base.py b/src/qgis_geonode/apiclient/base.py index 00d5889f..53543638 100644 --- a/src/qgis_geonode/apiclient/base.py +++ b/src/qgis_geonode/apiclient/base.py @@ -22,6 +22,7 @@ class BaseGeonodeClient(QtCore.QObject): network_fetcher_task: typing.Optional[network.NetworkRequestTask] capabilities: typing.List[models.ApiClientCapability] page_size: int + wfs_version: conf.WfsVersion network_requests_timeout: int dataset_list_received = QtCore.pyqtSignal(list, models.GeonodePaginationInfo) @@ -38,6 +39,7 @@ def __init__( self, base_url: str, page_size: int, + wfs_version: conf.WfsVersion, network_requests_timeout: int, auth_config: typing.Optional[str] = None, ): @@ -45,6 +47,7 @@ def __init__( self.auth_config = auth_config or "" self.base_url = base_url.rstrip("/") self.page_size = page_size + self.wfs_version = wfs_version self.network_requests_timeout = network_requests_timeout self.network_fetcher_task = None @@ -53,6 +56,7 @@ def from_connection_settings(cls, connection_settings: conf.ConnectionSettings): return cls( base_url=connection_settings.base_url, page_size=connection_settings.page_size, + wfs_version=connection_settings.wfs_version, auth_config=connection_settings.auth_config, network_requests_timeout=connection_settings.network_requests_timeout, ) diff --git a/src/qgis_geonode/apiclient/geonode_v3.py b/src/qgis_geonode/apiclient/geonode_v3.py index 902cbed1..a1dc7e10 100644 --- a/src/qgis_geonode/apiclient/geonode_v3.py +++ b/src/qgis_geonode/apiclient/geonode_v3.py @@ -303,9 +303,13 @@ def get_uploader_task( ) def handle_layer_upload(self, result: bool): + success_statuses = ( + 200, + 201, + ) if result: response_contents = self.network_fetcher_task.response_contents[0] - if response_contents.http_status_code == 201: + if response_contents.http_status_code in success_statuses: deserialized = network.deserialize_json_response( response_contents.response_body ) diff --git a/src/qgis_geonode/conf.py b/src/qgis_geonode/conf.py index 258757ef..48523eb1 100644 --- a/src/qgis_geonode/conf.py +++ b/src/qgis_geonode/conf.py @@ -1,5 +1,6 @@ import contextlib import dataclasses +import enum import json import typing import uuid @@ -33,6 +34,13 @@ def _get_network_requests_timeout(): ) +class WfsVersion(enum.Enum): + V_1_0_0 = "1.0.0" + V_1_1_0 = "1.1.0" + V_2_0_0 = "2.0.0" + AUTO = "auto" + + @dataclasses.dataclass class ConnectionSettings: """Helper class to manage settings for a Connection""" @@ -45,6 +53,7 @@ class ConnectionSettings: default_factory=_get_network_requests_timeout, init=False ) geonode_version: typing.Optional[packaging_version.Version] = None + wfs_version: typing.Optional[WfsVersion] = WfsVersion.AUTO auth_config: typing.Optional[str] = None @classmethod @@ -65,6 +74,7 @@ def from_qgs_settings(cls, connection_identifier: str, settings: QgsSettings): page_size=int(settings.value("page_size", defaultValue=10)), auth_config=reported_auth_cfg, geonode_version=geonode_version, + wfs_version=WfsVersion(settings.value("wfs_version", "1.1.0")), ) def to_json(self): @@ -78,6 +88,7 @@ def to_json(self): "geonode_version": str(self.geonode_version) if self.geonode_version is not None else None, + "wfs_version": self.wfs_version.value, } ) @@ -151,6 +162,7 @@ def save_connection_settings(self, connection_settings: ConnectionSettings): settings.setValue("name", connection_settings.name) settings.setValue("base_url", connection_settings.base_url) settings.setValue("page_size", connection_settings.page_size) + settings.setValue("wfs_version", connection_settings.wfs_version.value) settings.setValue("auth_config", connection_settings.auth_config) settings.setValue( "geonode_version", diff --git a/src/qgis_geonode/gui/connection_dialog.py b/src/qgis_geonode/gui/connection_dialog.py index c11e546a..6274df01 100644 --- a/src/qgis_geonode/gui/connection_dialog.py +++ b/src/qgis_geonode/gui/connection_dialog.py @@ -10,6 +10,7 @@ QtWidgets, QtCore, QtGui, + QtXml, ) from qgis.PyQt.uic import loadUiType @@ -17,6 +18,7 @@ from ..apiclient.base import BaseGeonodeClient from ..conf import ( ConnectionSettings, + WfsVersion, settings_manager, ) from ..utils import tr @@ -32,6 +34,8 @@ class ConnectionDialog(QtWidgets.QDialog, DialogUi): url_le: QtWidgets.QLineEdit authcfg_acs: qgis.gui.QgsAuthConfigSelect page_size_sb: QtWidgets.QSpinBox + wfs_version_cb: QtWidgets.QComboBox + detect_wfs_version_pb: QtWidgets.QPushButton network_timeout_sb: QtWidgets.QSpinBox test_connection_pb: QtWidgets.QPushButton buttonBox: QtWidgets.QDialogButtonBox @@ -66,7 +70,7 @@ def __init__(self, connection_settings: typing.Optional[ConnectionSettings] = No ) self.layout().insertWidget(0, self.bar, alignment=QtCore.Qt.AlignTop) self.discovery_task = None - + self._populate_wfs_version_combobox() if connection_settings is not None: self.connection_id = connection_settings.id self.remote_geonode_version = connection_settings.geonode_version @@ -74,10 +78,15 @@ 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) + wfs_version_index = self.wfs_version_cb.findData( + connection_settings.wfs_version + ) + self.wfs_version_cb.setCurrentIndex(wfs_version_index) if self.remote_geonode_version == network.UNSUPPORTED_REMOTE: - self.show_progress( + utils.show_message( + self.bar, tr("Invalid configuration. Correct GeoNode URL and/or test again."), - message_level=qgis.core.Qgis.Critical, + level=qgis.core.Qgis.Critical, ) else: self.connection_id = uuid.uuid4() @@ -90,6 +99,7 @@ def __init__(self, connection_settings: typing.Optional[ConnectionSettings] = No ] for signal in ok_signals: signal.connect(self.update_ok_buttons) + self.detect_wfs_version_pb.clicked.connect(self.detect_wfs_version) 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 @@ -98,6 +108,32 @@ def __init__(self, connection_settings: typing.Optional[ConnectionSettings] = No ) self.update_ok_buttons() + def _populate_wfs_version_combobox(self): + self.wfs_version_cb.clear() + for name, member in WfsVersion.__members__.items(): + self.wfs_version_cb.addItem(member.value, member) + + def detect_wfs_version(self): + for widget in self._widgets_to_toggle_during_connection_test: + widget.setEnabled(False) + current_settings = self.get_connection_settings() + query = QtCore.QUrlQuery() + query.addQueryItem("service", "WFS") + query.addQueryItem("request", "GetCapabilities") + url = QtCore.QUrl(f"{current_settings.base_url}/gs/ows") + url.setQuery(query) + self.discovery_task = network.NetworkRequestTask( + [network.RequestToPerform(url)], + network_task_timeout=current_settings.network_requests_timeout, + authcfg=current_settings.auth_config, + description="Detect WFS version", + ) + self.discovery_task.task_done.connect(self.handle_wfs_version_detection_test) + utils.show_message( + self.bar, tr("Detecting WFS version..."), add_loading_widget=True + ) + qgis.core.QgsApplication.taskManager().addTask(self.discovery_task) + def get_connection_settings(self) -> ConnectionSettings: return ConnectionSettings( id=self.connection_id, @@ -106,6 +142,7 @@ def get_connection_settings(self) -> ConnectionSettings: auth_config=self.authcfg_acs.configId(), page_size=self.page_size_sb.value(), geonode_version=self.remote_geonode_version, + wfs_version=self.wfs_version_cb.currentData(), ) def test_connection(self): @@ -123,7 +160,9 @@ def test_connection(self): description="Test GeoNode connection", ) self.discovery_task.task_done.connect(self.handle_discovery_test) - self.show_progress(tr("Testing connection..."), include_progress_bar=True) + utils.show_message( + self.bar, tr("Testing connection..."), add_loading_widget=True + ) qgis.core.QgsApplication.taskManager().addTask(self.discovery_task) def handle_discovery_test(self, task_result: bool): @@ -142,6 +181,40 @@ def handle_discovery_test(self, task_result: bool): utils.show_message(self.bar, message, level) self.update_connection_details() + def handle_wfs_version_detection_test(self, task_result: bool): + self.enable_post_test_connection_buttons() + # TODO: set the default to WfsVersion.AUTO when this QGIS issue has been resolved: + # + # https://github.com/qgis/QGIS/issues/47254 + # + default_version = WfsVersion.V_1_1_0 + version = default_version + if task_result: + response_contents = self.discovery_task.response_contents[0] + if response_contents is not None and response_contents.qt_error is None: + raw_response = response_contents.response_body + detected_versions = _get_wfs_declared_versions(raw_response) + preference_order = [ + "1.1.0", + "2.0.0", + "1.0.0", + ] + for preference in preference_order: + if preference in detected_versions: + version = WfsVersion(preference) + break + else: + version = default_version + self.bar.clearWidgets() + else: + utils.show_message( + self.bar, + tr("Unable to detect WFS version"), + level=qgis.core.Qgis.Warning, + ) + index = self.wfs_version_cb.findData(version) + self.wfs_version_cb.setCurrentIndex(index) + def update_connection_details(self): invalid_version = ( self.remote_geonode_version is None @@ -194,12 +267,31 @@ def update_ok_buttons(self): self.buttonBox.button(QtWidgets.QDialogButtonBox.Ok).setEnabled(enabled_state) self.test_connection_pb.setEnabled(enabled_state) - def show_progress( - self, - message: str, - message_level: typing.Optional[qgis.core.Qgis] = qgis.core.Qgis.Info, - include_progress_bar: typing.Optional[bool] = False, - ): - return utils.show_message( - self.bar, message, message_level, add_loading_widget=include_progress_bar - ) + +def _get_wfs_declared_versions(raw_response: QtCore.QByteArray) -> typing.List[str]: + """ + Parse capabilities response and retrieve WFS versions supported by the WFS server. + """ + + capabilities_doc = QtXml.QDomDocument() + loaded = capabilities_doc.setContent(raw_response, True) + result = [] + if loaded: + root = capabilities_doc.documentElement() + if not root.isNull(): + operations_meta_elements = root.elementsByTagName("ows:OperationsMetadata") + operations_meta_element = operations_meta_elements.at(0) + if not operations_meta_element.isNull(): + for operation_node in operations_meta_element.childNodes(): + op_name = operation_node.attributes().namedItem("name").nodeValue() + if op_name == "GetCapabilities": + operation_el = operation_node.toElement() + for par_node in operation_el.elementsByTagName("ows:Parameter"): + param_name = ( + par_node.attributes().namedItem("name").nodeValue() + ) + if param_name == "AcceptVersions": + param_el = par_node.toElement() + for val_node in param_el.elementsByTagName("ows:Value"): + result.append(val_node.firstChild().nodeValue()) + return result diff --git a/src/qgis_geonode/gui/search_result_widget.py b/src/qgis_geonode/gui/search_result_widget.py index 2d83fda7..851ddd69 100644 --- a/src/qgis_geonode/gui/search_result_widget.py +++ b/src/qgis_geonode/gui/search_result_widget.py @@ -394,7 +394,7 @@ def _load_wfs(self) -> qgis.core.QgsMapLayer: "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", + "version": self.api_client.wfs_version.value, } if self.api_client.auth_config: params["authcfg"] = self.api_client.auth_config diff --git a/src/qgis_geonode/ui/connection_dialog.ui b/src/qgis_geonode/ui/connection_dialog.ui index a6bb712a..4f5324ce 100644 --- a/src/qgis_geonode/ui/connection_dialog.ui +++ b/src/qgis_geonode/ui/connection_dialog.ui @@ -113,6 +113,27 @@ + + + + WFS version + + + + + + + + + + + + Detect + + + + + diff --git a/test/test_apiclient_geonode_v3.py b/test/test_apiclient_geonode_v3.py index 9ea62e91..218bfd19 100644 --- a/test/test_apiclient_geonode_v3.py +++ b/test/test_apiclient_geonode_v3.py @@ -7,6 +7,7 @@ import qgis.core from qgis.PyQt import QtCore +from qgis_geonode.conf import WfsVersion from qgis_geonode.apiclient import ( geonode_v3, models, @@ -110,7 +111,7 @@ def test_parse_datetime(raw_value, expected): ) def test_apiclient_v_3_3_0_dataset_list_url(base_url, expected): client = geonode_v3.GeonodeApiClientVersion_3_3_0( - base_url, 10, network_requests_timeout=0 + base_url, 10, wfs_version=WfsVersion.V_1_1_0, network_requests_timeout=0 ) assert client.dataset_list_url == expected @@ -131,7 +132,9 @@ def test_apiclient_v_3_3_0_dataset_list_url(base_url, expected): ], ) def test_apiclient_dataset_list_url(client_class: typing.Type, base_url, expected): - client = client_class(base_url, 10, network_requests_timeout=0) + client = client_class( + base_url, 10, wfs_version=WfsVersion.V_1_1_0, network_requests_timeout=0 + ) assert client.dataset_list_url == expected @@ -155,7 +158,9 @@ def test_apiclient_dataset_list_url(client_class: typing.Type, base_url, expecte def test_apiclient_get_dataset_detail_url( client_class: typing.Type, base_url, dataset_id, expected ): - client = client_class(base_url, 10, network_requests_timeout=0) + client = client_class( + base_url, 10, wfs_version=WfsVersion.V_1_1_0, network_requests_timeout=0 + ) result = client.get_dataset_detail_url(dataset_id) assert result.toString() == expected @@ -350,7 +355,9 @@ def test_apiclient_get_dataset_detail_url( def test_apiclient_build_search_filters( client_class: typing.Type, search_filters, expected ): - client = client_class("phony-base-url", 10, network_requests_timeout=0) + client = client_class( + "phony-base-url", 10, wfs_version=WfsVersion.V_1_1_0, network_requests_timeout=0 + ) result = client.build_search_query(search_filters) assert result.toString() == expected @@ -419,7 +426,9 @@ def test_get_common_model_properties_client_v_3_4_0(): name="fake-style-name", sld_url="fake-sld-url" ), } - client = geonode_v3.GeonodeApiClientVersion_3_4_0("fake-base-url", 10, 0) + client = geonode_v3.GeonodeApiClientVersion_3_4_0( + "fake-base-url", 10, WfsVersion.V_1_1_0, 0 + ) result = client._get_common_model_properties(raw_dataset) for k, v in expected.items(): assert result[k] == v