diff --git a/python/PyQt6/core/auto_generated/providers/sensorthings/qgssensorthingsutils.sip.in b/python/PyQt6/core/auto_generated/providers/sensorthings/qgssensorthingsutils.sip.in index 62e86d15bc20..abfde62514bf 100644 --- a/python/PyQt6/core/auto_generated/providers/sensorthings/qgssensorthingsutils.sip.in +++ b/python/PyQt6/core/auto_generated/providers/sensorthings/qgssensorthingsutils.sip.in @@ -61,6 +61,25 @@ Returns ``True`` if the specified entity ``type`` can have geometry attached. %Docstring Returns a filter string which restricts results to those matching the specified ``entityType`` and ``wkbType``. +%End + + static QString filterForExtent( const QString &geometryField, const QgsRectangle &extent ); +%Docstring +Returns a filter string which restricts results to those within the specified +``extent``. + +The ``extent`` should always be specified in EPSG:4326. + +.. versionadded:: 3.38 +%End + + static QString combineFilters( const QStringList &filters ); +%Docstring +Combines a set of SensorThings API filter operators. + +See https://docs.ogc.org/is/18-088/18-088.html#requirement-request-data-filter + +.. versionadded:: 3.38 %End static QList< Qgis::GeometryType > availableGeometryTypes( const QString &uri, Qgis::SensorThingsEntity type, QgsFeedback *feedback = 0, const QString &authCfg = QString() ); diff --git a/python/core/auto_generated/providers/sensorthings/qgssensorthingsutils.sip.in b/python/core/auto_generated/providers/sensorthings/qgssensorthingsutils.sip.in index 62e86d15bc20..abfde62514bf 100644 --- a/python/core/auto_generated/providers/sensorthings/qgssensorthingsutils.sip.in +++ b/python/core/auto_generated/providers/sensorthings/qgssensorthingsutils.sip.in @@ -61,6 +61,25 @@ Returns ``True`` if the specified entity ``type`` can have geometry attached. %Docstring Returns a filter string which restricts results to those matching the specified ``entityType`` and ``wkbType``. +%End + + static QString filterForExtent( const QString &geometryField, const QgsRectangle &extent ); +%Docstring +Returns a filter string which restricts results to those within the specified +``extent``. + +The ``extent`` should always be specified in EPSG:4326. + +.. versionadded:: 3.38 +%End + + static QString combineFilters( const QStringList &filters ); +%Docstring +Combines a set of SensorThings API filter operators. + +See https://docs.ogc.org/is/18-088/18-088.html#requirement-request-data-filter + +.. versionadded:: 3.38 %End static QList< Qgis::GeometryType > availableGeometryTypes( const QString &uri, Qgis::SensorThingsEntity type, QgsFeedback *feedback = 0, const QString &authCfg = QString() ); diff --git a/src/core/providers/sensorthings/qgssensorthingsprovider.cpp b/src/core/providers/sensorthings/qgssensorthingsprovider.cpp index 3703bbc57a22..ec2600f618dc 100644 --- a/src/core/providers/sensorthings/qgssensorthingsprovider.cpp +++ b/src/core/providers/sensorthings/qgssensorthingsprovider.cpp @@ -183,6 +183,13 @@ QString QgsSensorThingsProvider::htmlMetadata() const return metadata; } +Qgis::DataProviderFlags QgsSensorThingsProvider::flags() const +{ + QGIS_PROTECT_QOBJECT_THREAD_ACCESS + + return Qgis::DataProviderFlag::FastExtent2D; +} + QgsVectorDataProvider::Capabilities QgsSensorThingsProvider::capabilities() const { QGIS_PROTECT_QOBJECT_THREAD_ACCESS @@ -196,6 +203,8 @@ QgsVectorDataProvider::Capabilities QgsSensorThingsProvider::capabilities() cons void QgsSensorThingsProvider::setDataSourceUri( const QString &uri ) { + QGIS_PROTECT_QOBJECT_THREAD_ACCESS + mSharedData = std::make_shared< QgsSensorThingsSharedData >( uri ); QgsDataProvider::setDataSourceUri( uri ); } @@ -210,12 +219,7 @@ QgsCoordinateReferenceSystem QgsSensorThingsProvider::crs() const QgsRectangle QgsSensorThingsProvider::extent() const { QGIS_PROTECT_QOBJECT_THREAD_ACCESS - -#if 0 return mSharedData->extent(); -#endif - - return QgsRectangle(); } QString QgsSensorThingsProvider::name() const @@ -342,6 +346,22 @@ QVariantMap QgsSensorThingsProviderMetadata::decodeUri( const QString &uri ) con break; } + const QStringList bbox = dsUri.param( QStringLiteral( "bbox" ) ).split( ',' ); + if ( bbox.size() == 4 ) + { + QgsRectangle r; + bool xminOk = false; + bool yminOk = false; + bool xmaxOk = false; + bool ymaxOk = false; + r.setXMinimum( bbox[0].toDouble( &xminOk ) ); + r.setYMinimum( bbox[1].toDouble( &yminOk ) ); + r.setXMaximum( bbox[2].toDouble( &xmaxOk ) ); + r.setYMaximum( bbox[3].toDouble( &ymaxOk ) ); + if ( xminOk && yminOk && xmaxOk && ymaxOk ) + components.insert( QStringLiteral( "bounds" ), r ); + } + return components; } @@ -403,6 +423,12 @@ QString QgsSensorThingsProviderMetadata::encodeUri( const QVariantMap &parts ) c dsUri.setWkbType( Qgis::WkbType::MultiPolygonZ ); } + if ( parts.contains( QStringLiteral( "bounds" ) ) && parts.value( QStringLiteral( "bounds" ) ).userType() == QMetaType::type( "QgsRectangle" ) ) + { + const QgsRectangle bBox = parts.value( QStringLiteral( "bounds" ) ).value< QgsRectangle >(); + dsUri.setParam( QStringLiteral( "bbox" ), QStringLiteral( "%1,%2,%3,%4" ).arg( bBox.xMinimum() ).arg( bBox.yMinimum() ).arg( bBox.xMaximum() ).arg( bBox.yMaximum() ) ); + } + return dsUri.uri( false ); } diff --git a/src/core/providers/sensorthings/qgssensorthingsprovider.h b/src/core/providers/sensorthings/qgssensorthingsprovider.h index 2e88b52c14e6..c81fe1033ff1 100644 --- a/src/core/providers/sensorthings/qgssensorthingsprovider.h +++ b/src/core/providers/sensorthings/qgssensorthingsprovider.h @@ -30,7 +30,7 @@ * * \since QGIS 3.36 */ -class QgsSensorThingsProvider : public QgsVectorDataProvider +class QgsSensorThingsProvider final : public QgsVectorDataProvider { Q_OBJECT @@ -41,29 +41,30 @@ class QgsSensorThingsProvider : public QgsVectorDataProvider QgsSensorThingsProvider( const QString &uri, const QgsDataProvider::ProviderOptions &providerOptions, QgsDataProvider::ReadFlags flags = QgsDataProvider::ReadFlags() ); - QgsAbstractFeatureSource *featureSource() const override; - QString storageType() const override; - QgsFeatureIterator getFeatures( const QgsFeatureRequest &request = QgsFeatureRequest() ) const override; - Qgis::WkbType wkbType() const override; - long long featureCount() const override; - QgsFields fields() const override; - QgsLayerMetadata layerMetadata() const override; - QString htmlMetadata() const override; + QgsAbstractFeatureSource *featureSource() const final; + QString storageType() const final; + QgsFeatureIterator getFeatures( const QgsFeatureRequest &request = QgsFeatureRequest() ) const final; + Qgis::WkbType wkbType() const final; + long long featureCount() const final; + QgsFields fields() const final; + QgsLayerMetadata layerMetadata() const final; + QString htmlMetadata() const final; - QgsVectorDataProvider::Capabilities capabilities() const override; + Qgis::DataProviderFlags flags() const final; + QgsVectorDataProvider::Capabilities capabilities() const final; - QgsCoordinateReferenceSystem crs() const override; - void setDataSourceUri( const QString &uri ) override; - QgsRectangle extent() const override; - bool isValid() const override { return mValid; } + QgsCoordinateReferenceSystem crs() const final; + void setDataSourceUri( const QString &uri ) final; + QgsRectangle extent() const final; + bool isValid() const final { return mValid; } - QString name() const override; - QString description() const override; - bool renderInPreview( const QgsDataProvider::PreviewContext &context ) override; + QString name() const final; + QString description() const final; + bool renderInPreview( const QgsDataProvider::PreviewContext &context ) final; static QString providerKey(); - void handlePostCloneOperations( QgsVectorDataProvider *source ) override; + void handlePostCloneOperations( QgsVectorDataProvider *source ) final; private: bool mValid = false; @@ -71,28 +72,28 @@ class QgsSensorThingsProvider : public QgsVectorDataProvider QgsLayerMetadata mLayerMetadata; - void reloadProviderData() override; + void reloadProviderData() final; }; -class QgsSensorThingsProviderMetadata: public QgsProviderMetadata +class QgsSensorThingsProviderMetadata final: public QgsProviderMetadata { Q_OBJECT public: QgsSensorThingsProviderMetadata(); - QIcon icon() const override; - QList dataItemProviders() const override; - QVariantMap decodeUri( const QString &uri ) const override; - QString encodeUri( const QVariantMap &parts ) const override; - QgsSensorThingsProvider *createProvider( const QString &uri, const QgsDataProvider::ProviderOptions &options, QgsDataProvider::ReadFlags flags = QgsDataProvider::ReadFlags() ) override; - QList< Qgis::LayerType > supportedLayerTypes() const override; + QIcon icon() const final; + QList dataItemProviders() const final; + QVariantMap decodeUri( const QString &uri ) const final; + QString encodeUri( const QVariantMap &parts ) const final; + QgsSensorThingsProvider *createProvider( const QString &uri, const QgsDataProvider::ProviderOptions &options, QgsDataProvider::ReadFlags flags = QgsDataProvider::ReadFlags() ) final; + QList< Qgis::LayerType > supportedLayerTypes() const final; // handling of stored connections - QMap connections( bool cached ) override; - QgsAbstractProviderConnection *createConnection( const QString &name ) override; - void deleteConnection( const QString &name ) override; - void saveConnection( const QgsAbstractProviderConnection *connection, const QString &name ) override; + QMap connections( bool cached ) final; + QgsAbstractProviderConnection *createConnection( const QString &name ) final; + void deleteConnection( const QString &name ) final; + void saveConnection( const QgsAbstractProviderConnection *connection, const QString &name ) final; }; diff --git a/src/core/providers/sensorthings/qgssensorthingsshareddata.cpp b/src/core/providers/sensorthings/qgssensorthingsshareddata.cpp index 1e695cbb1da6..a2c87edca8d3 100644 --- a/src/core/providers/sensorthings/qgssensorthingsshareddata.cpp +++ b/src/core/providers/sensorthings/qgssensorthingsshareddata.cpp @@ -37,6 +37,7 @@ QgsSensorThingsSharedData::QgsSensorThingsSharedData( const QString &uri ) mGeometryField = QgsSensorThingsUtils::geometryFieldForEntityType( mEntityType ); // use initial value of maximum page size as default mMaximumPageSize = uriParts.value( QStringLiteral( "pageSize" ), mMaximumPageSize ).toInt(); + mFilterExtent = uriParts.value( QStringLiteral( "bounds" ) ).value< QgsRectangle >(); if ( QgsSensorThingsUtils::entityTypeHasGeometry( mEntityType ) ) { @@ -130,6 +131,16 @@ QUrl QgsSensorThingsSharedData::parseUrl( const QUrl &url, bool *isTestEndpoint return modifiedUrl; } +QgsRectangle QgsSensorThingsSharedData::extent() const +{ + QgsReadWriteLocker locker( mReadWriteLock, QgsReadWriteLocker::Read ); + + // Since we can't retrieve the actual layer extent via SensorThings API, we use a pessimistic + // global extent until we've retrieved all the features from the layer + return hasCachedAllFeatures() ? mFetchedFeatureExtent + : ( !mFilterExtent.isNull() ? mFilterExtent : QgsRectangle( -180, -90, 180, 90 ) ); +} + long long QgsSensorThingsSharedData::featureCount( QgsFeedback *feedback ) const { QgsReadWriteLocker locker( mReadWriteLock, QgsReadWriteLocker::Read ); @@ -142,8 +153,12 @@ long long QgsSensorThingsSharedData::featureCount( QgsFeedback *feedback ) const // return no features, just the total count QString countUri = QStringLiteral( "%1?$top=0&$count=true" ).arg( mEntityBaseUri ); const QString typeFilter = QgsSensorThingsUtils::filterForWkbType( mEntityType, mGeometryType ); - if ( !typeFilter.isEmpty() ) - countUri += QStringLiteral( "&$filter=" ) + typeFilter; + const QString extentFilter = QgsSensorThingsUtils::filterForExtent( mGeometryField, mFilterExtent ); + QString filterString = QgsSensorThingsUtils::combineFilters( { typeFilter, extentFilter } ); + if ( !filterString.isEmpty() ) + filterString = QStringLiteral( "&$filter=" ) + filterString; + if ( !filterString.isEmpty() ) + countUri += filterString; const QUrl url = parseUrl( QUrl( countUri ) ); @@ -215,8 +230,10 @@ bool QgsSensorThingsSharedData::getFeature( QgsFeatureId id, QgsFeature &f, QgsF locker.changeMode( QgsReadWriteLocker::Write ); mNextPage = QStringLiteral( "%1?$top=%2&$count=false" ).arg( mEntityBaseUri ).arg( mMaximumPageSize ); const QString typeFilter = QgsSensorThingsUtils::filterForWkbType( mEntityType, mGeometryType ); - if ( !typeFilter.isEmpty() ) - mNextPage += QStringLiteral( "&$filter=" ) + typeFilter; + const QString extentFilter = QgsSensorThingsUtils::filterForExtent( mGeometryField, mFilterExtent ); + const QString filterString = QgsSensorThingsUtils::combineFilters( { typeFilter, extentFilter } ); + if ( !filterString.isEmpty() ) + mNextPage += QStringLiteral( "&$filter=" ) + filterString; } locker.unlock(); @@ -243,18 +260,22 @@ bool QgsSensorThingsSharedData::getFeature( QgsFeatureId id, QgsFeature &f, QgsF QgsFeatureIds QgsSensorThingsSharedData::getFeatureIdsInExtent( const QgsRectangle &extent, QgsFeedback *feedback, const QString &thisPage, QString &nextPage, const QgsFeatureIds &alreadyFetchedIds ) { - const QgsGeometry extentGeom = QgsGeometry::fromRect( extent ); + const QgsRectangle requestExtent = mFilterExtent.isNull() ? extent : extent.intersect( mFilterExtent ); + const QgsGeometry extentGeom = QgsGeometry::fromRect( requestExtent ); QgsReadWriteLocker locker( mReadWriteLock, QgsReadWriteLocker::Read ); if ( hasCachedAllFeatures() || mCachedExtent.contains( extentGeom ) ) { // all features cached locally, rely on local spatial index - return qgis::listToSet( mSpatialIndex.intersects( extent ) ); + return qgis::listToSet( mSpatialIndex.intersects( requestExtent ) ); } - // TODO -- is using 'geography' always correct here? const QString typeFilter = QgsSensorThingsUtils::filterForWkbType( mEntityType, mGeometryType ); - QString queryUrl = !thisPage.isEmpty() ? thisPage : QStringLiteral( "%1?$top=%2&$count=false&$filter=geo.intersects(%3, geography'%4')%5" ).arg( mEntityBaseUri ).arg( mMaximumPageSize ).arg( mGeometryField, extent.asWktPolygon(), typeFilter.isEmpty() ? QString() : ( QStringLiteral( " and " ) + typeFilter ) ); + const QString extentFilter = QgsSensorThingsUtils::filterForExtent( mGeometryField, requestExtent ); + QString filterString = QgsSensorThingsUtils::combineFilters( { extentFilter, typeFilter } ); + if ( !filterString.isEmpty() ) + filterString = QStringLiteral( "&$filter=" ) + filterString; + QString queryUrl = !thisPage.isEmpty() ? thisPage : QStringLiteral( "%1?$top=%2&$count=false%3" ).arg( mEntityBaseUri ).arg( mMaximumPageSize ).arg( filterString ); if ( thisPage.isEmpty() && mCachedExtent.intersects( extentGeom ) ) { @@ -262,7 +283,7 @@ QgsFeatureIds QgsSensorThingsSharedData::getFeatureIdsInExtent( const QgsRectang // This is slightly nicer from a rendering point of view, because panning the map won't see features // previously visible disappear temporarily while we wait for them to be included in the service's result set... nextPage = queryUrl; - return qgis::listToSet( mSpatialIndex.intersects( extent ) ); + return qgis::listToSet( mSpatialIndex.intersects( requestExtent ) ); } locker.unlock(); @@ -306,6 +327,7 @@ void QgsSensorThingsSharedData::clearCache() mCachedFeatures.clear(); mIotIdToFeatureId.clear(); mSpatialIndex = QgsSpatialIndex(); + mFetchedFeatureExtent = QgsRectangle(); } bool QgsSensorThingsSharedData::processFeatureRequest( QString &nextPage, QgsFeedback *feedback, const std::function< void( const QgsFeature & ) > &fetchedFeatureCallback, const std::function &continueFetchingCallback, const std::function &onNoMoreFeaturesCallback ) @@ -614,6 +636,7 @@ bool QgsSensorThingsSharedData::processFeatureRequest( QString &nextPage, QgsFee mCachedFeatures.insert( feature.id(), feature ); mIotIdToFeatureId.insert( iotId, feature.id() ); mSpatialIndex.addFeature( feature ); + mFetchedFeatureExtent.combineExtentWith( feature.geometry().boundingBox() ); fetchedFeatureCallback( feature ); } diff --git a/src/core/providers/sensorthings/qgssensorthingsshareddata.h b/src/core/providers/sensorthings/qgssensorthingsshareddata.h index fb618f0df456..304a6674d1a3 100644 --- a/src/core/providers/sensorthings/qgssensorthingsshareddata.h +++ b/src/core/providers/sensorthings/qgssensorthingsshareddata.h @@ -50,6 +50,7 @@ class QgsSensorThingsSharedData QString error() const { return mError; } QgsCoordinateReferenceSystem crs() const { return mSourceCRS; } + QgsRectangle extent() const; long long featureCount( QgsFeedback *feedback = nullptr ) const; bool hasCachedAllFeatures() const; @@ -82,6 +83,12 @@ class QgsSensorThingsSharedData Qgis::WkbType mGeometryType = Qgis::WkbType::Unknown; QString mGeometryField; QgsFields mFields; + + QgsRectangle mFilterExtent; + + //! Extent calculated from features actually fetched so far + QgsRectangle mFetchedFeatureExtent; + QgsCoordinateReferenceSystem mSourceCRS; mutable long long mFeatureCount = static_cast< long long >( Qgis::FeatureCountState::Uncounted ); diff --git a/src/core/providers/sensorthings/qgssensorthingsutils.cpp b/src/core/providers/sensorthings/qgssensorthingsutils.cpp index 64eddcf16c6f..c6a0d8331e86 100644 --- a/src/core/providers/sensorthings/qgssensorthingsutils.cpp +++ b/src/core/providers/sensorthings/qgssensorthingsutils.cpp @@ -20,6 +20,7 @@ #include "qgsnetworkaccessmanager.h" #include "qgsblockingnetworkrequest.h" #include "qgslogger.h" +#include "qgsrectangle.h" #include #include #include @@ -264,6 +265,30 @@ QString QgsSensorThingsUtils::filterForWkbType( Qgis::SensorThingsEntity entityT return QString(); } +QString QgsSensorThingsUtils::filterForExtent( const QString &geometryField, const QgsRectangle &extent ) +{ + // TODO -- confirm using 'geography' is always correct here + return ( extent.isNull() || geometryField.isEmpty() ) + ? QString() + : QStringLiteral( "geo.intersects(%1, geography'%2')" ).arg( geometryField, extent.asWktPolygon() ); +} + +QString QgsSensorThingsUtils::combineFilters( const QStringList &filters ) +{ + QStringList nonEmptyFilters; + for ( const QString &filter : filters ) + { + if ( !filter.isEmpty() ) + nonEmptyFilters.append( filter ); + } + if ( nonEmptyFilters.empty() ) + return QString(); + if ( nonEmptyFilters.size() == 1 ) + return nonEmptyFilters.at( 0 ); + + return QStringLiteral( "(" ) + nonEmptyFilters.join( QStringLiteral( ") and (" ) ) + QStringLiteral( ")" ); +} + QList QgsSensorThingsUtils::availableGeometryTypes( const QString &uri, Qgis::SensorThingsEntity type, QgsFeedback *feedback, const QString &authCfg ) { QNetworkRequest request = QNetworkRequest( QUrl( uri ) ); diff --git a/src/core/providers/sensorthings/qgssensorthingsutils.h b/src/core/providers/sensorthings/qgssensorthingsutils.h index d73f3059addf..698cda29b263 100644 --- a/src/core/providers/sensorthings/qgssensorthingsutils.h +++ b/src/core/providers/sensorthings/qgssensorthingsutils.h @@ -21,6 +21,7 @@ class QgsFields; class QgsFeedback; +class QgsRectangle; /** * \ingroup core @@ -78,6 +79,25 @@ class CORE_EXPORT QgsSensorThingsUtils */ static QString filterForWkbType( Qgis::SensorThingsEntity entityType, Qgis::WkbType wkbType ); + /** + * Returns a filter string which restricts results to those within the specified + * \a extent. + * + * The \a extent should always be specified in EPSG:4326. + * + * \since QGIS 3.38 + */ + static QString filterForExtent( const QString &geometryField, const QgsRectangle &extent ); + + /** + * Combines a set of SensorThings API filter operators. + * + * See https://docs.ogc.org/is/18-088/18-088.html#requirement-request-data-filter + * + * \since QGIS 3.38 + */ + static QString combineFilters( const QStringList &filters ); + /** * Returns a list of available geometry types for the server at the specified \a uri * and entity \a type. diff --git a/src/gui/providers/sensorthings/qgssensorthingssourceselect.cpp b/src/gui/providers/sensorthings/qgssensorthingssourceselect.cpp index 44e850d17a5d..b05dbf8eba72 100644 --- a/src/gui/providers/sensorthings/qgssensorthingssourceselect.cpp +++ b/src/gui/providers/sensorthings/qgssensorthingssourceselect.cpp @@ -212,6 +212,12 @@ void QgsSensorThingsSourceSelect::addButtonClicked() emit addLayer( Qgis::LayerType::Vector, layerUri, baseName, QgsSensorThingsProvider::SENSORTHINGS_PROVIDER_KEY ); } +void QgsSensorThingsSourceSelect::setMapCanvas( QgsMapCanvas *mapCanvas ) +{ + QgsAbstractDataSourceWidget::setMapCanvas( mapCanvas ); + mSourceWidget->setMapCanvas( mapCanvas ); +} + void QgsSensorThingsSourceSelect::populateConnectionList() { cmbConnections->blockSignals( true ); diff --git a/src/gui/providers/sensorthings/qgssensorthingssourceselect.h b/src/gui/providers/sensorthings/qgssensorthingssourceselect.h index a2522ccf6db3..d15e64a2057b 100644 --- a/src/gui/providers/sensorthings/qgssensorthingssourceselect.h +++ b/src/gui/providers/sensorthings/qgssensorthingssourceselect.h @@ -33,9 +33,8 @@ class QgsSensorThingsSourceSelect : public QgsAbstractDataSourceWidget, private public: QgsSensorThingsSourceSelect( QWidget *parent = nullptr, Qt::WindowFlags fl = QgsGuiUtils::ModalDialogFlags, QgsProviderRegistry::WidgetMode widgetMode = QgsProviderRegistry::WidgetMode::None ); - - //! Determines the layers the user selected void addButtonClicked() override; + void setMapCanvas( QgsMapCanvas *mapCanvas ) override; private slots: diff --git a/src/gui/providers/sensorthings/qgssensorthingssourcewidget.cpp b/src/gui/providers/sensorthings/qgssensorthingssourcewidget.cpp index b64ac28a8d1e..bb091af0fa90 100644 --- a/src/gui/providers/sensorthings/qgssensorthingssourcewidget.cpp +++ b/src/gui/providers/sensorthings/qgssensorthingssourcewidget.cpp @@ -24,6 +24,7 @@ #include "qgsiconutils.h" #include "qgssensorthingsconnectionpropertiestask.h" #include "qgsapplication.h" +#include "qgsextentwidget.h" #include #include #include @@ -33,6 +34,14 @@ QgsSensorThingsSourceWidget::QgsSensorThingsSourceWidget( QWidget *parent ) { setupUi( this ); + QVBoxLayout *vl = new QVBoxLayout(); + vl->setContentsMargins( 0, 0, 0, 0 ); + mExtentWidget = new QgsExtentWidget( nullptr, QgsExtentWidget::CondensedStyle ); + mExtentWidget->setNullValueAllowed( true, tr( "Not set" ) ); + mExtentWidget->setOutputCrs( QgsCoordinateReferenceSystem( QStringLiteral( "EPSG:4326" ) ) ); + vl->addWidget( mExtentWidget ); + mExtentLimitFrame->setLayout( vl ); + mSpinPageSize->setClearValue( 0, tr( "Default (%1)" ).arg( QgsSensorThingsUtils::DEFAULT_PAGE_SIZE ) ); for ( Qgis::SensorThingsEntity type : @@ -58,6 +67,7 @@ QgsSensorThingsSourceWidget::QgsSensorThingsSourceWidget( QWidget *parent ) connect( mSpinPageSize, qOverload< int >( &QSpinBox::valueChanged ), this, &QgsSensorThingsSourceWidget::validate ); connect( mRetrieveTypesButton, &QToolButton::clicked, this, &QgsSensorThingsSourceWidget::retrieveTypes ); mRetrieveTypesButton->setEnabled( false ); + connect( mExtentWidget, &QgsExtentWidget::extentChanged, this, &QgsSensorThingsSourceWidget::validate ); validate(); } @@ -92,6 +102,17 @@ void QgsSensorThingsSourceWidget::setSourceUri( const QString &uri ) mSpinPageSize->setValue( maxPageSizeParam ); } + const QgsRectangle bounds = mSourceParts.value( QStringLiteral( "bounds" ) ).value< QgsRectangle >(); + if ( !bounds.isNull() ) + { + mExtentWidget->setCurrentExtent( bounds, QgsCoordinateReferenceSystem( QStringLiteral( "EPSG:4326" ) ) ); + mExtentWidget->setOutputExtentFromUser( bounds, QgsCoordinateReferenceSystem( QStringLiteral( "EPSG:4326" ) ) ); + } + else + { + mExtentWidget->clear(); + } + mIsValid = true; } @@ -108,6 +129,12 @@ QString QgsSensorThingsSourceWidget::groupTitle() const return QObject::tr( "SensorThings Configuration" ); } +void QgsSensorThingsSourceWidget::setMapCanvas( QgsMapCanvas *mapCanvas ) +{ + QgsProviderSourceWidget::setMapCanvas( mapCanvas ); + mExtentWidget->setMapCanvas( mapCanvas, false ); +} + QString QgsSensorThingsSourceWidget::updateUriFromGui( const QString &connectionUri ) const { QVariantMap parts = QgsProviderRegistry::instance()->decodeUri( @@ -152,6 +179,11 @@ QString QgsSensorThingsSourceWidget::updateUriFromGui( const QString &connection parts.remove( QStringLiteral( "pageSize" ) ); } + if ( mExtentWidget->outputExtent().isNull() ) + parts.remove( QStringLiteral( "bounds" ) ); + else + parts.insert( QStringLiteral( "bounds" ), QVariant::fromValue( mExtentWidget->outputExtent() ) ); + return QgsProviderRegistry::instance()->encodeUri( QgsSensorThingsProvider::SENSORTHINGS_PROVIDER_KEY, parts diff --git a/src/gui/providers/sensorthings/qgssensorthingssourcewidget.h b/src/gui/providers/sensorthings/qgssensorthingssourcewidget.h index a54c0ebb9c35..35a0eab82300 100644 --- a/src/gui/providers/sensorthings/qgssensorthingssourcewidget.h +++ b/src/gui/providers/sensorthings/qgssensorthingssourcewidget.h @@ -23,7 +23,7 @@ #include #include -class QgsFileWidget; +class QgsExtentWidget; class QgsSensorThingsConnectionPropertiesTask; ///@cond PRIVATE @@ -40,6 +40,7 @@ class QgsSensorThingsSourceWidget : public QgsProviderSourceWidget, protected Ui void setSourceUri( const QString &uri ) override; QString sourceUri() const override; QString groupTitle() const override; + void setMapCanvas( QgsMapCanvas *mapCanvas ) override; /** * Updates a connection uri with the layer specific URI settings defined in the widget. @@ -59,6 +60,7 @@ class QgsSensorThingsSourceWidget : public QgsProviderSourceWidget, protected Ui void rebuildGeometryTypes( Qgis::SensorThingsEntity type ); void setCurrentGeometryTypeFromString( const QString &geometryType ); + QgsExtentWidget *mExtentWidget = nullptr; QVariantMap mSourceParts; bool mIsValid = false; QPointer< QgsSensorThingsConnectionPropertiesTask > mPropertiesTask; diff --git a/src/ui/qgssensorthingssourcewidgetbase.ui b/src/ui/qgssensorthingssourcewidgetbase.ui index 6bdd976bf655..6bb319900932 100644 --- a/src/ui/qgssensorthingssourcewidgetbase.ui +++ b/src/ui/qgssensorthingssourcewidgetbase.ui @@ -7,13 +7,13 @@ 0 0 537 - 91 + 134 QgsSensorThingsSourceWidgetBase - + 0 @@ -40,23 +40,20 @@ - - - - Page size - - - - - + + + + Extent limit + + - - - - 9999999 + + + + Page size @@ -87,6 +84,23 @@ + + + + + + + 9999999 + + + + + + + Qt::StrongFocus + + + @@ -96,6 +110,13 @@
qgsspinbox.h
+ + mComboEntityType + mComboGeometryType + mRetrieveTypesButton + mSpinPageSize + mExtentLimitFrame + diff --git a/tests/src/python/test_provider_sensorthings.py b/tests/src/python/test_provider_sensorthings.py index f2efc0eb277d..f159a7959daa 100644 --- a/tests/src/python/test_provider_sensorthings.py +++ b/tests/src/python/test_provider_sensorthings.py @@ -80,19 +80,23 @@ def tearDownClass(cls): def test_filter_for_wkb_type(self): self.assertEqual( - QgsSensorThingsUtils.filterForWkbType(Qgis.SensorThingsEntity.Location, Qgis.WkbType.Point), "location/type eq 'Point'" + QgsSensorThingsUtils.filterForWkbType(Qgis.SensorThingsEntity.Location, Qgis.WkbType.Point), + "location/type eq 'Point'" ) self.assertEqual( - QgsSensorThingsUtils.filterForWkbType(Qgis.SensorThingsEntity.Location, Qgis.WkbType.PointZ), "location/type eq 'Point'" + QgsSensorThingsUtils.filterForWkbType(Qgis.SensorThingsEntity.Location, Qgis.WkbType.PointZ), + "location/type eq 'Point'" ) self.assertEqual( - QgsSensorThingsUtils.filterForWkbType(Qgis.SensorThingsEntity.FeatureOfInterest, Qgis.WkbType.Polygon), "feature/type eq 'Polygon'" + QgsSensorThingsUtils.filterForWkbType(Qgis.SensorThingsEntity.FeatureOfInterest, Qgis.WkbType.Polygon), + "feature/type eq 'Polygon'" ) # TODO -- there is NO documentation on what the type must be for line filtering, # and I can't find any public servers with line geometries to test with! # Find some way to confirm if this is 'Line' or 'LineString' or ... self.assertEqual( - QgsSensorThingsUtils.filterForWkbType(Qgis.SensorThingsEntity.Location, Qgis.WkbType.LineString), "location/type eq 'LineString'" + QgsSensorThingsUtils.filterForWkbType(Qgis.SensorThingsEntity.Location, Qgis.WkbType.LineString), + "location/type eq 'LineString'" ) def test_utils_string_to_entity(self): @@ -170,6 +174,21 @@ def test_utils_string_to_entityset(self): Qgis.SensorThingsEntity.FeatureOfInterest, ) + def test_filter_for_extent(self): + self.assertFalse(QgsSensorThingsUtils.filterForExtent('', QgsRectangle())) + self.assertFalse(QgsSensorThingsUtils.filterForExtent('test', QgsRectangle())) + self.assertFalse(QgsSensorThingsUtils.filterForExtent('', QgsRectangle(1, 2, 3, 4))) + self.assertEqual(QgsSensorThingsUtils.filterForExtent('test', QgsRectangle(1, 2, 3, 4)), + "geo.intersects(test, geography'POLYGON((1 2, 3 2, 3 4, 1 4, 1 2))')") + + def test_combine_filters(self): + self.assertFalse(QgsSensorThingsUtils.combineFilters([])) + self.assertFalse(QgsSensorThingsUtils.combineFilters([''])) + self.assertEqual(QgsSensorThingsUtils.combineFilters(['', 'a eq 1']), 'a eq 1') + self.assertEqual(QgsSensorThingsUtils.combineFilters(['a eq 1', 'b eq 2']), '(a eq 1) and (b eq 2)') + self.assertEqual(QgsSensorThingsUtils.combineFilters(['a eq 1', '', 'b eq 2', 'c eq 3']), + '(a eq 1) and (b eq 2) and (c eq 3)') + def test_invalid_layer(self): vl = QgsVectorLayer( "url='http://fake.com/fake_qgis_http_endpoint'", "test", "sensorthings" @@ -265,6 +284,8 @@ def test_layer(self): self.assertTrue(vl.isValid()) self.assertEqual(vl.storageType(), "OGC SensorThings API") self.assertEqual(vl.wkbType(), Qgis.WkbType.PointZ) + # pessimistic "worst case" extent should be used + self.assertEqual(vl.extent(), QgsRectangle(-180, -90, 180, 90)) self.assertEqual(vl.featureCount(), 4962) self.assertIn("Entity TypeLocation", vl.htmlMetadata()) self.assertIn(f'href="http://{endpoint}/Locations"', vl.htmlMetadata()) @@ -404,6 +425,7 @@ def test_thing(self): self.assertTrue(vl.isValid()) self.assertEqual(vl.storageType(), "OGC SensorThings API") self.assertEqual(vl.wkbType(), Qgis.WkbType.NoGeometry) + self.assertTrue(vl.extent().isNull()) self.assertEqual(vl.featureCount(), 3) self.assertFalse(vl.crs().isValid()) self.assertIn("Entity TypeThing", vl.htmlMetadata()) @@ -494,8 +516,8 @@ def test_location(self): "location": { "type": "Point", "coordinates": [ - 11.623373, - 52.132017 + 11.6, + 52.1 ] }, "properties": { @@ -513,8 +535,8 @@ def test_location(self): "location": { "type": "Point", "coordinates": [ - 12.623373, - 53.132017 + 12.6, + 53.1 ] }, "properties": { @@ -550,8 +572,8 @@ def test_location(self): "location": { "type": "Point", "coordinates": [ - 13.623373, - 55.132017 + 13.6, + 55.1 ] }, "properties": { @@ -575,6 +597,8 @@ def test_location(self): self.assertTrue(vl.isValid()) self.assertEqual(vl.storageType(), "OGC SensorThings API") self.assertEqual(vl.wkbType(), Qgis.WkbType.PointZ) + # pessimistic "worst case" extent should initially be used + self.assertEqual(vl.extent(), QgsRectangle(-180, -90, 180, 90)) self.assertEqual(vl.featureCount(), 3) self.assertEqual(vl.crs().authid(), "EPSG:4326") self.assertIn("Entity TypeLocation", vl.htmlMetadata()) @@ -625,6 +649,9 @@ def test_location(self): ["Point (11.6 52.1)", "Point (12.6 53.1)", "Point (13.6 55.1)"], ) + # all features fetched, accurate extent should be returned + self.assertEqual(vl.extent(), QgsRectangle(11.6, 52.1, 13.6, 55.1)) + def test_filter_rect(self): with tempfile.TemporaryDirectory() as temp_dir: base_path = temp_dir.replace("\\", "/") @@ -745,7 +772,8 @@ def test_filter_rect(self): ) with open( - sanitize(endpoint, "/Locations?$top=2&$count=false&$filter=geo.intersects(location, geography'POLYGON((1 0, 10 0, 10 80, 1 80, 1 0))') and location/type eq 'Point'"), + sanitize(endpoint, + "/Locations?$top=2&$count=false&$filter=(geo.intersects(location, geography'POLYGON((1 0, 10 0, 10 80, 1 80, 1 0))')) and (location/type eq 'Point')"), "wt", encoding="utf8", ) as f: @@ -798,7 +826,8 @@ def test_filter_rect(self): ) with open( - sanitize(endpoint, "/Locations?$top=2&$count=false&$filter=geo.intersects(location, geography'POLYGON((10 0, 20 0, 20 80, 10 80, 10 0))') and location/type eq 'Point'"), + sanitize(endpoint, + "/Locations?$top=2&$count=false&$filter=(geo.intersects(location, geography'POLYGON((10 0, 20 0, 20 80, 10 80, 10 0))')) and (location/type eq 'Point')"), "wt", encoding="utf8", ) as f: @@ -919,6 +948,256 @@ def test_filter_rect(self): ["/Locations(1)", "/Locations(3)", "/Locations(2)"], ) + def test_extent_limit(self): + with tempfile.TemporaryDirectory() as temp_dir: + base_path = temp_dir.replace("\\", "/") + endpoint = base_path + "/fake_qgis_http_endpoint" + with open(sanitize(endpoint, ""), "wt", encoding="utf8") as f: + f.write( + """ +{ + "value": [ + { + "name": "Locations", + "url": "endpoint/Locations" + } + ], + "serverSettings": { + } +}""".replace( + "endpoint", "http://" + endpoint + ) + ) + + with open( + sanitize(endpoint, + "/Locations?$top=0&$count=true&$filter=(location/type eq 'Point') and (geo.intersects(location, geography'POLYGON((1 0, 10 0, 10 80, 1 80, 1 0))'))"), + "wt", + encoding="utf8", + ) as f: + f.write("""{"@iot.count":2,"value":[]}""") + + with open( + sanitize(endpoint, + "/Locations?$top=2&$count=false&$filter=(location/type eq 'Point') and (geo.intersects(location, geography'POLYGON((1 0, 10 0, 10 80, 1 80, 1 0))'))"), + "wt", + encoding="utf8", + ) as f: + f.write( + """ +{ + "value": [ + { + "@iot.selfLink": "endpoint/Locations(1)", + "@iot.id": 1, + "name": "Location 1", + "description": "Desc 1", + "encodingType": "application/geo+json", + "location": { + "type": "Point", + "coordinates": [ + 1.623373, + 52.132017 + ] + }, + "properties": { + "owner": "owner 1" + }, + "Things@iot.navigationLink": "endpoint/Locations(1)/Things", + "HistoricalLocations@iot.navigationLink": "endpoint/Locations(1)/HistoricalLocations" + }, + { + "@iot.selfLink": "endpoint/Locations(3)", + "@iot.id": 3, + "name": "Location 3", + "description": "Desc 3", + "encodingType": "application/geo+json", + "location": { + "type": "Point", + "coordinates": [ + 3.623373, + 55.132017 + ] + }, + "properties": { + "owner": "owner 3" + }, + "Things@iot.navigationLink": "endpoint/Locations(3)/Things", + "HistoricalLocations@iot.navigationLink": "endpoint/Locations(3)/HistoricalLocations" + } + ] +} + """.replace( + "endpoint", "http://" + endpoint + ) + ) + + with open( + sanitize(endpoint, + "/Locations?$top=2&$count=false&$filter=(geo.intersects(location, geography'POLYGON((1 0, 10 0, 10 80, 1 80, 1 0))')) and (location/type eq 'Point')"), + "wt", + encoding="utf8", + ) as f: + f.write( + """ + { + "value": [ + { + "@iot.selfLink": "endpoint/Locations(1)", + "@iot.id": 1, + "name": "Location 1", + "description": "Desc 1", + "encodingType": "application/geo+json", + "location": { + "type": "Point", + "coordinates": [ + 1.623373, + 52.132017 + ] + }, + "properties": { + "owner": "owner 1" + }, + "Things@iot.navigationLink": "endpoint/Locations(1)/Things", + "HistoricalLocations@iot.navigationLink": "endpoint/Locations(1)/HistoricalLocations" + }, + { + "@iot.selfLink": "endpoint/Locations(3)", + "@iot.id": 3, + "name": "Location 3", + "description": "Desc 3", + "encodingType": "application/geo+json", + "location": { + "type": "Point", + "coordinates": [ + 3.623373, + 55.132017 + ] + }, + "properties": { + "owner": "owner 3" + }, + "Things@iot.navigationLink": "endpoint/Locations(3)/Things", + "HistoricalLocations@iot.navigationLink": "endpoint/Locations(3)/HistoricalLocations" + } + ] + }""".replace( + "endpoint", "http://" + endpoint + ) + ) + + with open( + sanitize(endpoint, + "/Locations?$top=2&$count=false&$filter=(geo.intersects(location, geography'POLYGON((1 0, 3 0, 3 50, 1 50, 1 0))')) and (location/type eq 'Point')"), + "wt", + encoding="utf8", + ) as f: + f.write( + """ + { + "value": [ + { + "@iot.selfLink": "endpoint/Locations(1)", + "@iot.id": 1, + "name": "Location 1", + "description": "Desc 1", + "encodingType": "application/geo+json", + "location": { + "type": "Point", + "coordinates": [ + 1.623373, + 52.132017 + ] + }, + "properties": { + "owner": "owner 1" + }, + "Things@iot.navigationLink": "endpoint/Locations(1)/Things", + "HistoricalLocations@iot.navigationLink": "endpoint/Locations(1)/HistoricalLocations" + } + ] + }""".replace( + "endpoint", "http://" + endpoint + ) + ) + + vl = QgsVectorLayer( + f"url='http://{endpoint}' bbox='1,0,10,80' type=PointZ pageSize=2 entity='Location'", + "test", + "sensorthings", + ) + self.assertTrue(vl.isValid()) + self.assertEqual(vl.storageType(), "OGC SensorThings API") + self.assertEqual(vl.wkbType(), Qgis.WkbType.PointZ) + self.assertEqual(vl.featureCount(), 2) + # should use the hardcoded extent limit as the initial guess, not global extents + self.assertEqual(vl.extent(), QgsRectangle(1, 0, 10, 80)) + self.assertEqual(vl.crs().authid(), "EPSG:4326") + self.assertIn("Entity TypeLocation", + vl.htmlMetadata()) + self.assertIn(f'href="http://{endpoint}/Locations"', + vl.htmlMetadata()) + + request = QgsFeatureRequest() + request.setFilterRect( + QgsRectangle(1, 0, 3, 50) + ) + + features = list(vl.getFeatures(request)) + self.assertEqual([f["id"] for f in features], ["1"]) + self.assertEqual( + [f["selfLink"][-13:] for f in features], + ["/Locations(1)"], + ) + self.assertEqual( + [f["name"] for f in features], + ["Location 1"], + ) + self.assertEqual( + [f["description"] for f in features], + ["Desc 1"] + ) + self.assertEqual( + [f["properties"] for f in features], + [{"owner": "owner 1"}], + ) + + self.assertEqual( + [f.geometry().asWkt(1) for f in features], + ["Point (1.6 52.1)"], + ) + + request = QgsFeatureRequest() + features = list(vl.getFeatures(request)) + self.assertEqual([f["id"] for f in features], ["1", "3"]) + self.assertEqual( + [f["selfLink"][-13:] for f in features], + ["/Locations(1)", "/Locations(3)"], + ) + self.assertEqual( + [f["name"] for f in features], + ["Location 1", "Location 3"], + ) + self.assertEqual( + [f["description"] for f in features], + ["Desc 1", "Desc 3"] + ) + self.assertEqual( + [f["properties"] for f in features], + [{"owner": "owner 1"}, + {"owner": "owner 3"}], + ) + + self.assertEqual( + [f.geometry().asWkt(1) for f in features], + ["Point (1.6 52.1)", + "Point (3.6 55.1)"], + ) + + # should have accurate layer extent now + self.assertEqual(vl.extent(), QgsRectangle(1.62337299999999995, 52.13201699999999761, 3.62337299999999995, + 55.13201699999999761)) + def test_historical_location(self): with tempfile.TemporaryDirectory() as temp_dir: base_path = temp_dir.replace("\\", "/") @@ -2022,7 +2301,15 @@ def test_feature_of_interest(self): ) self.assertEqual( [f["properties"] for f in features], - [{'localId': 'SAM.09.LAA.822.7.1', 'metadata': 'http://luft.umweltbundesamt.at/inspire/wfs?service=WFS&version=2.0.0&request=GetFeature&typeName=aqd:AQD_Sample', 'namespace': 'AT.0008.20.AQ', 'owner': 'http://luft.umweltbundesamt.at'}, {'localId': 'SAM.09.LOB.823.7.1', 'metadata': 'http://luft.umweltbundesamt.at/inspire/wfs?service=WFS&version=2.0.0&request=GetFeature&typeName=aqd:AQD_Sample', 'namespace': 'AT.0008.20.AQ', 'owner': 'http://luft.umweltbundesamt.at'}, {'localId': 'SAM.09.LOB.824.1.1', 'metadata': 'http://luft.umweltbundesamt.at/inspire/wfs?service=WFS&version=2.0.0&request=GetFeature&typeName=aqd:AQD_Sample', 'namespace': 'AT.0008.20.AQ', 'owner': 'http://luft.umweltbundesamt.at'}], + [{'localId': 'SAM.09.LAA.822.7.1', + 'metadata': 'http://luft.umweltbundesamt.at/inspire/wfs?service=WFS&version=2.0.0&request=GetFeature&typeName=aqd:AQD_Sample', + 'namespace': 'AT.0008.20.AQ', 'owner': 'http://luft.umweltbundesamt.at'}, + {'localId': 'SAM.09.LOB.823.7.1', + 'metadata': 'http://luft.umweltbundesamt.at/inspire/wfs?service=WFS&version=2.0.0&request=GetFeature&typeName=aqd:AQD_Sample', + 'namespace': 'AT.0008.20.AQ', 'owner': 'http://luft.umweltbundesamt.at'}, + {'localId': 'SAM.09.LOB.824.1.1', + 'metadata': 'http://luft.umweltbundesamt.at/inspire/wfs?service=WFS&version=2.0.0&request=GetFeature&typeName=aqd:AQD_Sample', + 'namespace': 'AT.0008.20.AQ', 'owner': 'http://luft.umweltbundesamt.at'}], ) self.assertEqual( @@ -2085,6 +2372,19 @@ def testDecodeUri(self): }, ) + uri = "url='https://sometest.com/api' bbox='1,2,3,4' type=MultiPolygonZ authcfg='abc' entity='Location'" + parts = QgsProviderRegistry.instance().decodeUri("sensorthings", uri) + self.assertEqual( + parts, + { + "url": "https://sometest.com/api", + "entity": "Location", + "geometryType": "polygon", + "authcfg": "abc", + "bounds": QgsRectangle(1, 2, 3, 4) + }, + ) + def testEncodeUri(self): """ Test encoding a SensorThings uri @@ -2138,6 +2438,19 @@ def testEncodeUri(self): "authcfg=aaaaa type=MultiPolygonZ entity='Location' url='http://blah.com'", ) + parts = { + "url": "http://blah.com", + "authcfg": "aaaaa", + "entity": "location", + "geometryType": "polygon", + "bounds": QgsRectangle(1, 2, 3, 4) + } + uri = QgsProviderRegistry.instance().encodeUri("sensorthings", parts) + self.assertEqual( + uri, + "authcfg=aaaaa type=MultiPolygonZ bbox='1,2,3,4' entity='Location' url='http://blah.com'", + ) + if __name__ == "__main__": unittest.main()