diff --git a/src/qgis_geonode/apiclient/base.py b/src/qgis_geonode/apiclient/base.py index 3b75c030..7757f5d3 100644 --- a/src/qgis_geonode/apiclient/base.py +++ b/src/qgis_geonode/apiclient/base.py @@ -23,11 +23,10 @@ class BaseGeonodeClient(QtCore.QObject): dataset_list_received = QtCore.pyqtSignal(list, models.GeonodePaginationInfo) dataset_detail_received = QtCore.pyqtSignal(object) + dataset_detail_error_received = QtCore.pyqtSignal([str], [str, int, str]) style_detail_received = QtCore.pyqtSignal(QtXml.QDomElement) keyword_list_received = QtCore.pyqtSignal(list) - search_error_received = QtCore.pyqtSignal([str], [str, int, str]) - dataset_detail_error_received = QtCore.pyqtSignal([str], [str, int, str]) def __init__( self, diff --git a/src/qgis_geonode/apiclient/version_postv2.py b/src/qgis_geonode/apiclient/version_postv2.py index 4d6abd99..daad1a5c 100644 --- a/src/qgis_geonode/apiclient/version_postv2.py +++ b/src/qgis_geonode/apiclient/version_postv2.py @@ -26,6 +26,7 @@ class GeonodePostV2ApiClient(BaseGeonodeClient): models.ApiClientCapability.FILTER_BY_TEMPORAL_EXTENT, models.ApiClientCapability.LOAD_LAYER_METADATA, models.ApiClientCapability.LOAD_VECTOR_LAYER_STYLE, + models.ApiClientCapability.MODIFY_LAYER_METADATA, # 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, @@ -265,7 +266,7 @@ def _get_common_model_properties(raw_dataset: typing.Dict) -> typing.Dict: "title": raw_dataset.get("title", ""), "abstract": raw_dataset.get("raw_abstract", ""), "thumbnail_url": raw_dataset["thumbnail_url"], - "link": raw_dataset.get("link"), + "link": raw_dataset["link"], "detail_url": raw_dataset["detail_url"], "dataset_sub_type": type_, "service_urls": service_urls, diff --git a/src/qgis_geonode/gui/geonode_map_layer_config_widget.py b/src/qgis_geonode/gui/geonode_map_layer_config_widget.py index a86575fa..a8f9a21e 100644 --- a/src/qgis_geonode/gui/geonode_map_layer_config_widget.py +++ b/src/qgis_geonode/gui/geonode_map_layer_config_widget.py @@ -1,3 +1,4 @@ +import json import typing import xml.etree.ElementTree as ET from pathlib import Path @@ -25,6 +26,7 @@ get_geonode_client, models, ) +from ..metadata import populate_metadata from ..utils import ( log, ) @@ -35,12 +37,15 @@ class GeonodeMapLayerConfigWidget(qgis.gui.QgsMapLayerConfigWidget, WidgetUi): download_style_pb: QtWidgets.QPushButton upload_style_pb: QtWidgets.QPushButton + download_metadata_pb: QtWidgets.QPushButton + upload_metadata_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 + _apply_geonode_metadata: bool @property def connection_settings(self) -> typing.Optional[conf.ConnectionSettings]: @@ -83,17 +88,22 @@ def __init__(self, layer, canvas, parent): self.layout().insertWidget(0, self.message_bar) self.network_task = None self._apply_geonode_style = False + self._apply_geonode_metadata = False self.layer = layer self._toggle_style_controls(enabled=False) self._toggle_link_controls(enabled=False) + self._toggle_metadata_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.download_metadata_pb.clicked.connect(self.download_metadata) + self.upload_metadata_pb.clicked.connect(self.upload_metadata) self._toggle_style_controls(enabled=True) self._toggle_link_controls(enabled=True) + self._toggle_metadata_controls(enabled=True) else: # this is not a GeoNode layer pass @@ -102,6 +112,9 @@ def apply(self): if self._apply_geonode_style: self._apply_sld() self._apply_geonode_style = False + if self._apply_geonode_metadata: + self._apply_metadata() + self._apply_geonode_metadata = False def get_dataset(self) -> typing.Optional[models.Dataset]: serialized_dataset = self.layer.customProperty( @@ -115,7 +128,7 @@ def get_dataset(self) -> typing.Optional[models.Dataset]: result = None return result - def update_dataset(self, new_dataset: models.Dataset): + def update_dataset(self, new_dataset: models.Dataset) -> None: serialized = new_dataset.to_json() self.layer.setCustomProperty(models.DATASET_CUSTOM_PROPERTY_KEY, serialized) @@ -176,10 +189,10 @@ def upload_style(self): 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) + qgis.core.QgsApplication.taskManager().addTask(self.network_task) def _prepare_style_for_upload(self) -> typing.Optional[typing.Tuple[str, str]]: doc = QtXml.QDomDocument() @@ -240,7 +253,7 @@ def _prepare_raster_style_for_upload( content_type = "application/vnd.ogc.sld+xml" return new_serialized, content_type - def handle_style_uploaded(self, task_result: bool): + def handle_style_uploaded(self, task_result: bool) -> None: self._toggle_style_controls(enabled=True) if task_result: parsed_reply = self.network_task.response_contents[0] @@ -262,6 +275,97 @@ def handle_style_uploaded(self, task_result: bool): level=qgis.core.Qgis.Warning, ) + def download_metadata(self) -> None: + """Initiate download of metadata from the remote GeoNode. + + The process of updating a QGIS layer's metadata from the corresponding GeoNode + dataset involves the following steps: + + 1. Perform a network request in order to retrieve the updated metadata. + 2. Update our internal representation of the remote dataset with the retrieved + metadata. + 3. When the QGIS layer properties dialogue has its apply button clicked, update + the QGIS layer metadata with the relevant information. + + """ + + dataset = self.get_dataset() + api_client = self.api_client + api_client.dataset_detail_received.connect(self.handle_metadata_downloaded) + api_client.dataset_detail_error_received.connect( + self.handle_metadata_downloaded + ) + self._toggle_metadata_controls(enabled=False) + self._show_message("Retrieving metadata...", add_loading_widget=True) + api_client.get_dataset_detail(dataset) + + def handle_metadata_download_error(self) -> None: + log("inside handle_metadata_download_error") + + def handle_metadata_downloaded(self, downloaded_dataset: models.Dataset) -> None: + self._toggle_metadata_controls(enabled=True) + self.update_dataset(downloaded_dataset) + self._apply_geonode_metadata = True + self.apply() + + def _apply_metadata(self) -> None: + dataset = self.get_dataset() + updated_metadata = populate_metadata(self.layer.metadata(), dataset) + self.layer.setMetadata(updated_metadata) + layer_properties_dialog = self._get_layer_properties_dialog() + layer_properties_dialog.syncToLayer() + + def upload_metadata(self) -> None: + self.apply() + current_metadata = self.layer.metadata() + self.network_task = network.NetworkRequestTask( + [ + network.RequestToPerform( + QtCore.QUrl(self.get_dataset().link), + method=network.HttpMethod.PATCH, + payload=json.dumps( + { + "title": current_metadata.title(), + "abstract": current_metadata.abstract(), + } + ), + content_type="application/json", + ) + ], + self.api_client.auth_config, + network_task_timeout=self.api_client.network_requests_timeout, + description="Upload metadata", + ) + self.network_task.task_done.connect(self.handle_metadata_uploaded) + self._toggle_metadata_controls(enabled=False) + self._show_message(message="Uploading metadata...", add_loading_widget=True) + qgis.core.QgsApplication.taskManager().addTask(self.network_task) + + def handle_metadata_uploaded(self, task_result: bool) -> None: + self._toggle_metadata_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("Metadata uploaded successfully!") + else: + error_message_parts = [ + "Could not upload metadata", + 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( + "Could not upload metadata", level=qgis.core.Qgis.Warning + ) + else: + self._show_message( + "Could not upload metadata", level=qgis.core.Qgis.Warning + ) + def open_detail_url(self) -> None: dataset = self.get_dataset() QtGui.QDesktopServices.openUrl(QtCore.QUrl(dataset.detail_url)) @@ -294,9 +398,8 @@ def _show_message( 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 + # FIXME: This is a very hacky way to get the layer properties dialog + # 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: @@ -331,3 +434,27 @@ def _toggle_style_controls(self, enabled: bool) -> None: ] for widget in widgets: widget.setEnabled(enabled) + + def _toggle_metadata_controls(self, enabled: bool) -> None: + if enabled: + widgets = [] + if self.connection_settings is not None: + can_load_metadata = ( + models.ApiClientCapability.LOAD_LAYER_METADATA + in self.api_client.capabilities + ) + if can_load_metadata: + widgets.append(self.download_metadata_pb) + can_modify_metadata = ( + models.ApiClientCapability.MODIFY_LAYER_METADATA + in self.api_client.capabilities + ) + if can_modify_metadata: + widgets.append(self.upload_metadata_pb) + else: + widgets = [ + self.upload_metadata_pb, + self.download_metadata_pb, + ] + for widget in widgets: + widget.setEnabled(enabled) diff --git a/src/qgis_geonode/gui/search_result_widget.py b/src/qgis_geonode/gui/search_result_widget.py index 60c61397..55985eac 100644 --- a/src/qgis_geonode/gui/search_result_widget.py +++ b/src/qgis_geonode/gui/search_result_widget.py @@ -20,6 +20,7 @@ from .. import network from ..apiclient.models import ApiClientCapability from ..conf import settings_manager +from ..metadata import populate_metadata from ..resources import * from ..utils import log, tr @@ -396,62 +397,3 @@ def _load_wfs(self) -> qgis.core.QgsMapLayer: 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): - metadata.setIdentifier(str(dataset.uuid)) - metadata.setTitle(dataset.title) - metadata.setAbstract(dataset.abstract) - metadata.setLanguage(dataset.language) - metadata.setKeywords({"layer": dataset.keywords}) - if dataset.category: - metadata.setCategories([dataset.category]) - if dataset.license: - metadata.setLicenses([dataset.license]) - if dataset.constraints: - constraints = [qgis.core.QgsLayerMetadata.Constraint(dataset.constraints)] - metadata.setConstraints(constraints) - metadata.setCrs(dataset.srid) - spatial_extent = qgis.core.QgsLayerMetadata.SpatialExtent() - spatial_extent.extentCrs = dataset.srid - if dataset.spatial_extent: - spatial_extent.bounds = dataset.spatial_extent.toBox3d(0, 0) - if dataset.temporal_extent: - metadata.extent().setTemporalExtents( - [ - qgis.core.QgsDateTimeRange( - dataset.temporal_extent[0], - dataset.temporal_extent[1], - ) - ] - ) - - metadata.extent().setSpatialExtents([spatial_extent]) - if dataset.owner: - owner_contact = qgis.core.QgsAbstractMetadataBase.Contact(dataset.owner) - owner_contact.role = tr("owner") - metadata.addContact(owner_contact) - if dataset.metadata_author: - metadata_author = qgis.core.QgsAbstractMetadataBase.Contact( - dataset.metadata_author - ) - metadata_author.role = tr("metadata_author") - metadata.addContact(metadata_author) - links = [] - if dataset.thumbnail_url: - link = qgis.core.QgsAbstractMetadataBase.Link( - tr("Thumbnail"), tr("Thumbail_link"), dataset.thumbnail_url - ) - links.append(link) - if dataset.link: - link = qgis.core.QgsAbstractMetadataBase.Link( - tr("API"), tr("API_URL"), dataset.link - ) - links.append(link) - if dataset.detail_url: - link = qgis.core.QgsAbstractMetadataBase.Link( - tr("Detail"), tr("Detail_URL"), dataset.detail_url - ) - links.append(link) - metadata.setLinks(links) - return metadata diff --git a/src/qgis_geonode/metadata.py b/src/qgis_geonode/metadata.py new file mode 100644 index 00000000..b049ef0f --- /dev/null +++ b/src/qgis_geonode/metadata.py @@ -0,0 +1,64 @@ +import qgis.core + +from .apiclient import models +from .utils import tr + + +def populate_metadata( + metadata: qgis.core.QgsLayerMetadata, dataset: models.Dataset +) -> qgis.core.QgsLayerMetadata: + metadata.setIdentifier(str(dataset.uuid)) + metadata.setTitle(dataset.title) + metadata.setAbstract(dataset.abstract) + metadata.setLanguage(dataset.language) + metadata.setKeywords({"layer": dataset.keywords}) + if dataset.category: + metadata.setCategories([dataset.category]) + if dataset.license: + metadata.setLicenses([dataset.license]) + if dataset.constraints: + constraints = [qgis.core.QgsLayerMetadata.Constraint(dataset.constraints)] + metadata.setConstraints(constraints) + metadata.setCrs(dataset.srid) + spatial_extent = qgis.core.QgsLayerMetadata.SpatialExtent() + spatial_extent.extentCrs = dataset.srid + if dataset.spatial_extent: + spatial_extent.bounds = dataset.spatial_extent.toBox3d(0, 0) + if dataset.temporal_extent: + metadata.extent().setTemporalExtents( + [ + qgis.core.QgsDateTimeRange( + dataset.temporal_extent[0], + dataset.temporal_extent[1], + ) + ] + ) + metadata.extent().setSpatialExtents([spatial_extent]) + if dataset.owner: + owner_contact = qgis.core.QgsAbstractMetadataBase.Contact(dataset.owner) + owner_contact.role = tr("owner") + metadata.addContact(owner_contact) + if dataset.metadata_author: + metadata_author = qgis.core.QgsAbstractMetadataBase.Contact( + dataset.metadata_author + ) + metadata_author.role = tr("metadata_author") + metadata.addContact(metadata_author) + links = [] + if dataset.thumbnail_url: + link = qgis.core.QgsAbstractMetadataBase.Link( + tr("Thumbnail"), tr("Thumbail_link"), dataset.thumbnail_url + ) + links.append(link) + if dataset.link: + link = qgis.core.QgsAbstractMetadataBase.Link( + tr("API"), tr("API_URL"), dataset.link + ) + links.append(link) + if dataset.detail_url: + link = qgis.core.QgsAbstractMetadataBase.Link( + tr("Detail"), tr("Detail_URL"), dataset.detail_url + ) + links.append(link) + metadata.setLinks(links) + return metadata diff --git a/src/qgis_geonode/network.py b/src/qgis_geonode/network.py index df911bd5..e153468e 100644 --- a/src/qgis_geonode/network.py +++ b/src/qgis_geonode/network.py @@ -18,9 +18,10 @@ class HttpMethod(enum.Enum): - GET = "get" - POST = "post" - PUT = "put" + GET = "GET" + POST = "POST" + PUT = "PUT" + PATCH = "PATCH" @dataclasses.dataclass() @@ -219,6 +220,12 @@ def _dispatch_request( elif method == HttpMethod.PUT: data_ = QtCore.QByteArray(payload.encode()) reply = self.network_access_manager.put(request, data_) + elif method == HttpMethod.PATCH: + data_ = QtCore.QByteArray(payload.encode()) + # QNetworkAccess manager does not have a patch() method + reply = self.network_access_manager.sendCustomRequest( + request, QtCore.QByteArray(HttpMethod.PATCH.value.encode()), data_ + ) else: raise NotImplementedError return reply diff --git a/src/qgis_geonode/ui/qgis_geonode_layer_dialog.ui b/src/qgis_geonode/ui/qgis_geonode_layer_dialog.ui index b8deee3d..15fb82e5 100644 --- a/src/qgis_geonode/ui/qgis_geonode_layer_dialog.ui +++ b/src/qgis_geonode/ui/qgis_geonode_layer_dialog.ui @@ -7,13 +7,13 @@ 0 0 664 - 333 + 366 Geonode Layer - + @@ -90,6 +90,36 @@ + + + + Metadata + + + false + + + + + + + + Reload metadata from GeoNode + + + + + + + Save current metadata to GeoNode + + + + + + + +