PK x,T\6 , qgis_geonode/ui/qgis_geonode_layer_dialog.ui
LayerPropertiesDialog00664561Geonode LayerLinksfalseView dataset on GeoNode...View dataset API details...Layer StyleReload default style from GeoNodeSave current style to GeoNodeQt::Horizontal00<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>trueMetadatafalseReload metadata from GeoNodeSave current metadata to GeoNodeUpload layer to GeoNodefalseGeoNode connectionMake layer publicly availableUpload layer to GeoNodeQt::Vertical20202QgsCollapsibleGroupBoxQGroupBoxqgscollapsiblegroupbox.h1
PK x,T.w w $ qgis_geonode/ui/connection_dialog.ui
NewConnectionDialog00596608GeoNode Connection ConfigurationQFrame::NoFrameQFrame::Raised0000NameGeoNode URL00030OptionsPage size0015011000Test ConnectionDetected version informationDetected GeoNode versiontrueUsing API client classtrueAPI client capabilitiesQt::HorizontalQDialogButtonBox::Cancel|QDialogButtonBox::Help|QDialogButtonBox::OkQgsAuthConfigSelectQWidgetqgsauthconfigselect.hQgsCollapsibleGroupBoxQGroupBoxqgscollapsiblegroupbox.h1name_leurl_lebuttonBoxaccepted()NewConnectionDialogaccept()248254157274buttonBoxrejected()NewConnectionDialogreject()316260286274
PK x,T~
(E E , qgis_geonode/ui/geonode_datasource_widget.ui
QgsGeonode00728965ConnectionConnectionsCreate a new service connection&NewfalseEdit selected service connectionEditfalseRemove connection to selected serviceRemoveQt::HorizontalQSizePolicy::Expanding17130SearchtrueTitletitle_letrueDisplay WFS FeatureTypes containing this word in the title, name or abstract<html><head/><body><p>Title (or part of it) of the remote GeoNode dataset</p></body></html>trueAdvancedfalsefalsefalsetrueAbstractabstract_letrue<html><head/><body><p><html><head/><body><p>Abstract (or part of it) of the remote GeoNode dataset</p></body></html></p></body></html>trueKeywordtrueTopic Categorycategory_cmbtruetrueTypes of resources to be filtered, must select at least one of these in order to searchResource typesVectortrueresource_types_btngrpRastertrueresource_types_btngrpQt::Horizontal4020Temporal extenttrueStarttruetrueEndtruePublication datetrueStartEndSpatial Extenttrue0000<html><head/><body><p>Search for resources from the GeoNode instance</p></body></html>Search Geonodetrue00<html><head/><body><p>Go to previous page</p></body></html>Previous00<html><head/><body><p>Go to next page</p><p><br/></p></body></html>Next00Sort bysort_field_cmb000000Qt::LeftToRightReverse orderQt::Horizontal4020Zero ResultsQFrame::StyledPanelQFrame::PlaintrueQt::AlignLeading|Qt::AlignLeft|Qt::AlignTop00708366Qt::HorizontalQDialogButtonBox::HelpQgsCollapsibleGroupBoxQGroupBoxqgscollapsiblegroupbox.h1QgsDateTimeEditQDateTimeEditqgsdatetimeedit.hQgsExtentGroupBoxQgsCollapsibleGroupBoxqgsextentgroupbox.h1title_leabstract_lecategory_cmbvector_chbraster_chbtemporal_extent_start_dtetemporal_extent_end_dtesearch_btnprevious_btnnext_btnsort_field_cmbreverse_order_chbscroll_areaconnections_cmbnew_connection_btnedit_connection_btndelete_connection_btnfalse
PK x,Thv[ ' qgis_geonode/ui/search_result_widget.ui
Form007682220016777215222Resourcebackground-color: rgb(255, 255, 255);000000QFrame::StyledPanelQFrame::Plain00QFrame::NoFrameResource titleQt::AlignLeading|Qt::AlignLeft|Qt::AlignVCentertrueQt::LinksAccessibleByMouse|Qt::TextSelectableByMouse00Type icon00Resource typetrueQt::LinksAccessibleByMouse|Qt::TextSelectableByMouse00New descriptionQt::AlignLeading|Qt::AlignLeft|Qt::AlignToptrueQt::LinksAccessibleByMouse|Qt::TextSelectableByMouseQt::Horizontal4020<html><head/><body><p>Open resource in Web Browser</p></body></html>Open in Browser00200200QFrame::StyledPanelThumbnailQt::AlignCenter
PK x,T*l 2 qgis_geonode/gui/geonode_source_select_provider.pyimport qgis.core
import qgis.gui
from qgis.PyQt import QtGui
from ..utils import tr
from .geonode_data_source_widget import GeonodeDataSourceWidget
class GeonodeSourceSelectProvider(qgis.gui.QgsSourceSelectProvider):
def createDataSourceWidget(self, parent, fl, widgetMode):
return GeonodeDataSourceWidget(parent, fl, widgetMode)
def providerKey(self):
return "qgis_geonode_plugin_provider"
def icon(self):
return QtGui.QIcon(":/plugins/qgis_geonode/mIconGeonode.svg")
def text(self):
return tr("GeoNode Plugin")
def toolTip(self):
return tr("Add Geonode Layer")
def ordering(self):
return qgis.gui.QgsSourceSelectProvider.OrderOtherProvider
PK x,Ta0R R 3 qgis_geonode/gui/geonode_map_layer_config_widget.pyimport json
import typing
import xml.etree.ElementTree as ET
from pathlib import Path
from uuid import UUID
import qgis.core
import qgis.gui
import qgis.utils
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 ..metadata import populate_metadata
from ..utils import (
log,
)
WidgetUi, _ = loadUiType(Path(__file__).parents[1] / "ui/qgis_geonode_layer_dialog.ui")
class GeonodeMapLayerConfigWidget(qgis.gui.QgsMapLayerConfigWidget, WidgetUi):
style_gb: qgis.gui.QgsCollapsibleGroupBox
download_style_pb: QtWidgets.QPushButton
upload_style_pb: QtWidgets.QPushButton
metadata_gb: qgis.gui.QgsCollapsibleGroupBox
download_metadata_pb: QtWidgets.QPushButton
upload_metadata_pb: QtWidgets.QPushButton
links_gb: qgis.gui.QgsCollapsibleGroupBox
open_detail_url_pb: QtWidgets.QPushButton
open_link_url_pb: QtWidgets.QPushButton
upload_gb: qgis.gui.QgsCollapsibleGroupBox
geonode_connection_cb: QtWidgets.QComboBox
public_access_chb: QtWidgets.QCheckBox
upload_layer_pb: QtWidgets.QPushButton
message_bar: qgis.gui.QgsMessageBar
network_task: typing.Optional[network.NetworkRequestTask]
_apply_geonode_style: bool
_apply_geonode_metadata: bool
_layer_upload_api_client: typing.Optional[base.BaseGeonodeClient]
_api_client: typing.Optional[base.BaseGeonodeClient]
@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
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.public_access_chb.setChecked(True)
self.network_task = None
self._apply_geonode_style = False
self._apply_geonode_metadata = False
self.layer = layer
self._layer_upload_api_client = None
self._api_client = get_geonode_client(self.connection_settings)
self.upload_layer_pb.clicked.connect(self.upload_layer_to_geonode)
suitable_connections = self._get_suitable_upload_connections()
if len(suitable_connections) > 0:
self._populate_geonode_connection_combo_box(suitable_connections)
self._toggle_upload_controls(enabled=True)
else:
self._toggle_upload_controls(enabled=False)
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
def apply(self):
self.message_bar.clearWidgets()
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(
models.DATASET_CUSTOM_PROPERTY_KEY
)
if serialized_dataset is not None:
result = models.Dataset.from_json(serialized_dataset)
else:
result = None
return result
def update_dataset(self, new_dataset: models.Dataset) -> None:
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._api_client.network_requests_timeout,
self.connection_settings.auth_config,
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._api_client.network_requests_timeout,
self.connection_settings.auth_config,
description="Upload dataset style to GeoNode",
)
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()
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) -> None:
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 download_metadata(self) -> None:
"""Initiate download of metadata from the remote GeoNode"""
self._api_client.dataset_detail_received.connect(
self.handle_metadata_downloaded
)
self._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)
dataset = self.get_dataset()
self._api_client.get_dataset_detail(dataset, get_style_too=False)
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()
# FIXME: rather use the api_client to perform the metadata upload
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.network_requests_timeout,
self._api_client.auth_config,
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))
def open_link_url(self) -> None:
dataset = self.get_dataset()
QtGui.QDesktopServices.openUrl(QtCore.QUrl(dataset.link))
def upload_layer_to_geonode(self) -> None:
self._toggle_upload_controls(enabled=False)
self._show_message("Uploading layer to GeoNode...", add_loading_widget=True)
connection_settings: conf.ConnectionSettings = (
self.geonode_connection_cb.currentData()
)
self._layer_upload_api_client = get_geonode_client(connection_settings)
self._layer_upload_api_client.dataset_uploaded.connect(
self.handle_layer_uploaded
)
self._layer_upload_api_client.dataset_upload_error_received.connect(
self.handle_layer_upload_error
)
self._layer_upload_api_client.upload_layer(
self.layer, allow_public_access=self.public_access_chb.isChecked()
)
def handle_layer_uploaded(self, dataset_pk: int):
self._toggle_upload_controls(enabled=True)
self._show_message("Layer uploaded successfully!")
def handle_layer_upload_error(self, *args):
self._toggle_upload_controls(enabled=True)
self._show_message(
" - ".join(str(i) for i in args), level=qgis.core.Qgis.Critical
)
self._layer_upload_api_client = None
def _get_suitable_upload_connections(self) -> typing.List[conf.ConnectionSettings]:
result = []
for connection_settings in conf.settings_manager.list_connections():
client: typing.Optional[base.BaseGeonodeClient] = get_geonode_client(
connection_settings
)
if client is not None:
target_capability = {
qgis.core.QgsMapLayerType.VectorLayer: models.ApiClientCapability.UPLOAD_VECTOR_LAYER,
qgis.core.QgsMapLayerType.RasterLayer: models.ApiClientCapability.UPLOAD_RASTER_LAYER,
}[self.layer.type()]
if target_capability in client.capabilities:
result.append(connection_settings)
return result
def _populate_geonode_connection_combo_box(
self, suitable_connections: typing.List[conf.ConnectionSettings]
) -> None:
for connection in suitable_connections:
self.geonode_connection_cb.addItem(connection.name, connection)
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
# 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:
self.links_gb.setEnabled(enabled)
def _toggle_style_controls(self, enabled: bool) -> None:
widgets = []
if enabled and self.connection_settings is not None:
can_load_style = models.loading_style_supported(
self.layer.type(), self._api_client.capabilities
)
log(f"can_load_style: {can_load_style}")
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)
if len(widgets) > 0:
widgets.append(self.style_gb)
else:
widgets.append(self.style_gb)
for widget in widgets:
widget.setEnabled(enabled)
def _toggle_metadata_controls(self, enabled: bool) -> None:
widgets = []
if enabled and 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)
if len(widgets) > 0:
widgets.append(self.metadata_gb)
else:
widgets.append(self.metadata_gb)
for widget in widgets:
widget.setEnabled(enabled)
def _toggle_upload_controls(self, enabled: bool) -> None:
self.upload_gb.setEnabled(enabled)
PK x,T qgis_geonode/gui/__init__.pyPK x,TY{n n . qgis_geonode/gui/geonode_data_source_widget.pyimport typing
from functools import partial
from pathlib import Path
import qgis.gui
from qgis.PyQt import (
QtCore,
QtGui,
QtWidgets,
)
from qgis.PyQt.uic import loadUiType
from qgis.utils import iface
from ..apiclient import (
base,
get_geonode_client,
models,
)
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 (
log,
tr,
)
WidgetUi, _ = loadUiType(Path(__file__).parents[1] / "ui/geonode_datasource_widget.ui")
_INVALID_CONNECTION_MESSAGE = (
"Current connection is invalid. Please review connection settings."
)
class GeonodeDataSourceWidget(qgis.gui.QgsAbstractDataSourceWidget, WidgetUi):
api_client: typing.Optional[base.BaseGeonodeClient] = None
discovery_task: typing.Optional[network.NetworkRequestTask]
abstract_la: QtWidgets.QLabel
abstract_le: QtWidgets.QLineEdit
category_la: QtWidgets.QLabel
category_cmb: QtWidgets.QComboBox
connections_cmb: QtWidgets.QComboBox
current_page: int = 0
edit_connection_btn: QtWidgets.QPushButton
delete_connection_btn: QtWidgets.QPushButton
keyword_la: QtWidgets.QLabel
keyword_le: QtWidgets.QLineEdit
message_bar: qgis.gui.QgsMessageBar
next_btn: QtWidgets.QPushButton
new_connection_btn: QtWidgets.QPushButton
pagination_info_la: QtWidgets.QLabel
previous_btn: QtWidgets.QPushButton
publication_date_box: qgis.gui.QgsCollapsibleGroupBox
publication_start_dte: qgis.gui.QgsDateTimeEdit
publication_end_dte: qgis.gui.QgsDateTimeEdit
raster_chb: QtWidgets.QCheckBox
resource_types_la: QtWidgets.QLabel
resource_types_btngrp: QtWidgets.QButtonGroup
reverse_order_chb: QtWidgets.QCheckBox
scroll_area: QtWidgets.QScrollArea
search_btn: QtWidgets.QPushButton
sort_field_cmb: QtWidgets.QComboBox
spatial_extent_box: qgis.gui.QgsExtentGroupBox
temporal_extent_box: qgis.gui.QgsCollapsibleGroupBox
temporal_extent_start_dte: qgis.gui.QgsDateTimeEdit
temporal_extent_end_dte: qgis.gui.QgsDateTimeEdit
title_la: QtWidgets.QLabel
title_le: QtWidgets.QLineEdit
total_pages: int = 0
vector_chb: QtWidgets.QCheckBox
search_started = QtCore.pyqtSignal()
search_finished = QtCore.pyqtSignal(str)
load_layer_started = QtCore.pyqtSignal()
load_layer_finished = QtCore.pyqtSignal()
_connection_controls = typing.List[QtWidgets.QWidget]
_search_controls = typing.List[QtWidgets.QWidget]
_search_filters = typing.List[QtWidgets.QWidget]
_usable_search_filters = typing.List[QtWidgets.QWidget]
_unusable_search_filters = typing.List[QtWidgets.QWidget]
def __init__(self, parent, fl, widgetMode):
super().__init__(parent, fl, widgetMode)
self.setupUi(self)
self.search_btn.setIcon(QtGui.QIcon(":/images/themes/default/search.svg"))
self.next_btn.setIcon(
QtGui.QIcon(":/images/themes/default/mActionAtlasNext.svg")
)
self.previous_btn.setIcon(
QtGui.QIcon(":/images/themes/default/mActionAtlasPrev.svg")
)
self.grid_layout = QtWidgets.QGridLayout()
self.message_bar = qgis.gui.QgsMessageBar()
self.message_bar.setSizePolicy(
QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Fixed
)
self.grid_layout.addWidget(self.scroll_area, 0, 0, 1, 1)
self.grid_layout.addWidget(
self.message_bar, 0, 0, 1, 1, alignment=QtCore.Qt.AlignTop
)
self.layout().insertLayout(4, self.grid_layout)
self.project = qgis.core.QgsProject.instance()
self.discovery_task = None
self.current_page = 1
self.total_pages = 1
# we use these to control enabling/disabling UI controls during searches
self._connection_controls = [
self.connections_cmb,
self.new_connection_btn,
self.edit_connection_btn,
self.delete_connection_btn,
]
self._search_filters = [
self.title_la,
self.title_le,
self.abstract_la,
self.abstract_le,
self.keyword_la,
self.keyword_le,
self.category_la,
self.category_cmb,
self.vector_chb,
self.raster_chb,
self.temporal_extent_box,
self.publication_date_box,
self.spatial_extent_box,
self.resource_types_la,
]
self._search_controls = [
self.search_btn,
self.next_btn,
self.previous_btn,
self.sort_field_cmb,
self.reverse_order_chb,
self.pagination_info_la,
]
# these are populated below, based on the capabilities supported by the
# api client
self._usable_search_filters = []
self._unusable_search_filters = []
self.resource_types_btngrp.buttonClicked.connect(self.toggle_search_buttons)
self.new_connection_btn.clicked.connect(
partial(self.spawn_connection_config_dialog, True)
)
self.edit_connection_btn.clicked.connect(self.spawn_connection_config_dialog)
self.delete_connection_btn.clicked.connect(self.delete_connection_configuration)
self.connections_cmb.currentIndexChanged.connect(
self.activate_connection_configuration
)
self.search_started.connect(self.handle_search_start)
self.search_finished.connect(self.handle_search_end)
# TODO: these signals should only be connected/disconnected when we update the
# GUI with the capabilities of the API client
self.search_btn.clicked.connect(
partial(self.search_geonode, reset_pagination=True)
)
self.next_btn.clicked.connect(self.request_next_page)
self.previous_btn.clicked.connect(self.request_previous_page)
self.temporal_extent_start_dte.clear()
self.temporal_extent_end_dte.clear()
self.publication_start_dte.clear()
self.publication_end_dte.clear()
self._load_categories()
self._initialize_spatial_extent_box()
self.title_le.textChanged.connect(self.store_search_filters)
self.abstract_le.textChanged.connect(self.store_search_filters)
self.keyword_le.textChanged.connect(self.store_search_filters)
self.category_cmb.currentIndexChanged.connect(self.store_search_filters)
self.resource_types_btngrp.buttonToggled.connect(self.store_search_filters)
self.temporal_extent_start_dte.valueChanged.connect(self.store_search_filters)
self.temporal_extent_end_dte.valueChanged.connect(self.store_search_filters)
self.publication_start_dte.valueChanged.connect(self.store_search_filters)
self.publication_end_dte.valueChanged.connect(self.store_search_filters)
self.spatial_extent_box.extentChanged.connect(self.store_search_filters)
self.sort_field_cmb.currentIndexChanged.connect(self.store_search_filters)
self.reverse_order_chb.toggled.connect(self.store_search_filters)
self.restore_search_filters()
# this method calls connections_cmb.setCurrentIndex(), which in turn emits
# connections_cmb.currentIndexChanged, which causes
# self.activate_connection_configuration to run
self.update_connections_combobox()
def _initialize_spatial_extent_box(self):
# ATTENTION: the order of initialization of the self.spatial_extent_box widget
# is crucial here. Only call self.spatial_extent_box.setMapCanvas() after
# having called self.spatial_extent_box.setOutputExtentFromCurrent()
epsg_4326 = qgis.core.QgsCoordinateReferenceSystem("EPSG:4326")
self.spatial_extent_box.setOutputCrs(epsg_4326)
map_canvas = iface.mapCanvas()
current_crs = map_canvas.mapSettings().destinationCrs()
self.spatial_extent_box.setCurrentExtent(current_crs.bounds(), current_crs)
self.spatial_extent_box.setOutputExtentFromCurrent()
self.spatial_extent_box.setMapCanvas(map_canvas)
def toggle_connection_management_buttons(self):
"""Enable/disable connection edit and delete buttons."""
current_name = self.connections_cmb.currentText()
enabled = current_name != ""
self.edit_connection_btn.setEnabled(enabled)
self.delete_connection_btn.setEnabled(enabled)
def spawn_connection_config_dialog(self, add_new: bool):
if add_new:
dialog = ConnectionDialog()
else:
connection_settings = (
conf.settings_manager.get_current_connection_settings()
)
dialog = ConnectionDialog(connection_settings=connection_settings)
dialog.exec_()
self.update_connections_combobox()
def delete_connection_configuration(self):
name = self.connections_cmb.currentText()
current_connection = conf.settings_manager.find_connection_by_name(name)
if self._confirm_deletion(name):
existing_connections = conf.settings_manager.list_connections()
if len(existing_connections) == 1:
next_current_connection = None
else:
for i in range(len(existing_connections)):
current_ = existing_connections[i]
if current_.id == current_connection.id:
try:
next_current_connection = existing_connections[i - 1]
except IndexError:
try:
next_current_connection = existing_connections[i + 1]
except IndexError:
next_current_connection = None
break
else:
next_current_connection = None
conf.settings_manager.delete_connection(current_connection.id)
if next_current_connection is not None:
conf.settings_manager.set_current_connection(next_current_connection.id)
else:
self.message_bar.clearWidgets()
self.update_connections_combobox()
def update_connections_combobox(self):
"""Populate the connections combobox with existing connection configurations
Also, set the currently selected combobox item accordingly.
"""
self.connections_cmb.clear()
existing_connections = conf.settings_manager.list_connections()
if len(existing_connections) > 0:
# NOTE: self.connections_cmb.addItems() emits the currentIndexChanged
# signal with the index of the first element that gets added. This causes
# the self.activate_connection_configuration() method to run and eventually
# messes the current connection settings up. As such, we store the id of
# the current connection before adding items to the combo box and then,
# after having added them, manually set the current connection back to the
# original value
current_id = conf.settings_manager.get_current_connection_settings().id
self.connections_cmb.addItems(conn.name for conn in existing_connections)
conf.settings_manager.set_current_connection(current_id)
current_connection = conf.settings_manager.get_current_connection_settings()
if current_connection is not None:
current_index = self.connections_cmb.findText(current_connection.name)
else:
current_index = 0
self.connections_cmb.setCurrentIndex(current_index)
def activate_connection_configuration(self, index: int):
self.toggle_connection_management_buttons()
self.clear_search_results()
self.current_page = 1
self.total_pages = 1
current_text = self.connections_cmb.itemText(index)
try:
current_connection = conf.settings_manager.find_connection_by_name(
current_text
)
except ValueError:
self.toggle_search_buttons(enable=False)
else:
conf.settings_manager.set_current_connection(current_connection.id)
if current_connection.geonode_version == network.UNSUPPORTED_REMOTE:
self.show_message(
tr(_INVALID_CONNECTION_MESSAGE), level=qgis.core.Qgis.Critical
)
else:
if current_connection.geonode_version:
self.api_client = get_geonode_client(current_connection)
self._load_sorting_fields()
self.api_client.dataset_list_received.connect(
self.handle_dataset_list
)
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
self.update_gui(current_connection)
self.toggle_search_buttons()
def toggle_search_buttons(self, enable: typing.Optional[bool] = None):
enable_search = False
enable_previous = False
enable_next = False
if enable is None or enable:
if self.connections_cmb.currentText() != "":
for check_box in self.resource_types_btngrp.buttons():
if check_box.isChecked():
enable_search = True
enable_previous = self.current_page > 1
enable_next = self.current_page < self.total_pages
break
self.search_btn.setEnabled(enable_search)
self.previous_btn.setEnabled(enable_previous)
self.next_btn.setEnabled(enable_next)
#
# def update_current_connection(self, current_index: int):
# current_text = self.connections_cmb.itemText(current_index)
# try:
# current_connection = conf.settings_manager.find_connection_by_name(
# current_text
# )
# except ValueError:
# pass
# else:
# conf.settings_manager.set_current_connection(current_connection.id)
# self.api_client = get_geonode_client(current_connection)
# self.api_client.layer_list_received.connect(self.handle_layer_list)
# self.api_client.error_received.connect(self.show_search_error)
# self.update_gui(current_connection)
def update_gui(self, connection_settings: conf.ConnectionSettings):
"""Update our UI based on the capabilities of the current API client"""
for search_widget in self._search_controls:
search_widget.setEnabled(True)
self._usable_search_filters = self._get_usable_search_filters()
self._unusable_search_filters = [
i for i in self._search_filters if i not in self._usable_search_filters
]
for usable_search_widget in self._usable_search_filters:
usable_search_widget.setEnabled(True)
for unusable_search_widget in self._unusable_search_filters:
unusable_search_widget.setEnabled(False)
def _get_usable_search_filters(self) -> typing.List:
capabilities = self.api_client.capabilities if self.api_client else []
result = []
if ApiClientCapability.FILTER_BY_TITLE in capabilities:
result.extend((self.title_la, self.title_le))
if ApiClientCapability.FILTER_BY_ABSTRACT in capabilities:
result.extend((self.abstract_la, self.abstract_le))
if ApiClientCapability.FILTER_BY_KEYWORD in capabilities:
result.extend(
(
self.keyword_la,
self.keyword_le,
)
)
if ApiClientCapability.FILTER_BY_TOPIC_CATEGORY in capabilities:
result.extend((self.category_la, self.category_cmb))
if ApiClientCapability.FILTER_BY_RESOURCE_TYPES in capabilities:
result.extend(
(
self.resource_types_la,
self.vector_chb,
self.raster_chb,
)
)
if ApiClientCapability.FILTER_BY_TEMPORAL_EXTENT in capabilities:
result.append(self.temporal_extent_box)
if ApiClientCapability.FILTER_BY_PUBLICATION_DATE in capabilities:
result.append(self.publication_date_box)
if ApiClientCapability.FILTER_BY_SPATIAL_EXTENT in capabilities:
result.append(self.spatial_extent_box)
return result
def _confirm_deletion(self, connection_name: str):
message = tr('Remove the following connection "{}"?').format(connection_name)
confirmation = QtWidgets.QMessageBox.warning(
self,
tr("QGIS GeoNode"),
message,
QtWidgets.QMessageBox.Yes,
QtWidgets.QMessageBox.No,
)
return confirmation == QtWidgets.QMessageBox.Yes
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
self.search_geonode()
def request_previous_page(self):
self.current_page = max(self.current_page - 1, 1)
self.search_geonode()
def discover_api_client(self, next_: typing.Callable, *next_args, **next_kwargs):
current_connection = conf.settings_manager.get_current_connection_settings()
self.discovery_task = network.NetworkRequestTask(
[
network.RequestToPerform(
QtCore.QUrl(f"{current_connection.base_url}/version.txt")
)
],
current_connection.network_requests_timeout,
current_connection.auth_config,
description="Test GeoNode connection",
)
self.discovery_task.task_done.connect(
partial(self.handle_api_client_discovery, next_, *next_args, **next_kwargs)
)
qgis.core.QgsApplication.taskManager().addTask(self.discovery_task)
def handle_api_client_discovery(
self, next_: typing.Callable, task_result: bool, *next_args, **next_kwargs
):
geonode_version = network.handle_discovery_test(
task_result, self.discovery_task
)
current_connection = conf.settings_manager.get_current_connection_settings()
current_connection.geonode_version = (
geonode_version
if geonode_version is not None
else network.UNSUPPORTED_REMOTE
)
conf.settings_manager.save_connection_settings(current_connection)
self.update_connections_combobox()
next_(*next_args, **next_kwargs)
def search_geonode(self, reset_pagination: bool = False):
search_params = self.get_search_filters()
if len(search_params.layer_types) > 0:
self.search_started.emit()
if reset_pagination:
self.current_page = 1
self.total_pages = 1
current_connection = conf.settings_manager.get_current_connection_settings()
if not current_connection.geonode_version:
self.discover_api_client(
next_=self.search_geonode, reset_pagination=reset_pagination
)
elif self.api_client is None:
self.search_finished.emit(tr(_INVALID_CONNECTION_MESSAGE))
else:
self.api_client.get_dataset_list(search_params)
def toggle_search_controls(self, enabled: bool):
for widget in self._unusable_search_filters:
widget.setEnabled(False)
for widget in self._usable_search_filters + self._search_controls:
widget.setEnabled(enabled)
def handle_search_start(self):
self.toggle_search_controls(False)
self.clear_search_results()
self.show_message(tr("Searching..."), add_loading_widget=True)
def handle_search_end(self, message: str):
self.message_bar.clearWidgets()
if message != "":
self.show_message(message, level=qgis.core.Qgis.Critical)
self.toggle_search_controls(True)
self.toggle_search_buttons()
def handle_search_error(
self,
qt_error_message: str,
http_status_code: int = 0,
http_status_reason: str = None,
):
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(
self,
dataset_list: typing.List[models.BriefDataset],
pagination_info: models.GeonodePaginationInfo,
):
"""Handle incoming dataset list
This method is called when the api client emits the `layer_list_received`
signal. It expects to receive a list of brief dataset descriptions, as found
on the remote GeoNode server.
"""
self.handle_pagination(pagination_info)
if len(dataset_list) > 0:
scroll_container = QtWidgets.QWidget()
layout = QtWidgets.QVBoxLayout()
layout.setContentsMargins(1, 1, 1, 1)
layout.setSpacing(1)
for brief_dataset in dataset_list:
search_result_widget = SearchResultWidget(
brief_dataset,
self.api_client,
data_source_widget=self,
)
layout.addWidget(search_result_widget)
layout.setAlignment(search_result_widget, QtCore.Qt.AlignTop)
scroll_container.setLayout(layout)
self.scroll_area.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOn)
self.scroll_area.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
self.scroll_area.setWidgetResizable(True)
self.scroll_area.setWidget(scroll_container)
self.message_bar.clearWidgets()
self.search_finished.emit("")
def handle_pagination(
self,
pagination_info: models.GeonodePaginationInfo,
):
self.current_page = pagination_info.current_page
self.total_pages = pagination_info.total_pages
if pagination_info.total_records > 0:
self.pagination_info_la.setText(
tr(
f"Showing page {self.current_page} of "
f"{pagination_info.total_pages} ({pagination_info.total_records} "
f"results)"
)
)
else:
self.pagination_info_la.setText(tr("No results found"))
def clear_search_results(self):
self.scroll_area.setWidget(QtWidgets.QWidget())
self.pagination_info_la.clear()
def _load_categories(self):
self.category_cmb.addItem("", "")
items_to_add = []
for name, member in IsoTopicCategory.__members__.items():
items_to_add.append((tr(member.value), name))
items_to_add.sort()
for display_name, data_ in items_to_add:
self.category_cmb.addItem(display_name, data_)
def _load_sorting_fields(self):
self.sort_field_cmb.clear()
for ordering_type, description in self.api_client.get_ordering_fields():
self.sort_field_cmb.addItem(tr(description), ordering_type)
def restore_search_filters(self):
current_search_filters = conf.settings_manager.get_current_search_filters()
self.keyword_le.setText(current_search_filters.keyword or "")
self.title_le.setText(current_search_filters.title or "")
self.abstract_le.setText(current_search_filters.abstract or "")
if current_search_filters.topic_category is not None:
index = self.category_cmb.findData(
current_search_filters.topic_category.name
)
self.category_cmb.setCurrentIndex(index)
if current_search_filters.temporal_extent_start is not None:
self.temporal_extent_start_dte.setDateTime(
current_search_filters.temporal_extent_start
)
if current_search_filters.temporal_extent_end is not None:
self.temporal_extent_end_dte.setDateTime(
current_search_filters.temporal_extent_end
)
if current_search_filters.publication_date_start is not None:
self.publication_start_dte.setDateTime(
current_search_filters.publication_date_start
)
if current_search_filters.publication_date_end is not None:
self.publication_end_dte.setDateTime(
current_search_filters.publication_date_end
)
if current_search_filters.spatial_extent is not None:
self.spatial_extent_box.setOutputExtentFromUser(
current_search_filters.spatial_extent,
qgis.core.QgsCoordinateReferenceSystem("EPSG:4326"),
)
self.vector_chb.setChecked(
(
models.GeonodeResourceType.VECTOR_LAYER
in current_search_filters.layer_types
)
)
self.raster_chb.setChecked(
(
models.GeonodeResourceType.RASTER_LAYER
in current_search_filters.layer_types
)
)
# trigger actions when resource types buttons have been toggled
self.resource_types_btngrp.buttonClicked.emit(None)
sort_index = self.sort_field_cmb.findData(current_search_filters.ordering_field)
self.sort_field_cmb.setCurrentIndex(sort_index)
self.reverse_order_chb.setChecked(current_search_filters.reverse_ordering)
def store_search_filters(self):
"""Store current search filters in the QGIS Settings."""
current_search_params = self.get_search_filters()
conf.settings_manager.store_current_search_filters(current_search_params)
def get_search_filters(self) -> base.GeonodeApiSearchFilters:
resource_types = []
if self.vector_chb.isChecked():
resource_types.append(models.GeonodeResourceType.VECTOR_LAYER)
if self.raster_chb.isChecked():
resource_types.append(models.GeonodeResourceType.RASTER_LAYER)
temp_ex_start = self.temporal_extent_start_dte.dateTime()
temp_ex_end = self.temporal_extent_end_dte.dateTime()
pub_start = self.publication_start_dte.dateTime()
pub_end = self.publication_end_dte.dateTime()
try:
current_raw_category = self.category_cmb.currentData()
category = IsoTopicCategory[current_raw_category]
except KeyError:
category = None
result = models.GeonodeApiSearchFilters(
page=self.current_page,
title=self.title_le.text() or None,
abstract=self.abstract_le.text() or None,
keyword=self.keyword_le.text() or None,
topic_category=category,
layer_types=resource_types,
ordering_field=self.sort_field_cmb.currentData(QtCore.Qt.UserRole),
reverse_ordering=self.reverse_order_chb.isChecked(),
temporal_extent_start=temp_ex_start if not temp_ex_start.isNull() else None,
temporal_extent_end=temp_ex_end if not temp_ex_end.isNull() else None,
publication_date_start=pub_start if not pub_start.isNull() else None,
publication_date_end=pub_end if not pub_end.isNull() else None,
spatial_extent=self.spatial_extent_box.outputExtent(),
)
return result
PK x,T : qgis_geonode/gui/geonode_maplayer_config_widget_factory.pyfrom 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")
PK x,TK % qgis_geonode/gui/connection_dialog.pyimport os
import re
import typing
import uuid
import qgis.core
from qgis.gui import QgsMessageBar
from qgis.PyQt import (
QtWidgets,
QtCore,
QtGui,
)
from qgis.PyQt.uic import loadUiType
from .. import apiclient, network, utils
from ..apiclient.base import BaseGeonodeClient
from ..conf import (
ConnectionSettings,
settings_manager,
)
from ..utils import tr
from ..vendor.packaging import version as packaging_version
DialogUi, _ = loadUiType(
os.path.join(os.path.dirname(__file__), "../ui/connection_dialog.ui")
)
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
detected_version_gb: qgis.gui.QgsCollapsibleGroupBox
detected_version_le: QtWidgets.QLineEdit
detected_capabilities_lw: QtWidgets.QListWidget
api_client_class_le: QtWidgets.QLineEdit
connection_id: uuid.UUID
remote_geonode_version: typing.Optional[
typing.Union[packaging_version.Version, str]
]
discovery_task: typing.Optional[network.NetworkRequestTask]
geonode_client: BaseGeonodeClient = None
def __init__(self, connection_settings: typing.Optional[ConnectionSettings] = None):
super().__init__()
self.setupUi(self)
self._widgets_to_toggle_during_connection_test = [
self.test_connection_pb,
self.buttonBox,
self.authcfg_acs,
self.options_gb,
self.connection_details,
self.detected_version_gb,
]
self.bar = QgsMessageBar()
self.bar.setSizePolicy(
QtWidgets.QSizePolicy.Minimum, QtWidgets.QSizePolicy.Fixed
)
self.layout().insertWidget(0, self.bar, alignment=QtCore.Qt.AlignTop)
self.discovery_task = None
if connection_settings is not None:
self.connection_id = connection_settings.id
self.remote_geonode_version = connection_settings.geonode_version
self.name_le.setText(connection_settings.name)
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.remote_geonode_version == network.UNSUPPORTED_REMOTE:
self.show_progress(
tr("Invalid configuration. Correct GeoNode URL and/or test again."),
message_level=qgis.core.Qgis.Critical,
)
else:
self.connection_id = uuid.uuid4()
self.remote_geonode_version = None
self.update_connection_details()
self.buttonBox.button(QtWidgets.QDialogButtonBox.Ok).setEnabled(False)
ok_signals = [
self.name_le.textChanged,
self.url_le.textChanged,
]
for signal in ok_signals:
signal.connect(self.update_ok_buttons)
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(
QtGui.QRegExpValidator(QtCore.QRegExp("[^\\/]+"), self.name_le)
)
self.update_ok_buttons()
def get_connection_settings(self) -> ConnectionSettings:
return ConnectionSettings(
id=self.connection_id,
name=self.name_le.text().strip(),
base_url=self.url_le.text().strip().rstrip("/"),
auth_config=self.authcfg_acs.configId(),
page_size=self.page_size_sb.value(),
geonode_version=self.remote_geonode_version,
)
def test_connection(self):
for widget in self._widgets_to_toggle_during_connection_test:
widget.setEnabled(False)
current_settings = self.get_connection_settings()
self.discovery_task = network.NetworkRequestTask(
[
network.RequestToPerform(
QtCore.QUrl(f"{current_settings.base_url}/version.txt")
)
],
network_task_timeout=current_settings.network_requests_timeout,
authcfg=current_settings.auth_config,
description="Test GeoNode connection",
)
self.discovery_task.task_done.connect(self.handle_discovery_test)
self.show_progress(tr("Testing connection..."), include_progress_bar=True)
qgis.core.QgsApplication.taskManager().addTask(self.discovery_task)
def handle_discovery_test(self, task_result: bool):
self.enable_post_test_connection_buttons()
geonode_version = network.handle_discovery_test(
task_result, self.discovery_task
)
if geonode_version is not None:
self.remote_geonode_version = geonode_version
message = "Connection is valid"
level = qgis.core.Qgis.Info
else:
message = "Connection is not valid"
level = qgis.core.Qgis.Critical
self.remote_geonode_version = network.UNSUPPORTED_REMOTE
utils.show_message(self.bar, message, level)
self.update_connection_details()
def update_connection_details(self):
invalid_version = (
self.remote_geonode_version is None
or self.remote_geonode_version == network.UNSUPPORTED_REMOTE
)
self.detected_capabilities_lw.clear()
self.api_client_class_le.clear()
self.detected_version_le.clear()
if not invalid_version:
self.detected_version_gb.setEnabled(True)
current_settings = self.get_connection_settings()
client: BaseGeonodeClient = apiclient.get_geonode_client(current_settings)
self.detected_version_le.setText(str(current_settings.geonode_version))
self.api_client_class_le.setText(client.__class__.__name__)
self.detected_capabilities_lw.insertItems(
0, [cap.name for cap in client.capabilities]
)
else:
self.detected_version_gb.setEnabled(False)
def enable_post_test_connection_buttons(self):
for widget in self._widgets_to_toggle_during_connection_test:
try:
widget.setEnabled(True)
except RuntimeError:
pass
self.update_ok_buttons()
def accept(self):
connection_settings = self.get_connection_settings()
name_pattern = re.compile(
f"^{connection_settings.name}$|^{connection_settings.name}(\(\d+\))$"
)
duplicate_names = []
for connection_conf in settings_manager.list_connections():
if connection_conf.id == connection_settings.id:
continue # we don't want to compare against ourselves
if name_pattern.search(connection_conf.name) is not None:
duplicate_names.append(connection_conf.name)
if len(duplicate_names) > 0:
connection_settings.name = (
f"{connection_settings.name}({len(duplicate_names)})"
)
settings_manager.save_connection_settings(connection_settings)
settings_manager.set_current_connection(connection_settings.id)
super().accept()
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_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
)
PK x,Tpum= = ( qgis_geonode/gui/search_result_widget.pyimport os
import typing
import urllib.parse
from functools import partial
from qgis.PyQt import (
QtCore,
QtGui,
QtWidgets,
)
from qgis.PyQt.uic import loadUiType
import qgis.core
import qgis.gui
from ..apiclient import (
base,
models,
)
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
WidgetUi, _ = loadUiType(
os.path.join(os.path.dirname(__file__), "../ui/search_result_widget.ui")
)
class SearchResultWidget(QtWidgets.QWidget, WidgetUi):
action_buttons_layout: QtWidgets.QHBoxLayout
browser_btn: QtWidgets.QPushButton
description_la: QtWidgets.QLabel
title_la: QtWidgets.QLabel
resource_type_icon_la: QtWidgets.QLabel
resource_type_la: QtWidgets.QLabel
thumbnail_la: QtWidgets.QLabel
dataset_loader_task: typing.Optional[qgis.core.QgsTask]
# thumbnail_fetcher_task fetches the thumbnail over the network
# thumbnail_loader_task then loads the thumbnail
thumbnail_fetcher_task: typing.Optional[network.NetworkRequestTask]
thumbnail_loader_task: typing.Optional[qgis.core.QgsTask]
load_layer_started = QtCore.pyqtSignal()
load_layer_ended = QtCore.pyqtSignal()
api_client: base.BaseGeonodeClient
brief_dataset: models.BriefDataset
layer: typing.Optional["QgsMapLayer"]
data_source_widget: "GeonodeDataSourceWidget"
def __init__(
self,
brief_dataset: models.BriefDataset,
api_client: base.BaseGeonodeClient,
data_source_widget: "GeonodeDataSourceWidget",
parent=None,
):
super().__init__(parent)
self.setupUi(self)
self.project = qgis.core.QgsProject.instance()
self.data_source_widget = data_source_widget
self.thumbnail_loader_task = None
self.thumbnail_fetcher_task = None
self.dataset_loader_task = None
self.layer = None
self.brief_dataset = brief_dataset
self.api_client = api_client
self._initialize_ui()
self.toggle_service_url_buttons(True)
self.load_thumbnail()
def _add_loadable_button(self, geonode_service: models.GeonodeService):
url = self.brief_dataset.service_urls.get(geonode_service)
if url is not None:
icon = QtGui.QIcon(
f":/plugins/qgis_geonode/icon_{geonode_service.value}.svg"
)
button = QtWidgets.QPushButton()
button.setObjectName(f"{geonode_service.value.lower()}_btn")
button.setIcon(icon)
button.setToolTip(tr(f"Load layer via {geonode_service.value}"))
button.clicked.connect(partial(self.load_dataset, geonode_service))
order = 1 if geonode_service == models.GeonodeService.OGC_WMS else 2
self.action_buttons_layout.insertWidget(order, button)
def _initialize_ui_for_vector_dataset(self):
self.resource_type_icon_la.setPixmap(
QtGui.QPixmap(":/images/themes/default/mIconVector.svg")
)
able_to_load_wms = (
ApiClientCapability.LOAD_VECTOR_DATASET_VIA_WMS
in self.api_client.capabilities
)
if able_to_load_wms:
self._add_loadable_button(models.GeonodeService.OGC_WMS)
able_to_load_wfs = (
ApiClientCapability.LOAD_VECTOR_DATASET_VIA_WFS
in self.api_client.capabilities
)
if able_to_load_wfs:
self._add_loadable_button(models.GeonodeService.OGC_WFS)
def _initialize_ui_for_raster_dataset(self):
self.resource_type_icon_la.setPixmap(
QtGui.QPixmap(":/images/themes/default/mIconRaster.svg")
)
able_to_load_wms = (
ApiClientCapability.LOAD_RASTER_DATASET_VIA_WMS
in self.api_client.capabilities
)
if able_to_load_wms:
self._add_loadable_button(models.GeonodeService.OGC_WMS)
able_to_load_wcs = (
ApiClientCapability.LOAD_RASTER_DATASET_VIA_WCS
in self.api_client.capabilities
)
if able_to_load_wcs:
self._add_loadable_button(models.GeonodeService.OGC_WCS)
def _initialize_ui(self):
self.title_la.setText(f"
{self.brief_dataset.title}
")
self.resource_type_la.setText(self.brief_dataset.dataset_sub_type.value)
self.description_la.setText(self.brief_dataset.abstract)
if self.brief_dataset.detail_url:
self.browser_btn.setIcon(
QtGui.QIcon(":/plugins/qgis_geonode/mIconGeonode.svg")
)
self.browser_btn.clicked.connect(self.open_resource_page)
else:
self.browser_btn.setEnabled(False)
if (
self.brief_dataset.dataset_sub_type
== models.GeonodeResourceType.VECTOR_LAYER
):
self._initialize_ui_for_vector_dataset()
elif (
self.brief_dataset.dataset_sub_type
== models.GeonodeResourceType.RASTER_LAYER
):
self._initialize_ui_for_raster_dataset()
else:
pass
def open_resource_page(self):
QtGui.QDesktopServices.openUrl(QtCore.QUrl(self.brief_dataset.detail_url))
def toggle_service_url_buttons(self, enabled: bool):
for index in range(self.action_buttons_layout.count()):
widget = self.action_buttons_layout.itemAt(index).widget()
if widget is not None:
widget.setEnabled(enabled)
def load_thumbnail(self):
"""Fetch the thumbnail from its remote URL and load it"""
self.thumbnail_fetcher_task = network.NetworkRequestTask(
[
network.RequestToPerform(
url=QtCore.QUrl(self.brief_dataset.thumbnail_url)
)
],
self.api_client.network_requests_timeout,
self.api_client.auth_config,
description=f"Get thumbnail for {self.brief_dataset.title!r}",
)
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):
if fetch_result:
data_ = self.thumbnail_fetcher_task.response_contents[0].response_body
self.thumbnail_loader_task = ThumbnailLoaderTask(
data_, self.thumbnail_la, self.brief_dataset.title
)
qgis.core.QgsApplication.taskManager().addTask(self.thumbnail_loader_task)
else:
log(f"Could not fetch thumbnail")
def handle_dataset_load_start(self):
self.data_source_widget.toggle_search_controls(False)
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):
self.data_source_widget.toggle_search_controls(True)
self.data_source_widget.toggle_search_buttons()
self.toggle_service_url_buttons(True)
if clear_message_bar:
self.data_source_widget.message_bar.clearWidgets()
def load_dataset(self, service_type: models.GeonodeService):
self.handle_dataset_load_start()
self.dataset_loader_task = LayerLoaderTask(
self.brief_dataset,
service_type,
api_client=self.api_client,
)
self.dataset_loader_task.taskCompleted.connect(self.prepare_loaded_layer)
self.dataset_loader_task.taskTerminated.connect(self.handle_loading_error)
qgis.core.QgsApplication.taskManager().addTask(self.dataset_loader_task)
def prepare_loaded_layer(self):
if self.dataset_loader_task._exception is not None:
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.dataset_detail_error_received.connect(self.handle_loading_error)
self.api_client.get_dataset_detail(
self.brief_dataset, get_style_too=self.layer.dataProvider().name() != "wms"
)
def handle_layer_detail(self, dataset: typing.Optional[models.Dataset]):
self.api_client.dataset_detail_received.disconnect(self.handle_layer_detail)
self.layer.setCustomProperty(
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)
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.dataset_detail_error_received.disconnect(
self.handle_loading_error
)
self.project.addMapLayer(self.layer)
self.handle_layer_load_end()
class ThumbnailLoaderTask(qgis.core.QgsTask):
def __init__(
self,
raw_thumbnail: QtCore.QByteArray,
label: QtWidgets.QLabel,
resource_title: str,
):
"""Load thumbnail data
This task reads the thumbnail gotten over the network into a QImage object in a
separate thread and then loads it up onto the main GUI in its `finished()`
method - `finished` runs in the main thread. This is done because this plugin's
GUI wants to load multiple thumbnails concurrently. If we were to read the raw
thumbnail bytes into a pixmap in the main thread it would not be possible to
load them in parallel because QPixmap does blocking IO.
"""
super().__init__()
self.raw_thumbnail = raw_thumbnail
self.label = label
self.resource_title = resource_title
self.thumbnail_image = None
self.exception = None
def run(self):
self.thumbnail_image = QtGui.QImage.fromData(self.raw_thumbnail)
return True
def finished(self, result: bool):
if result:
thumbnail = QtGui.QPixmap.fromImage(self.thumbnail_image)
self.label.setPixmap(thumbnail)
else:
log(f"Error retrieving thumbnail for {self.resource_title!r}")
class LayerLoaderTask(qgis.core.QgsTask):
brief_dataset: models.BriefDataset
brief_resource: models.BriefDataset
service_type: models.GeonodeService
api_client: base.BaseGeonodeClient
layer: typing.Optional["QgsMapLayer"]
_exception: typing.Optional[str]
def __init__(
self,
brief_dataset: models.BriefDataset,
service_type: models.GeonodeService,
api_client: base.BaseGeonodeClient,
):
"""Load a QGIS layer
This is done in a QgsTask in order to allow the loading of a layer from the
network to be done in a background thread and not block the main QGIS UI.
"""
super().__init__()
self.brief_dataset = brief_dataset
self.service_type = service_type
self.api_client = api_client
self.layer = None
self._exception = None
def run(self):
if self.service_type == models.GeonodeService.OGC_WMS:
layer = self._load_wms()
elif self.service_type == models.GeonodeService.OGC_WFS:
layer = self._load_wfs()
elif self.service_type == models.GeonodeService.OGC_WCS:
layer = self._load_wcs()
else:
layer = None
self._exception = f"Unrecognized layer type: {self.service_type!r}"
result = False
if layer is not None and layer.isValid():
self.layer = layer
result = True
else:
layer_error_message_list = layer.error().messageList()
layer_error = ", ".join(err.message() for err in layer_error_message_list)
self._exception = layer_error
log(f"layer errors: {layer_error}")
provider_error_message_list = layer.dataProvider().error().messageList()
log(
f"provider errors: "
f"{', '.join([err.message() for err in provider_error_message_list])}"
)
return result
def finished(self, result: bool):
if result:
# Cloning the layer seems to be required in order to make sure the WMS
# layers work appropriately - Otherwise we get random crashes when loading
# WMS layers. This may be related to how the layer is moved from the
# secondary thread by QgsTaskManager and the layer's ownership.
self.layer = self.layer.clone()
else:
message = (
f"Error loading layer {self.brief_dataset.title!r} from "
f"{self.brief_dataset.service_urls[self.service_type]!r}: "
f"{self._exception}"
)
log(message)
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": "",
"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,
"wms",
)
def _load_wcs(self) -> qgis.core.QgsMapLayer:
params = {
"url": self.brief_dataset.service_urls[self.service_type],
"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,
"wcs",
)
def _load_wfs(self) -> qgis.core.QgsMapLayer:
params = {
"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",
}
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")
PK ,Tza qgis_geonode/resources.py# -*- coding: utf-8 -*-
# Resource object code
#
# Created by: The Resource Compiler for PyQt5 (Qt v5.12.8)
#
# WARNING! All changes made in this file will be lost!
from PyQt5 import QtCore
qt_resource_data = b"\
\x00\x00\x06\xd3\
\x3c\
\x73\x76\x67\x20\x68\x65\x69\x67\x68\x74\x3d\x22\x32\x34\x22\x20\
\x77\x69\x64\x74\x68\x3d\x22\x32\x34\x22\x20\x78\x6d\x6c\x6e\x73\
\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x77\x77\x77\x2e\x77\x33\x2e\
\x6f\x72\x67\x2f\x32\x30\x30\x30\x2f\x73\x76\x67\x22\x3e\x3c\x70\
\x61\x74\x68\x20\x64\x3d\x22\x6d\x31\x32\x20\x2e\x37\x38\x34\x63\
\x37\x2e\x31\x31\x36\x20\x33\x2e\x35\x33\x33\x20\x36\x2e\x31\x20\
\x31\x36\x2e\x37\x37\x38\x20\x30\x20\x31\x39\x2e\x34\x32\x37\x6d\
\x2d\x31\x31\x2e\x31\x38\x32\x2d\x39\x2e\x37\x31\x33\x68\x32\x32\
\x2e\x33\x36\x34\x6d\x2d\x32\x30\x2e\x39\x39\x32\x2d\x35\x2e\x33\
\x31\x32\x63\x35\x2e\x36\x39\x33\x2e\x37\x36\x20\x31\x33\x2e\x38\
\x32\x35\x2e\x37\x36\x20\x31\x39\x2e\x35\x31\x38\x20\x30\x6d\x2d\
\x31\x39\x2e\x35\x31\x38\x20\x31\x30\x2e\x36\x32\x33\x63\x35\x2e\
\x36\x39\x33\x2d\x2e\x37\x35\x39\x20\x31\x33\x2e\x38\x32\x35\x2d\
\x2e\x37\x35\x39\x20\x31\x39\x2e\x35\x31\x38\x20\x30\x6d\x2d\x39\
\x2e\x37\x30\x38\x2d\x31\x35\x2e\x30\x32\x35\x63\x2d\x36\x2e\x31\
\x20\x32\x2e\x36\x35\x2d\x37\x2e\x31\x31\x36\x20\x31\x35\x2e\x38\
\x39\x35\x20\x30\x20\x31\x39\x2e\x34\x32\x37\x22\x20\x66\x69\x6c\
\x6c\x3d\x22\x6e\x6f\x6e\x65\x22\x20\x73\x74\x72\x6f\x6b\x65\x3d\
\x22\x23\x37\x34\x39\x66\x63\x66\x22\x20\x73\x74\x72\x6f\x6b\x65\
\x2d\x77\x69\x64\x74\x68\x3d\x22\x2e\x37\x35\x31\x36\x32\x22\x2f\
\x3e\x3c\x70\x61\x74\x68\x20\x64\x3d\x22\x6d\x36\x2e\x39\x36\x36\
\x20\x31\x33\x2e\x38\x31\x63\x30\x20\x36\x2e\x37\x31\x33\x2d\x35\
\x2e\x34\x32\x33\x20\x31\x32\x2e\x31\x35\x36\x2d\x31\x32\x2e\x31\
\x31\x33\x20\x31\x32\x2e\x31\x35\x36\x73\x2d\x31\x32\x2e\x31\x31\
\x32\x2d\x35\x2e\x34\x34\x33\x2d\x31\x32\x2e\x31\x31\x32\x2d\x31\
\x32\x2e\x31\x35\x37\x20\x35\x2e\x34\x32\x33\x2d\x31\x32\x2e\x31\
\x35\x36\x20\x31\x32\x2e\x31\x31\x32\x2d\x31\x32\x2e\x31\x35\x36\
\x63\x36\x2e\x36\x39\x20\x30\x20\x31\x32\x2e\x31\x31\x33\x20\x35\
\x2e\x34\x34\x32\x20\x31\x32\x2e\x31\x31\x33\x20\x31\x32\x2e\x31\
\x35\x36\x7a\x22\x20\x66\x69\x6c\x6c\x3d\x22\x6e\x6f\x6e\x65\x22\
\x20\x73\x74\x72\x6f\x6b\x65\x3d\x22\x23\x35\x34\x38\x38\x63\x34\
\x22\x20\x73\x74\x72\x6f\x6b\x65\x2d\x77\x69\x64\x74\x68\x3d\x22\
\x31\x2e\x32\x38\x34\x22\x20\x74\x72\x61\x6e\x73\x66\x6f\x72\x6d\
\x3d\x22\x6d\x61\x74\x72\x69\x78\x28\x2e\x39\x34\x34\x31\x39\x20\
\x30\x20\x30\x20\x2e\x38\x31\x37\x31\x36\x20\x31\x36\x2e\x38\x36\
\x20\x2d\x2e\x37\x38\x37\x29\x22\x2f\x3e\x3c\x67\x20\x73\x74\x72\
\x6f\x6b\x65\x3d\x22\x23\x32\x62\x33\x62\x34\x64\x22\x20\x73\x74\
\x72\x6f\x6b\x65\x2d\x6c\x69\x6e\x65\x63\x61\x70\x3d\x22\x72\x6f\
\x75\x6e\x64\x22\x20\x73\x74\x72\x6f\x6b\x65\x2d\x6c\x69\x6e\x65\
\x6a\x6f\x69\x6e\x3d\x22\x72\x6f\x75\x6e\x64\x22\x3e\x3c\x70\x61\
\x74\x68\x20\x64\x3d\x22\x6d\x35\x2e\x34\x39\x31\x20\x35\x2e\x34\
\x34\x35\x20\x34\x2e\x33\x39\x34\x20\x31\x31\x2e\x30\x38\x35\x20\
\x34\x2e\x38\x33\x31\x2d\x31\x30\x2e\x39\x38\x32\x20\x33\x2e\x39\
\x37\x33\x2d\x2e\x30\x31\x36\x22\x20\x66\x69\x6c\x6c\x3d\x22\x6e\
\x6f\x6e\x65\x22\x20\x73\x74\x72\x6f\x6b\x65\x2d\x77\x69\x64\x74\
\x68\x3d\x22\x2e\x39\x37\x35\x36\x30\x33\x22\x2f\x3e\x3c\x70\x61\
\x74\x68\x20\x64\x3d\x22\x6d\x34\x2e\x35\x20\x31\x32\x2e\x35\x61\
\x31\x20\x31\x20\x30\x20\x31\x20\x31\x20\x2d\x32\x20\x30\x20\x31\
\x20\x31\x20\x30\x20\x30\x20\x31\x20\x32\x20\x30\x7a\x22\x20\x66\
\x69\x6c\x6c\x3d\x22\x23\x66\x66\x66\x22\x20\x66\x69\x6c\x6c\x2d\
\x72\x75\x6c\x65\x3d\x22\x65\x76\x65\x6e\x6f\x64\x64\x22\x20\x73\
\x74\x72\x6f\x6b\x65\x2d\x77\x69\x64\x74\x68\x3d\x22\x2e\x39\x34\
\x37\x22\x20\x74\x72\x61\x6e\x73\x66\x6f\x72\x6d\x3d\x22\x6d\x61\
\x74\x72\x69\x78\x28\x31\x2e\x31\x36\x33\x31\x34\x20\x30\x20\x30\
\x20\x31\x2e\x32\x31\x38\x35\x39\x20\x31\x2e\x34\x32\x20\x2d\x39\
\x2e\x37\x38\x38\x29\x22\x2f\x3e\x3c\x70\x61\x74\x68\x20\x64\x3d\
\x22\x6d\x34\x2e\x35\x20\x31\x32\x2e\x35\x61\x31\x20\x31\x20\x30\
\x20\x31\x20\x31\x20\x2d\x32\x20\x30\x20\x31\x20\x31\x20\x30\x20\
\x30\x20\x31\x20\x32\x20\x30\x7a\x22\x20\x66\x69\x6c\x6c\x3d\x22\
\x23\x66\x66\x66\x22\x20\x66\x69\x6c\x6c\x2d\x72\x75\x6c\x65\x3d\
\x22\x65\x76\x65\x6e\x6f\x64\x64\x22\x20\x73\x74\x72\x6f\x6b\x65\
\x2d\x77\x69\x64\x74\x68\x3d\x22\x2e\x39\x33\x36\x22\x20\x74\x72\
\x61\x6e\x73\x66\x6f\x72\x6d\x3d\x22\x6d\x61\x74\x72\x69\x78\x28\
\x31\x2e\x31\x39\x30\x35\x31\x20\x30\x20\x30\x20\x31\x2e\x32\x31\
\x38\x35\x39\x20\x35\x2e\x37\x31\x38\x20\x31\x2e\x32\x39\x38\x29\
\x22\x2f\x3e\x3c\x70\x61\x74\x68\x20\x64\x3d\x22\x6d\x34\x2e\x35\
\x20\x31\x32\x2e\x35\x61\x31\x20\x31\x20\x30\x20\x31\x20\x31\x20\
\x2d\x32\x20\x30\x20\x31\x20\x31\x20\x30\x20\x30\x20\x31\x20\x32\
\x20\x30\x7a\x22\x20\x66\x69\x6c\x6c\x3d\x22\x23\x66\x66\x66\x22\
\x20\x66\x69\x6c\x6c\x2d\x72\x75\x6c\x65\x3d\x22\x65\x76\x65\x6e\
\x6f\x64\x64\x22\x20\x73\x74\x72\x6f\x6b\x65\x2d\x77\x69\x64\x74\
\x68\x3d\x22\x2e\x39\x34\x37\x22\x20\x74\x72\x61\x6e\x73\x66\x6f\
\x72\x6d\x3d\x22\x6d\x61\x74\x72\x69\x78\x28\x31\x2e\x31\x36\x33\
\x31\x34\x20\x30\x20\x30\x20\x31\x2e\x32\x31\x38\x35\x39\x20\x31\
\x30\x2e\x35\x32\x32\x20\x2d\x39\x2e\x37\x31\x29\x22\x2f\x3e\x3c\
\x70\x61\x74\x68\x20\x64\x3d\x22\x6d\x34\x2e\x35\x20\x31\x32\x2e\
\x35\x61\x31\x20\x31\x20\x30\x20\x31\x20\x31\x20\x2d\x32\x20\x30\
\x20\x31\x20\x31\x20\x30\x20\x30\x20\x31\x20\x32\x20\x30\x7a\x22\
\x20\x66\x69\x6c\x6c\x3d\x22\x23\x66\x66\x66\x22\x20\x66\x69\x6c\
\x6c\x2d\x72\x75\x6c\x65\x3d\x22\x65\x76\x65\x6e\x6f\x64\x64\x22\
\x20\x73\x74\x72\x6f\x6b\x65\x2d\x77\x69\x64\x74\x68\x3d\x22\x2e\
\x39\x34\x37\x22\x20\x74\x72\x61\x6e\x73\x66\x6f\x72\x6d\x3d\x22\
\x6d\x61\x74\x72\x69\x78\x28\x31\x2e\x31\x36\x33\x31\x34\x20\x30\
\x20\x30\x20\x31\x2e\x32\x31\x38\x35\x39\x20\x31\x34\x2e\x36\x31\
\x38\x20\x2d\x39\x2e\x37\x29\x22\x2f\x3e\x3c\x2f\x67\x3e\x3c\x67\
\x20\x74\x72\x61\x6e\x73\x66\x6f\x72\x6d\x3d\x22\x6d\x61\x74\x72\
\x69\x78\x28\x2e\x36\x39\x32\x33\x20\x30\x20\x30\x20\x2e\x36\x39\
\x32\x33\x20\x31\x2e\x38\x34\x36\x20\x31\x2e\x38\x34\x36\x29\x22\
\x3e\x3c\x72\x65\x63\x74\x20\x66\x69\x6c\x6c\x3d\x22\x23\x35\x61\
\x38\x63\x35\x61\x22\x20\x68\x65\x69\x67\x68\x74\x3d\x22\x31\x33\
\x22\x20\x72\x78\x3d\x22\x32\x2e\x36\x31\x35\x22\x20\x77\x69\x64\
\x74\x68\x3d\x22\x31\x33\x22\x20\x78\x3d\x22\x31\x39\x22\x20\x79\
\x3d\x22\x31\x39\x22\x2f\x3e\x3c\x67\x20\x66\x69\x6c\x6c\x2d\x72\
\x75\x6c\x65\x3d\x22\x65\x76\x65\x6e\x6f\x64\x64\x22\x3e\x3c\x70\
\x61\x74\x68\x20\x64\x3d\x22\x6d\x32\x31\x2e\x36\x20\x32\x35\x2e\
\x35\x68\x37\x2e\x38\x6d\x2d\x33\x2e\x39\x20\x33\x2e\x39\x76\x2d\
\x37\x2e\x38\x22\x20\x66\x69\x6c\x6c\x3d\x22\x23\x66\x66\x66\x22\
\x20\x73\x74\x72\x6f\x6b\x65\x3d\x22\x23\x66\x66\x66\x22\x20\x73\
\x74\x72\x6f\x6b\x65\x2d\x6c\x69\x6e\x65\x63\x61\x70\x3d\x22\x72\
\x6f\x75\x6e\x64\x22\x20\x73\x74\x72\x6f\x6b\x65\x2d\x6c\x69\x6e\
\x65\x6a\x6f\x69\x6e\x3d\x22\x72\x6f\x75\x6e\x64\x22\x20\x73\x74\
\x72\x6f\x6b\x65\x2d\x77\x69\x64\x74\x68\x3d\x22\x32\x2e\x36\x22\
\x2f\x3e\x3c\x70\x61\x74\x68\x20\x64\x3d\x22\x6d\x32\x30\x2e\x33\
\x20\x32\x35\x2e\x35\x68\x31\x30\x2e\x34\x76\x2d\x32\x2e\x36\x63\
\x30\x2d\x32\x2e\x36\x2d\x2e\x36\x35\x2d\x32\x2e\x36\x2d\x35\x2e\
\x32\x2d\x32\x2e\x36\x73\x2d\x35\x2e\x32\x20\x30\x2d\x35\x2e\x32\
\x20\x32\x2e\x36\x7a\x22\x20\x66\x69\x6c\x6c\x3d\x22\x23\x66\x63\
\x66\x66\x66\x66\x22\x20\x6f\x70\x61\x63\x69\x74\x79\x3d\x22\x2e\
\x33\x22\x2f\x3e\x3c\x2f\x67\x3e\x3c\x2f\x67\x3e\x3c\x2f\x73\x76\
\x67\x3e\
\x00\x00\x1d\x95\
\x3c\
\x73\x76\x67\x20\x68\x65\x69\x67\x68\x74\x3d\x22\x32\x34\x22\x20\
\x77\x69\x64\x74\x68\x3d\x22\x32\x34\x22\x20\x78\x6d\x6c\x6e\x73\
\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x77\x77\x77\x2e\x77\x33\x2e\
\x6f\x72\x67\x2f\x32\x30\x30\x30\x2f\x73\x76\x67\x22\x3e\x3c\x70\
\x61\x74\x68\x20\x64\x3d\x22\x6d\x32\x33\x2e\x34\x20\x31\x30\x2e\
\x35\x63\x30\x20\x35\x2e\x34\x38\x2d\x35\x2e\x31\x31\x20\x39\x2e\
\x39\x31\x2d\x31\x31\x2e\x34\x20\x39\x2e\x39\x31\x2d\x36\x2e\x33\
\x20\x30\x2d\x31\x31\x2e\x34\x2d\x34\x2e\x34\x34\x2d\x31\x31\x2e\
\x34\x2d\x39\x2e\x39\x31\x20\x30\x2d\x35\x2e\x34\x38\x20\x35\x2e\
\x31\x31\x2d\x39\x2e\x39\x31\x20\x31\x31\x2e\x34\x2d\x39\x2e\x39\
\x31\x20\x36\x2e\x33\x20\x30\x20\x31\x31\x2e\x34\x20\x34\x2e\x34\
\x34\x20\x31\x31\x2e\x34\x20\x39\x2e\x39\x31\x7a\x22\x20\x66\x69\
\x6c\x6c\x3d\x22\x23\x61\x65\x63\x37\x65\x32\x22\x20\x73\x74\x72\
\x6f\x6b\x65\x3d\x22\x23\x32\x65\x34\x65\x37\x32\x22\x20\x73\x74\
\x72\x6f\x6b\x65\x2d\x77\x69\x64\x74\x68\x3d\x22\x31\x2e\x31\x38\
\x22\x2f\x3e\x3c\x67\x20\x73\x74\x72\x6f\x6b\x65\x3d\x22\x23\x66\
\x66\x66\x22\x3e\x3c\x70\x61\x74\x68\x20\x64\x3d\x22\x6d\x37\x2e\
\x39\x38\x20\x32\x2e\x36\x36\x63\x2d\x2e\x32\x37\x32\x2e\x31\x30\
\x31\x2d\x2e\x34\x36\x38\x2e\x32\x39\x32\x2d\x2e\x37\x37\x33\x2e\
\x33\x30\x34\x2d\x2e\x33\x37\x32\x2e\x31\x33\x31\x2e\x30\x34\x33\
\x32\x2e\x33\x35\x38\x2e\x31\x38\x34\x2e\x34\x32\x34\x2e\x32\x35\
\x36\x2d\x2e\x30\x38\x38\x31\x2e\x34\x33\x32\x2d\x2e\x33\x34\x39\
\x2e\x38\x32\x31\x2d\x2e\x34\x30\x39\x2e\x34\x36\x33\x2d\x2e\x31\
\x39\x32\x2e\x31\x32\x33\x2e\x32\x38\x32\x2d\x2e\x31\x36\x39\x2e\
\x31\x36\x38\x2e\x32\x38\x38\x2e\x31\x33\x32\x2e\x35\x38\x34\x2e\
\x34\x30\x32\x2e\x35\x36\x32\x2e\x38\x30\x34\x2e\x30\x39\x37\x31\
\x2e\x33\x34\x32\x2d\x2e\x34\x34\x31\x2e\x31\x30\x39\x2d\x2e\x32\
\x31\x31\x2e\x34\x37\x36\x2d\x2e\x30\x34\x35\x33\x2e\x33\x30\x32\
\x2e\x33\x32\x39\x2e\x39\x34\x34\x2e\x34\x35\x37\x2e\x33\x34\x36\
\x2e\x30\x38\x36\x39\x2d\x2e\x33\x33\x37\x2e\x34\x34\x36\x2d\x2e\
\x34\x33\x38\x2e\x36\x37\x36\x2d\x2e\x36\x32\x33\x2e\x33\x36\x35\
\x2d\x2e\x30\x31\x35\x34\x2e\x35\x39\x2d\x2e\x32\x30\x36\x2e\x35\
\x32\x37\x2d\x2e\x35\x38\x34\x2e\x31\x33\x35\x2d\x2e\x31\x31\x31\
\x2e\x33\x39\x38\x2d\x2e\x31\x33\x32\x2e\x33\x35\x2d\x2e\x34\x31\
\x36\x2e\x33\x38\x31\x2d\x2e\x31\x38\x39\x2e\x30\x38\x38\x36\x2d\
\x2e\x34\x39\x38\x2d\x2e\x32\x31\x35\x2d\x2e\x33\x34\x39\x2d\x2e\
\x34\x32\x32\x2e\x30\x38\x30\x35\x2d\x2e\x38\x32\x35\x2d\x2e\x31\
\x31\x34\x2d\x31\x2e\x32\x31\x2d\x2e\x30\x32\x35\x37\x2d\x2e\x33\
\x33\x31\x2e\x30\x31\x35\x35\x2d\x2e\x36\x35\x31\x2d\x2e\x30\x34\
\x34\x31\x2d\x2e\x39\x37\x32\x2d\x2e\x31\x32\x31\x7a\x6d\x38\x2e\
\x34\x2e\x36\x38\x34\x63\x2d\x2e\x34\x31\x35\x2d\x2e\x30\x34\x39\
\x39\x2d\x2e\x35\x39\x38\x2e\x33\x33\x33\x2d\x31\x2e\x30\x33\x2e\
\x33\x33\x38\x2e\x30\x30\x32\x38\x2e\x31\x31\x35\x2e\x30\x32\x38\
\x33\x2e\x37\x30\x38\x2d\x2e\x31\x34\x37\x2e\x33\x30\x32\x2d\x2e\
\x32\x36\x31\x2d\x2e\x36\x30\x36\x2e\x32\x33\x39\x2e\x35\x32\x38\
\x2d\x2e\x31\x33\x31\x2e\x32\x38\x34\x2e\x31\x36\x33\x2d\x2e\x32\
\x37\x2d\x2e\x33\x35\x34\x2d\x2e\x38\x35\x39\x2d\x2e\x32\x38\x36\
\x2d\x2e\x33\x34\x33\x2e\x33\x38\x32\x2e\x34\x32\x33\x2d\x2e\x32\
\x36\x31\x2e\x33\x31\x35\x2d\x2e\x33\x39\x39\x2e\x31\x34\x2d\x2e\
\x31\x38\x39\x2e\x32\x39\x36\x2d\x2e\x35\x31\x31\x2e\x30\x30\x34\
\x32\x32\x2d\x2e\x37\x34\x39\x2e\x33\x32\x2d\x2e\x31\x35\x39\x2e\
\x31\x37\x38\x2d\x2e\x33\x32\x39\x2e\x35\x31\x35\x2d\x2e\x34\x37\
\x35\x2e\x31\x31\x39\x2d\x2e\x32\x32\x31\x2d\x2e\x33\x30\x39\x2e\
\x35\x34\x33\x2e\x30\x33\x36\x37\x2e\x32\x32\x32\x2d\x2e\x33\x31\
\x38\x2d\x2e\x33\x34\x31\x2d\x2e\x31\x34\x38\x2d\x2e\x38\x38\x34\
\x2d\x2e\x34\x34\x34\x2d\x31\x2e\x31\x35\x2d\x2e\x30\x32\x36\x39\
\x2d\x2e\x32\x35\x31\x2e\x32\x38\x39\x2d\x2e\x34\x37\x31\x2e\x36\
\x33\x34\x2d\x2e\x36\x32\x37\x2e\x38\x33\x35\x2d\x2e\x30\x30\x32\
\x34\x2e\x31\x33\x34\x2d\x2e\x30\x32\x32\x35\x2e\x34\x30\x33\x2e\
\x30\x39\x32\x33\x2e\x31\x36\x31\x2e\x32\x36\x36\x2e\x31\x30\x32\
\x2e\x33\x35\x35\x2e\x37\x36\x35\x2e\x35\x37\x34\x2e\x32\x32\x34\
\x2e\x31\x31\x31\x2d\x2e\x32\x35\x32\x2d\x2e\x31\x30\x34\x2d\x2e\
\x38\x30\x32\x2e\x33\x34\x37\x2d\x2e\x38\x31\x33\x2d\x2e\x30\x36\
\x31\x34\x2e\x32\x33\x31\x2d\x2e\x31\x36\x39\x2e\x36\x31\x34\x2e\
\x32\x32\x31\x2e\x34\x36\x31\x2e\x31\x37\x32\x2e\x33\x32\x34\x2d\
\x2e\x33\x35\x34\x2e\x30\x31\x33\x31\x2d\x2e\x31\x37\x36\x2e\x33\
\x30\x33\x2e\x30\x33\x30\x35\x2e\x32\x35\x33\x2d\x2e\x32\x34\x31\
\x2e\x30\x34\x32\x38\x2d\x2e\x31\x38\x38\x2e\x33\x36\x33\x2d\x2e\
\x31\x36\x37\x2e\x30\x37\x31\x35\x2d\x2e\x33\x34\x32\x2e\x32\x31\
\x2d\x2e\x35\x2e\x30\x34\x33\x34\x2d\x2e\x31\x38\x33\x2e\x30\x38\
\x37\x39\x2d\x2e\x32\x31\x31\x2d\x2e\x30\x37\x31\x39\x2d\x2e\x33\
\x31\x37\x2e\x31\x35\x2d\x2e\x32\x34\x33\x2e\x30\x38\x36\x32\x2d\
\x2e\x32\x35\x36\x2e\x34\x37\x39\x2d\x2e\x35\x37\x34\x2e\x35\x2d\
\x2e\x32\x37\x2e\x30\x30\x32\x38\x38\x2e\x32\x33\x36\x2e\x32\x35\
\x35\x2d\x2e\x31\x34\x38\x2e\x32\x30\x33\x2e\x31\x36\x31\x2e\x31\
\x34\x32\x2e\x32\x38\x34\x2e\x34\x39\x32\x2e\x30\x32\x31\x2e\x35\
\x37\x36\x2d\x2e\x34\x35\x34\x2d\x2e\x33\x38\x35\x2d\x2e\x34\x31\
\x36\x2e\x34\x35\x33\x2d\x2e\x33\x33\x2e\x37\x30\x39\x2e\x34\x30\
\x35\x2e\x32\x36\x37\x2e\x35\x32\x38\x2d\x2e\x32\x34\x2e\x36\x36\
\x37\x2d\x2e\x35\x30\x31\x2e\x31\x30\x33\x2d\x2e\x31\x34\x31\x2e\
\x31\x35\x38\x2d\x2e\x33\x34\x35\x2e\x33\x37\x32\x2d\x2e\x32\x35\
\x39\x2e\x30\x31\x32\x36\x2d\x2e\x34\x31\x2e\x33\x31\x39\x2e\x32\
\x35\x38\x2e\x34\x39\x33\x2e\x33\x33\x33\x2e\x32\x31\x38\x2e\x31\
\x36\x32\x2d\x2e\x32\x38\x36\x2e\x35\x34\x39\x2e\x30\x35\x39\x33\
\x2e\x32\x37\x32\x2e\x30\x34\x33\x37\x2d\x2e\x32\x38\x34\x2e\x32\
\x34\x39\x2d\x2e\x31\x34\x33\x2e\x30\x33\x36\x39\x2d\x2e\x34\x31\
\x37\x2d\x2e\x32\x33\x32\x2d\x2e\x30\x35\x38\x2d\x2e\x34\x33\x33\
\x2d\x2e\x37\x32\x39\x2d\x2e\x30\x39\x36\x32\x2d\x2e\x33\x34\x35\
\x2e\x33\x31\x38\x2e\x32\x33\x39\x2e\x33\x30\x33\x2e\x35\x39\x2e\
\x34\x38\x36\x2e\x39\x33\x34\x2e\x33\x30\x32\x2d\x2e\x30\x35\x30\
\x33\x2d\x2e\x31\x34\x36\x2d\x2e\x35\x32\x39\x2e\x32\x2d\x2e\x34\
\x34\x34\x2e\x32\x35\x31\x2d\x2e\x31\x38\x39\x2d\x2e\x30\x33\x37\
\x34\x2e\x36\x31\x36\x2e\x34\x30\x34\x2e\x35\x31\x2e\x34\x39\x38\
\x2d\x2e\x31\x38\x35\x2e\x34\x31\x31\x2e\x33\x37\x31\x2e\x32\x33\
\x36\x2e\x36\x35\x2d\x2e\x32\x39\x33\x2e\x30\x36\x34\x34\x2d\x2e\
\x37\x33\x38\x2e\x30\x34\x37\x38\x2d\x2e\x39\x30\x37\x2d\x2e\x32\
\x34\x34\x2e\x30\x33\x39\x36\x2e\x35\x33\x38\x2d\x2e\x33\x31\x31\
\x2e\x32\x35\x31\x2d\x2e\x35\x35\x34\x2e\x31\x31\x37\x2d\x2e\x32\
\x39\x32\x2d\x2e\x31\x2d\x2e\x31\x30\x32\x2d\x2e\x34\x36\x36\x2d\
\x2e\x31\x38\x36\x2d\x2e\x36\x31\x35\x2d\x2e\x32\x35\x2e\x31\x30\
\x36\x2d\x2e\x35\x37\x35\x2e\x31\x38\x38\x2d\x2e\x38\x34\x35\x2e\
\x32\x32\x38\x2d\x2e\x31\x31\x36\x2e\x30\x35\x33\x34\x2d\x2e\x32\
\x31\x38\x2e\x30\x32\x31\x33\x2d\x2e\x32\x35\x2e\x30\x30\x33\x32\
\x34\x2d\x2e\x30\x33\x37\x2e\x31\x36\x2d\x2e\x31\x38\x35\x2e\x32\
\x35\x34\x2d\x2e\x32\x35\x32\x2e\x33\x38\x33\x2d\x2e\x30\x31\x32\
\x37\x2e\x34\x30\x38\x2d\x2e\x34\x31\x2e\x36\x36\x39\x2d\x2e\x34\
\x37\x39\x20\x31\x2e\x30\x38\x2d\x2e\x31\x31\x34\x2e\x34\x31\x37\
\x2d\x2e\x30\x35\x30\x35\x2e\x38\x36\x36\x2d\x2e\x30\x39\x38\x31\
\x20\x31\x2e\x32\x38\x2e\x31\x32\x37\x2e\x33\x36\x38\x2e\x32\x36\
\x36\x2e\x38\x37\x33\x2e\x36\x32\x34\x20\x31\x2e\x31\x32\x2e\x32\
\x32\x32\x2d\x2e\x30\x32\x32\x39\x2e\x34\x31\x32\x2d\x2e\x31\x31\
\x36\x2e\x36\x33\x39\x2d\x2e\x32\x31\x31\x2e\x32\x31\x37\x2d\x2e\
\x31\x35\x36\x2e\x33\x33\x35\x2e\x31\x38\x36\x2e\x34\x35\x33\x2e\
\x31\x39\x38\x2e\x32\x34\x34\x2e\x30\x35\x35\x34\x2e\x32\x31\x35\
\x2e\x34\x34\x39\x2e\x31\x35\x33\x2e\x36\x35\x33\x2e\x30\x39\x38\
\x34\x2e\x34\x30\x39\x2e\x33\x34\x33\x2e\x38\x33\x35\x2e\x33\x32\
\x37\x20\x31\x2e\x32\x39\x2e\x30\x35\x33\x38\x2e\x33\x34\x2d\x2e\
\x32\x37\x38\x2e\x36\x36\x32\x2d\x2e\x30\x38\x38\x39\x2e\x39\x39\
\x33\x2e\x32\x32\x38\x2e\x34\x35\x34\x2e\x30\x39\x32\x39\x2e\x39\
\x39\x32\x2e\x33\x34\x34\x20\x31\x2e\x34\x33\x2e\x30\x33\x36\x33\
\x2e\x33\x34\x33\x2e\x31\x31\x31\x2e\x37\x37\x2e\x34\x39\x32\x2e\
\x34\x38\x32\x2e\x32\x36\x31\x2d\x2e\x31\x34\x35\x2e\x34\x32\x34\
\x2d\x2e\x34\x38\x32\x2e\x35\x34\x39\x2d\x2e\x37\x35\x32\x2e\x31\
\x39\x32\x2d\x2e\x33\x36\x32\x2e\x32\x37\x32\x2d\x2e\x36\x31\x38\
\x2e\x32\x34\x39\x2d\x31\x20\x2e\x31\x39\x39\x2d\x2e\x33\x37\x2e\
\x35\x31\x39\x2d\x2e\x36\x38\x34\x2e\x33\x37\x32\x2d\x31\x2e\x31\
\x35\x2d\x2e\x31\x34\x31\x2d\x2e\x34\x33\x31\x2d\x2e\x31\x30\x31\
\x2d\x2e\x38\x36\x39\x2e\x31\x37\x36\x2d\x31\x2e\x32\x34\x2e\x31\
\x37\x39\x2d\x2e\x34\x31\x35\x2e\x34\x37\x33\x2d\x2e\x37\x38\x32\
\x2e\x35\x36\x37\x2d\x31\x2e\x32\x33\x2e\x32\x30\x39\x2d\x2e\x34\
\x37\x36\x2d\x2e\x32\x36\x38\x2d\x2e\x31\x37\x35\x2d\x2e\x35\x34\
\x39\x2d\x2e\x32\x30\x35\x2d\x2e\x30\x32\x39\x35\x2d\x2e\x34\x37\
\x39\x2d\x2e\x34\x30\x36\x2d\x2e\x38\x32\x38\x2d\x2e\x34\x38\x37\
\x2d\x31\x2e\x32\x39\x2d\x2e\x31\x2d\x2e\x33\x35\x36\x2d\x2e\x31\
\x39\x2d\x2e\x37\x31\x35\x2d\x2e\x33\x30\x38\x2d\x31\x2e\x30\x37\
\x2e\x33\x35\x34\x2e\x31\x39\x32\x2e\x33\x39\x38\x2e\x37\x33\x35\
\x2e\x35\x37\x34\x20\x31\x2e\x31\x32\x2e\x32\x37\x38\x2e\x32\x31\
\x36\x2e\x30\x36\x34\x36\x2e\x39\x36\x34\x2e\x34\x37\x36\x2e\x38\
\x39\x31\x2e\x32\x39\x32\x2d\x2e\x32\x34\x33\x2e\x36\x36\x36\x2d\
\x2e\x34\x34\x33\x2e\x37\x36\x36\x2d\x2e\x38\x34\x39\x2e\x33\x33\
\x38\x2d\x2e\x32\x38\x33\x2d\x2e\x31\x36\x32\x2d\x2e\x34\x39\x36\
\x2d\x2e\x31\x33\x2d\x2e\x38\x30\x37\x2d\x2e\x30\x38\x31\x32\x2e\
\x33\x36\x31\x2d\x2e\x34\x39\x32\x2e\x34\x30\x33\x2d\x2e\x34\x38\
\x32\x2d\x2e\x30\x31\x39\x36\x2d\x2e\x30\x35\x39\x31\x2d\x2e\x31\
\x37\x38\x2d\x2e\x31\x36\x33\x2d\x2e\x37\x38\x32\x2e\x30\x35\x34\
\x34\x2d\x2e\x33\x32\x38\x2e\x31\x39\x35\x2e\x32\x35\x37\x2e\x35\
\x30\x35\x2e\x32\x31\x36\x2e\x36\x38\x39\x2e\x34\x36\x31\x2e\x32\
\x33\x35\x2e\x30\x31\x33\x37\x2e\x35\x35\x2d\x2e\x30\x32\x39\x34\
\x2e\x36\x32\x37\x2e\x32\x36\x38\x2e\x31\x33\x32\x2e\x31\x31\x33\
\x2e\x31\x38\x33\x2e\x34\x36\x2e\x33\x37\x32\x2e\x32\x31\x35\x2e\
\x30\x32\x34\x36\x2e\x34\x31\x32\x2e\x31\x31\x37\x2e\x38\x32\x37\
\x2e\x32\x34\x36\x20\x31\x2e\x32\x33\x2d\x2e\x30\x34\x34\x36\x2e\
\x33\x31\x39\x2e\x32\x32\x35\x2e\x36\x33\x31\x2e\x33\x31\x32\x2e\
\x31\x37\x36\x2d\x2e\x30\x35\x36\x31\x2d\x2e\x34\x33\x33\x2e\x30\
\x30\x30\x37\x32\x31\x2d\x2e\x38\x39\x36\x2e\x33\x34\x37\x2d\x31\
\x2e\x31\x39\x2e\x31\x33\x39\x2d\x2e\x35\x33\x32\x2e\x35\x39\x33\
\x2d\x2e\x31\x38\x35\x2e\x36\x32\x2e\x31\x39\x39\x2e\x32\x30\x31\
\x2e\x31\x30\x33\x2e\x30\x34\x39\x2e\x35\x33\x31\x2e\x32\x38\x39\
\x2e\x33\x32\x32\x2e\x31\x34\x36\x2e\x32\x31\x32\x2e\x30\x32\x30\
\x31\x2e\x39\x34\x31\x2e\x31\x34\x38\x2e\x38\x35\x34\x2d\x2e\x30\
\x33\x34\x35\x2d\x2e\x32\x36\x35\x2e\x30\x34\x34\x2d\x2e\x37\x37\
\x35\x2e\x32\x30\x36\x2d\x2e\x32\x39\x37\x2e\x31\x30\x36\x2e\x33\
\x37\x2e\x33\x35\x31\x2e\x33\x35\x32\x2e\x34\x36\x32\x2d\x2e\x30\
\x32\x30\x38\x2e\x32\x31\x36\x2d\x2e\x34\x37\x36\x2d\x2e\x35\x35\
\x37\x2d\x2e\x38\x33\x38\x2d\x2e\x31\x39\x36\x2d\x31\x2e\x32\x37\
\x2e\x33\x31\x32\x2d\x2e\x32\x33\x33\x2e\x31\x30\x39\x2e\x38\x2e\
\x32\x38\x34\x2e\x32\x37\x33\x2e\x30\x35\x34\x38\x2d\x2e\x34\x37\
\x32\x2e\x36\x37\x36\x2d\x2e\x35\x36\x2e\x35\x39\x38\x2d\x31\x2e\
\x31\x2e\x30\x35\x35\x33\x2d\x2e\x33\x39\x2d\x2e\x34\x38\x31\x2d\
\x2e\x36\x36\x34\x2d\x2e\x32\x35\x32\x2d\x31\x2e\x30\x34\x2e\x30\
\x37\x39\x32\x2d\x2e\x31\x37\x39\x2d\x2e\x35\x34\x36\x2e\x30\x38\
\x35\x38\x2d\x2e\x32\x37\x36\x2d\x2e\x32\x36\x33\x2e\x30\x39\x34\
\x38\x2d\x2e\x33\x33\x33\x2e\x32\x34\x35\x2d\x2e\x33\x35\x31\x2e\
\x32\x39\x32\x2d\x2e\x30\x32\x38\x36\x2e\x32\x33\x34\x2d\x2e\x33\
\x30\x39\x2e\x31\x32\x39\x2e\x32\x33\x36\x2e\x33\x36\x35\x2e\x32\
\x37\x31\x2d\x2e\x30\x38\x37\x34\x2e\x33\x33\x32\x2e\x32\x33\x31\
\x2e\x33\x39\x32\x2e\x32\x36\x32\x2e\x30\x34\x39\x33\x2d\x2e\x32\
\x33\x34\x2d\x2e\x32\x32\x2d\x2e\x34\x33\x36\x2d\x2e\x36\x37\x39\
\x2d\x2e\x31\x31\x35\x2d\x2e\x38\x37\x39\x2e\x32\x39\x2d\x2e\x30\
\x39\x31\x39\x2e\x32\x33\x38\x2d\x2e\x37\x32\x32\x2e\x31\x31\x35\
\x2d\x2e\x39\x35\x38\x2d\x2e\x30\x32\x31\x2d\x2e\x34\x39\x35\x2d\
\x2e\x37\x37\x38\x2d\x2e\x30\x34\x34\x38\x2d\x2e\x35\x37\x31\x2d\
\x2e\x36\x32\x36\x2e\x30\x31\x37\x2d\x2e\x34\x33\x32\x2e\x34\x35\
\x31\x2d\x2e\x34\x31\x37\x2e\x37\x33\x31\x2d\x2e\x32\x34\x2e\x30\
\x34\x38\x2d\x2e\x32\x35\x34\x2e\x30\x38\x35\x39\x2d\x2e\x34\x37\
\x31\x2e\x33\x30\x33\x2d\x2e\x33\x35\x2e\x32\x38\x37\x2d\x2e\x32\
\x35\x39\x2e\x30\x35\x32\x2e\x34\x36\x36\x2e\x31\x32\x31\x2e\x36\
\x32\x2e\x31\x36\x34\x2e\x31\x34\x33\x2e\x32\x33\x36\x2e\x36\x31\
\x38\x2e\x34\x31\x32\x2e\x36\x2e\x30\x34\x39\x34\x2d\x2e\x33\x34\
\x36\x2e\x30\x31\x30\x39\x2d\x2e\x36\x39\x37\x2d\x2e\x32\x30\x37\
\x2d\x2e\x39\x32\x31\x2e\x30\x36\x35\x34\x2d\x2e\x31\x39\x39\x2e\
\x33\x33\x32\x2d\x2e\x33\x36\x2e\x32\x34\x37\x2d\x2e\x30\x35\x37\
\x39\x2e\x31\x31\x31\x2d\x2e\x32\x35\x36\x2e\x31\x39\x33\x2d\x2e\
\x32\x36\x33\x2e\x33\x32\x39\x2d\x2e\x33\x36\x33\x2d\x2e\x32\x30\
\x33\x2d\x2e\x31\x31\x2d\x2e\x34\x32\x31\x2d\x2e\x33\x31\x35\x2d\
\x2e\x31\x32\x37\x2d\x2e\x33\x39\x2e\x31\x38\x36\x2e\x30\x39\x39\
\x36\x2e\x35\x36\x36\x2e\x32\x33\x34\x2e\x32\x33\x37\x2d\x2e\x30\
\x37\x36\x36\x2d\x2e\x34\x2d\x2e\x30\x32\x30\x38\x2d\x2e\x37\x39\
\x37\x2d\x2e\x32\x39\x39\x2d\x31\x2e\x31\x38\x2d\x2e\x32\x37\x38\
\x2d\x2e\x30\x39\x30\x31\x2e\x32\x37\x31\x2d\x2e\x35\x32\x2d\x2e\
\x31\x35\x37\x2d\x2e\x37\x36\x39\x2d\x2e\x30\x34\x30\x33\x2d\x2e\
\x32\x39\x31\x2d\x2e\x32\x31\x39\x2d\x2e\x37\x38\x2e\x30\x34\x34\
\x34\x2d\x31\x2e\x30\x32\x2d\x2e\x33\x31\x37\x2d\x2e\x30\x39\x31\
\x32\x2e\x34\x37\x38\x2d\x2e\x36\x35\x32\x2e\x31\x34\x37\x2d\x2e\
\x39\x32\x33\x2e\x30\x35\x32\x2d\x2e\x33\x33\x2e\x31\x33\x2d\x2e\
\x36\x31\x31\x2d\x2e\x30\x33\x36\x2d\x2e\x39\x34\x2d\x2e\x30\x33\
\x36\x32\x2e\x31\x36\x33\x2d\x2e\x32\x31\x39\x2e\x31\x30\x32\x2d\
\x2e\x33\x38\x34\x2d\x2e\x31\x38\x36\x2d\x2e\x33\x31\x2d\x2e\x30\
\x36\x39\x2d\x2e\x30\x30\x38\x34\x31\x2d\x2e\x30\x33\x32\x36\x2d\
\x2e\x31\x33\x2d\x2e\x31\x35\x38\x2d\x2e\x30\x35\x34\x37\x7a\x6d\
\x2d\x31\x31\x2e\x32\x2e\x32\x30\x37\x63\x2d\x2e\x32\x35\x32\x2e\
\x31\x34\x36\x2d\x2e\x31\x37\x2e\x32\x39\x34\x2e\x30\x37\x36\x37\
\x2e\x32\x38\x36\x2d\x2e\x30\x38\x35\x38\x2e\x34\x33\x39\x2e\x34\
\x34\x39\x2e\x32\x33\x34\x2e\x36\x37\x31\x2e\x31\x38\x36\x2e\x31\
\x30\x35\x2d\x2e\x31\x32\x35\x2e\x30\x38\x39\x34\x2d\x2e\x34\x39\
\x32\x2d\x2e\x31\x35\x2d\x2e\x32\x35\x2d\x2e\x32\x39\x33\x2e\x31\
\x32\x37\x2d\x2e\x32\x38\x38\x2d\x2e\x33\x30\x39\x2d\x2e\x35\x39\
\x38\x2d\x2e\x32\x32\x33\x7a\x6d\x31\x2e\x37\x37\x2e\x30\x35\x36\
\x36\x63\x2d\x2e\x32\x31\x32\x2e\x30\x39\x33\x2d\x2e\x30\x37\x37\
\x36\x2e\x35\x33\x35\x2e\x30\x35\x38\x33\x2e\x32\x34\x39\x2e\x34\
\x33\x36\x2e\x30\x31\x36\x36\x2e\x35\x2e\x35\x37\x37\x2e\x31\x37\
\x37\x2e\x37\x37\x2e\x30\x39\x38\x31\x2e\x32\x31\x37\x2e\x35\x35\
\x37\x2e\x33\x39\x38\x2e\x33\x36\x37\x2e\x30\x30\x33\x34\x37\x2e\
\x31\x39\x35\x2e\x30\x39\x32\x36\x2e\x31\x34\x38\x2d\x2e\x30\x38\
\x39\x36\x2e\x30\x36\x39\x34\x2d\x2e\x32\x37\x31\x2e\x32\x31\x2e\
\x31\x37\x39\x2e\x31\x36\x31\x2d\x2e\x32\x31\x32\x2e\x30\x36\x31\
\x39\x2d\x2e\x33\x31\x33\x2d\x2e\x30\x31\x32\x31\x2d\x2e\x32\x37\
\x38\x2d\x2e\x33\x37\x35\x2d\x2e\x35\x33\x39\x2d\x2e\x36\x37\x33\
\x2d\x2e\x33\x34\x32\x2d\x2e\x30\x32\x30\x32\x2d\x2e\x30\x33\x31\
\x39\x2d\x2e\x30\x34\x30\x34\x2d\x2e\x30\x36\x33\x38\x2d\x2e\x30\
\x36\x30\x35\x2d\x2e\x30\x39\x35\x37\x7a\x6d\x2d\x2e\x34\x34\x39\
\x2e\x32\x32\x35\x63\x2d\x2e\x32\x38\x32\x2e\x30\x38\x39\x37\x2d\
\x2e\x32\x34\x37\x2e\x37\x33\x31\x2d\x2e\x36\x34\x36\x2e\x33\x36\
\x36\x2d\x2e\x32\x39\x31\x2d\x2e\x31\x34\x39\x2d\x2e\x33\x35\x39\
\x2e\x32\x37\x35\x2d\x2e\x35\x32\x32\x2e\x30\x37\x34\x31\x2d\x2e\
\x32\x36\x33\x2d\x2e\x31\x36\x33\x2d\x2e\x35\x37\x36\x2d\x2e\x32\
\x35\x35\x2d\x2e\x39\x34\x32\x2d\x2e\x32\x31\x36\x2d\x2e\x32\x34\
\x37\x2e\x31\x32\x37\x2d\x2e\x36\x32\x31\x2e\x32\x36\x31\x2d\x2e\
\x36\x34\x38\x2d\x2e\x31\x33\x39\x2d\x2e\x34\x32\x36\x2d\x2e\x30\
\x33\x31\x39\x2d\x2e\x38\x34\x36\x2e\x30\x39\x32\x34\x2d\x31\x2e\
\x32\x34\x2e\x32\x31\x31\x2d\x2e\x32\x37\x2e\x30\x34\x35\x38\x2e\
\x30\x38\x31\x32\x2e\x35\x30\x31\x2d\x2e\x32\x39\x34\x2e\x32\x36\
\x38\x2d\x2e\x32\x36\x38\x2e\x30\x37\x37\x37\x2d\x2e\x30\x34\x32\
\x2e\x32\x38\x35\x2e\x31\x30\x32\x2e\x31\x35\x38\x2d\x2e\x30\x39\
\x34\x31\x2e\x32\x38\x36\x2d\x2e\x36\x36\x33\x2e\x32\x30\x31\x2d\
\x2e\x35\x30\x33\x2e\x36\x31\x31\x2e\x32\x34\x38\x2e\x30\x38\x36\
\x38\x2e\x30\x30\x35\x38\x37\x2e\x32\x38\x37\x2e\x33\x31\x34\x2e\
\x31\x31\x39\x2e\x31\x35\x36\x2d\x2e\x30\x35\x39\x31\x2e\x32\x38\
\x38\x2d\x2e\x33\x31\x33\x2e\x33\x31\x36\x2d\x2e\x31\x33\x39\x2e\
\x31\x38\x2d\x2e\x32\x30\x38\x2e\x36\x30\x32\x2d\x2e\x30\x37\x34\
\x38\x2e\x37\x32\x33\x2e\x31\x30\x37\x2e\x32\x36\x35\x2e\x32\x31\
\x32\x2d\x2e\x30\x31\x36\x35\x2e\x36\x38\x2e\x31\x31\x37\x2e\x39\
\x38\x33\x2e\x30\x30\x39\x34\x37\x2e\x32\x39\x2d\x2e\x31\x35\x39\
\x2e\x35\x38\x35\x2d\x2e\x33\x30\x31\x2e\x38\x38\x32\x2d\x2e\x32\
\x32\x33\x2e\x33\x39\x32\x2d\x2e\x32\x34\x38\x2e\x39\x36\x36\x2e\
\x30\x38\x32\x34\x20\x31\x2e\x33\x31\x2d\x2e\x30\x31\x37\x35\x2e\
\x33\x38\x36\x2e\x30\x38\x39\x36\x2e\x37\x36\x2e\x32\x31\x31\x20\
\x31\x2e\x31\x32\x2e\x30\x35\x39\x37\x2d\x2e\x33\x33\x31\x2d\x2e\
\x32\x34\x35\x2d\x2e\x37\x32\x36\x2d\x2e\x30\x38\x32\x39\x2d\x2e\
\x39\x39\x37\x2e\x31\x31\x37\x2e\x34\x30\x33\x2e\x32\x32\x37\x2e\
\x37\x39\x39\x2e\x33\x38\x39\x20\x31\x2e\x31\x39\x2d\x2e\x30\x35\
\x36\x33\x2e\x33\x36\x38\x2e\x32\x39\x37\x2e\x38\x33\x38\x2e\x36\
\x37\x31\x2e\x37\x37\x39\x2e\x31\x38\x31\x2e\x30\x37\x34\x2e\x33\
\x37\x34\x2e\x32\x39\x32\x2e\x35\x35\x33\x2e\x33\x37\x36\x2e\x31\
\x31\x34\x2e\x32\x34\x33\x2e\x31\x34\x33\x2e\x35\x30\x35\x2e\x33\
\x39\x2e\x36\x34\x34\x2e\x32\x30\x31\x2e\x30\x35\x2d\x2e\x32\x38\
\x37\x2d\x2e\x33\x36\x32\x2d\x2e\x31\x34\x37\x2d\x2e\x35\x34\x38\
\x2e\x30\x36\x35\x39\x2d\x2e\x32\x33\x34\x2e\x30\x38\x30\x33\x2d\
\x2e\x36\x34\x38\x2d\x2e\x32\x39\x32\x2d\x2e\x34\x36\x32\x2e\x30\
\x35\x34\x37\x2d\x2e\x31\x38\x32\x2e\x32\x36\x39\x2d\x2e\x39\x30\
\x31\x2d\x2e\x30\x36\x34\x35\x2d\x2e\x36\x32\x39\x2d\x2e\x30\x35\
\x33\x2e\x36\x33\x36\x2d\x2e\x37\x35\x34\x2e\x31\x32\x2d\x2e\x34\
\x39\x36\x2d\x2e\x32\x39\x38\x2e\x30\x31\x35\x38\x2d\x2e\x33\x34\
\x31\x2e\x32\x30\x31\x2d\x2e\x38\x39\x35\x2e\x35\x37\x32\x2d\x2e\
\x38\x32\x31\x2e\x31\x35\x35\x2e\x32\x35\x39\x2e\x32\x37\x34\x2d\
\x2e\x33\x36\x35\x2e\x34\x39\x38\x2d\x2e\x30\x30\x37\x31\x35\x2e\
\x31\x32\x35\x2e\x31\x34\x36\x2e\x30\x39\x35\x2e\x38\x36\x38\x2e\
\x32\x34\x37\x2e\x33\x34\x31\x2d\x2e\x31\x37\x34\x2d\x2e\x34\x34\
\x38\x2e\x31\x31\x34\x2d\x2e\x37\x38\x39\x2e\x34\x33\x37\x2d\x31\
\x2e\x30\x36\x2e\x30\x39\x33\x31\x2d\x2e\x34\x32\x37\x2e\x34\x36\
\x37\x2d\x2e\x36\x33\x31\x2e\x36\x31\x35\x2d\x2e\x39\x33\x34\x2e\
\x30\x39\x33\x35\x2d\x2e\x31\x36\x39\x2e\x35\x39\x32\x2d\x2e\x35\
\x32\x2e\x33\x32\x32\x2d\x2e\x31\x34\x38\x2d\x2e\x30\x35\x33\x31\
\x2e\x33\x34\x36\x2e\x32\x31\x38\x2d\x2e\x32\x33\x36\x2e\x33\x39\
\x34\x2d\x2e\x31\x37\x2e\x32\x2d\x2e\x32\x35\x31\x2d\x2e\x34\x37\
\x2e\x30\x39\x32\x36\x2d\x2e\x32\x30\x36\x2d\x2e\x32\x34\x2e\x32\
\x33\x35\x2d\x2e\x32\x33\x36\x2d\x2e\x35\x33\x34\x2e\x30\x35\x37\
\x31\x2d\x2e\x31\x38\x33\x2d\x2e\x32\x32\x31\x2e\x32\x39\x39\x2d\
\x2e\x31\x32\x37\x2e\x39\x39\x38\x2d\x2e\x30\x36\x34\x35\x2e\x38\
\x33\x35\x2d\x2e\x35\x37\x38\x2d\x2e\x32\x34\x33\x2e\x30\x37\x31\
\x38\x2d\x2e\x31\x31\x2d\x2e\x35\x34\x38\x2d\x2e\x32\x33\x32\x2d\
\x2e\x36\x34\x31\x2d\x2e\x31\x39\x35\x2e\x32\x37\x32\x2d\x2e\x33\
\x33\x33\x2e\x31\x37\x36\x2d\x2e\x33\x31\x31\x2d\x2e\x31\x31\x31\
\x2d\x2e\x31\x36\x36\x2d\x2e\x32\x39\x39\x2d\x2e\x33\x37\x38\x2d\
\x2e\x30\x35\x33\x38\x2d\x2e\x34\x38\x2e\x30\x39\x39\x2d\x2e\x31\
\x33\x35\x2e\x31\x33\x33\x2e\x30\x39\x32\x33\x2e\x36\x33\x39\x2d\
\x2e\x33\x32\x36\x2e\x36\x37\x31\x2e\x30\x38\x31\x34\x2e\x32\x37\
\x34\x2d\x2e\x32\x37\x33\x2e\x34\x36\x2d\x2e\x32\x33\x37\x2e\x30\
\x39\x36\x38\x2e\x32\x32\x33\x2d\x2e\x33\x36\x31\x2d\x2e\x35\x37\
\x34\x2d\x2e\x31\x39\x37\x2d\x2e\x34\x33\x37\x2d\x2e\x36\x35\x34\
\x2e\x30\x34\x37\x36\x2d\x2e\x34\x34\x32\x2e\x37\x33\x2d\x2e\x33\
\x39\x37\x2e\x36\x39\x37\x2d\x2e\x38\x32\x32\x2e\x31\x30\x34\x2e\
\x32\x35\x36\x2e\x33\x35\x2d\x2e\x31\x34\x31\x2e\x35\x31\x37\x2d\
\x2e\x32\x31\x39\x2e\x30\x36\x33\x39\x2d\x2e\x33\x37\x39\x2d\x2e\
\x34\x32\x37\x2e\x33\x34\x37\x2d\x2e\x33\x36\x34\x2d\x2e\x31\x30\
\x35\x2d\x2e\x31\x2d\x2e\x30\x31\x38\x34\x2d\x2e\x30\x31\x36\x2d\
\x2e\x31\x37\x35\x2d\x2e\x30\x34\x38\x38\x2d\x2e\x32\x34\x32\x7a\
\x6d\x2d\x2e\x36\x36\x20\x37\x2e\x31\x36\x63\x2d\x2e\x31\x36\x38\
\x2e\x32\x35\x38\x2d\x2e\x34\x35\x34\x2e\x35\x35\x31\x2d\x2e\x32\
\x39\x37\x2e\x38\x39\x39\x2d\x2e\x30\x38\x36\x38\x2e\x32\x38\x32\
\x2d\x2e\x32\x31\x36\x2e\x35\x36\x2d\x2e\x33\x31\x35\x2e\x38\x33\
\x38\x2e\x31\x32\x35\x2e\x32\x37\x31\x2d\x2e\x31\x36\x38\x2e\x35\
\x31\x38\x2e\x31\x32\x39\x2e\x37\x38\x33\x2e\x31\x33\x31\x2e\x34\
\x31\x36\x2e\x32\x33\x31\x2e\x38\x34\x34\x2e\x35\x36\x35\x20\x31\
\x2e\x31\x35\x2e\x33\x37\x31\x2e\x31\x37\x31\x2e\x32\x30\x31\x2e\
\x36\x30\x34\x2e\x32\x32\x39\x2e\x39\x34\x31\x2e\x30\x32\x36\x37\
\x2e\x36\x35\x2e\x30\x35\x37\x31\x20\x31\x2e\x33\x2e\x30\x38\x37\
\x39\x20\x31\x2e\x39\x36\x2e\x32\x37\x2e\x33\x31\x37\x2e\x31\x31\
\x35\x2e\x36\x38\x37\x2e\x32\x31\x36\x20\x31\x2e\x30\x32\x2d\x2e\
\x30\x30\x39\x34\x32\x2e\x32\x31\x35\x2e\x34\x33\x37\x2e\x37\x36\
\x34\x2e\x34\x31\x33\x2e\x34\x30\x33\x2e\x30\x31\x30\x39\x2d\x2e\
\x32\x32\x2e\x31\x36\x35\x2d\x2e\x35\x38\x2d\x2e\x30\x35\x39\x33\
\x2d\x2e\x36\x35\x37\x2e\x31\x39\x33\x2d\x2e\x31\x38\x36\x2d\x2e\
\x30\x33\x34\x38\x2d\x2e\x35\x37\x37\x2e\x30\x36\x31\x35\x2d\x2e\
\x36\x34\x2e\x32\x34\x2e\x31\x32\x32\x2d\x2e\x30\x30\x33\x33\x39\
\x2d\x2e\x34\x36\x2e\x32\x39\x32\x2d\x2e\x32\x37\x36\x2e\x32\x32\
\x35\x2d\x2e\x32\x32\x35\x2e\x30\x34\x39\x34\x2d\x2e\x34\x30\x33\
\x2d\x2e\x30\x32\x35\x32\x2d\x2e\x36\x32\x34\x2e\x32\x38\x36\x2e\
\x33\x34\x38\x2e\x34\x31\x39\x2d\x2e\x32\x36\x2e\x34\x36\x34\x2d\
\x2e\x34\x34\x37\x2e\x31\x36\x34\x2d\x2e\x33\x34\x34\x2d\x2e\x30\
\x33\x33\x35\x2d\x2e\x38\x33\x37\x2e\x34\x36\x38\x2d\x2e\x39\x2e\
\x32\x35\x31\x2d\x2e\x32\x33\x35\x2e\x31\x39\x33\x2d\x2e\x36\x36\
\x38\x2e\x32\x34\x32\x2d\x2e\x39\x39\x35\x2d\x2e\x30\x33\x37\x39\
\x2d\x2e\x34\x33\x32\x2e\x32\x36\x32\x2d\x2e\x37\x34\x36\x2e\x32\
\x36\x38\x2d\x31\x2e\x31\x36\x2d\x2e\x31\x31\x37\x2d\x2e\x31\x36\
\x31\x2d\x2e\x32\x39\x32\x2d\x2e\x33\x34\x2d\x2e\x34\x38\x32\x2d\
\x2e\x34\x30\x38\x2d\x2e\x32\x33\x36\x2e\x30\x35\x35\x36\x2d\x2e\
\x34\x31\x31\x2d\x2e\x32\x35\x36\x2d\x2e\x35\x30\x31\x2d\x2e\x32\
\x35\x38\x2d\x2e\x31\x30\x36\x2d\x2e\x31\x30\x33\x2d\x2e\x33\x39\
\x31\x2e\x31\x35\x34\x2d\x2e\x31\x34\x36\x2d\x2e\x31\x37\x36\x2e\
\x30\x39\x38\x33\x2d\x2e\x34\x32\x33\x2d\x2e\x32\x39\x35\x2d\x2e\
\x34\x39\x33\x2d\x2e\x34\x36\x37\x2d\x2e\x37\x33\x33\x2d\x2e\x31\
\x33\x37\x2d\x2e\x31\x39\x34\x2d\x2e\x33\x38\x35\x2d\x2e\x33\x36\
\x35\x2d\x2e\x34\x36\x36\x2d\x2e\x35\x30\x33\x2d\x2e\x32\x39\x35\
\x2e\x31\x35\x39\x2d\x2e\x35\x32\x2d\x2e\x34\x33\x32\x2d\x2e\x35\
\x32\x39\x2e\x31\x30\x32\x2d\x2e\x30\x39\x33\x33\x2e\x32\x39\x31\
\x2d\x2e\x30\x33\x32\x34\x2d\x2e\x33\x32\x38\x2d\x2e\x31\x34\x38\
\x2d\x2e\x33\x31\x39\x7a\x6d\x31\x33\x2e\x38\x2e\x37\x34\x63\x2d\
\x2e\x31\x34\x39\x2e\x32\x34\x36\x2d\x2e\x33\x35\x39\x2e\x34\x31\
\x34\x2d\x2e\x34\x30\x39\x2e\x36\x39\x32\x2d\x2e\x32\x33\x36\x2d\
\x2e\x30\x35\x36\x38\x2d\x2e\x30\x38\x30\x31\x2e\x36\x39\x38\x2e\
\x32\x34\x32\x2e\x35\x33\x34\x2e\x32\x32\x2d\x2e\x30\x36\x35\x31\
\x2e\x33\x33\x39\x2d\x2e\x34\x39\x39\x2e\x32\x34\x36\x2d\x2e\x37\
\x34\x32\x2e\x30\x33\x37\x37\x2d\x2e\x31\x37\x32\x2e\x31\x33\x34\
\x2d\x2e\x33\x38\x2d\x2e\x30\x37\x39\x33\x2d\x2e\x34\x38\x35\x7a\
\x6d\x2d\x31\x2e\x34\x39\x2e\x31\x35\x38\x63\x2e\x31\x32\x32\x2e\
\x33\x39\x31\x2e\x33\x33\x38\x2e\x38\x33\x32\x2e\x34\x39\x31\x20\
\x31\x2e\x32\x35\x2e\x32\x35\x37\x2e\x34\x39\x2e\x32\x36\x34\x2d\
\x2e\x32\x39\x32\x2e\x30\x36\x38\x2d\x2e\x34\x34\x36\x2d\x2e\x31\
\x30\x36\x2d\x2e\x33\x32\x37\x2d\x2e\x33\x34\x39\x2d\x2e\x35\x36\
\x39\x2d\x2e\x35\x35\x39\x2d\x2e\x38\x30\x31\x7a\x6d\x32\x2e\x37\
\x2e\x37\x33\x34\x63\x2d\x2e\x32\x37\x36\x2e\x30\x32\x37\x32\x2e\
\x30\x39\x38\x33\x2e\x32\x35\x33\x2d\x2e\x30\x30\x31\x37\x2e\x33\
\x39\x39\x2e\x33\x34\x35\x2d\x2e\x30\x31\x37\x37\x2e\x33\x37\x36\
\x2e\x33\x39\x33\x2e\x34\x33\x37\x2e\x35\x31\x33\x2e\x31\x34\x34\
\x2e\x31\x38\x37\x2e\x32\x39\x32\x2e\x31\x31\x33\x2e\x34\x32\x34\
\x2e\x30\x31\x36\x33\x2e\x30\x31\x33\x2e\x32\x31\x34\x2e\x36\x31\
\x32\x2e\x35\x36\x39\x2e\x33\x32\x32\x2e\x31\x38\x37\x2d\x2e\x32\
\x34\x39\x2d\x2e\x32\x35\x31\x2d\x2e\x31\x31\x37\x2d\x2e\x36\x31\
\x31\x2d\x2e\x35\x32\x33\x2d\x2e\x37\x37\x36\x2d\x2e\x32\x36\x34\
\x2d\x2e\x32\x31\x33\x2d\x2e\x33\x33\x38\x2d\x2e\x31\x34\x34\x2d\
\x2e\x35\x31\x36\x2e\x30\x36\x30\x33\x2d\x2e\x31\x32\x35\x2d\x2e\
\x30\x39\x39\x31\x2d\x2e\x30\x34\x34\x35\x2d\x2e\x33\x31\x39\x2d\
\x2e\x31\x34\x33\x2d\x2e\x34\x7a\x6d\x2e\x36\x32\x37\x20\x31\x2e\
\x33\x38\x63\x2d\x2e\x30\x38\x30\x37\x2e\x31\x38\x33\x2d\x2e\x30\
\x30\x34\x38\x2e\x37\x37\x2d\x2e\x33\x32\x36\x2e\x37\x31\x37\x2d\
\x2e\x33\x36\x39\x2d\x2e\x31\x37\x2e\x31\x2d\x2e\x37\x36\x36\x2d\
\x2e\x34\x30\x34\x2d\x2e\x36\x34\x38\x2d\x2e\x32\x32\x36\x2e\x31\
\x34\x37\x2d\x2e\x32\x34\x36\x2e\x36\x37\x39\x2d\x2e\x34\x32\x37\
\x2e\x32\x32\x37\x2d\x2e\x32\x30\x32\x2e\x30\x39\x36\x37\x2d\x2e\
\x32\x30\x38\x2e\x34\x39\x34\x2d\x2e\x33\x33\x2e\x33\x39\x32\x2e\
\x30\x30\x34\x37\x2e\x34\x37\x31\x2d\x2e\x37\x31\x34\x2e\x34\x30\
\x34\x2d\x2e\x37\x31\x32\x2e\x38\x35\x31\x2d\x2e\x30\x37\x35\x34\
\x2e\x34\x33\x37\x2e\x30\x30\x39\x31\x2e\x39\x35\x39\x2d\x2e\x30\
\x37\x33\x32\x20\x31\x2e\x33\x34\x2e\x32\x36\x37\x2e\x30\x32\x34\
\x38\x2e\x35\x34\x31\x2d\x2e\x31\x36\x34\x2e\x37\x39\x32\x2d\x2e\
\x32\x36\x34\x2e\x32\x32\x2d\x2e\x32\x33\x38\x2e\x35\x39\x31\x2d\
\x2e\x30\x36\x34\x34\x2e\x35\x35\x33\x2e\x32\x32\x37\x2e\x31\x35\
\x37\x2d\x2e\x30\x36\x35\x35\x2e\x33\x31\x33\x2d\x2e\x33\x31\x37\
\x2e\x31\x38\x35\x2e\x30\x33\x31\x2e\x30\x33\x39\x39\x2e\x32\x37\
\x33\x2e\x30\x32\x39\x33\x2e\x35\x36\x37\x2e\x33\x35\x32\x2e\x35\
\x33\x37\x2e\x32\x2e\x30\x34\x36\x34\x2e\x36\x30\x31\x2d\x2e\x35\
\x34\x39\x2e\x37\x32\x38\x2d\x2e\x38\x34\x2e\x31\x36\x33\x2d\x2e\
\x33\x32\x31\x2e\x33\x31\x2d\x2e\x38\x36\x32\x2e\x30\x37\x31\x37\
\x2d\x31\x2e\x31\x34\x2d\x2e\x31\x39\x34\x2d\x2e\x33\x36\x2d\x2e\
\x32\x35\x35\x2d\x2e\x36\x38\x37\x2d\x2e\x33\x30\x34\x2d\x31\x2e\
\x31\x2d\x2e\x30\x35\x31\x37\x2d\x2e\x31\x30\x34\x2d\x2e\x30\x36\
\x39\x38\x2d\x2e\x32\x31\x39\x2d\x2e\x31\x30\x35\x2d\x2e\x33\x32\
\x38\x7a\x6d\x2d\x36\x2e\x37\x36\x2e\x31\x33\x33\x63\x2e\x30\x30\
\x30\x36\x36\x39\x2e\x33\x38\x2d\x2e\x35\x34\x37\x2e\x33\x39\x2d\
\x2e\x33\x36\x38\x2e\x38\x30\x32\x2d\x2e\x30\x31\x36\x35\x2e\x32\
\x37\x39\x2d\x2e\x32\x30\x32\x2e\x35\x34\x38\x2d\x2e\x30\x31\x38\
\x33\x2e\x38\x32\x37\x2e\x33\x38\x34\x2d\x2e\x31\x33\x2e\x32\x31\
\x2d\x2e\x37\x31\x32\x2e\x33\x39\x31\x2d\x31\x2e\x30\x33\x2e\x30\
\x32\x39\x39\x2d\x2e\x32\x30\x33\x2e\x30\x36\x30\x38\x2d\x2e\x33\
\x39\x36\x2d\x2e\x30\x30\x34\x35\x2d\x2e\x35\x39\x35\x7a\x22\x20\
\x66\x69\x6c\x6c\x3d\x22\x23\x32\x65\x34\x65\x37\x32\x22\x20\x73\
\x74\x72\x6f\x6b\x65\x2d\x77\x69\x64\x74\x68\x3d\x22\x2e\x30\x37\
\x35\x22\x2f\x3e\x3c\x70\x61\x74\x68\x20\x64\x3d\x22\x6d\x31\x32\
\x20\x2e\x38\x30\x38\x63\x37\x2e\x31\x20\x33\x2e\x35\x32\x20\x36\
\x2e\x30\x39\x20\x31\x36\x2e\x37\x20\x30\x20\x31\x39\x2e\x34\x22\
\x20\x66\x69\x6c\x6c\x3d\x22\x6e\x6f\x6e\x65\x22\x20\x73\x74\x72\
\x6f\x6b\x65\x2d\x77\x69\x64\x74\x68\x3d\x22\x2e\x34\x35\x22\x2f\
\x3e\x3c\x70\x61\x74\x68\x20\x64\x3d\x22\x6d\x2e\x38\x34\x32\x20\
\x31\x30\x2e\x35\x68\x32\x32\x2e\x33\x22\x20\x66\x69\x6c\x6c\x3d\
\x22\x6e\x6f\x6e\x65\x22\x20\x73\x74\x72\x6f\x6b\x65\x2d\x77\x69\
\x64\x74\x68\x3d\x22\x2e\x34\x35\x22\x2f\x3e\x3c\x70\x61\x74\x68\
\x20\x64\x3d\x22\x6d\x32\x2e\x32\x31\x20\x35\x2e\x32\x63\x35\x2e\
\x36\x38\x2e\x37\x35\x37\x20\x31\x33\x2e\x38\x2e\x37\x35\x37\x20\
\x31\x39\x2e\x35\x20\x30\x22\x20\x66\x69\x6c\x6c\x3d\x22\x6e\x6f\
\x6e\x65\x22\x20\x73\x74\x72\x6f\x6b\x65\x2d\x77\x69\x64\x74\x68\
\x3d\x22\x2e\x34\x35\x22\x2f\x3e\x3c\x70\x61\x74\x68\x20\x64\x3d\
\x22\x6d\x32\x2e\x32\x31\x20\x31\x35\x2e\x38\x63\x35\x2e\x36\x38\
\x2d\x2e\x37\x35\x37\x20\x31\x33\x2e\x38\x2d\x2e\x37\x35\x37\x20\
\x31\x39\x2e\x35\x20\x30\x22\x20\x66\x69\x6c\x6c\x3d\x22\x6e\x6f\
\x6e\x65\x22\x20\x73\x74\x72\x6f\x6b\x65\x2d\x77\x69\x64\x74\x68\
\x3d\x22\x2e\x34\x35\x22\x2f\x3e\x3c\x70\x61\x74\x68\x20\x64\x3d\
\x22\x6d\x31\x32\x20\x2e\x38\x30\x38\x63\x2d\x36\x2e\x30\x39\x20\
\x32\x2e\x36\x34\x2d\x37\x2e\x31\x20\x31\x35\x2e\x39\x20\x30\x20\
\x31\x39\x2e\x34\x22\x20\x66\x69\x6c\x6c\x3d\x22\x6e\x6f\x6e\x65\
\x22\x20\x73\x74\x72\x6f\x6b\x65\x2d\x77\x69\x64\x74\x68\x3d\x22\
\x2e\x34\x35\x22\x2f\x3e\x3c\x2f\x67\x3e\x3c\x70\x61\x74\x68\x20\
\x64\x3d\x22\x6d\x32\x33\x2e\x34\x20\x31\x30\x2e\x35\x63\x30\x20\
\x35\x2e\x34\x38\x2d\x35\x2e\x31\x31\x20\x39\x2e\x39\x31\x2d\x31\
\x31\x2e\x34\x20\x39\x2e\x39\x31\x2d\x36\x2e\x33\x20\x30\x2d\x31\
\x31\x2e\x34\x2d\x34\x2e\x34\x34\x2d\x31\x31\x2e\x34\x2d\x39\x2e\
\x39\x31\x20\x30\x2d\x35\x2e\x34\x38\x20\x35\x2e\x31\x31\x2d\x39\
\x2e\x39\x31\x20\x31\x31\x2e\x34\x2d\x39\x2e\x39\x31\x20\x36\x2e\
\x33\x20\x30\x20\x31\x31\x2e\x34\x20\x34\x2e\x34\x34\x20\x31\x31\
\x2e\x34\x20\x39\x2e\x39\x31\x7a\x22\x20\x66\x69\x6c\x6c\x3d\x22\
\x6e\x6f\x6e\x65\x22\x20\x73\x74\x72\x6f\x6b\x65\x3d\x22\x23\x36\
\x65\x39\x36\x63\x34\x22\x20\x73\x74\x72\x6f\x6b\x65\x2d\x77\x69\
\x64\x74\x68\x3d\x22\x31\x2e\x31\x38\x22\x2f\x3e\x3c\x67\x20\x74\
\x72\x61\x6e\x73\x66\x6f\x72\x6d\x3d\x22\x6d\x61\x74\x72\x69\x78\
\x28\x2e\x36\x39\x32\x20\x30\x20\x30\x20\x2e\x36\x39\x32\x20\x31\
\x2e\x38\x35\x20\x31\x2e\x38\x35\x29\x22\x3e\x3c\x72\x65\x63\x74\
\x20\x66\x69\x6c\x6c\x3d\x22\x23\x35\x61\x38\x63\x35\x61\x22\x20\
\x68\x65\x69\x67\x68\x74\x3d\x22\x31\x33\x22\x20\x72\x78\x3d\x22\
\x32\x2e\x36\x31\x22\x20\x77\x69\x64\x74\x68\x3d\x22\x31\x33\x22\
\x20\x78\x3d\x22\x31\x39\x22\x20\x79\x3d\x22\x31\x39\x22\x2f\x3e\
\x3c\x67\x20\x66\x69\x6c\x6c\x2d\x72\x75\x6c\x65\x3d\x22\x65\x76\
\x65\x6e\x6f\x64\x64\x22\x3e\x3c\x70\x61\x74\x68\x20\x64\x3d\x22\
\x6d\x32\x31\x2e\x36\x20\x32\x35\x2e\x35\x68\x37\x2e\x38\x22\x20\
\x66\x69\x6c\x6c\x3d\x22\x23\x66\x66\x66\x22\x20\x73\x74\x72\x6f\
\x6b\x65\x3d\x22\x23\x66\x66\x66\x22\x20\x73\x74\x72\x6f\x6b\x65\
\x2d\x6c\x69\x6e\x65\x63\x61\x70\x3d\x22\x72\x6f\x75\x6e\x64\x22\
\x20\x73\x74\x72\x6f\x6b\x65\x2d\x6c\x69\x6e\x65\x6a\x6f\x69\x6e\
\x3d\x22\x72\x6f\x75\x6e\x64\x22\x20\x73\x74\x72\x6f\x6b\x65\x2d\
\x77\x69\x64\x74\x68\x3d\x22\x32\x2e\x36\x22\x2f\x3e\x3c\x70\x61\
\x74\x68\x20\x64\x3d\x22\x6d\x32\x35\x2e\x35\x20\x32\x39\x2e\x34\
\x76\x2d\x37\x2e\x38\x22\x20\x66\x69\x6c\x6c\x3d\x22\x23\x66\x66\
\x66\x22\x20\x73\x74\x72\x6f\x6b\x65\x3d\x22\x23\x66\x66\x66\x22\
\x20\x73\x74\x72\x6f\x6b\x65\x2d\x6c\x69\x6e\x65\x63\x61\x70\x3d\
\x22\x72\x6f\x75\x6e\x64\x22\x20\x73\x74\x72\x6f\x6b\x65\x2d\x6c\
\x69\x6e\x65\x6a\x6f\x69\x6e\x3d\x22\x72\x6f\x75\x6e\x64\x22\x20\
\x73\x74\x72\x6f\x6b\x65\x2d\x77\x69\x64\x74\x68\x3d\x22\x32\x2e\
\x36\x22\x2f\x3e\x3c\x70\x61\x74\x68\x20\x64\x3d\x22\x6d\x32\x30\
\x2e\x33\x20\x32\x35\x2e\x35\x68\x31\x30\x2e\x34\x76\x2d\x32\x2e\
\x36\x63\x30\x2d\x32\x2e\x36\x2d\x2e\x36\x35\x2d\x32\x2e\x36\x2d\
\x35\x2e\x32\x2d\x32\x2e\x36\x73\x2d\x35\x2e\x32\x20\x30\x2d\x35\
\x2e\x32\x20\x32\x2e\x36\x7a\x22\x20\x66\x69\x6c\x6c\x3d\x22\x23\
\x66\x63\x66\x66\x66\x66\x22\x20\x6f\x70\x61\x63\x69\x74\x79\x3d\
\x22\x2e\x33\x22\x2f\x3e\x3c\x2f\x67\x3e\x3c\x2f\x67\x3e\x3c\x2f\
\x73\x76\x67\x3e\
\x00\x00\x03\x09\
\x3c\
\x73\x76\x67\x20\x68\x65\x69\x67\x68\x74\x3d\x22\x32\x34\x22\x20\
\x76\x69\x65\x77\x42\x6f\x78\x3d\x22\x30\x20\x30\x20\x32\x34\x30\
\x20\x32\x34\x30\x22\x20\x77\x69\x64\x74\x68\x3d\x22\x32\x34\x22\
\x20\x78\x6d\x6c\x6e\x73\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x77\
\x77\x77\x2e\x77\x33\x2e\x6f\x72\x67\x2f\x32\x30\x30\x30\x2f\x73\
\x76\x67\x22\x3e\x3c\x70\x61\x74\x68\x20\x64\x3d\x22\x6d\x39\x37\
\x2e\x39\x31\x34\x20\x31\x30\x36\x2e\x35\x38\x34\x20\x32\x32\x2e\
\x38\x34\x38\x2d\x31\x31\x2e\x37\x36\x34\x20\x32\x32\x2e\x38\x34\
\x39\x20\x31\x31\x2e\x37\x36\x34\x76\x32\x35\x2e\x33\x34\x6c\x2d\
\x32\x32\x2e\x38\x34\x39\x20\x31\x32\x2e\x36\x36\x39\x2d\x32\x32\
\x2e\x38\x34\x38\x2d\x31\x32\x2e\x36\x37\x7a\x6d\x32\x33\x2e\x31\
\x39\x31\x2d\x31\x39\x2e\x35\x36\x36\x2d\x31\x38\x2e\x37\x38\x38\
\x2d\x31\x34\x2e\x31\x32\x32\x2d\x34\x2e\x34\x30\x33\x2d\x35\x30\
\x2e\x32\x34\x37\x63\x35\x2e\x32\x33\x2d\x32\x32\x2e\x31\x33\x20\
\x33\x37\x2e\x39\x35\x2d\x32\x34\x2e\x38\x39\x38\x20\x34\x34\x2e\
\x32\x39\x39\x20\x30\x6c\x2d\x34\x2e\x33\x36\x33\x20\x35\x30\x2e\
\x31\x30\x39\x7a\x6d\x2d\x32\x2e\x30\x38\x34\x20\x36\x35\x2e\x39\
\x36\x34\x20\x31\x38\x2e\x37\x38\x38\x20\x31\x34\x2e\x31\x32\x32\
\x20\x34\x2e\x34\x30\x34\x20\x35\x30\x2e\x32\x34\x37\x63\x2d\x35\
\x2e\x32\x33\x31\x20\x32\x32\x2e\x31\x33\x2d\x33\x37\x2e\x39\x35\
\x31\x20\x32\x34\x2e\x38\x39\x38\x2d\x34\x34\x2e\x33\x20\x30\x6c\
\x34\x2e\x33\x36\x33\x2d\x35\x30\x2e\x31\x30\x39\x7a\x6d\x33\x30\
\x2e\x31\x30\x39\x2d\x34\x38\x2e\x38\x33\x32\x20\x33\x2e\x30\x38\
\x32\x2d\x32\x33\x2e\x30\x31\x20\x34\x32\x2e\x31\x39\x2d\x32\x38\
\x2e\x38\x36\x32\x63\x32\x32\x2e\x31\x36\x36\x2d\x36\x2e\x36\x32\
\x34\x20\x34\x30\x2e\x39\x37\x31\x20\x31\x39\x2e\x37\x37\x20\x32\
\x32\x2e\x31\x35\x20\x33\x37\x2e\x36\x30\x38\x6c\x2d\x34\x36\x2e\
\x34\x35\x31\x20\x32\x31\x2e\x33\x35\x7a\x6d\x30\x20\x33\x32\x2e\
\x36\x38\x36\x20\x32\x31\x2e\x38\x37\x2d\x38\x2e\x38\x38\x39\x20\
\x34\x36\x2e\x35\x39\x33\x20\x32\x31\x2e\x33\x38\x35\x63\x31\x36\
\x2e\x39\x33\x35\x20\x31\x35\x2e\x35\x30\x36\x20\x33\x2e\x30\x32\
\x20\x34\x34\x2e\x36\x36\x37\x2d\x32\x32\x2e\x31\x35\x20\x33\x37\
\x2e\x36\x30\x38\x6c\x2d\x34\x32\x2e\x30\x38\x37\x2d\x32\x38\x2e\
\x37\x35\x38\x7a\x6d\x2d\x35\x38\x2e\x32\x37\x36\x2d\x33\x32\x2e\
\x36\x38\x36\x2d\x33\x2e\x30\x38\x32\x2d\x32\x33\x2e\x30\x31\x2d\
\x34\x32\x2e\x31\x39\x2d\x32\x38\x2e\x38\x36\x32\x63\x2d\x32\x32\
\x2e\x31\x36\x36\x2d\x36\x2e\x36\x32\x34\x2d\x34\x30\x2e\x39\x37\
\x31\x20\x31\x39\x2e\x37\x37\x2d\x32\x32\x2e\x31\x35\x20\x33\x37\
\x2e\x36\x30\x38\x6c\x34\x36\x2e\x34\x35\x20\x32\x31\x2e\x33\x35\
\x7a\x6d\x30\x20\x33\x32\x2e\x36\x38\x36\x2d\x32\x31\x2e\x38\x37\
\x2d\x38\x2e\x38\x38\x39\x2d\x34\x36\x2e\x35\x39\x33\x20\x32\x31\
\x2e\x33\x38\x35\x63\x2d\x31\x36\x2e\x39\x33\x35\x20\x31\x35\x2e\
\x35\x30\x36\x2d\x33\x2e\x30\x32\x31\x20\x34\x34\x2e\x36\x36\x38\
\x20\x32\x32\x2e\x31\x34\x39\x20\x33\x37\x2e\x36\x30\x38\x6c\x34\
\x32\x2e\x30\x38\x37\x2d\x32\x38\x2e\x37\x35\x38\x7a\x22\x20\x66\
\x69\x6c\x6c\x3d\x22\x23\x36\x64\x39\x37\x63\x34\x22\x20\x73\x74\
\x72\x6f\x6b\x65\x3d\x22\x23\x34\x36\x36\x32\x38\x30\x22\x20\x73\
\x74\x72\x6f\x6b\x65\x2d\x77\x69\x64\x74\x68\x3d\x22\x31\x30\x22\
\x2f\x3e\x3c\x2f\x73\x76\x67\x3e\
\x00\x00\x0c\xeb\
\x3c\
\x73\x76\x67\x20\x68\x65\x69\x67\x68\x74\x3d\x22\x32\x34\x22\x20\
\x77\x69\x64\x74\x68\x3d\x22\x32\x34\x22\x20\x78\x6d\x6c\x6e\x73\
\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x77\x77\x77\x2e\x77\x33\x2e\
\x6f\x72\x67\x2f\x32\x30\x30\x30\x2f\x73\x76\x67\x22\x3e\x3c\x67\
\x20\x74\x72\x61\x6e\x73\x66\x6f\x72\x6d\x3d\x22\x6d\x61\x74\x72\
\x69\x78\x28\x2e\x37\x35\x30\x30\x31\x34\x33\x35\x20\x30\x20\x30\
\x20\x2e\x37\x35\x30\x30\x31\x34\x33\x35\x20\x2d\x2e\x30\x30\x30\
\x32\x32\x39\x20\x30\x29\x22\x3e\x3c\x70\x61\x74\x68\x20\x64\x3d\
\x22\x6d\x32\x38\x2e\x39\x31\x36\x35\x37\x32\x20\x36\x2e\x39\x33\
\x33\x39\x36\x31\x38\x2d\x37\x2e\x33\x32\x32\x38\x32\x32\x2e\x35\
\x39\x37\x32\x38\x37\x39\x2e\x38\x37\x35\x20\x33\x2e\x33\x37\x35\
\x30\x30\x30\x33\x2e\x31\x38\x37\x35\x20\x33\x2e\x30\x36\x32\x35\
\x20\x38\x2e\x32\x32\x31\x34\x37\x35\x2e\x30\x33\x31\x35\x34\x2d\
\x2e\x31\x35\x38\x39\x37\x35\x2d\x33\x2e\x33\x34\x34\x30\x34\x35\
\x7a\x22\x20\x66\x69\x6c\x6c\x3d\x22\x23\x32\x66\x35\x30\x37\x34\
\x22\x2f\x3e\x3c\x70\x61\x74\x68\x20\x64\x3d\x22\x6d\x32\x38\x2e\
\x39\x31\x36\x35\x37\x32\x20\x32\x31\x2e\x30\x36\x36\x36\x32\x39\
\x2d\x37\x2e\x33\x32\x32\x38\x32\x31\x2d\x2e\x35\x39\x37\x32\x38\
\x38\x2e\x38\x37\x34\x39\x39\x39\x2d\x33\x2e\x33\x37\x34\x39\x39\
\x39\x2e\x31\x38\x37\x35\x2d\x33\x2e\x30\x36\x32\x35\x20\x38\x2e\
\x32\x32\x31\x34\x37\x35\x2d\x2e\x30\x33\x31\x35\x34\x2d\x2e\x31\
\x35\x38\x39\x37\x35\x20\x33\x2e\x33\x34\x34\x30\x34\x34\x7a\x22\
\x20\x66\x69\x6c\x6c\x3d\x22\x23\x36\x65\x39\x37\x63\x34\x22\x2f\
\x3e\x3c\x70\x61\x74\x68\x20\x64\x3d\x22\x6d\x32\x38\x2e\x39\x31\
\x36\x35\x37\x32\x20\x32\x31\x2e\x30\x36\x36\x36\x32\x39\x2d\x37\
\x2e\x33\x32\x32\x38\x32\x31\x2d\x2e\x35\x39\x37\x32\x38\x38\x2d\
\x31\x2e\x37\x38\x31\x32\x35\x20\x33\x2e\x34\x36\x38\x37\x35\x2d\
\x33\x2e\x37\x32\x35\x38\x32\x31\x20\x33\x2e\x33\x39\x36\x31\x36\
\x39\x20\x35\x2e\x36\x35\x36\x38\x35\x35\x2d\x2e\x39\x37\x32\x32\
\x37\x32\x20\x34\x2e\x34\x38\x35\x37\x30\x37\x2d\x32\x2e\x32\x33\
\x31\x38\x30\x36\x7a\x22\x20\x66\x69\x6c\x6c\x3d\x22\x23\x32\x66\
\x35\x30\x37\x35\x22\x2f\x3e\x3c\x70\x61\x74\x68\x20\x64\x3d\x22\
\x6d\x31\x36\x2e\x30\x38\x36\x36\x37\x39\x20\x32\x37\x2e\x33\x33\
\x34\x32\x36\x2d\x33\x2e\x34\x34\x37\x31\x34\x34\x2d\x33\x2e\x30\
\x30\x35\x32\x30\x34\x2d\x32\x2e\x30\x33\x32\x39\x33\x32\x2d\x33\
\x2e\x38\x34\x34\x38\x39\x32\x20\x35\x2e\x35\x31\x38\x33\x39\x38\
\x2d\x2e\x31\x30\x38\x35\x37\x33\x20\x35\x2e\x34\x36\x38\x37\x35\
\x2e\x30\x39\x33\x37\x35\x2d\x31\x2e\x37\x38\x31\x32\x35\x20\x33\
\x2e\x34\x36\x38\x37\x35\x2d\x33\x2e\x37\x32\x35\x38\x32\x20\x33\
\x2e\x33\x39\x36\x31\x36\x39\x7a\x22\x20\x66\x69\x6c\x6c\x3d\x22\
\x23\x36\x65\x39\x37\x63\x34\x22\x2f\x3e\x3c\x70\x61\x74\x68\x20\
\x64\x3d\x22\x6d\x31\x30\x2e\x36\x30\x36\x36\x30\x33\x20\x32\x30\
\x2e\x34\x38\x34\x31\x36\x34\x20\x31\x30\x2e\x39\x38\x37\x31\x34\
\x38\x2d\x2e\x30\x31\x34\x38\x32\x2e\x38\x37\x34\x39\x39\x39\x2d\
\x33\x2e\x33\x37\x35\x2e\x31\x38\x37\x35\x2d\x33\x2e\x30\x36\x32\
\x35\x2d\x31\x33\x2e\x31\x35\x36\x32\x34\x39\x33\x2d\x2e\x30\x36\
\x32\x35\x2e\x31\x38\x37\x35\x30\x31\x31\x20\x33\x2e\x36\x38\x37\
\x34\x39\x39\x2e\x39\x31\x39\x31\x30\x31\x32\x20\x32\x2e\x38\x32\
\x37\x33\x32\x32\x7a\x22\x20\x66\x69\x6c\x6c\x3d\x22\x23\x32\x66\
\x35\x30\x37\x35\x22\x2f\x3e\x3c\x70\x61\x74\x68\x20\x64\x3d\x22\
\x6d\x31\x36\x2e\x30\x38\x36\x36\x37\x39\x20\x32\x37\x2e\x33\x33\
\x34\x32\x36\x2d\x33\x2e\x35\x33\x35\x35\x33\x33\x2d\x2e\x33\x35\
\x33\x35\x35\x33\x2d\x34\x2e\x35\x30\x37\x38\x30\x36\x33\x2d\x31\
\x2e\x38\x31\x31\x39\x36\x32\x2d\x33\x2e\x38\x38\x39\x30\x38\x37\
\x2d\x32\x2e\x33\x38\x36\x34\x38\x35\x2d\x31\x2e\x33\x37\x30\x30\
\x32\x2d\x31\x2e\x37\x32\x33\x35\x37\x33\x20\x34\x2e\x39\x39\x33\
\x39\x34\x32\x2d\x2e\x35\x33\x30\x33\x33\x20\x32\x2e\x38\x32\x38\
\x34\x32\x37\x33\x2d\x2e\x30\x34\x34\x31\x39\x20\x32\x2e\x30\x33\
\x32\x39\x33\x33\x20\x33\x2e\x38\x34\x34\x38\x39\x33\x7a\x22\x20\
\x66\x69\x6c\x6c\x3d\x22\x23\x32\x66\x35\x30\x37\x35\x22\x2f\x3e\
\x3c\x70\x61\x74\x68\x20\x64\x3d\x22\x6d\x32\x2e\x39\x34\x38\x31\
\x37\x38\x37\x20\x32\x31\x2e\x30\x36\x36\x36\x32\x39\x2d\x31\x2e\
\x37\x36\x30\x36\x37\x39\x2d\x33\x2e\x36\x35\x39\x37\x38\x38\x2d\
\x2e\x34\x30\x33\x33\x35\x36\x2d\x33\x2e\x34\x30\x36\x35\x34\x35\
\x20\x38\x2e\x37\x31\x35\x38\x35\x37\x2d\x2e\x30\x33\x30\x39\x35\
\x2e\x31\x38\x37\x35\x30\x30\x31\x20\x33\x2e\x36\x38\x37\x34\x39\
\x39\x2e\x39\x31\x39\x31\x30\x32\x32\x20\x32\x2e\x38\x32\x37\x33\
\x32\x33\x2d\x32\x2e\x38\x32\x38\x34\x32\x38\x33\x2e\x30\x34\x34\
\x31\x39\x7a\x22\x20\x66\x69\x6c\x6c\x3d\x22\x23\x36\x65\x39\x37\
\x63\x34\x22\x2f\x3e\x3c\x70\x61\x74\x68\x20\x64\x3d\x22\x6d\x32\
\x2e\x39\x34\x38\x31\x37\x38\x36\x20\x36\x2e\x39\x33\x33\x39\x36\
\x31\x38\x2d\x31\x2e\x37\x36\x30\x36\x37\x38\x36\x20\x33\x2e\x36\
\x35\x39\x37\x38\x38\x32\x2d\x2e\x34\x30\x33\x33\x35\x36\x33\x20\
\x33\x2e\x34\x30\x36\x35\x34\x36\x20\x38\x2e\x37\x31\x35\x38\x35\
\x36\x33\x2e\x30\x33\x30\x39\x35\x34\x2e\x31\x38\x37\x35\x2d\x33\
\x2e\x36\x38\x37\x35\x2e\x39\x31\x39\x31\x30\x32\x2d\x32\x2e\x38\
\x32\x37\x33\x32\x32\x33\x2d\x32\x2e\x38\x32\x38\x34\x32\x37\x34\
\x2d\x2e\x30\x34\x34\x31\x39\x34\x32\x7a\x22\x20\x66\x69\x6c\x6c\
\x3d\x22\x23\x32\x66\x35\x30\x37\x34\x22\x2f\x3e\x3c\x70\x61\x74\
\x68\x20\x64\x3d\x22\x6d\x31\x36\x2e\x30\x38\x36\x36\x37\x39\x2e\
\x36\x36\x36\x33\x33\x30\x37\x35\x2d\x33\x2e\x35\x33\x35\x35\x33\
\x34\x2e\x33\x35\x33\x35\x35\x33\x33\x35\x2d\x34\x2e\x35\x30\x37\
\x38\x30\x35\x34\x20\x31\x2e\x38\x31\x31\x39\x36\x31\x32\x2d\x33\
\x2e\x38\x38\x39\x30\x38\x37\x33\x20\x32\x2e\x33\x38\x36\x34\x38\
\x35\x34\x2d\x31\x2e\x33\x37\x30\x30\x31\x39\x33\x20\x31\x2e\x37\
\x32\x33\x35\x37\x32\x37\x20\x34\x2e\x39\x39\x33\x39\x34\x31\x36\
\x2e\x35\x33\x30\x33\x33\x30\x31\x20\x32\x2e\x38\x32\x38\x34\x32\
\x37\x34\x2e\x30\x34\x34\x31\x39\x34\x32\x20\x32\x2e\x30\x33\x32\
\x39\x33\x32\x2d\x33\x2e\x38\x34\x34\x38\x39\x33\x31\x7a\x22\x20\
\x66\x69\x6c\x6c\x3d\x22\x23\x36\x65\x39\x37\x63\x34\x22\x2f\x3e\
\x3c\x70\x61\x74\x68\x20\x64\x3d\x22\x6d\x31\x36\x2e\x30\x38\x36\
\x36\x37\x39\x2e\x36\x36\x36\x33\x33\x30\x37\x35\x2d\x33\x2e\x34\
\x34\x37\x31\x34\x35\x20\x33\x2e\x30\x30\x35\x32\x30\x33\x38\x35\
\x2d\x32\x2e\x30\x33\x32\x39\x33\x32\x20\x33\x2e\x38\x34\x34\x38\
\x39\x33\x31\x20\x35\x2e\x35\x31\x38\x33\x39\x38\x2e\x31\x30\x38\
\x35\x37\x32\x33\x20\x35\x2e\x34\x36\x38\x37\x35\x2d\x2e\x30\x39\
\x33\x37\x35\x2d\x31\x2e\x37\x38\x31\x32\x35\x2d\x33\x2e\x34\x36\
\x38\x37\x35\x7a\x22\x20\x66\x69\x6c\x6c\x3d\x22\x23\x32\x66\x35\
\x30\x37\x34\x22\x2f\x3e\x3c\x70\x61\x74\x68\x20\x64\x3d\x22\x6d\
\x32\x38\x2e\x39\x31\x36\x35\x37\x32\x20\x36\x2e\x39\x33\x33\x39\
\x36\x31\x38\x2d\x37\x2e\x33\x32\x32\x38\x32\x32\x2e\x35\x39\x37\
\x32\x38\x38\x32\x2d\x31\x2e\x37\x38\x31\x32\x35\x2d\x33\x2e\x34\
\x36\x38\x37\x35\x2d\x33\x2e\x37\x32\x35\x38\x32\x31\x2d\x33\x2e\
\x33\x39\x36\x31\x36\x39\x32\x35\x20\x35\x2e\x36\x35\x36\x38\x35\
\x35\x2e\x39\x37\x32\x32\x37\x31\x38\x35\x20\x34\x2e\x34\x38\x35\
\x37\x30\x38\x20\x32\x2e\x32\x33\x31\x38\x30\x35\x37\x7a\x22\x20\
\x66\x69\x6c\x6c\x3d\x22\x23\x36\x65\x39\x37\x63\x34\x22\x2f\x3e\
\x3c\x70\x61\x74\x68\x20\x64\x3d\x22\x6d\x31\x30\x2e\x36\x30\x36\
\x36\x30\x32\x20\x37\x2e\x35\x31\x36\x34\x32\x37\x37\x20\x31\x30\
\x2e\x39\x38\x37\x31\x34\x38\x2e\x30\x31\x34\x38\x32\x32\x2e\x38\
\x37\x35\x20\x33\x2e\x33\x37\x35\x30\x30\x30\x33\x2e\x31\x38\x37\
\x35\x20\x33\x2e\x30\x36\x32\x35\x2d\x31\x33\x2e\x31\x35\x36\x32\
\x35\x2e\x30\x36\x32\x35\x2e\x31\x38\x37\x35\x2d\x33\x2e\x36\x38\
\x37\x35\x2e\x39\x31\x39\x31\x30\x32\x2d\x32\x2e\x38\x32\x37\x33\
\x32\x32\x36\x7a\x22\x20\x66\x69\x6c\x6c\x3d\x22\x23\x36\x65\x39\
\x37\x63\x34\x22\x2f\x3e\x3c\x67\x20\x66\x69\x6c\x6c\x3d\x22\x6e\
\x6f\x6e\x65\x22\x3e\x3c\x70\x61\x74\x68\x20\x64\x3d\x22\x6d\x31\
\x36\x20\x31\x2e\x30\x37\x37\x35\x33\x36\x38\x63\x2d\x38\x2e\x31\
\x31\x35\x31\x32\x32\x38\x20\x33\x2e\x35\x32\x34\x33\x38\x39\x2d\
\x39\x2e\x34\x36\x37\x36\x34\x33\x32\x20\x32\x31\x2e\x31\x34\x36\
\x33\x33\x33\x32\x20\x30\x20\x32\x35\x2e\x38\x34\x35\x35\x31\x38\
\x32\x22\x20\x73\x74\x72\x6f\x6b\x65\x3d\x22\x23\x66\x66\x66\x22\
\x20\x73\x74\x72\x6f\x6b\x65\x2d\x77\x69\x64\x74\x68\x3d\x22\x31\
\x2e\x32\x22\x2f\x3e\x3c\x70\x61\x74\x68\x20\x64\x3d\x22\x6d\x31\
\x36\x20\x31\x2e\x30\x37\x37\x35\x33\x36\x38\x63\x39\x2e\x34\x36\
\x37\x36\x34\x33\x20\x34\x2e\x36\x39\x39\x31\x38\x35\x32\x20\x38\
\x2e\x31\x31\x35\x31\x32\x33\x20\x32\x32\x2e\x33\x32\x31\x31\x32\
\x39\x32\x20\x30\x20\x32\x35\x2e\x38\x34\x35\x35\x31\x38\x32\x22\
\x20\x73\x74\x72\x6f\x6b\x65\x3d\x22\x23\x66\x66\x66\x22\x20\x73\
\x74\x72\x6f\x6b\x65\x2d\x77\x69\x64\x74\x68\x3d\x22\x31\x2e\x32\
\x22\x2f\x3e\x3c\x70\x61\x74\x68\x20\x64\x3d\x22\x6d\x31\x2e\x31\
\x32\x32\x32\x37\x34\x39\x20\x31\x34\x2e\x30\x30\x30\x32\x39\x35\
\x68\x32\x39\x2e\x37\x35\x35\x34\x35\x30\x31\x22\x20\x73\x74\x72\
\x6f\x6b\x65\x3d\x22\x23\x66\x66\x66\x22\x20\x73\x74\x72\x6f\x6b\
\x65\x2d\x77\x69\x64\x74\x68\x3d\x22\x31\x2e\x32\x22\x2f\x3e\x3c\
\x70\x61\x74\x68\x20\x64\x3d\x22\x6d\x32\x2e\x39\x34\x38\x31\x37\
\x38\x36\x20\x36\x2e\x39\x33\x33\x39\x36\x31\x38\x63\x37\x2e\x35\
\x37\x34\x31\x31\x35\x34\x20\x31\x2e\x30\x30\x39\x34\x37\x36\x34\
\x20\x31\x38\x2e\x33\x39\x34\x32\x37\x37\x34\x20\x31\x2e\x30\x30\
\x39\x34\x37\x36\x34\x20\x32\x35\x2e\x39\x36\x38\x33\x39\x33\x34\
\x20\x30\x22\x20\x73\x74\x72\x6f\x6b\x65\x3d\x22\x23\x66\x66\x66\
\x22\x20\x73\x74\x72\x6f\x6b\x65\x2d\x77\x69\x64\x74\x68\x3d\x22\
\x31\x2e\x32\x22\x2f\x3e\x3c\x70\x61\x74\x68\x20\x64\x3d\x22\x6d\
\x32\x2e\x39\x34\x38\x31\x37\x38\x36\x20\x32\x31\x2e\x30\x36\x36\
\x36\x33\x63\x37\x2e\x35\x37\x34\x31\x31\x35\x34\x2d\x31\x2e\x30\
\x30\x39\x34\x37\x37\x20\x31\x38\x2e\x33\x39\x34\x32\x37\x37\x34\
\x2d\x31\x2e\x30\x30\x39\x34\x37\x37\x20\x32\x35\x2e\x39\x36\x38\
\x33\x39\x33\x34\x20\x30\x22\x20\x73\x74\x72\x6f\x6b\x65\x3d\x22\
\x23\x66\x66\x66\x22\x20\x73\x74\x72\x6f\x6b\x65\x2d\x77\x69\x64\
\x74\x68\x3d\x22\x31\x2e\x32\x22\x2f\x3e\x3c\x70\x61\x74\x68\x20\
\x64\x3d\x22\x6d\x36\x2e\x39\x36\x35\x36\x39\x34\x39\x20\x31\x33\
\x2e\x38\x30\x39\x33\x33\x32\x63\x30\x20\x36\x2e\x37\x31\x33\x39\
\x35\x35\x2d\x35\x2e\x34\x32\x32\x38\x37\x31\x36\x20\x31\x32\x2e\
\x31\x35\x36\x36\x39\x2d\x31\x32\x2e\x31\x31\x32\x33\x32\x32\x38\
\x20\x31\x32\x2e\x31\x35\x36\x36\x39\x2d\x36\x2e\x36\x38\x39\x34\
\x35\x31\x31\x20\x30\x2d\x31\x32\x2e\x31\x31\x32\x33\x32\x33\x31\
\x2d\x35\x2e\x34\x34\x32\x37\x33\x35\x2d\x31\x32\x2e\x31\x31\x32\
\x33\x32\x33\x31\x2d\x31\x32\x2e\x31\x35\x36\x36\x39\x20\x30\x2d\
\x36\x2e\x37\x31\x33\x39\x35\x34\x39\x20\x35\x2e\x34\x32\x32\x38\
\x37\x32\x2d\x31\x32\x2e\x31\x35\x36\x36\x39\x30\x37\x20\x31\x32\
\x2e\x31\x31\x32\x33\x32\x33\x31\x2d\x31\x32\x2e\x31\x35\x36\x36\
\x39\x30\x37\x20\x36\x2e\x36\x38\x39\x34\x35\x31\x32\x20\x30\x20\
\x31\x32\x2e\x31\x31\x32\x33\x32\x32\x38\x20\x35\x2e\x34\x34\x32\
\x37\x33\x35\x38\x20\x31\x32\x2e\x31\x31\x32\x33\x32\x32\x38\x20\
\x31\x32\x2e\x31\x35\x36\x36\x39\x30\x37\x7a\x22\x20\x73\x74\x72\
\x6f\x6b\x65\x3d\x22\x23\x32\x62\x33\x62\x34\x64\x22\x20\x73\x74\
\x72\x6f\x6b\x65\x2d\x77\x69\x64\x74\x68\x3d\x22\x31\x2e\x33\x34\
\x31\x34\x34\x32\x22\x20\x74\x72\x61\x6e\x73\x66\x6f\x72\x6d\x3d\
\x22\x6d\x61\x74\x72\x69\x78\x28\x31\x2e\x32\x35\x36\x32\x32\x39\
\x34\x20\x30\x20\x30\x20\x31\x2e\x30\x38\x37\x31\x37\x35\x37\x20\
\x32\x32\x2e\x34\x36\x35\x33\x34\x35\x20\x2d\x31\x2e\x30\x31\x32\
\x38\x37\x34\x29\x22\x2f\x3e\x3c\x2f\x67\x3e\x3c\x2f\x67\x3e\x3c\
\x67\x20\x74\x72\x61\x6e\x73\x66\x6f\x72\x6d\x3d\x22\x6d\x61\x74\
\x72\x69\x78\x28\x2e\x36\x39\x32\x33\x30\x37\x36\x39\x20\x30\x20\
\x30\x20\x2e\x36\x39\x32\x33\x30\x37\x36\x39\x20\x31\x2e\x38\x34\
\x36\x31\x35\x34\x20\x31\x2e\x38\x34\x36\x31\x35\x34\x29\x22\x3e\
\x3c\x72\x65\x63\x74\x20\x66\x69\x6c\x6c\x3d\x22\x23\x35\x61\x38\
\x63\x35\x61\x22\x20\x68\x65\x69\x67\x68\x74\x3d\x22\x31\x33\x22\
\x20\x72\x78\x3d\x22\x32\x2e\x36\x31\x34\x39\x33\x37\x22\x20\x77\
\x69\x64\x74\x68\x3d\x22\x31\x33\x22\x20\x78\x3d\x22\x31\x39\x22\
\x20\x79\x3d\x22\x31\x39\x22\x2f\x3e\x3c\x67\x20\x66\x69\x6c\x6c\
\x2d\x72\x75\x6c\x65\x3d\x22\x65\x76\x65\x6e\x6f\x64\x64\x22\x3e\
\x3c\x70\x61\x74\x68\x20\x64\x3d\x22\x6d\x32\x31\x2e\x36\x20\x32\
\x35\x2e\x34\x39\x39\x39\x39\x39\x68\x37\x2e\x38\x22\x20\x66\x69\
\x6c\x6c\x3d\x22\x23\x66\x66\x66\x22\x20\x73\x74\x72\x6f\x6b\x65\
\x3d\x22\x23\x66\x66\x66\x22\x20\x73\x74\x72\x6f\x6b\x65\x2d\x6c\
\x69\x6e\x65\x63\x61\x70\x3d\x22\x72\x6f\x75\x6e\x64\x22\x20\x73\
\x74\x72\x6f\x6b\x65\x2d\x6c\x69\x6e\x65\x6a\x6f\x69\x6e\x3d\x22\
\x72\x6f\x75\x6e\x64\x22\x20\x73\x74\x72\x6f\x6b\x65\x2d\x77\x69\
\x64\x74\x68\x3d\x22\x32\x2e\x36\x22\x2f\x3e\x3c\x70\x61\x74\x68\
\x20\x64\x3d\x22\x6d\x32\x35\x2e\x35\x20\x32\x39\x2e\x33\x39\x39\
\x39\x39\x39\x76\x2d\x37\x2e\x37\x39\x39\x39\x39\x39\x22\x20\x66\
\x69\x6c\x6c\x3d\x22\x23\x66\x66\x66\x22\x20\x73\x74\x72\x6f\x6b\
\x65\x3d\x22\x23\x66\x66\x66\x22\x20\x73\x74\x72\x6f\x6b\x65\x2d\
\x6c\x69\x6e\x65\x63\x61\x70\x3d\x22\x72\x6f\x75\x6e\x64\x22\x20\
\x73\x74\x72\x6f\x6b\x65\x2d\x6c\x69\x6e\x65\x6a\x6f\x69\x6e\x3d\
\x22\x72\x6f\x75\x6e\x64\x22\x20\x73\x74\x72\x6f\x6b\x65\x2d\x77\
\x69\x64\x74\x68\x3d\x22\x32\x2e\x36\x22\x2f\x3e\x3c\x70\x61\x74\
\x68\x20\x64\x3d\x22\x6d\x32\x30\x2e\x33\x20\x32\x35\x2e\x34\x39\
\x39\x39\x39\x39\x68\x31\x30\x2e\x34\x73\x30\x20\x30\x20\x30\x2d\
\x32\x2e\x36\x63\x30\x2d\x32\x2e\x35\x39\x39\x39\x39\x39\x2d\x2e\
\x36\x35\x2d\x32\x2e\x35\x39\x39\x39\x39\x39\x2d\x35\x2e\x32\x2d\
\x32\x2e\x35\x39\x39\x39\x39\x39\x73\x2d\x35\x2e\x32\x20\x30\x2d\
\x35\x2e\x32\x20\x32\x2e\x35\x39\x39\x39\x39\x39\x7a\x22\x20\x66\
\x69\x6c\x6c\x3d\x22\x23\x66\x63\x66\x66\x66\x66\x22\x20\x6f\x70\
\x61\x63\x69\x74\x79\x3d\x22\x2e\x33\x22\x2f\x3e\x3c\x2f\x67\x3e\
\x3c\x2f\x67\x3e\x3c\x2f\x73\x76\x67\x3e\
"
qt_resource_name = b"\
\x00\x07\
\x07\x3b\xe0\xb3\
\x00\x70\
\x00\x6c\x00\x75\x00\x67\x00\x69\x00\x6e\x00\x73\
\x00\x0c\
\x03\x7a\x47\x05\
\x00\x71\
\x00\x67\x00\x69\x00\x73\x00\x5f\x00\x67\x00\x65\x00\x6f\x00\x6e\x00\x6f\x00\x64\x00\x65\
\x00\x0c\
\x00\x22\x92\x07\
\x00\x69\
\x00\x63\x00\x6f\x00\x6e\x00\x5f\x00\x77\x00\x66\x00\x73\x00\x2e\x00\x73\x00\x76\x00\x67\
\x00\x0c\
\x03\xb2\x92\x07\
\x00\x69\
\x00\x63\x00\x6f\x00\x6e\x00\x5f\x00\x77\x00\x6d\x00\x73\x00\x2e\x00\x73\x00\x76\x00\x67\
\x00\x10\
\x09\xe3\x20\xe7\
\x00\x6d\
\x00\x49\x00\x63\x00\x6f\x00\x6e\x00\x47\x00\x65\x00\x6f\x00\x6e\x00\x6f\x00\x64\x00\x65\x00\x2e\x00\x73\x00\x76\x00\x67\
\x00\x0c\
\x00\x52\x92\x07\
\x00\x69\
\x00\x63\x00\x6f\x00\x6e\x00\x5f\x00\x77\x00\x63\x00\x73\x00\x2e\x00\x73\x00\x76\x00\x67\
"
qt_resource_struct_v1 = b"\
\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x01\
\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x02\
\x00\x00\x00\x14\x00\x02\x00\x00\x00\x04\x00\x00\x00\x03\
\x00\x00\x00\x32\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\
\x00\x00\x00\x94\x00\x00\x00\x00\x00\x01\x00\x00\x27\x7d\
\x00\x00\x00\x50\x00\x00\x00\x00\x00\x01\x00\x00\x06\xd7\
\x00\x00\x00\x6e\x00\x00\x00\x00\x00\x01\x00\x00\x24\x70\
"
qt_resource_struct_v2 = b"\
\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x01\
\x00\x00\x00\x00\x00\x00\x00\x00\
\x00\x00\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x02\
\x00\x00\x00\x00\x00\x00\x00\x00\
\x00\x00\x00\x14\x00\x02\x00\x00\x00\x04\x00\x00\x00\x03\
\x00\x00\x00\x00\x00\x00\x00\x00\
\x00\x00\x00\x32\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\
\x00\x00\x01\x7e\x50\x58\xdf\x34\
\x00\x00\x00\x94\x00\x00\x00\x00\x00\x01\x00\x00\x27\x7d\
\x00\x00\x01\x7e\x50\x58\xdf\x34\
\x00\x00\x00\x50\x00\x00\x00\x00\x00\x01\x00\x00\x06\xd7\
\x00\x00\x01\x7e\x50\x58\xdf\x34\
\x00\x00\x00\x6e\x00\x00\x00\x00\x00\x01\x00\x00\x24\x70\
\x00\x00\x01\x7e\x50\x58\xdf\x34\
"
qt_version = [int(v) for v in QtCore.qVersion().split('.')]
if qt_version < [5, 8, 0]:
rcc_version = 1
qt_resource_struct = qt_resource_struct_v1
else:
rcc_version = 2
qt_resource_struct = qt_resource_struct_v2
def qInitResources():
QtCore.qRegisterResourceData(rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data)
def qCleanupResources():
QtCore.qUnregisterResourceData(rcc_version, qt_resource_struct, qt_resource_name, qt_resource_data)
qInitResources()
PK ,T-73 3 qgis_geonode/__init__.py# -*- coding: utf-8 -*-
"""
/***************************************************************************
QgisGeoNode
A QGIS plugin
A QGIS plugin that provides integration with GeoNode
Generated by Plugin Builder: http://g-sherman.github.io/Qgis-Plugin-Builder/
-------------------
begin : 2020-12-23
copyright : (C) 2020 by Kartoza
email : info@kartoza.com
git sha : $Format:%H$
***************************************************************************/
/***************************************************************************
* *
* This program is free software; you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation; either version 2 of the License, or *
* (at your option) any later version. *
* *
***************************************************************************/
This script initializes the plugin, making it known to QGIS.
"""
# noinspection PyPep8Naming
def classFactory(iface): # pylint: disable=invalid-name
"""Load QgisGeoNode class from file QgisGeoNode.
:param iface: A QGIS interface instance.
:type iface: QgsInterface
"""
#
from .main import QgisGeoNode
return QgisGeoNode(iface)
PK ,TTa a qgis_geonode/metadata.txt[general]
name = QGIS GeoNode
qgisMinimumVersion = 3.18
icon = mIconGeonode.svg
experimental = True
deprecated = False
homepage = https://kartoza.github.io/qgis_geonode
tracker = https://github.com/kartoza/qgis_geonode/issues
repository = https://github.com/kartoza/qgis_geonode
tags = geonode, wms, wfs, wcs
category = plugins
hasProcessingProvider = no
about = This plugin adds GeoNode client functionality to QGIS - Search, load and manage GeoNode resources from inside QGIS
author = Kartoza
email = info@kartoza.com
description = A QGIS plugin for integrating with GeoNode
version = 0.9.0
changelog = 0.9.0 - 2022-01-12
Added
- Add connection capabilities and detected version details
- Connection test now uses auth credentials, if available
Changed
- Layer uploads also send SLD style
- Update user guide
Fixed
- Fix re-download of metadata for loaded layers
- Fix dataset abstract not being shown on the UI anymore
0.5.0 - 2021-12-29
Added
- Allow uploading QGIS layers to GeoNode as new datasets
Fixed
- QGIS plugins menu no longer shows empy reference to this plugin
0.4.0 - 2021-12-20
Added
- Allow loading and saving layer title and abstract from/to GeoNode
Changed
- Bump minimum QGIS version to 3.18
0.3.4 - 2021-12-17
Added
- Modify style of GeoNode layer and save it on the remote GeoNode
Changed
- Network fetcher task is now able to perform PUT requests
- Better handling of network errors
0.3.3 - 2021-11-22
Fixed
- This release is functionally equivalent to v0.3.2
0.3.2 - 2021-11-22 - [YANKED]
Changed
- Introduce compatibility with the latest developments of upstream GeoNode API
0.3.1 - 2021-05-07
Added
- Persist current search filters between restarts of QGIS
- Add icon to button that fetches keywords
- Improve user feedback when testing connections
Fixed
- Improved layer loading with the CSW API
- Fix incorrect pagination results with the CSW API
0.3.0 - 2021-04-07
Added
- Allow filtering searches by temporal extent and publication date
- Add ordering of search results
- Add Changelog to the online documentation
- Further improve the look of search results
Changed
- All HTTP requests are now done in a background thread to avoid blocking QGIS UI
- Load layers in a background thread in order to avoid blocking QGIS UI
- Improve feedback shown when searching and loading layers
- Move Title search filter out of the collapsible group, so that it is easier to access
Fixed
- Improved error handling
- Fix incorrect visibility of the Search/Next/Previous search buttons
- Reset pagination when pressing Search button
- Remove unused Add/Close buttons on datasource manager dialogue
0.2.0 - 2021-02-28
Added
- Add initial support for earlier GeoNode versions
- Initial support for search filters
- Add support for applying a vector layer's default SLD style when loading
Changed
- Improve look of search results
Fixed
- Fix invalid update date for versions released via custom plugin repo
0.1.1 - 2021-02-02
Fixed
- Invalid tag format in previous version prevented automated distribution to our custom QGIS repo
0.1.0 - 2021-02-02 [YANKED]
Added
- Load GeoNode layers into QGIS
- Load a GeoNode metadata into the corresponding QGIS layer
- Manage GeoNode connections through the plugin GUI
- Improve plugin metadata and documentation
Fixed
- Current connection settings are now always up-to-date with the GUI
0.0.9 - 2021-01-11
Fixed
- Invalid plugin zip name
0.0.8 - 2021-01-08
Fixed
- Remove pycache files from plugin zip
0.0.7 - 2021-01-08
Fixed
- Invalid CI settings
0.0.6 - 2021-01-08
Fixed
- Invalid CI settings
0.0.5 - 2021-01-08
Added
- Initial project structure
- Add infrastructure for automated testing
- Add infrastructure for managing releases
- Add geonode API client
PK x,TPWN N " qgis_geonode/apiclient/__init__.pyimport importlib
import typing
from ..network import UNSUPPORTED_REMOTE
from ..vendor.packaging import version as packaging_version
def get_geonode_client(
connection_settings: "ConnectionSettings",
) -> typing.Optional["BaseGeonodeClient"]:
version = connection_settings.geonode_version
if version is not None and version != UNSUPPORTED_REMOTE:
class_path = select_client_class_path(connection_settings.geonode_version)
module_path, class_name = class_path.rpartition(".")[::2]
imported_module = importlib.import_module(module_path)
class_type = getattr(imported_module, class_name)
result = class_type.from_connection_settings(connection_settings)
else:
result = None
return result
def select_client_class_path(geonode_version: packaging_version.Version) -> str:
if geonode_version.major == 4:
result = "qgis_geonode.apiclient.geonode_v3.GeonodeApiClientVersion_3_4_0"
elif geonode_version.major == 3 and geonode_version.minor >= 4:
result = "qgis_geonode.apiclient.geonode_v3.GeonodeApiClientVersion_3_4_0"
elif geonode_version.major == 3 and geonode_version.minor == 3:
result = "qgis_geonode.apiclient.geonode_v3.GeonodeApiClientVersion_3_3_0"
else:
result = "qgis_geonode.apiclient.legacy.GeonodeLegacyApiClient"
return result
PK x,TR $ qgis_geonode/apiclient/geonode_v3.py"""API client classes for GeoNode versions 3.3.0 and up"""
import dataclasses
import datetime as dt
import json
import shutil
import tempfile
import typing
import uuid
from pathlib import Path
import qgis.core
import qgis.utils
from qgis.PyQt import (
QtCore,
QtNetwork,
)
from .. import network
from .. import styles as geonode_styles
from ..utils import (
log,
)
from . import models
from .base import BaseGeonodeClient
@dataclasses.dataclass()
class ExportFormat:
driver_name: str
file_extension: str
class GeonodeApiClientVersion_3_x(BaseGeonodeClient):
_DATASET_NAME = "dataset"
_DATASET_NAME_PLURAL = "datasets"
@property
def api_url(self):
return f"{self.base_url}/api/v2"
@property
def dataset_list_url(self):
return f"{self.api_url}/{self._DATASET_NAME_PLURAL}/"
def get_ordering_fields(self) -> typing.List[typing.Tuple[str, str]]:
return [
("title", "Title"),
]
def build_search_query(
self, search_filters: models.GeonodeApiSearchFilters
) -> QtCore.QUrlQuery:
query = QtCore.QUrlQuery()
query.addQueryItem("page", str(search_filters.page))
query.addQueryItem("page_size", str(self.page_size))
if search_filters.title is not None:
query.addQueryItem("filter{title.icontains}", search_filters.title)
if search_filters.abstract is not None:
query.addQueryItem("filter{abstract.icontains}", search_filters.abstract)
if search_filters.keyword is not None:
query.addQueryItem(
"filter{keywords.name.icontains}", search_filters.keyword
)
if search_filters.topic_category is not None:
query.addQueryItem(
"filter{category.identifier}",
search_filters.topic_category.name.lower(),
)
if search_filters.temporal_extent_start is not None:
query.addQueryItem(
"filter{temporal_extent_start.gte}",
search_filters.temporal_extent_start.toString(QtCore.Qt.ISODate),
)
if search_filters.temporal_extent_end is not None:
query.addQueryItem(
"filter{temporal_extent_end.lte}",
search_filters.temporal_extent_end.toString(QtCore.Qt.ISODate),
)
if search_filters.publication_date_start is not None:
query.addQueryItem(
"filter{date.gte}",
search_filters.publication_date_start.toString(QtCore.Qt.ISODate),
)
if search_filters.publication_date_end is not None:
query.addQueryItem(
"filter{date.lte}",
search_filters.publication_date_end.toString(QtCore.Qt.ISODate),
)
# TODO revisit once the support for spatial extent is available on
# GeoNode API V2
if (
search_filters.spatial_extent is not None
and not search_filters.spatial_extent.isNull()
):
pass
if search_filters.layer_types is None:
types = [
models.GeonodeResourceType.VECTOR_LAYER,
models.GeonodeResourceType.RASTER_LAYER,
]
else:
types = list(search_filters.layer_types)
is_vector = models.GeonodeResourceType.VECTOR_LAYER in types
is_raster = models.GeonodeResourceType.RASTER_LAYER in types
if is_vector:
query.addQueryItem("filter{subtype.in}", "vector")
if is_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"
)
return query
def get_dataset_list_url(
self, search_filters: models.GeonodeApiSearchFilters
) -> QtCore.QUrl:
url = QtCore.QUrl(self.dataset_list_url)
query = self.build_search_query(search_filters)
url.setQuery(query.query())
return 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, task_result: bool) -> None:
deserialized_content = self._retrieve_response(
task_result, 0, self.search_error_received
)
if deserialized_content is not None:
brief_datasets = []
for raw_brief_ds in deserialized_content.get(self._DATASET_NAME_PLURAL, []):
try:
parsed_properties = self._get_common_model_properties(raw_brief_ds)
brief_dataset = models.BriefDataset(**parsed_properties)
except ValueError:
log(
f"Could not parse {raw_brief_ds!r} into 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)
def handle_dataset_detail(self, task_result: bool) -> None:
log("inside the API client's handle_dataset_detail")
deserialized_resource = self._retrieve_response(
task_result, 0, self.dataset_detail_error_received
)
if deserialized_resource is not None:
try:
dataset = self._parse_dataset_detail(
deserialized_resource[self._DATASET_NAME]
)
except KeyError as exc:
log(
f"Could not parse server response into a dataset: {str(exc)}",
debug=False,
)
else:
try:
style_response_contents = (
self.network_fetcher_task.response_contents[1]
)
except IndexError:
pass
else:
(
sld_named_layer,
error_message,
) = geonode_styles.get_usable_sld(style_response_contents)
if sld_named_layer is None:
raise RuntimeError(error_message)
dataset.default_style.sld = sld_named_layer
self.dataset_detail_received.emit(dataset)
def handle_dataset_style(
self,
dataset: models.Dataset,
task_result: bool,
emit_dataset_detail_received: bool = False,
) -> None:
response_contents = self._retrieve_response(
task_result, 0, self.style_detail_error_received, deserialize_as_json=False
)
if response_contents is not None:
sld_named_layer, error_message = geonode_styles.get_usable_sld(
response_contents
)
if sld_named_layer is None:
self.style_detail_error_received[str].emit(
f"Could not parse downloaded SLD: {error_message}"
)
dataset.default_style.sld = sld_named_layer
if emit_dataset_detail_received:
self.dataset_detail_received.emit(dataset)
def _retrieve_response(
self,
task_result: bool,
contents_index: int,
error_signal,
deserialize_as_json: typing.Optional[bool] = True,
) -> typing.Optional[typing.Union[typing.Dict, network.ParsedNetworkReply]]:
"""Internal method that takes care of boilerplate-ish response parsing."""
result = None
if task_result:
response_content = self.network_fetcher_task.response_contents[
contents_index
]
if response_content.qt_error is None:
result = response_content
if deserialize_as_json:
deserialized = network.deserialize_json_response(
response_content.response_body
)
if deserialized is not None:
result = deserialized
else:
error_signal[str].emit(
"Could not parse response from remote GeoNode"
)
else:
error_signal[str, int, str].emit(
response_content.qt_error,
response_content.http_status_code,
response_content.http_status_reason,
)
else:
error_signal[str].emit("Could not complete network request")
return result
def _get_common_model_properties(self, raw_dataset: typing.Dict) -> typing.Dict:
raise NotImplementedError
def _parse_dataset_detail(self, raw_dataset: typing.Dict) -> models.Dataset:
raise NotImplementedError
class GeonodeApiClientVersion_3_4_0(GeonodeApiClientVersion_3_x):
capabilities = [
models.ApiClientCapability.FILTER_BY_TITLE,
models.ApiClientCapability.FILTER_BY_RESOURCE_TYPES,
models.ApiClientCapability.FILTER_BY_ABSTRACT,
models.ApiClientCapability.FILTER_BY_KEYWORD,
models.ApiClientCapability.FILTER_BY_TOPIC_CATEGORY,
models.ApiClientCapability.FILTER_BY_PUBLICATION_DATE,
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,
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,
models.ApiClientCapability.LOAD_RASTER_DATASET_VIA_WCS,
models.ApiClientCapability.UPLOAD_VECTOR_LAYER,
models.ApiClientCapability.UPLOAD_RASTER_LAYER,
]
def get_dataset_upload_url(self) -> QtCore.QUrl:
return QtCore.QUrl(f"{self.api_url}/uploads/upload/")
def handle_dataset_detail_from_id(self, task_result: bool) -> None:
deserialized_resource = self._retrieve_response(
task_result, 0, self.dataset_detail_error_received
)
if deserialized_resource is not None:
try:
dataset = self._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:
if dataset.dataset_sub_type == models.GeonodeResourceType.VECTOR_LAYER:
self.get_dataset_style(dataset, emit_dataset_detail_received=True)
else:
self.dataset_detail_received.emit(dataset)
def get_uploader_task(
self, layer: qgis.core.QgsMapLayer, allow_public_access: bool, timeout: int
) -> qgis.core.QgsTask:
return LayerUploaderTask(
layer,
self.get_dataset_upload_url(),
allow_public_access,
self.auth_config,
network_task_timeout=timeout,
description="Upload layer to GeoNode",
)
def handle_layer_upload(self, result: bool):
if result:
response_contents = self.network_fetcher_task.response_contents[0]
if response_contents.http_status_code == 201:
deserialized = network.deserialize_json_response(
response_contents.response_body
)
catalogue_url = deserialized["url"]
dataset_pk = catalogue_url.rsplit("/")[-1]
self.dataset_uploaded.emit(int(dataset_pk))
else:
self.dataset_upload_error_received[str, int, str].emit(
response_contents.qt_error,
response_contents.http_status_code,
response_contents.http_status_reason,
)
else:
self.dataset_upload_error_received[str].emit(
"Could not upload layer to GeoNode"
)
def _get_common_model_properties(self, raw_dataset: typing.Dict) -> typing.Dict:
type_ = _get_resource_type(raw_dataset)
raw_links = raw_dataset.get("links", [])
if type_ == models.GeonodeResourceType.VECTOR_LAYER:
service_urls = _get_vector_service_urls(raw_links)
elif type_ == models.GeonodeResourceType.RASTER_LAYER:
service_urls = _get_raster_service_urls(raw_links)
else:
service_urls = {}
raw_style = raw_dataset.get("default_style") or {}
return {
"pk": int(raw_dataset["pk"]),
"uuid": uuid.UUID(raw_dataset["uuid"]),
"name": raw_dataset.get("alternate", raw_dataset.get("name", "")),
"title": raw_dataset.get("title", ""),
"abstract": raw_dataset.get(
"raw_abstract", raw_dataset.get("abstract", "")
),
"thumbnail_url": raw_dataset["thumbnail_url"],
"link": raw_dataset["link"],
"detail_url": raw_dataset["detail_url"],
"dataset_sub_type": type_,
"service_urls": service_urls,
"spatial_extent": _get_spatial_extent(raw_dataset["bbox_polygon"]),
"srid": qgis.core.QgsCoordinateReferenceSystem(raw_dataset["srid"]),
"published_date": _get_published_date(raw_dataset),
"temporal_extent": _get_temporal_extent(raw_dataset),
"keywords": [k["name"] for k in raw_dataset.get("keywords", [])],
"category": (raw_dataset.get("category") or {}).get("identifier"),
"default_style": models.BriefGeonodeStyle(
name=raw_style.get("name", ""), sld_url=raw_style.get("sld_url")
),
}
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)
class GeonodeApiClientVersion_3_3_0(GeonodeApiClientVersion_3_x):
"""API client for GeoNode version 3.3.x.
GeoNode version 3.3.0 still used `layers` instead of `datasets`. It also did not
allow the upload of new datasets via API when using OAuth2 auth.
"""
_DATASET_NAME = "layer"
_DATASET_NAME_PLURAL = "layers"
capabilities = [
models.ApiClientCapability.FILTER_BY_TITLE,
models.ApiClientCapability.FILTER_BY_RESOURCE_TYPES,
models.ApiClientCapability.FILTER_BY_ABSTRACT,
models.ApiClientCapability.FILTER_BY_KEYWORD,
models.ApiClientCapability.FILTER_BY_TOPIC_CATEGORY,
models.ApiClientCapability.FILTER_BY_PUBLICATION_DATE,
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,
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,
models.ApiClientCapability.LOAD_RASTER_DATASET_VIA_WCS,
# upload of datasets via API using OAuth2 auth does not work, so the relevant
# capabilities are not included
]
def build_search_query(
self, search_filters: models.GeonodeApiSearchFilters
) -> QtCore.QUrlQuery:
# GeoNode v3.3.0 layers did not have the `subtype` property,
# but rather a `dataStore` property
query = super().build_search_query(search_filters)
subtype_key = "filter{subtype.in}"
datastore_key = "filter{storeType.in}"
while query.hasQueryItem(subtype_key):
old_value = query.queryItemValue(subtype_key)
value = {
"vector": "dataStore",
"raster": "coverageStore",
}[old_value]
query.addQueryItem(datastore_key, value)
query.removeQueryItem(subtype_key)
return query
def _get_common_model_properties(self, raw_dataset: typing.Dict) -> typing.Dict:
type_ = {
"coverageStore": models.GeonodeResourceType.RASTER_LAYER,
"dataStore": models.GeonodeResourceType.VECTOR_LAYER,
}.get(raw_dataset.get("storeType"))
service_urls = {
models.GeonodeService.OGC_WMS: raw_dataset["ows_url"],
}
if type_ == models.GeonodeResourceType.VECTOR_LAYER:
service_urls[models.GeonodeService.OGC_WFS] = raw_dataset["ows_url"]
elif type_ == models.GeonodeResourceType.RASTER_LAYER:
service_urls[models.GeonodeService.OGC_WCS] = raw_dataset["ows_url"]
raw_style = raw_dataset.get("default_style") or {}
return {
"pk": int(raw_dataset["pk"]),
"uuid": uuid.UUID(raw_dataset["uuid"]),
"name": raw_dataset.get("alternate", raw_dataset.get("name", "")),
"title": raw_dataset.get("title", ""),
"abstract": raw_dataset.get("raw_abstract", ""),
"thumbnail_url": raw_dataset["thumbnail_url"],
"link": raw_dataset["link"],
"detail_url": raw_dataset["detail_url"],
"dataset_sub_type": type_,
"service_urls": service_urls,
"spatial_extent": _get_spatial_extent(raw_dataset["bbox_polygon"]),
"srid": qgis.core.QgsCoordinateReferenceSystem(raw_dataset["srid"]),
"published_date": _get_published_date(raw_dataset),
"temporal_extent": _get_temporal_extent(raw_dataset),
"keywords": [k["name"] for k in raw_dataset.get("keywords", [])],
"category": (raw_dataset.get("category") or {}).get("identifier"),
"default_style": models.BriefGeonodeStyle(
name=raw_style.get("name", ""), sld_url=raw_style.get("sld_url")
),
}
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)
class LayerUploaderTask(network.NetworkRequestTask):
VECTOR_UPLOAD_FORMAT: typing.Final[ExportFormat] = ExportFormat(
"ESRI Shapefile", "shp"
)
RASTER_UPLOAD_FORMAT: typing.Final[ExportFormat] = ExportFormat("GTiff", "tif")
layer: qgis.core.QgsMapLayer
allow_public_access: bool
_upload_url: QtCore.QUrl
_temporary_directory: typing.Optional[Path]
def __init__(
self,
layer: qgis.core.QgsMapLayer,
upload_url: QtCore.QUrl,
allow_public_access: bool,
authcfg: str,
network_task_timeout: int,
description: str = "LayerUploaderTask",
):
"""Task to perform upload of QGIS layers to remote GeoNode servers."""
super().__init__(
requests_to_perform=[],
authcfg=authcfg,
description=description,
network_task_timeout=network_task_timeout,
)
self.response_contents = [None]
self.layer = layer
self.allow_public_access = allow_public_access
self._upload_url = upload_url
self._temporary_directory = None
def run(self) -> bool:
if self._is_layer_uploadable():
source_path = Path(
self.layer.dataProvider().dataSourceUri().partition("|")[0]
)
export_error = None
else:
log(
"Exporting layer to an uploadable format before proceeding with "
"the upload..."
)
source_path, export_error = self._export_layer_to_temp_dir()
log(f"source_path: {source_path}")
if export_error is None:
sld_path, sld_error = self._export_layer_style()
log(f"sld_path: {sld_path}")
if sld_path is None:
log(
f"Could not export the layer's style as SLD "
f"({sld_error}), skipping..."
)
multipart = self._prepare_multipart(source_path, sld_path=sld_path)
with network.wait_for_signal(
self._all_requests_finished, timeout=self.network_task_timeout
) as event_loop_result:
request = QtNetwork.QNetworkRequest(self._upload_url)
request.setHeader(
QtNetwork.QNetworkRequest.ContentTypeHeader,
f"multipart/form-data; boundary={multipart.boundary().data().decode()}",
)
if self.authcfg:
auth_manager = qgis.core.QgsApplication.authManager()
auth_added, _ = auth_manager.updateNetworkRequest(
request, self.authcfg
)
else:
auth_added = True
if auth_added:
qt_reply = self._dispatch_request(
request, network.HttpMethod.POST, multipart
)
multipart.setParent(qt_reply)
request_id = qt_reply.property("requestId")
self._pending_replies[request_id] = (0, 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)
else:
result = False
return result
def finished(self, result: bool) -> None:
if self._temporary_directory is not None:
shutil.rmtree(self._temporary_directory, ignore_errors=True)
super().finished(result)
def _prepare_multipart(
self, source_path: Path, sld_path: typing.Optional[Path] = None
) -> QtNetwork.QHttpMultiPart:
main_file = QtCore.QFile(str(source_path))
main_file.open(QtCore.QIODevice.ReadOnly)
sidecar_files = []
if sld_path is not None:
sld_file = QtCore.QFile(str(sld_path))
sld_file.open(QtCore.QIODevice.ReadOnly)
sidecar_files.append(("sld_file", sld_file))
if self.layer.type() == qgis.core.QgsMapLayerType.VectorLayer:
dbf_file = QtCore.QFile(str(source_path.parent / f"{source_path.stem}.dbf"))
dbf_file.open(QtCore.QIODevice.ReadOnly)
sidecar_files.append(("dbf_file", dbf_file))
prj_file = QtCore.QFile(str(source_path.parent / f"{source_path.stem}.prj"))
prj_file.open(QtCore.QIODevice.ReadOnly)
sidecar_files.append(("prj_file", prj_file))
shx_file = QtCore.QFile(str(source_path.parent / f"{source_path.stem}.shx"))
shx_file.open(QtCore.QIODevice.ReadOnly)
sidecar_files.append(("shx_file", shx_file))
elif self.layer.type() == qgis.core.QgsMapLayerType.RasterLayer:
# when uploading tif files GeoNode seems to want the same file be uploaded
# twice - one under the `base_file` form field and another under the
# `tif_file` form field. This seems like a bug in GeoNode though
tif_file = QtCore.QFile(str(source_path))
tif_file.open(QtCore.QIODevice.ReadOnly)
sidecar_files.append(("tif_file", tif_file))
permissions = {
"users": {},
"groups": {},
}
if self.allow_public_access:
permissions["users"]["AnonymousUser"] = [
"view_resourcebase",
"download_resourcebase",
]
multipart = build_multipart(
self.layer.metadata(), permissions, main_file, sidecar_files=sidecar_files
)
# below we set all QFiles as children of the multipart object and later we
# also make the multipart object a children on the network reply object. This is
# done in order to ensure deletion of resources at the correct time, as
# recommended by the Qt documentation at:
# https://doc.qt.io/qt-5/qhttppart.html#details
main_file.setParent(multipart)
for _, qt_file in sidecar_files:
qt_file.setParent(multipart)
return multipart
def _is_layer_uploadable(self) -> bool:
"""Check if the layer is in a format suitable for uploading to GeoNode."""
ds_uri = self.layer.dataProvider().dataSourceUri()
fragment = ds_uri.split("|")[0]
extension = fragment.rpartition(".")[-1]
return extension in (
self.VECTOR_UPLOAD_FORMAT.file_extension,
self.RASTER_UPLOAD_FORMAT.file_extension,
)
def _export_layer_to_temp_dir(
self,
) -> typing.Tuple[typing.Optional[Path], typing.Optional[str]]:
if self._temporary_directory is None:
self._temporary_directory = Path(tempfile.mkdtemp(prefix="qgis_geonode_"))
if self.layer.type() == qgis.core.QgsMapLayerType.VectorLayer:
exported_path, error_message = self._export_vector_layer()
elif self.layer.type() == qgis.core.QgsMapLayerType.RasterLayer:
exported_path, export_error = self._export_raster_layer()
error_message = str(export_error)
else:
raise NotImplementedError()
return exported_path, (error_message or None)
def _export_vector_layer(
self,
) -> typing.Tuple[typing.Optional[Path], str]:
sanitized_layer_name = network.sanitize_layer_name(self.layer.name())
target_path = self._temporary_directory / f"{sanitized_layer_name}.shp"
export_code, error_message = qgis.core.QgsVectorLayerExporter.exportLayer(
layer=self.layer,
uri=str(target_path),
providerKey="ogr",
destCRS=qgis.core.QgsCoordinateReferenceSystem(),
options={
"driverName": "ESRI Shapefile",
},
)
if export_code == qgis.core.Qgis.VectorExportResult.Success:
result = (target_path, error_message)
else:
result = (None, error_message)
return result
def _export_raster_layer(
self,
) -> typing.Tuple[typing.Optional[Path], typing.Optional[int]]:
sanitized_layer_name = network.sanitize_layer_name(self.layer.name())
target_path = (
self._temporary_directory
/ f"{sanitized_layer_name}.{self.RASTER_UPLOAD_FORMAT.file_extension}"
)
writer = qgis.core.QgsRasterFileWriter(str(target_path))
writer.setOutputFormat(self.RASTER_UPLOAD_FORMAT.driver_name)
pipe = self.layer.pipe()
raster_interface = self.layer.dataProvider()
write_error = writer.writeRaster(
pipe,
raster_interface.xSize(),
raster_interface.ySize(),
raster_interface.extent(),
raster_interface.crs(),
qgis.core.QgsCoordinateTransformContext(),
)
if write_error == qgis.core.QgsRasterFileWriter.NoError:
result = (target_path, None)
else:
result = (None, write_error)
return result
def _export_layer_style(self) -> typing.Tuple[typing.Optional[Path], str]:
sanitized_layer_name = network.sanitize_layer_name(self.layer.name())
if self._temporary_directory is None:
self._temporary_directory = Path(tempfile.mkdtemp(prefix="qgis_geonode_"))
target_path = self._temporary_directory / f"{sanitized_layer_name}.sld"
saved_sld_details, sld_exported_flag = self.layer.saveSldStyle(str(target_path))
if "created default style" in saved_sld_details.lower():
result = (target_path, "")
else:
result = (None, saved_sld_details)
return result
def build_multipart(
layer_metadata: qgis.core.QgsLayerMetadata,
permissions: typing.Dict,
main_file: QtCore.QFile,
sidecar_files: typing.List[typing.Tuple[str, QtCore.QFile]],
) -> QtNetwork.QHttpMultiPart:
encoding = "utf-8"
multipart = QtNetwork.QHttpMultiPart(QtNetwork.QHttpMultiPart.FormDataType)
title = layer_metadata.title()
if title:
title_part = QtNetwork.QHttpPart()
title_part.setHeader(
QtNetwork.QNetworkRequest.ContentDispositionHeader,
'form-data; name="dataset_title"',
)
title_part.setBody(layer_metadata.title().encode(encoding))
multipart.append(title_part)
abstract = layer_metadata.abstract()
if abstract:
abstract_part = QtNetwork.QHttpPart()
abstract_part.setHeader(
QtNetwork.QNetworkRequest.ContentDispositionHeader,
'form-data; name="abstract"',
)
abstract_part.setBody(layer_metadata.abstract().encode(encoding))
multipart.append(abstract_part)
false_items = (
"time",
"mosaic",
"metadata_uploaded_preserve",
"metadata_upload_form",
"style_upload_form",
)
for item in false_items:
part = QtNetwork.QHttpPart()
part.setHeader(
QtNetwork.QNetworkRequest.ContentDispositionHeader,
f'form-data; name="{item}"',
)
part.setBody("false".encode("utf-8"))
multipart.append(part)
permissions_part = QtNetwork.QHttpPart()
permissions_part.setHeader(
QtNetwork.QNetworkRequest.ContentDispositionHeader,
'form-data; name="permissions"',
)
permissions_part.setBody(json.dumps(permissions).encode(encoding))
multipart.append(permissions_part)
file_parts = [("base_file", main_file)]
for additional_file_form_name, additional_file_handler in sidecar_files:
file_parts.append((additional_file_form_name, additional_file_handler))
for form_element_name, file_handler in file_parts:
file_name = file_handler.fileName().rpartition("/")[-1]
part = QtNetwork.QHttpPart()
part.setHeader(
QtNetwork.QNetworkRequest.ContentDispositionHeader,
f'form-data; name="{form_element_name}"; filename="{file_name}"',
)
if file_name.rpartition(".")[-1] == "tif":
content_type = "image/tiff"
else:
content_type = "application/qgis"
part.setHeader(QtNetwork.QNetworkRequest.ContentTypeHeader, content_type)
part.setBodyDevice(file_handler)
multipart.append(part)
return multipart
def _get_link(raw_links: typing.List, link_type: str) -> typing.Optional[str]:
for link_info in raw_links:
if link_info.get("link_type") == link_type:
result = link_info.get("url")
break
else:
result = None
return result
def _get_temporal_extent(
payload: typing.Dict,
) -> typing.Optional[typing.List[typing.Optional[dt.datetime]]]:
start = payload.get("temporal_extent_start") or None
end = payload.get("temporal_extent_end") or None
if start is not None and end is not None:
result = [_parse_datetime(start), _parse_datetime(end)]
elif start is not None and end is None:
result = [_parse_datetime(start), None]
elif start is None and end is not None:
result = [None, _parse_datetime(end)]
else:
result = None
return result
def _get_resource_type(
raw_dataset: typing.Dict,
) -> typing.Optional[models.GeonodeResourceType]:
result = {
"raster": models.GeonodeResourceType.RASTER_LAYER,
"vector": models.GeonodeResourceType.VECTOR_LAYER,
}.get(raw_dataset.get("subtype"))
return result
def _get_vector_service_urls(
raw_links: typing.Dict,
) -> typing.Dict[models.GeonodeService, str]:
return {
models.GeonodeService.OGC_WMS: _get_link(raw_links, "OGC:WMS"),
models.GeonodeService.OGC_WFS: _get_link(raw_links, "OGC:WFS"),
}
def _get_raster_service_urls(
raw_links: typing.Dict,
) -> typing.Dict[models.GeonodeService, str]:
return {
models.GeonodeService.OGC_WMS: _get_link(raw_links, "OGC:WMS"),
models.GeonodeService.OGC_WCS: _get_link(raw_links, "OGC:WCS"),
}
def _get_spatial_extent(
geojson_polygon_geometry: typing.Dict,
) -> qgis.core.QgsRectangle:
min_x = None
min_y = None
max_x = None
max_y = None
for coord in geojson_polygon_geometry["coordinates"][0]:
x, y = coord
min_x = x if min_x is None else min(x, min_x)
min_y = y if min_y is None else min(y, min_y)
max_x = x if max_x is None else max(x, max_x)
max_y = y if max_y is None else max(y, max_y)
return qgis.core.QgsRectangle(min_x, min_y, max_x, max_y)
def _parse_datetime(raw_value: str) -> dt.datetime:
format_ = "%Y-%m-%dT%H:%M:%SZ"
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(payload: typing.Dict) -> typing.Optional[dt.datetime]:
if payload["date_type"] == "publication":
result = _parse_datetime(payload["date"])
else:
result = None
return result
PK x,TBA7* * qgis_geonode/apiclient/base.pyimport typing
from functools import partial
import qgis.core
from qgis.PyQt import (
QtCore,
QtXml,
)
from .. import (
conf,
network,
)
from . import models
from .models import GeonodeApiSearchFilters
class BaseGeonodeClient(QtCore.QObject):
auth_config: str
base_url: str
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)
dataset_detail_error_received = QtCore.pyqtSignal([str], [str, int, str])
style_detail_received = QtCore.pyqtSignal(QtXml.QDomElement)
style_detail_error_received = QtCore.pyqtSignal([str], [str, int, str])
keyword_list_received = QtCore.pyqtSignal(list)
search_error_received = QtCore.pyqtSignal([str], [str, int, str])
dataset_uploaded = QtCore.pyqtSignal(int)
dataset_upload_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: 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]]:
raise NotImplementedError
def get_dataset_list_url(
self, search_filters: models.GeonodeApiSearchFilters
) -> QtCore.QUrl:
raise NotImplementedError
def get_dataset_detail_url(self, dataset_id: int) -> QtCore.QUrl:
raise NotImplementedError
def get_dataset_upload_url(self) -> QtCore.QUrl:
raise NotImplementedError
def get_dataset_list(self, search_filters: GeonodeApiSearchFilters) -> None:
self.network_fetcher_task = network.NetworkRequestTask(
[network.RequestToPerform(url=self.get_dataset_list_url(search_filters))],
self.network_requests_timeout,
self.auth_config,
description="Get 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):
"""Handle the list of datasets returned by the remote
This must emit the `dataset_list_received` signal.
"""
raise NotImplementedError
def get_dataset_style(
self, dataset: models.Dataset, emit_dataset_detail_received: bool = False
) -> None:
self.network_fetcher_task = network.NetworkRequestTask(
[network.RequestToPerform(QtCore.QUrl(dataset.default_style.sld_url))],
self.network_requests_timeout,
self.auth_config,
description="Get dataset style",
)
self.network_fetcher_task.task_done.connect(
partial(
self.handle_dataset_style,
dataset,
emit_dataset_detail_received=emit_dataset_detail_received,
)
)
qgis.core.QgsApplication.taskManager().addTask(self.network_fetcher_task)
def handle_dataset_style(
self,
dataset: models.Dataset,
task_result: bool,
emit_dataset_detail_received: typing.Optional[bool] = False,
) -> None:
raise NotImplementedError
def get_dataset_detail(
self,
dataset: typing.Union[models.BriefDataset, models.Dataset],
get_style_too: bool = False,
) -> None:
requests_to_perform = [
network.RequestToPerform(url=self.get_dataset_detail_url(dataset.pk))
]
if get_style_too:
is_vector = (
dataset.dataset_sub_type == models.GeonodeResourceType.VECTOR_LAYER
)
should_load_vector_style = (
models.ApiClientCapability.LOAD_VECTOR_LAYER_STYLE in self.capabilities
)
if is_vector and should_load_vector_style:
sld_url = QtCore.QUrl(dataset.default_style.sld_url)
requests_to_perform.append(network.RequestToPerform(url=sld_url))
self.network_fetcher_task = network.NetworkRequestTask(
requests_to_perform,
self.network_requests_timeout,
self.auth_config,
description="Get dataset detail",
)
self.network_fetcher_task.task_done.connect(self.handle_dataset_detail)
qgis.core.QgsApplication.taskManager().addTask(self.network_fetcher_task)
def handle_dataset_detail(self, result: bool):
"""Handle dataset detail retrieval outcome.
This method should emit either `dataset_detail_received` or
`dataset_detail_error_received`.
"""
raise NotImplementedError
def get_dataset_detail_from_id(self, dataset_id: int):
self.network_fetcher_task = network.NetworkRequestTask(
[network.RequestToPerform(url=self.get_dataset_detail_url(dataset_id))],
self.network_requests_timeout,
self.auth_config,
description="Get dataset detail",
)
self.network_fetcher_task.task_done.connect(self.handle_dataset_detail_from_id)
qgis.core.QgsApplication.taskManager().addTask(self.network_fetcher_task)
def handle_dataset_detail_from_id(self, task_result: bool):
raise NotImplementedError
def get_uploader_task(
self, layer: qgis.core.QgsMapLayer, allow_public_access: bool, timeout: int
) -> qgis.core.QgsTask:
raise NotImplementedError
def upload_layer(
self, layer: qgis.core.QgsMapLayer, allow_public_access: bool
) -> None:
self.network_fetcher_task = self.get_uploader_task(
layer, allow_public_access, timeout=10 * 60 * 1000
) # the GeoNode GUI also uses a 10 minute timeout for uploads
self.network_fetcher_task.task_done.connect(self.handle_layer_upload)
qgis.core.QgsApplication.taskManager().addTask(self.network_fetcher_task)
def handle_layer_upload(self, result: bool):
"""Handle layer upload outcome.
This method should emit either `dataset_uploaded` or
`dataset_upload_error_received`.
"""
raise NotImplementedError
PK x,T|b' b' qgis_geonode/apiclient/models.pyimport dataclasses
import datetime as dt
import enum
import json
import math
import typing
from uuid import UUID
import qgis.core
from qgis.PyQt import (
QtCore,
QtXml,
)
from qgis.core import (
QgsCoordinateReferenceSystem,
QgsRectangle,
)
from .. import styles as qgis_geonode_styles
from ..utils import log
DATASET_CUSTOM_PROPERTY_KEY = "plugins/qgis_geonode/dataset"
DATASET_CONNECTION_CUSTOM_PROPERTY_KEY = "plugins/qgis_geonode/dataset_connection"
class ApiClientCapability(enum.Enum):
# NOTE - Some capabilities are not made explicit here because their support
# is mandatory far all API clients. For example, all clients must support
# searching datasets, as otherwise there wouldn't be much point to their existence
FILTER_BY_TITLE = enum.auto()
FILTER_BY_ABSTRACT = enum.auto()
FILTER_BY_KEYWORD = enum.auto()
FILTER_BY_TOPIC_CATEGORY = enum.auto()
FILTER_BY_RESOURCE_TYPES = enum.auto()
FILTER_BY_TEMPORAL_EXTENT = enum.auto()
FILTER_BY_PUBLICATION_DATE = enum.auto()
FILTER_BY_SPATIAL_EXTENT = enum.auto()
LOAD_LAYER_METADATA = enum.auto()
MODIFY_LAYER_METADATA = 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()
UPLOAD_VECTOR_LAYER = enum.auto()
UPLOAD_RASTER_LAYER = 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"
OGC_WCS = "wcs"
FILE_DOWNLOAD = "file_download"
class GeonodeResourceType(enum.Enum):
VECTOR_LAYER = "vector"
RASTER_LAYER = "raster"
@dataclasses.dataclass
class GeonodePaginationInfo:
total_records: int
current_page: int
page_size: int
@property
def total_pages(self):
try:
result = math.ceil(self.total_records / self.page_size)
except ZeroDivisionError:
result = 1
return result
@dataclasses.dataclass()
class BriefGeonodeStyle:
name: str
sld_url: str
sld: typing.Optional[QtXml.QDomElement] = None
@dataclasses.dataclass()
class BriefDataset:
pk: int
uuid: UUID
name: str
dataset_sub_type: GeonodeResourceType
title: str
abstract: str
published_date: typing.Optional[dt.datetime]
spatial_extent: QgsRectangle
temporal_extent: typing.Optional[typing.List[dt.datetime]]
srid: QgsCoordinateReferenceSystem
thumbnail_url: str
link: str
detail_url: str
keywords: typing.List[str]
category: typing.Optional[str]
service_urls: typing.Dict[GeonodeService, str]
default_style: BriefGeonodeStyle
@dataclasses.dataclass()
class Dataset(BriefDataset):
language: str
license: str
constraints: str
owner: typing.Dict[str, str]
metadata_author: typing.Dict[str, str]
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,
"uuid": str(self.uuid),
"name": self.name,
"dataset_sub_type": self.dataset_sub_type.value,
"title": self.title,
"abstract": self.abstract,
"published_date": self.published_date.isoformat()
if self.published_date
else None,
"spatial_extent": self.spatial_extent.asWktPolygon(),
"temporal_extent": serialized_temporal_extent,
"srid": self.srid.postgisSrid(),
"thumbnail_url": self.thumbnail_url,
"link": self.link,
"detail_url": self.detail_url,
"keywords": self.keywords,
"category": self.category,
"service_urls": {
service.value: value for service, value in self.service_urls.items()
},
"language": self.language,
"license": self.license,
"constraints": self.constraints,
"owner": self.owner,
"metadata_author": self.metadata_author,
"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)
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
class GeonodeApiSearchFilters:
page: typing.Optional[int] = 1
title: typing.Optional[str] = None
abstract: typing.Optional[str] = None
keyword: typing.Optional[str] = None
topic_category: typing.Optional[IsoTopicCategory] = None
layer_types: typing.Optional[typing.List[GeonodeResourceType]] = dataclasses.field(
default_factory=list
)
ordering_field: typing.Optional[str] = None
reverse_ordering: typing.Optional[bool] = False
temporal_extent_start: typing.Optional[QtCore.QDateTime] = None
temporal_extent_end: typing.Optional[QtCore.QDateTime] = None
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
PK x,TxhB B qgis_geonode/apiclient/apiv2.pyimport datetime as dt
import json
import typing
import uuid
import qgis_geonode.apiclient.models
from qgis.core import (
QgsCoordinateReferenceSystem,
QgsDateTimeRange,
QgsRectangle,
)
from qgis.PyQt import (
QtCore,
)
from ..utils import log
from . import models
from . import base
class GeonodeApiV2Client(base.BaseGeonodeClient):
capabilities = [
models.ApiClientCapability.FILTER_BY_TITLE,
models.ApiClientCapability.FILTER_BY_ABSTRACT,
models.ApiClientCapability.FILTER_BY_KEYWORD,
models.ApiClientCapability.FILTER_BY_TOPIC_CATEGORY,
models.ApiClientCapability.FILTER_BY_RESOURCE_TYPES,
models.ApiClientCapability.FILTER_BY_TEMPORAL_EXTENT,
models.ApiClientCapability.FILTER_BY_PUBLICATION_DATE,
models.ApiClientCapability.LOAD_VECTOR_DATASET_VIA_WMS,
models.ApiClientCapability.LOAD_VECTOR_DATASET_VIA_WFS,
]
@property
def api_url(self):
return f"{self.base_url}/api/v2"
def get_ordering_filter_name(
self,
ordering_type: models.OrderingType,
reverse_sort: typing.Optional[bool] = False,
) -> str:
name = {
models.OrderingType.TITLE: "name",
}[ordering_type]
return f"{'-' if reverse_sort else ''}{name}"
def get_search_result_identifier(
self, resource: models.BriefGeonodeResource
) -> str:
return resource.name
def get_layers_url_endpoint(
self, search_params: models.GeonodeApiSearchFilters
) -> QtCore.QUrl:
url = QtCore.QUrl(f"{self.api_url}/datasets/")
query = self._build_search_query(search_params)
url.setQuery(query.query())
return url
def _build_search_query(
self, search_params: models.GeonodeApiSearchFilters
) -> QtCore.QUrlQuery:
query = QtCore.QUrlQuery()
query.addQueryItem("page", str(search_params.page))
query.addQueryItem("page_size", str(search_params.page_size))
if search_params.title is not None:
query.addQueryItem("filter{title.icontains}", search_params.title)
if search_params.abstract is not None:
query.addQueryItem("filter{abstract.icontains}", search_params.abstract)
if search_params.selected_keyword is not None:
query.addQueryItem(
"filter{keywords.name.icontains}", search_params.selected_keyword
)
if search_params.topic_category is not None:
query.addQueryItem(
"filter{category.identifier}", search_params.topic_category
)
if search_params.temporal_extent_start is not None:
query.addQueryItem(
"filter{temporal_extent_start.gte}",
search_params.temporal_extent_start.toString(QtCore.Qt.ISODate),
)
if search_params.temporal_extent_end is not None:
query.addQueryItem(
"filter{temporal_extent_end.lte}",
search_params.temporal_extent_end.toString(QtCore.Qt.ISODate),
)
if search_params.publication_date_start is not None:
query.addQueryItem(
"filter{date.gte}",
search_params.publication_date_start.toString(QtCore.Qt.ISODate),
)
if search_params.publication_date_end is not None:
query.addQueryItem(
"filter{date.lte}",
search_params.publication_date_end.toString(QtCore.Qt.ISODate),
)
# TODO revisit once the support for spatial extent is available on
# GeoNode API V2
if (
search_params.spatial_extent is not None
and not search_params.spatial_extent.isNull()
):
pass
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)
is_vector = models.GeonodeResourceType.VECTOR_LAYER in types
is_raster = models.GeonodeResourceType.RASTER_LAYER in types
is_map = models.GeonodeResourceType.MAP in types
if is_vector and is_raster:
pass
elif is_vector:
query.addQueryItem("filter{subtype}", "vector")
elif is_raster:
query.addQueryItem("filter{subtype}", "raster")
else:
raise NotImplementedError
if search_params.ordering_field is not None:
ordering_field_value = self.get_ordering_filter_name(
search_params.ordering_field,
reverse_sort=search_params.reverse_ordering,
)
query.addQueryItem("sort[]", ordering_field_value)
return query
def get_layer_detail_url_endpoint(self, id_: int) -> QtCore.QUrl:
return QtCore.QUrl(f"{self.api_url}/datasets/{id_}/")
def get_layer_styles_url_endpoint(self, layer_id: int):
return QtCore.QUrl(f"{self.api_url}/datasets/{layer_id}/styles/")
def get_maps_url_endpoint(
self,
search_params: base.GeonodeApiSearchFilters,
) -> QtCore.QUrl:
url = QtCore.QUrl(f"{self.api_url}/maps/")
query = self._build_search_query(search_params)
url.setQuery(query.query())
return url
def get_layer_detail_from_brief_resource(
self, brief_resource: models.BriefGeonodeResource
):
self.get_layer_detail(brief_resource.pk)
def deserialize_response_contents(
self, contents: QtCore.QByteArray
) -> typing.Optional[typing.Union[typing.List, typing.Dict]]:
decoded_contents: str = contents.data().decode()
try:
contents = json.loads(decoded_contents)
except json.JSONDecodeError as exc:
log(f"decoded_contents: {decoded_contents}")
log(exc, debug=False)
contents = None
return contents
#
# def old_handle_layer_list(
# self,
# original_search_params: base.GeonodeApiSearchParameters,
# raw_reply_contents: QtCore.QByteArray,
# ):
# deserialized = self.deserialize_response_contents(raw_reply_contents)
# layers = []
# for item in deserialized.get("layers", []):
# try:
# brief_resource = get_brief_geonode_resource(
# item, self.base_url, self.auth_config
# )
# except ValueError:
# log(f"Could not parse {item!r} into a valid item")
# else:
# layers.append(brief_resource)
# pagination_info = models.GeoNodePaginationInfo(
# total_records=deserialized["total"],
# current_page=deserialized["page"],
# page_size=deserialized["page_size"],
# )
# self.layer_list_received.emit(layers, pagination_info)
def handle_layer_list(
self,
original_search_params: base.GeonodeApiSearchFilters,
):
deserialized = self.deserialize_response_contents(
self.network_fetcher_task.reply_content
)
layers = []
if deserialized is not None:
for item in deserialized.get("datasets", []):
try:
brief_resource = get_brief_geonode_resource(
item, self.base_url, self.auth_config
)
except ValueError:
log(f"Could not parse {item!r} into a valid item")
else:
layers.append(brief_resource)
pagination_info = models.GeonodePaginationInfo(
total_records=deserialized.get("total") or 0,
current_page=deserialized.get("page") or 1,
page_size=deserialized.get("page_size") or 0,
)
else:
pagination_info = models.GeonodePaginationInfo(
total_records=0, current_page=1, page_size=0
)
self.layer_list_received.emit(layers, pagination_info)
def handle_layer_detail(self):
deserialized = self.deserialize_response_contents(
self.network_fetcher_task.reply_content
)
if deserialized is not None:
layer = get_geonode_resource(
deserialized["dataset"], self.base_url, self.auth_config
)
self.layer_detail_received.emit(layer)
def handle_layer_style_list(self):
deserialized = self.deserialize_response_contents(
self.network_fetcher_task.reply_content
)
styles = []
for item in deserialized.get("styles", []):
styles.append(get_brief_geonode_style(item, self.base_url))
self.layer_styles_received.emit(styles)
def handle_map_list(
self,
original_search_params: base.GeonodeApiSearchFilters,
):
deserialized = self.deserialize_response_contents(
self.network_fetcher_task.reply_content
)
maps = []
for item in deserialized.get("maps", []):
maps.append(
get_brief_geonode_resource(item, self.base_url, self.auth_config)
)
pagination_info = models.GeonodePaginationInfo(
total_records=deserialized["total"],
current_page=deserialized["page"],
page_size=deserialized["page_size"],
)
self.map_list_received.emit(maps, pagination_info)
def get_brief_geonode_resource(
deserialized_resource: typing.Dict,
geonode_base_url: str,
auth_config: str,
) -> models.BriefGeonodeResource:
return models.BriefGeonodeResource(
**_get_common_model_fields(deserialized_resource, geonode_base_url, auth_config)
)
def get_geonode_resource(
deserialized_resource: typing.Dict, geonode_base_url: str, auth_config: str
) -> models.GeonodeResource:
common_fields = _get_common_model_fields(
deserialized_resource, geonode_base_url, auth_config
)
license_value = deserialized_resource.get("license", "")
if license_value and isinstance(license_value, dict):
license_ = license_value["identifier"]
else:
license_ = license_value
default_style = get_brief_geonode_style(deserialized_resource, geonode_base_url)
styles = []
for item in deserialized_resource.get("styles", []):
styles.append(get_brief_geonode_style(item, geonode_base_url))
return models.GeonodeResource(
language=deserialized_resource.get("language", ""),
license=license_,
constraints=deserialized_resource.get("constraints_other", ""),
owner=deserialized_resource.get("owner", ""),
metadata_author=deserialized_resource.get("metadata_author", ""),
default_style=default_style,
styles=styles,
**common_fields,
)
def _get_common_model_fields(
deserialized_resource: typing.Dict, geonode_base_url: str, auth_config: str
) -> typing.Dict:
resource_type = _get_resource_type(deserialized_resource)
if resource_type == models.GeonodeResourceType.VECTOR_LAYER:
service_urls = {
models.GeonodeService.OGC_WMS: _get_wms_uri(
geonode_base_url, deserialized_resource, auth_config=auth_config
),
models.GeonodeService.OGC_WFS: _get_wfs_uri(
geonode_base_url, deserialized_resource, auth_config=auth_config
),
}
elif resource_type == models.GeonodeResourceType.RASTER_LAYER:
service_urls = {
models.GeonodeService.OGC_WMS: _get_wms_uri(
geonode_base_url, deserialized_resource, auth_config=auth_config
),
models.GeonodeService.OGC_WCS: _get_wcs_uri(
geonode_base_url, deserialized_resource, auth_config=auth_config
),
}
elif resource_type == models.GeonodeResourceType.MAP:
service_urls = None # FIXME: devise a way to retrieve WMS URL for maps
else:
service_urls = None
reported_category = deserialized_resource.get("category")
category = reported_category["identifier"] if reported_category else None
return {
"pk": int(deserialized_resource["pk"]),
"uuid": uuid.UUID(deserialized_resource["uuid"]),
"name": deserialized_resource.get("name", ""),
"resource_type": resource_type,
"title": deserialized_resource.get("title", ""),
"abstract": deserialized_resource.get("abstract", ""),
"spatial_extent": _get_spatial_extent(deserialized_resource["bbox_polygon"]),
"crs": QgsCoordinateReferenceSystem(deserialized_resource["srid"]),
"thumbnail_url": deserialized_resource["thumbnail_url"],
"api_url": (
f"{geonode_base_url}/api/v2/datasets/{deserialized_resource['pk']}"
),
"gui_url": deserialized_resource["detail_url"],
"published_date": _get_published_date(deserialized_resource),
"temporal_extent": _get_temporal_extent(deserialized_resource),
"keywords": [k["name"] for k in deserialized_resource.get("keywords", [])],
"category": category,
"service_urls": service_urls,
}
def get_brief_geonode_style(deserialized_style: typing.Dict, geonode_base_url: str):
sld_url = (
f"{geonode_base_url}/geoserver/rest/workspaces/"
f"{deserialized_style['workspace']}/styles/{deserialized_style['name']}.sld"
)
return models.BriefGeonodeStyle(
name=deserialized_style["name"],
sld_url=sld_url,
)
def _get_resource_type(
payload: typing.Dict,
) -> typing.Optional[models.GeonodeResourceType]:
resource_type = payload.get("resource_type")
if resource_type == "map":
result = models.GeonodeResourceType.MAP
elif resource_type == "dataset":
result = {
"raster": models.GeonodeResourceType.RASTER_LAYER,
"vector": models.GeonodeResourceType.VECTOR_LAYER,
}.get(payload.get("subtype"))
else:
result = None
return result
def _get_spatial_extent(geojson_polygon_geometry: typing.Dict) -> QgsRectangle:
min_x = None
min_y = None
max_x = None
max_y = None
for coord in geojson_polygon_geometry["coordinates"][0]:
x, y = coord
min_x = x if min_x is None else min(x, min_x)
min_y = y if min_y is None else min(y, min_y)
max_x = x if max_x is None else max(x, max_x)
max_y = y if max_y is None else max(y, max_y)
return QgsRectangle(min_x, min_y, max_x, max_y)
def _get_temporal_extent(
payload: typing.Dict,
) -> typing.Optional[typing.List[typing.Optional[dt.datetime]]]:
start = payload["temporal_extent_start"]
end = payload["temporal_extent_end"]
if start is not None and end is not None:
result = [_parse_datetime(start), _parse_datetime(end)]
elif start is not None and end is None:
result = [_parse_datetime(start), None]
elif start is None and end is not None:
result = [None, _parse_datetime(end)]
else:
result = None
return result
def _parse_datetime(raw_value: str) -> dt.datetime:
format_ = "%Y-%m-%dT%H:%M:%SZ"
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(payload: typing.Dict) -> typing.Optional[dt.datetime]:
if payload["date_type"] == "publication":
result = _parse_datetime(payload["date"])
else:
result = None
return result
def _get_wms_uri(
base_url: str,
payload: typing.Dict,
auth_config: typing.Optional[str] = None,
) -> str:
params = {
"url": f"{base_url}/geoserver/ows",
"format": "image/png",
"layers": f"{payload['workspace']}:{payload['name']}",
"crs": payload["srid"],
"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(
base_url: str,
payload: typing.Dict,
auth_config: typing.Optional[str] = None,
) -> str:
params = {
"identifier": f"{payload['workspace']}:{payload['name']}",
"url": f"{base_url}/geoserver/ows",
}
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(
base_url: str, payload: typing.Dict, auth_config: typing.Optional[str] = None
) -> str:
params = {
"url": f"{base_url}/geoserver/ows",
"typename": f"{payload['workspace']}:{payload['name']}",
"version": "auto",
}
if auth_config is not None:
params["authcfg"] = auth_config
return " ".join(f"{k}='{v}'" for k, v in params.items())
PK x,T.\0 0 qgis_geonode/apiclient/legacy.pyimport datetime as dt
import typing
import uuid
import qgis.core
from qgis.PyQt import QtCore
from .. import network
from ..utils import (
log,
)
from .models import IsoTopicCategory
from . import models
from .base import BaseGeonodeClient
# NOTE: the legacy GeoNode API's dataset list endpoint does not return a dataset's
# category identifier, just its description. In order to simplify and not having to
# perform additional requests, this is a mapping of each category description to the
# corresponding enum variant
_TOPIC_CATEGORY_MAP = {
"Biota": IsoTopicCategory.biota,
"Boundaries": IsoTopicCategory.boundaries,
"Climatology Meteorology Atmosphere": IsoTopicCategory.climatologyMeteorologyAtmosphere,
"Economy": IsoTopicCategory.economy,
"Elevation": IsoTopicCategory.elevation,
"Environment": IsoTopicCategory.environment,
"Farming": IsoTopicCategory.farming,
"Geoscientific Information": IsoTopicCategory.geoscientificInformation,
"Health": IsoTopicCategory.health,
"Imagery Base Maps Earth Cover": IsoTopicCategory.imageryBaseMapsEarthCover,
"Inland waters": IsoTopicCategory.inlandWaters,
"Intelligence Military": IsoTopicCategory.intelligenceMilitary,
"Location": IsoTopicCategory.location,
"Oceans": IsoTopicCategory.oceans,
# there seems to be an additional Population category, which does not seems to be on the ISO - lets ignore it for now
"Planning Cadastre": IsoTopicCategory.planningCadastre,
"Society": IsoTopicCategory.society,
"Structure": IsoTopicCategory.structure,
"Transportation": IsoTopicCategory.transportation,
"Utilities Communication": IsoTopicCategory.utilitiesCommunication,
}
class GeonodeLegacyApiClient(BaseGeonodeClient):
"""API client for GeoNode versions where there is no v2 API."""
capabilities = [
models.ApiClientCapability.FILTER_BY_TITLE,
models.ApiClientCapability.FILTER_BY_ABSTRACT,
models.ApiClientCapability.FILTER_BY_RESOURCE_TYPES,
models.ApiClientCapability.LOAD_VECTOR_DATASET_VIA_WMS,
models.ApiClientCapability.LOAD_VECTOR_DATASET_VIA_WFS,
models.ApiClientCapability.LOAD_RASTER_DATASET_VIA_WMS,
models.ApiClientCapability.LOAD_RASTER_DATASET_VIA_WCS,
]
@property
def api_url(self):
return f"{self.base_url}/api"
@property
def dataset_list_url(self):
return f"{self.api_url}/layers/"
def get_ordering_fields(self) -> typing.List[typing.Tuple[str, str]]:
return [
("title", "Title"),
]
def build_search_query(
self, search_filters: models.GeonodeApiSearchFilters
) -> QtCore.QUrlQuery:
query = QtCore.QUrlQuery()
query.addQueryItem("limit", str(self.page_size))
query.addQueryItem("offset", str((search_filters.page - 1) * self.page_size))
if search_filters.title is not None:
query.addQueryItem("title__icontains", search_filters.title)
if search_filters.abstract is not None:
query.addQueryItem("abstract__icontains", search_filters.abstract)
if search_filters.layer_types is None:
types = [
models.GeonodeResourceType.VECTOR_LAYER,
models.GeonodeResourceType.RASTER_LAYER,
]
else:
types = search_filters.layer_types
is_vector = models.GeonodeResourceType.VECTOR_LAYER in types
is_raster = models.GeonodeResourceType.RASTER_LAYER in types
if is_vector:
query.addQueryItem("type__in", "vector")
if is_raster:
query.addQueryItem("type__in", "raster")
if search_filters.ordering_field is not None:
query.addQueryItem(
"order_by",
(
f"{'-' if search_filters.reverse_ordering else ''}"
f"{search_filters.ordering_field}"
),
)
return query
def get_dataset_list_url(
self, search_filters: models.GeonodeApiSearchFilters
) -> QtCore.QUrl:
url = QtCore.QUrl(self.dataset_list_url)
query = self.build_search_query(search_filters)
url.setQuery(query.query())
return 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) -> None:
if result:
response_content: network.ParsedNetworkReply = (
self.network_fetcher_task.response_contents[0]
)
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(
raw_brief_dataset
)
brief_dataset = models.BriefDataset(**parsed_properties)
except ValueError as exc:
log(
f"Could not parse {raw_brief_dataset!r} into a "
f"valid item: {str(exc)}",
debug=False,
)
else:
brief_datasets.append(brief_dataset)
meta = deserialized_content.get("meta", {})
page_size = meta.get("limit", self.page_size)
current_page = int((meta.get("offset", 0) / page_size) + 1)
pagination_info = models.GeonodePaginationInfo(
total_records=meta.get("total_count") or 0,
current_page=current_page,
page_size=page_size,
)
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, result: bool) -> None:
if result:
detail_response_content: network.ParsedNetworkReply = (
self.network_fetcher_task.response_contents[0]
)
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:
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, 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)
return {
"pk": int(raw_dataset["id"]),
"uuid": uuid.UUID(raw_dataset["uuid"]),
"name": raw_dataset.get("alternate", raw_dataset.get("name", "")),
"title": raw_dataset.get("title", ""),
"abstract": raw_dataset.get("raw_abstract", ""),
"thumbnail_url": raw_dataset["thumbnail_url"],
"link": f"{self.base_url}{raw_dataset['resource_uri']}",
"detail_url": f"{self.base_url}{raw_dataset['detail_url']}",
"dataset_sub_type": type_,
"service_urls": self._get_service_urls(type_),
"spatial_extent": qgis.core.QgsRectangle.fromWkt(
raw_dataset["csw_wkt_geometry"]
),
"srid": qgis.core.QgsCoordinateReferenceSystem(raw_dataset["srid"]),
"published_date": _get_published_date(raw_dataset),
"temporal_extent": _get_temporal_extent(raw_dataset),
"keywords": raw_dataset.get("keywords", []),
"category": _TOPIC_CATEGORY_MAP.get(
raw_dataset.get("category__gn_description")
),
"default_style": models.BriefGeonodeStyle(
name="", sld_url=f"{self.base_url}/{raw_dataset.get('default_style')}"
),
}
def _get_service_urls(
self, resource_type: models.GeonodeResourceType
) -> typing.Dict:
common_url = f"{self.base_url}/geoserver/ows"
result = {models.GeonodeService.OGC_WMS: common_url}
if resource_type == models.GeonodeResourceType.VECTOR_LAYER:
result[models.GeonodeService.OGC_WFS] = common_url
elif resource_type == models.GeonodeResourceType.RASTER_LAYER:
result[models.GeonodeService.OGC_WCS] = common_url
return result
def _get_resource_type(
raw_dataset: typing.Dict,
) -> typing.Optional[models.GeonodeResourceType]:
type_ = {
"dataStore": models.GeonodeResourceType.VECTOR_LAYER,
"coverageStore": models.GeonodeResourceType.RASTER_LAYER,
}.get(raw_dataset.get("storeType", raw_dataset.get("store_type")))
return type_
def _parse_datetime(raw_value: str) -> dt.datetime:
format_ = "%Y-%m-%dT%H:%M:%S"
try:
result = dt.datetime.strptime(raw_value, format_)
except ValueError:
microsecond_format = "%Y-%m-%dT%H:%M:%S.%f"
result = dt.datetime.strptime(raw_value, microsecond_format)
return result
def _get_published_date(payload: typing.Dict) -> typing.Optional[dt.datetime]:
if payload["date_type"] == "publication":
result = _parse_datetime(payload["date"])
else:
result = None
return result
def _get_temporal_extent(
payload: typing.Dict,
) -> typing.Optional[typing.List[typing.Optional[dt.datetime]]]:
start = payload.get("temporal_extent_start")
end = payload.get("temporal_extent_end")
if start is not None and end is not None:
result = [_parse_datetime(start), _parse_datetime(end)]
elif start is not None and end is None:
result = [_parse_datetime(start), None]
elif start is None and end is not None:
result = [None, _parse_datetime(end)]
else:
result = None
return result
PK ,TAA/ A/ qgis_geonode/conf.pyimport contextlib
import dataclasses
import json
import typing
import uuid
from qgis.PyQt import (
QtCore,
)
from qgis.core import QgsRectangle, QgsSettings
from .apiclient import models
from .apiclient.models import GeonodeResourceType, IsoTopicCategory
from .utils import log
from .vendor.packaging import version as packaging_version
@contextlib.contextmanager
def qgis_settings(group_root: str):
"""A simple context manager to help managing our own settings in QgsSettings"""
settings = QgsSettings()
settings.beginGroup(group_root)
try:
yield settings
finally:
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"""
id: uuid.UUID
name: str
base_url: str
page_size: int
network_requests_timeout: int = dataclasses.field(
default_factory=_get_network_requests_timeout, init=False
)
geonode_version: typing.Optional[packaging_version.Version] = None
auth_config: typing.Optional[str] = None
@classmethod
def from_qgs_settings(cls, connection_identifier: str, settings: QgsSettings):
try:
reported_auth_cfg = settings.value("auth_config").strip()
except AttributeError:
reported_auth_cfg = None
raw_geonode_version = settings.value("geonode_version") or None
if raw_geonode_version is not None:
geonode_version = packaging_version.parse(raw_geonode_version)
else:
geonode_version = None
return cls(
id=uuid.UUID(connection_identifier),
name=settings.value("name"),
base_url=settings.value("base_url"),
page_size=int(settings.value("page_size", defaultValue=10)),
auth_config=reported_auth_cfg,
geonode_version=geonode_version,
)
def to_json(self):
return json.dumps(
{
"id": str(self.id),
"name": self.name,
"base_url": self.base_url,
"page_size": self.page_size,
"auth_config": self.auth_config,
"geonode_version": str(self.geonode_version)
if self.geonode_version is not None
else None,
}
)
class SettingsManager(QtCore.QObject):
"""Manage saving/loading settings for the plugin in QgsSettings"""
BASE_GROUP_NAME: str = "qgis_geonode"
SELECTED_CONNECTION_KEY: str = "selected_connection"
CURRENT_FILTERS_KEY: str = "current_search_filters"
current_connection_changed = QtCore.pyqtSignal(str)
_TEMPORAL_FILTER_NAMES = (
"temporal_extent_start",
"temporal_extent_end",
"publication_date_start",
"publication_date_end",
)
def list_connections(self) -> typing.List[ConnectionSettings]:
result = []
with qgis_settings(f"{self.BASE_GROUP_NAME}/connections") as settings:
for connection_id in settings.childGroups():
connection_settings_key = self._get_connection_settings_base(
connection_id
)
with qgis_settings(connection_settings_key) as connection_settings:
result.append(
ConnectionSettings.from_qgs_settings(
connection_id, connection_settings
)
)
result.sort(key=lambda obj: obj.name)
return result
def delete_all_connections(self):
with qgis_settings(f"{self.BASE_GROUP_NAME}/connections") as settings:
for connection_name in settings.childGroups():
settings.remove(connection_name)
self.clear_current_connection()
def find_connection_by_name(self, name) -> ConnectionSettings:
with qgis_settings(f"{self.BASE_GROUP_NAME}/connections") as settings:
for connection_id in settings.childGroups():
connection_settings_key = self._get_connection_settings_base(
connection_id
)
with qgis_settings(connection_settings_key) as connection_settings:
connection_name = connection_settings.value("name")
if connection_name == name:
found_id = uuid.UUID(connection_id)
break
else:
raise ValueError(
f"Could not find a connection named {name!r} in QgsSettings"
)
return self.get_connection_settings(found_id)
def get_connection_settings(self, connection_id: uuid.UUID) -> ConnectionSettings:
settings_key = self._get_connection_settings_base(connection_id)
with qgis_settings(settings_key) as settings:
connection_settings = ConnectionSettings.from_qgs_settings(
str(connection_id), settings
)
return connection_settings
def save_connection_settings(self, connection_settings: ConnectionSettings):
settings_key = self._get_connection_settings_base(connection_settings.id)
with qgis_settings(settings_key) as settings:
settings.setValue("name", connection_settings.name)
settings.setValue("base_url", connection_settings.base_url)
settings.setValue("page_size", connection_settings.page_size)
settings.setValue("auth_config", connection_settings.auth_config)
settings.setValue(
"geonode_version",
(
str(connection_settings.geonode_version)
if connection_settings.geonode_version is not None
else ""
),
)
def delete_connection(self, connection_id: uuid.UUID):
if self.is_current_connection(connection_id):
self.clear_current_connection()
with qgis_settings(f"{self.BASE_GROUP_NAME}/connections") as settings:
settings.remove(str(connection_id))
def get_current_connection_settings(self) -> typing.Optional[ConnectionSettings]:
with qgis_settings(self.BASE_GROUP_NAME) as settings:
current = settings.value(self.SELECTED_CONNECTION_KEY)
if current is not None:
result = self.get_connection_settings(uuid.UUID(current))
else:
result = None
return result
def set_current_connection(self, connection_id: uuid.UUID):
if connection_id not in [conn.id for conn in self.list_connections()]:
raise ValueError(f"Invalid connection identifier: {connection_id!r}")
serialized_id = str(connection_id)
with qgis_settings(self.BASE_GROUP_NAME) as settings:
settings.setValue(self.SELECTED_CONNECTION_KEY, serialized_id)
self.current_connection_changed.emit(serialized_id)
def clear_current_connection(self):
with qgis_settings(self.BASE_GROUP_NAME) as settings:
settings.setValue(self.SELECTED_CONNECTION_KEY, None)
self.current_connection_changed.emit("")
def is_current_connection(self, connection_id: uuid.UUID):
current = self.get_current_connection_settings()
return False if current is None else current.id == connection_id
def _get_connection_settings_base(self, identifier: typing.Union[str, uuid.UUID]):
return f"{self.BASE_GROUP_NAME}/connections/{str(identifier)}"
def store_current_search_filters(self, filters: models.GeonodeApiSearchFilters):
with qgis_settings(
f"{self.BASE_GROUP_NAME}/{self.CURRENT_FILTERS_KEY}"
) as settings:
settings.setValue("title", filters.title)
settings.setValue("abstract", filters.abstract)
settings.setValue("keyword", filters.keyword)
if filters.topic_category is not None:
settings.setValue("topic_category", filters.topic_category.name)
else:
settings.setValue("topic_category", None)
if filters.layer_types is not None:
settings.setValue(
"resource_types_vector",
GeonodeResourceType.VECTOR_LAYER in filters.layer_types,
)
settings.setValue(
"resource_types_raster",
GeonodeResourceType.RASTER_LAYER in filters.layer_types,
)
for temporal_filter_name in self._TEMPORAL_FILTER_NAMES:
filter_value: typing.Optional[QtCore.QDateTime] = getattr(
filters, temporal_filter_name
)
if filter_value is not None:
settings.setValue(
temporal_filter_name, filter_value.toString(QtCore.Qt.ISODate)
)
else:
settings.setValue(temporal_filter_name, None)
if filters.spatial_extent is not None:
settings.setValue(
"spatial_extent_north", filters.spatial_extent.yMaximum()
)
settings.setValue(
"spatial_extent_south", filters.spatial_extent.yMinimum()
)
settings.setValue(
"spatial_extent_east", filters.spatial_extent.xMaximum()
)
settings.setValue(
"spatial_extent_west", filters.spatial_extent.xMinimum()
)
settings.setValue("sort_by_field", filters.ordering_field)
settings.setValue("reverse_sort_order", filters.reverse_ordering)
def get_current_search_filters(self) -> models.GeonodeApiSearchFilters:
result = models.GeonodeApiSearchFilters()
with qgis_settings(
f"{self.BASE_GROUP_NAME}/{self.CURRENT_FILTERS_KEY}"
) as settings:
stored_category = settings.value("topic_category", None)
try:
category = IsoTopicCategory[stored_category]
except KeyError:
category = None
result.title = settings.value("title", None)
result.abstract = settings.value("abstract", None)
result.keyword = settings.value("keyword", None)
result.topic_category = category
if settings.value("resource_types_vector", True, type=bool):
result.layer_types.append(models.GeonodeResourceType.VECTOR_LAYER)
if settings.value("resource_types_raster", True, type=bool):
result.layer_types.append(models.GeonodeResourceType.RASTER_LAYER)
for temporal_filter_name in self._TEMPORAL_FILTER_NAMES:
value = settings.value(temporal_filter_name)
if value is not None:
setattr(
result,
temporal_filter_name,
QtCore.QDateTime.fromString(value, QtCore.Qt.ISODate),
)
if settings.value("spatial_extent_north") is not None:
result.spatial_extent = QgsRectangle(
float(settings.value("spatial_extent_east")),
float(settings.value("spatial_extent_south")),
float(settings.value("spatial_extent_west")),
float(settings.value("spatial_extent_north")),
)
result.ordering_field = settings.value("sort_by_field")
result.reverse_ordering = settings.value(
"reverse_sort_order", False, type=bool
)
return result
def clear_current_search_filters(self):
with qgis_settings(self.BASE_GROUP_NAME) as settings:
settings.setValue(self.CURRENT_FILTERS_KEY, None)
self.current_connection_changed.emit("")
settings_manager = SettingsManager()
PK x,TPG@ @ ) qgis_geonode/vendor/packaging/LICENSE.BSDCopyright (c) Donald Stufft and individual contributors.
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
1. Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
PK x,T ) qgis_geonode/vendor/packaging/__init__.py# This file is dual licensed under the terms of the Apache License, Version
# 2.0, and the BSD License. See the LICENSE file in the root of this repository
# for complete details.
from .__about__ import (
__author__,
__copyright__,
__email__,
__license__,
__summary__,
__title__,
__uri__,
__version__,
)
__all__ = [
"__title__",
"__summary__",
"__uri__",
"__version__",
"__author__",
"__email__",
"__license__",
"__copyright__",
]
PK x,TҼ % qgis_geonode/vendor/packaging/LICENSEThis software is made available under the terms of *either* of the licenses
found in LICENSE.APACHE or LICENSE.BSD. Contributions to this software is made
under the terms of *both* these licenses.
PK x,T0 , qgis_geonode/vendor/packaging/_structures.py# This file is dual licensed under the terms of the Apache License, Version
# 2.0, and the BSD License. See the LICENSE file in the root of this repository
# for complete details.
class InfinityType:
def __repr__(self) -> str:
return "Infinity"
def __hash__(self) -> int:
return hash(repr(self))
def __lt__(self, other: object) -> bool:
return False
def __le__(self, other: object) -> bool:
return False
def __eq__(self, other: object) -> bool:
return isinstance(other, self.__class__)
def __gt__(self, other: object) -> bool:
return True
def __ge__(self, other: object) -> bool:
return True
def __neg__(self: object) -> "NegativeInfinityType":
return NegativeInfinity
Infinity = InfinityType()
class NegativeInfinityType:
def __repr__(self) -> str:
return "-Infinity"
def __hash__(self) -> int:
return hash(repr(self))
def __lt__(self, other: object) -> bool:
return True
def __le__(self, other: object) -> bool:
return True
def __eq__(self, other: object) -> bool:
return isinstance(other, self.__class__)
def __gt__(self, other: object) -> bool:
return False
def __ge__(self, other: object) -> bool:
return False
def __neg__(self: object) -> InfinityType:
return Infinity
NegativeInfinity = NegativeInfinityType()
PK x,T2I9 I9 ( qgis_geonode/vendor/packaging/version.py# This file is dual licensed under the terms of the Apache License, Version
# 2.0, and the BSD License. See the LICENSE file in the root of this repository
# for complete details.
import collections
import itertools
import re
import warnings
from typing import Callable, Iterator, List, Optional, SupportsInt, Tuple, Union
from ._structures import Infinity, InfinityType, NegativeInfinity, NegativeInfinityType
__all__ = ["parse", "Version", "LegacyVersion", "InvalidVersion", "VERSION_PATTERN"]
InfiniteTypes = Union[InfinityType, NegativeInfinityType]
PrePostDevType = Union[InfiniteTypes, Tuple[str, int]]
SubLocalType = Union[InfiniteTypes, int, str]
LocalType = Union[
NegativeInfinityType,
Tuple[
Union[
SubLocalType,
Tuple[SubLocalType, str],
Tuple[NegativeInfinityType, SubLocalType],
],
...,
],
]
CmpKey = Tuple[
int, Tuple[int, ...], PrePostDevType, PrePostDevType, PrePostDevType, LocalType
]
LegacyCmpKey = Tuple[int, Tuple[str, ...]]
VersionComparisonMethod = Callable[
[Union[CmpKey, LegacyCmpKey], Union[CmpKey, LegacyCmpKey]], bool
]
_Version = collections.namedtuple(
"_Version", ["epoch", "release", "dev", "pre", "post", "local"]
)
def parse(version: str) -> Union["LegacyVersion", "Version"]:
"""
Parse the given version string and return either a :class:`Version` object
or a :class:`LegacyVersion` object depending on if the given version is
a valid PEP 440 version or a legacy version.
"""
try:
return Version(version)
except InvalidVersion:
return LegacyVersion(version)
class InvalidVersion(ValueError):
"""
An invalid version was found, users should refer to PEP 440.
"""
class _BaseVersion:
_key: Union[CmpKey, LegacyCmpKey]
def __hash__(self) -> int:
return hash(self._key)
# Please keep the duplicated `isinstance` check
# in the six comparisons hereunder
# unless you find a way to avoid adding overhead function calls.
def __lt__(self, other: "_BaseVersion") -> bool:
if not isinstance(other, _BaseVersion):
return NotImplemented
return self._key < other._key
def __le__(self, other: "_BaseVersion") -> bool:
if not isinstance(other, _BaseVersion):
return NotImplemented
return self._key <= other._key
def __eq__(self, other: object) -> bool:
if not isinstance(other, _BaseVersion):
return NotImplemented
return self._key == other._key
def __ge__(self, other: "_BaseVersion") -> bool:
if not isinstance(other, _BaseVersion):
return NotImplemented
return self._key >= other._key
def __gt__(self, other: "_BaseVersion") -> bool:
if not isinstance(other, _BaseVersion):
return NotImplemented
return self._key > other._key
def __ne__(self, other: object) -> bool:
if not isinstance(other, _BaseVersion):
return NotImplemented
return self._key != other._key
class LegacyVersion(_BaseVersion):
def __init__(self, version: str) -> None:
self._version = str(version)
self._key = _legacy_cmpkey(self._version)
warnings.warn(
"Creating a LegacyVersion has been deprecated and will be "
"removed in the next major release",
DeprecationWarning,
)
def __str__(self) -> str:
return self._version
def __repr__(self) -> str:
return f""
@property
def public(self) -> str:
return self._version
@property
def base_version(self) -> str:
return self._version
@property
def epoch(self) -> int:
return -1
@property
def release(self) -> None:
return None
@property
def pre(self) -> None:
return None
@property
def post(self) -> None:
return None
@property
def dev(self) -> None:
return None
@property
def local(self) -> None:
return None
@property
def is_prerelease(self) -> bool:
return False
@property
def is_postrelease(self) -> bool:
return False
@property
def is_devrelease(self) -> bool:
return False
_legacy_version_component_re = re.compile(r"(\d+ | [a-z]+ | \.| -)", re.VERBOSE)
_legacy_version_replacement_map = {
"pre": "c",
"preview": "c",
"-": "final-",
"rc": "c",
"dev": "@",
}
def _parse_version_parts(s: str) -> Iterator[str]:
for part in _legacy_version_component_re.split(s):
part = _legacy_version_replacement_map.get(part, part)
if not part or part == ".":
continue
if part[:1] in "0123456789":
# pad for numeric comparison
yield part.zfill(8)
else:
yield "*" + part
# ensure that alpha/beta/candidate are before final
yield "*final"
def _legacy_cmpkey(version: str) -> LegacyCmpKey:
# We hardcode an epoch of -1 here. A PEP 440 version can only have a epoch
# greater than or equal to 0. This will effectively put the LegacyVersion,
# which uses the defacto standard originally implemented by setuptools,
# as before all PEP 440 versions.
epoch = -1
# This scheme is taken from pkg_resources.parse_version setuptools prior to
# it's adoption of the packaging library.
parts: List[str] = []
for part in _parse_version_parts(version.lower()):
if part.startswith("*"):
# remove "-" before a prerelease tag
if part < "*final":
while parts and parts[-1] == "*final-":
parts.pop()
# remove trailing zeros from each series of numeric parts
while parts and parts[-1] == "00000000":
parts.pop()
parts.append(part)
return epoch, tuple(parts)
# Deliberately not anchored to the start and end of the string, to make it
# easier for 3rd party code to reuse
VERSION_PATTERN = r"""
v?
(?:
(?:(?P[0-9]+)!)? # epoch
(?P[0-9]+(?:\.[0-9]+)*) # release segment
(?P
# pre-release
[-_\.]?
(?P(a|b|c|rc|alpha|beta|pre|preview))
[-_\.]?
(?P[0-9]+)?
)?
(?P # post release
(?:-(?P[0-9]+))
|
(?:
[-_\.]?
(?Ppost|rev|r)
[-_\.]?
(?P[0-9]+)?
)
)?
(?P # dev release
[-_\.]?
(?Pdev)
[-_\.]?
(?P[0-9]+)?
)?
)
(?:\+(?P[a-z0-9]+(?:[-_\.][a-z0-9]+)*))? # local version
"""
class Version(_BaseVersion):
_regex = re.compile(r"^\s*" + VERSION_PATTERN + r"\s*$", re.VERBOSE | re.IGNORECASE)
def __init__(self, version: str) -> None:
# Validate the version and parse it into pieces
match = self._regex.search(version)
if not match:
raise InvalidVersion(f"Invalid version: '{version}'")
# Store the parsed out pieces of the version
self._version = _Version(
epoch=int(match.group("epoch")) if match.group("epoch") else 0,
release=tuple(int(i) for i in match.group("release").split(".")),
pre=_parse_letter_version(match.group("pre_l"), match.group("pre_n")),
post=_parse_letter_version(
match.group("post_l"), match.group("post_n1") or match.group("post_n2")
),
dev=_parse_letter_version(match.group("dev_l"), match.group("dev_n")),
local=_parse_local_version(match.group("local")),
)
# Generate a key which will be used for sorting
self._key = _cmpkey(
self._version.epoch,
self._version.release,
self._version.pre,
self._version.post,
self._version.dev,
self._version.local,
)
def __repr__(self) -> str:
return f""
def __str__(self) -> str:
parts = []
# Epoch
if self.epoch != 0:
parts.append(f"{self.epoch}!")
# Release segment
parts.append(".".join(str(x) for x in self.release))
# Pre-release
if self.pre is not None:
parts.append("".join(str(x) for x in self.pre))
# Post-release
if self.post is not None:
parts.append(f".post{self.post}")
# Development release
if self.dev is not None:
parts.append(f".dev{self.dev}")
# Local version segment
if self.local is not None:
parts.append(f"+{self.local}")
return "".join(parts)
@property
def epoch(self) -> int:
_epoch: int = self._version.epoch
return _epoch
@property
def release(self) -> Tuple[int, ...]:
_release: Tuple[int, ...] = self._version.release
return _release
@property
def pre(self) -> Optional[Tuple[str, int]]:
_pre: Optional[Tuple[str, int]] = self._version.pre
return _pre
@property
def post(self) -> Optional[int]:
return self._version.post[1] if self._version.post else None
@property
def dev(self) -> Optional[int]:
return self._version.dev[1] if self._version.dev else None
@property
def local(self) -> Optional[str]:
if self._version.local:
return ".".join(str(x) for x in self._version.local)
else:
return None
@property
def public(self) -> str:
return str(self).split("+", 1)[0]
@property
def base_version(self) -> str:
parts = []
# Epoch
if self.epoch != 0:
parts.append(f"{self.epoch}!")
# Release segment
parts.append(".".join(str(x) for x in self.release))
return "".join(parts)
@property
def is_prerelease(self) -> bool:
return self.dev is not None or self.pre is not None
@property
def is_postrelease(self) -> bool:
return self.post is not None
@property
def is_devrelease(self) -> bool:
return self.dev is not None
@property
def major(self) -> int:
return self.release[0] if len(self.release) >= 1 else 0
@property
def minor(self) -> int:
return self.release[1] if len(self.release) >= 2 else 0
@property
def micro(self) -> int:
return self.release[2] if len(self.release) >= 3 else 0
def _parse_letter_version(
letter: str, number: Union[str, bytes, SupportsInt]
) -> Optional[Tuple[str, int]]:
if letter:
# We consider there to be an implicit 0 in a pre-release if there is
# not a numeral associated with it.
if number is None:
number = 0
# We normalize any letters to their lower case form
letter = letter.lower()
# We consider some words to be alternate spellings of other words and
# in those cases we want to normalize the spellings to our preferred
# spelling.
if letter == "alpha":
letter = "a"
elif letter == "beta":
letter = "b"
elif letter in ["c", "pre", "preview"]:
letter = "rc"
elif letter in ["rev", "r"]:
letter = "post"
return letter, int(number)
if not letter and number:
# We assume if we are given a number, but we are not given a letter
# then this is using the implicit post release syntax (e.g. 1.0-1)
letter = "post"
return letter, int(number)
return None
_local_version_separators = re.compile(r"[\._-]")
def _parse_local_version(local: str) -> Optional[LocalType]:
"""
Takes a string like abc.1.twelve and turns it into ("abc", 1, "twelve").
"""
if local is not None:
return tuple(
part.lower() if not part.isdigit() else int(part)
for part in _local_version_separators.split(local)
)
return None
def _cmpkey(
epoch: int,
release: Tuple[int, ...],
pre: Optional[Tuple[str, int]],
post: Optional[Tuple[str, int]],
dev: Optional[Tuple[str, int]],
local: Optional[Tuple[SubLocalType]],
) -> CmpKey:
# When we compare a release version, we want to compare it with all of the
# trailing zeros removed. So we'll use a reverse the list, drop all the now
# leading zeros until we come to something non zero, then take the rest
# re-reverse it back into the correct order and make it a tuple and use
# that for our sorting key.
_release = tuple(
reversed(list(itertools.dropwhile(lambda x: x == 0, reversed(release))))
)
# We need to "trick" the sorting algorithm to put 1.0.dev0 before 1.0a0.
# We'll do this by abusing the pre segment, but we _only_ want to do this
# if there is not a pre or a post segment. If we have one of those then
# the normal sorting rules will handle this case correctly.
if pre is None and post is None and dev is not None:
_pre: PrePostDevType = NegativeInfinity
# Versions without a pre-release (except as noted above) should sort after
# those with one.
elif pre is None:
_pre = Infinity
else:
_pre = pre
# Versions without a post segment should sort before those with one.
if post is None:
_post: PrePostDevType = NegativeInfinity
else:
_post = post
# Versions without a development segment should sort after those with one.
if dev is None:
_dev: PrePostDevType = Infinity
else:
_dev = dev
if local is None:
# Versions without a local segment should sort before those with one.
_local: LocalType = NegativeInfinity
else:
# Versions with a local segment need that segment parsed to implement
# the sorting rules in PEP440.
# - Alpha numeric segments sort before numeric segments
# - Alpha numeric segments sort lexicographically
# - Numeric segments sort numerically
# - Shorter versions sort before longer versions when the prefixes
# match exactly
_local = tuple(
(i, "") if isinstance(i, int) else (NegativeInfinity, i) for i in local
)
return epoch, _release, _pre, _post, _dev, _local
PK x,T' ' , qgis_geonode/vendor/packaging/LICENSE.APACHE
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
PK x,TFc * qgis_geonode/vendor/packaging/__about__.py# This file is dual licensed under the terms of the Apache License, Version
# 2.0, and the BSD License. See the LICENSE file in the root of this repository
# for complete details.
__all__ = [
"__title__",
"__summary__",
"__uri__",
"__version__",
"__author__",
"__email__",
"__license__",
"__copyright__",
]
__title__ = "packaging"
__summary__ = "Core utilities for Python packages"
__uri__ = "https://github.com/pypa/packaging"
__version__ = "21.3"
__author__ = "Donald Stufft and individual contributors"
__email__ = "donald@stufft.io"
__license__ = "BSD-2-Clause or Apache-2.0"
__copyright__ = "2014-2019 %s" % __author__
PK x,T qgis_geonode/vendor/README.md# Vendorized packages
This project vendorizes the
[packaging](https://packaging.pypa.io/en/latest/index.html) package. It does
so in order to use its ability to parse
[PEP440](https://www.python.org/dev/peps/pep-0440/)-compliant versions, as is
the case with the GeoNode version, which we eventually need to parse.
**Vendorized version: packaging 21.3**
In order to keep the vendorized code as small as possible, we only
keep the following:
- `packaging.__init__`
- `packaging.__about__`
- `packaging._structures`
- `packaging.version`
- `LICENSE`
- `LICENSE.APACHE`
- `LICENSE.BSD`
The rest of the code has been removed.
The `packaging` project is dual-licensed, using either BSD-2-Clause or
Apache-2.0.
Thanks to the original `packaging` contributors.
PK ,T38 8 qgis_geonode/main.py"""
/***************************************************************************
* *
* This program is free software; you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation; either version 2 of the License, or *
* (at your option) any later version. *
* *
***************************************************************************/
"""
import os.path
from qgis.gui import QgsGui
from qgis.PyQt.QtCore import QSettings, QTranslator, QCoreApplication
from qgis.PyQt.QtGui import QIcon
from qgis.PyQt.QtWidgets import QAction
# Initialize Qt resources from file resources.py
from .resources import *
from .gui.geonode_source_select_provider import GeonodeSourceSelectProvider
from .gui.geonode_maplayer_config_widget_factory import (
GeonodeMapLayerConfigWidgetFactory,
)
class QgisGeoNode:
def __init__(self, iface):
self.iface = iface
self.plugin_dir = os.path.dirname(__file__)
locale = QSettings().value("locale/userLocale")[0:2]
locale_path = os.path.join(
self.plugin_dir, "i18n", "QgisGeoNode_{}.qm".format(locale)
)
if os.path.exists(locale_path):
self.translator = QTranslator()
self.translator.load(locale_path)
QCoreApplication.installTranslator(self.translator)
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.layer_properties_config_widget_factory = (
GeonodeMapLayerConfigWidgetFactory()
)
self.pluginIsActive = False
self.geonodeSourceSelectProvider = GeonodeSourceSelectProvider()
# noinspection PyMethodMayBeStatic
def tr(self, message):
"""Get the translation for a string using Qt translation API.
We implement this ourselves since we do not inherit QObject.
:param message: String for translation.
:type message: str, QString
:returns: Translated version of message.
:rtype: QString
"""
# noinspection PyTypeChecker,PyArgumentList,PyCallByClass
return QCoreApplication.translate("QgisGeoNode", message)
def add_action(
self,
icon_path,
text,
callback,
enabled_flag=True,
add_to_menu=True,
status_tip=None,
whats_this=None,
parent=None,
):
"""Add a toolbar icon to the toolbar.
:param icon_path: Path to the icon for this action. Can be a resource
path (e.g. ':/plugins/foo/bar.png') or a normal file system path.
:type icon_path: str
:param text: Text that should be shown in menu items for this action.
:type text: str
:param callback: Function to be called when the action is triggered.
:type callback: function
:param enabled_flag: A flag indicating if the action should be enabled
by default. Defaults to True.
:type enabled_flag: bool
:param add_to_menu: Flag indicating whether the action should also
be added to the menu. Defaults to True.
:type add_to_menu: bool
:param status_tip: Optional text to show in a popup when mouse pointer
hovers over the action.
:type status_tip: str
:param parent: Parent widget for the new action. Defaults None.
:type parent: QWidget
:param whats_this: Optional text to show in the status bar when the
mouse pointer hovers over the action.
:returns: The action that was created. Note that the action is also
added to self.actions list.
:rtype: QAction
"""
icon = QIcon(icon_path)
action = QAction(icon, text, parent)
action.triggered.connect(callback)
action.setEnabled(enabled_flag)
if status_tip is not None:
action.setStatusTip(status_tip)
if whats_this is not None:
action.setWhatsThis(whats_this)
if add_to_menu:
self.iface.addPluginToMenu(self.menu, action)
self.actions.append(action)
return action
def initGui(self):
self.iface.registerMapLayerConfigWidgetFactory(
self.layer_properties_config_widget_factory
)
QgsGui.sourceSelectProviderRegistry().addProvider(
self.geonodeSourceSelectProvider
)
def onClosePlugin(self):
"""Cleanup necessary items here when plugin dockwidget is closed"""
self.pluginIsActive = False
def unload(self):
"""Removes the plugin menu item and icon from QGIS GUI."""
for action in self.actions:
self.iface.removePluginMenu(self.tr(u"&QGIS GeoNode Plugin"), action)
self.iface.removeToolBarIcon(action)
self.iface.unregisterMapLayerConfigWidgetFactory(
self.layer_properties_config_widget_factory
)
QgsGui.sourceSelectProviderRegistry().removeProvider(
self.geonodeSourceSelectProvider
)
def run(self):
if not self.pluginIsActive:
self.pluginIsActive = True
PK ,TYT qgis_geonode/utils.pyimport typing
import qgis.gui
from PyQt5 import QtCore, QtWidgets
from qgis.core import (
Qgis,
QgsMessageLog,
)
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)
def tr(text):
"""Get the translation for a string using Qt translation API."""
# noinspection PyTypeChecker,PyArgumentList,PyCallByClass
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)
PK ,T qgis_geonode/mIconGeonode.svgPK ,T}1 qgis_geonode/styles.pyimport 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)
PK ,T