From 6112da45f6955f93f6d7684cf91721c304fd6aaf Mon Sep 17 00:00:00 2001 From: Alessandro Pasotti Date: Fri, 18 Oct 2019 08:01:19 +0200 Subject: [PATCH] Server OAPIF datetime support --- .../core/auto_generated/qgsvectorlayer.sip.in | 12 ++ .../auto_generated/qgsserverapiutils.sip.in | 35 ++++ src/app/qgssourcefieldsproperties.cpp | 38 +++- src/app/qgssourcefieldsproperties.h | 1 + src/core/qgsvectorlayer.cpp | 23 +++ src/core/qgsvectorlayer.h | 15 ++ src/server/qgsserverapiutils.cpp | 194 ++++++++++++++++++ src/server/qgsserverapiutils.h | 39 ++++ src/server/services/wfs3/qgswfs3handlers.cpp | 47 ++++- tests/src/python/test_qgsserver_api.py | 66 +++++- .../api/test_wfs3_api_project.json | 3 +- .../api/test_wfs3_collections_empty.json | 4 +- ...collections_items_exclude_attribute_0.json | 4 +- ...ems_layer1_with_short_name_eq_tw_star.json | 4 +- ...s_items_layer1_with_short_name_eq_two.json | 4 +- ...wfs3_collections_items_testlayer_èé.json | 4 +- ...collections_items_testlayer_èé_bbox.json | 4 +- ...ctions_items_testlayer_èé_bbox_3857.json | 4 +- ...ections_items_testlayer_èé_crs_3857.json | 4 +- ...lections_items_testlayer_èé_limit_1.json | 4 +- ...items_testlayer_èé_limit_1_offset_1.json | 4 +- .../api/test_wfs3_collections_project.json | 92 ++++----- .../api/test_wfs3_conformance.json | 4 +- .../api/test_wfs3_landing_page.json | 4 +- 24 files changed, 519 insertions(+), 94 deletions(-) diff --git a/python/core/auto_generated/qgsvectorlayer.sip.in b/python/core/auto_generated/qgsvectorlayer.sip.in index fa3e88e72d7..9137551b8d4 100644 --- a/python/core/auto_generated/qgsvectorlayer.sip.in +++ b/python/core/auto_generated/qgsvectorlayer.sip.in @@ -1904,6 +1904,18 @@ A set of attributes that are not advertised in WFS requests with QGIS server. void setExcludeAttributesWfs( const QSet &att ); %Docstring A set of attributes that are not advertised in WFS requests with QGIS server. +%End + + QSet includeAttributesOapifTemporalFilters() const; +%Docstring +Returns the attributes that are used for temporal filtering with QGIS server OAPIF (WFS3). +\since: QGIS 3.12 +%End + + void setIncludeAttributesOapifTemporalFilters( const QSet &att ); +%Docstring +Sets the attributes that are used for temporal filtering with QGIS server OAPIF (WFS3). +\since: QGIS 3.12 %End virtual bool deleteAttribute( int attr ); diff --git a/python/server/auto_generated/qgsserverapiutils.sip.in b/python/server/auto_generated/qgsserverapiutils.sip.in index d5fc1f0a46d..d894ccdafb1 100644 --- a/python/server/auto_generated/qgsserverapiutils.sip.in +++ b/python/server/auto_generated/qgsserverapiutils.sip.in @@ -35,6 +35,41 @@ Parses a comma separated ``bbox`` into a (possibly empty) :py:class:`QgsRectangl Z values (i.e. a 6 elements bbox) are silently discarded %End + struct TemporalDateInterval + { + QDate begin; + QDate end; + }; + + struct TemporalDateTimeInterval + { + QDateTime begin; + QDateTime end; + }; + + static TemporalDateInterval parseTemporalDateInterval( const QString &interval ) throw( QgsServerApiBadRequestException ); +%Docstring +Parse a date time ``interval`` and returns a TemporalInterval + +:raises QgsServerApiBadRequestException: if interval cannot be parsed +%End + static TemporalDateTimeInterval parseTemporalDateTimeInterval( const QString &interval ) throw( QgsServerApiBadRequestException ); + + + + static QgsExpression temporalFilterExpression( const QgsVectorLayer *layer, const QString &interval ); +%Docstring +Parse the ``interval`` and constructs a (possibily invalid) temporal filter expression for the given ``layer`` + +Syntax: + +interval-closed = date-time "/" date-time +interval-open-start = [".."] "/" date-time +interval-open-end = date-time "/" [".."] +interval = interval-closed / interval-open-start / interval-open-end +datetime = date-time / interval +%End + static QgsCoordinateReferenceSystem parseCrs( const QString &bboxCrs ); %Docstring diff --git a/src/app/qgssourcefieldsproperties.cpp b/src/app/qgssourcefieldsproperties.cpp index 9dbf6049840..767da27e9b0 100644 --- a/src/app/qgssourcefieldsproperties.cpp +++ b/src/app/qgssourcefieldsproperties.cpp @@ -60,8 +60,16 @@ QgsSourceFieldsProperties::QgsSourceFieldsProperties( QgsVectorLayer *layer, QWi mFieldsList->setHorizontalHeaderItem( AttrLengthCol, new QTableWidgetItem( tr( "Length" ) ) ); mFieldsList->setHorizontalHeaderItem( AttrPrecCol, new QTableWidgetItem( tr( "Precision" ) ) ); mFieldsList->setHorizontalHeaderItem( AttrCommentCol, new QTableWidgetItem( tr( "Comment" ) ) ); - mFieldsList->setHorizontalHeaderItem( AttrWMSCol, new QTableWidgetItem( QStringLiteral( "WMS" ) ) ); - mFieldsList->setHorizontalHeaderItem( AttrWFSCol, new QTableWidgetItem( QStringLiteral( "WFS" ) ) ); + const auto wmsWi = new QTableWidgetItem( QStringLiteral( "WMS" ) ); + wmsWi->setToolTip( tr( "Defines if this field is available in QGIS Server WMS service" ) ); + mFieldsList->setHorizontalHeaderItem( AttrWMSCol, wmsWi ); + const auto wfsWi = new QTableWidgetItem( QStringLiteral( "WFS" ) ); + wfsWi->setToolTip( tr( "Defines if this field is available in QGIS Server WFS (and OAPIF) service" ) ); + mFieldsList->setHorizontalHeaderItem( AttrWFSCol, wfsWi ); + mFieldsList->setHorizontalHeaderItem( AttrAliasCol, new QTableWidgetItem( tr( "Alias" ) ) ); + const auto oapifWi = new QTableWidgetItem( QStringLiteral( "Temporal OAPIF" ) ); + oapifWi->setToolTip( tr( "Defines if this field will be used for temporal filtering in QGIS Server OAPIF service" ) ); + mFieldsList->setHorizontalHeaderItem( AttrOapifDateTimeCol, oapifWi ); mFieldsList->setHorizontalHeaderItem( AttrAliasCol, new QTableWidgetItem( tr( "Alias" ) ) ); mFieldsList->setSortingEnabled( true ); @@ -265,6 +273,23 @@ void QgsSourceFieldsProperties::setRow( int row, int idx, const QgsField &field wfsAttrItem->setCheckState( mLayer->excludeAttributesWfs().contains( field.name() ) ? Qt::Unchecked : Qt::Checked ); wfsAttrItem->setFlags( Qt::ItemIsSelectable | Qt::ItemIsEnabled | Qt::ItemIsUserCheckable ); mFieldsList->setItem( row, AttrWFSCol, wfsAttrItem ); + + // OAPIF temporal + QTableWidgetItem *oapifAttrItem = new QTableWidgetItem(); + // ok, in theory, we could support any field type that + // can contain something convertible to a date/datetime, but + // let's keep it simple for now + if ( field.isDateOrTime() ) + { + oapifAttrItem->setFlags( Qt::ItemIsSelectable | Qt::ItemIsEnabled | Qt::ItemIsUserCheckable ); + oapifAttrItem->setCheckState( mLayer->includeAttributesOapifTemporalFilters().contains( field.name() ) ? Qt::Checked : Qt::Unchecked ); + } + else + { + oapifAttrItem->setFlags( oapifAttrItem->flags() & ~Qt::ItemIsSelectable & ~Qt::ItemIsEnabled & ~Qt::ItemIsUserCheckable ); + } + mFieldsList->setItem( row, AttrOapifDateTimeCol, oapifAttrItem ); + } bool QgsSourceFieldsProperties::addAttribute( const QgsField &field ) @@ -286,7 +311,7 @@ bool QgsSourceFieldsProperties::addAttribute( const QgsField &field ) void QgsSourceFieldsProperties::apply() { - QSet excludeAttributesWMS, excludeAttributesWFS; + QSet excludeAttributesWMS, excludeAttributesWFS, includeTemporalFilters; for ( int i = 0; i < mFieldsList->rowCount(); i++ ) { @@ -298,11 +323,16 @@ void QgsSourceFieldsProperties::apply() { excludeAttributesWFS.insert( mFieldsList->item( i, AttrNameCol )->text() ); } + if ( mFieldsList->item( i, AttrOapifDateTimeCol )->checkState() == Qt::Checked ) + { + includeTemporalFilters.insert( mFieldsList->item( i, AttrNameCol )->text() ); + } } mLayer->setExcludeAttributesWms( excludeAttributesWMS ); mLayer->setExcludeAttributesWfs( excludeAttributesWFS ); - + // Note: this is a whitelist unlike the previous two! + mLayer->setIncludeAttributesOapifTemporalFilters( includeTemporalFilters ); } //SLOTS diff --git a/src/app/qgssourcefieldsproperties.h b/src/app/qgssourcefieldsproperties.h index 1283ab0cf9f..c97cb627569 100644 --- a/src/app/qgssourcefieldsproperties.h +++ b/src/app/qgssourcefieldsproperties.h @@ -79,6 +79,7 @@ class APP_EXPORT QgsSourceFieldsProperties : public QWidget, private Ui_QgsSourc AttrCommentCol, AttrWMSCol, AttrWFSCol, + AttrOapifDateTimeCol, //! OAPIF (WFS3) datetime filter column AttrColCount, }; diff --git a/src/core/qgsvectorlayer.cpp b/src/core/qgsvectorlayer.cpp index 86387572dc2..43fcc1dc25b 100644 --- a/src/core/qgsvectorlayer.cpp +++ b/src/core/qgsvectorlayer.cpp @@ -2192,6 +2192,17 @@ bool QgsVectorLayer::readSymbology( const QDomNode &layerNode, QString &errorMes } } + mIncludeAttributesOapifTemporalFilters.clear(); + QDomNode includeOapifTemporalFiltersNode = layerNode.namedItem( QStringLiteral( "includeAttributesOapifTemporalFilters" ) ); + if ( !excludeWFSNode.isNull() ) + { + QDomNodeList attributeNodeList = includeOapifTemporalFiltersNode.toElement().elementsByTagName( QStringLiteral( "attribute" ) ); + for ( int i = 0; i < attributeNodeList.size(); ++i ) + { + mIncludeAttributesOapifTemporalFilters.insert( attributeNodeList.at( i ).toElement().text() ); + } + } + // Load editor widget configuration QDomElement widgetsElem = layerNode.namedItem( QStringLiteral( "fieldConfiguration" ) ).toElement(); QDomNodeList fieldConfigurationElementList = widgetsElem.elementsByTagName( QStringLiteral( "field" ) ); @@ -2505,6 +2516,18 @@ bool QgsVectorLayer::writeSymbology( QDomNode &node, QDomDocument &doc, QString } node.appendChild( excludeWFSElem ); + //include attributes OAPIF + QDomElement includeOapifElem = doc.createElement( QStringLiteral( "includeAttributesOapifTemporalFilters" ) ); + QSet::const_iterator attOapifTemporalIt = mIncludeAttributesOapifTemporalFilters.constBegin(); + for ( ; attOapifTemporalIt != mIncludeAttributesOapifTemporalFilters.constEnd(); ++attOapifTemporalIt ) + { + QDomElement attrElem = doc.createElement( QStringLiteral( "attribute" ) ); + QDomText attrText = doc.createTextNode( *attOapifTemporalIt ); + attrElem.appendChild( attrText ); + includeOapifElem.appendChild( attrElem ); + } + node.appendChild( includeOapifElem ); + //default expressions QDomElement defaultsElem = doc.createElement( QStringLiteral( "defaults" ) ); for ( const QgsField &field : mFields ) diff --git a/src/core/qgsvectorlayer.h b/src/core/qgsvectorlayer.h index cdc14a6bbb7..fac60e8266d 100644 --- a/src/core/qgsvectorlayer.h +++ b/src/core/qgsvectorlayer.h @@ -1775,6 +1775,18 @@ class CORE_EXPORT QgsVectorLayer : public QgsMapLayer, public QgsExpressionConte */ void setExcludeAttributesWfs( const QSet &att ) { mExcludeAttributesWFS = att; } + /** + * Returns the attributes that are used for temporal filtering with QGIS server OAPIF (WFS3). + * \since: QGIS 3.12 + */ + QSet includeAttributesOapifTemporalFilters() const { return mIncludeAttributesOapifTemporalFilters; } + + /** + * Sets the attributes that are used for temporal filtering with QGIS server OAPIF (WFS3). + * \since: QGIS 3.12 + */ + void setIncludeAttributesOapifTemporalFilters( const QSet &att ) { mIncludeAttributesOapifTemporalFilters = att; } + /** * Deletes an attribute field (but does not commit it). * @@ -2728,6 +2740,9 @@ class CORE_EXPORT QgsVectorLayer : public QgsMapLayer, public QgsExpressionConte //! Attributes which are not published in WFS QSet mExcludeAttributesWFS; + //! Attributes which are used for OAPIF temporal filtering + QSet mIncludeAttributesOapifTemporalFilters; + //! Geometry type as defined in enum WkbType (qgis.h) QgsWkbTypes::Type mWkbType = QgsWkbTypes::Unknown; diff --git a/src/server/qgsserverapiutils.cpp b/src/server/qgsserverapiutils.cpp index 0118ad643a4..9ca47e25946 100644 --- a/src/server/qgsserverapiutils.cpp +++ b/src/server/qgsserverapiutils.cpp @@ -24,6 +24,7 @@ #include "qgsserverprojectutils.h" #include "qgsmessagelog.h" + #include "nlohmann/json.hpp" #include @@ -61,6 +62,199 @@ QgsRectangle QgsServerApiUtils::parseBbox( const QString &bbox ) return QgsRectangle(); } + +template T QgsServerApiUtils::parseTemporalInterval( const QString &interval ) +{ + auto parseDate = [ ]( const QString & date ) -> T2 + { + T2 result; + if ( date == QStringLiteral( ".." ) || date.isEmpty() ) + { + return result; + } + else + { + T2 result { T2::fromString( date, Qt::DateFormat::ISODate ) }; + if ( !result.isValid() ) + { + throw QgsServerApiBadRequestException( QStringLiteral( "%1 is not a valid date/datetime." ).arg( date ) ); + } + return result; + } + }; + const QStringList parts { interval.split( '/' ) }; + return { parseDate( parts[0] ), parseDate( parts[1] ) }; + +} + +QgsServerApiUtils::TemporalDateInterval QgsServerApiUtils::parseTemporalDateInterval( const QString &interval ) +{ + return QgsServerApiUtils::parseTemporalInterval( interval ); +} + +QgsServerApiUtils::TemporalDateTimeInterval QgsServerApiUtils::parseTemporalDateTimeInterval( const QString &interval ) +{ + return QgsServerApiUtils::parseTemporalInterval( interval ); +} + + +QgsExpression QgsServerApiUtils::temporalFilterExpression( const QgsVectorLayer *layer, const QString &interval ) +{ + QgsExpression expression; + QStringList conditions; + + // Is it an interval? + if ( interval.contains( '/' ) ) + { + // Try date first + try + { + TemporalDateInterval dateInterval { QgsServerApiUtils::parseTemporalDateInterval( interval ) }; + for ( const auto &fieldName : layer->includeAttributesOapifTemporalFilters() ) + { + int fieldIdx { layer->fields().lookupField( fieldName ) }; + if ( fieldIdx < 0 ) + { + continue; + } + const QgsField field { layer->fields().at( fieldIdx ) }; + QString fieldValue; + if ( field.type() == QVariant::Date ) + { + fieldValue = QStringLiteral( R"raw("%1")raw" ).arg( fieldName ); + } + else + { + fieldValue = QStringLiteral( R"raw(to_date( "%1" ))raw" ).arg( fieldName ); + } + + if ( ! dateInterval.begin.isValid( ) && ! dateInterval.end.isValid( ) ) + { + // Nothing to do here: log? + } + else + { + if ( dateInterval.begin.isValid( ) ) + { + conditions.push_back( QStringLiteral( R"raw(%1 >= to_date('%2'))raw" ) + .arg( fieldValue ) + .arg( dateInterval.begin.toString( Qt::DateFormat::ISODate ) ) ); + + } + if ( dateInterval.end.isValid( ) ) + { + conditions.push_back( QStringLiteral( R"raw(%1 <= to_date('%2'))raw" ) + .arg( fieldValue ) + .arg( dateInterval.end.toString( Qt::DateFormat::ISODate ) ) ); + + } + } + + } + } + catch ( QgsServerApiBadRequestException & ) // try datetime + { + TemporalDateTimeInterval dateTimeInterval { QgsServerApiUtils::parseTemporalDateTimeInterval( interval ) }; + for ( const auto &fieldName : layer->includeAttributesOapifTemporalFilters() ) + { + int fieldIdx { layer->fields().lookupField( fieldName ) }; + if ( fieldIdx < 0 ) + { + continue; + } + const QgsField field { layer->fields().at( fieldIdx ) }; + QString fieldValue; + if ( field.type() == QVariant::Date ) + { + fieldValue = QStringLiteral( R"raw("%1")raw" ).arg( fieldName ); + } + else + { + fieldValue = QStringLiteral( R"raw(to_datetime( "%1" ))raw" ).arg( fieldName ); + } + + if ( ! dateTimeInterval.begin.isValid( ) && ! dateTimeInterval.end.isValid( ) ) + { + // Nothing to do here: log? + } + else + { + if ( dateTimeInterval.begin.isValid( ) ) + { + conditions.push_back( QStringLiteral( R"raw(%1 >= to_datetime('%2'))raw" ) + .arg( fieldValue ) + .arg( dateTimeInterval.begin.toString( Qt::DateFormat::ISODate ) ) ); + + } + if ( dateTimeInterval.end.isValid( ) ) + { + conditions.push_back( QStringLiteral( R"raw(%1 <= to_datetime('%2'))raw" ) + .arg( fieldValue ) + .arg( dateTimeInterval.end.toString( Qt::DateFormat::ISODate ) ) ); + + } + } + } + } + } + else // plain value + { + for ( const auto &fieldName : layer->includeAttributesOapifTemporalFilters() ) + { + int fieldIdx { layer->fields().lookupField( fieldName ) }; + if ( fieldIdx < 0 ) + { + continue; + } + + const QgsField field { layer->fields().at( fieldIdx ) }; + + if ( field.type() == QVariant::Date ) + { + conditions.push_back( QStringLiteral( R"raw("%1" = to_date('%2'))raw" ) + .arg( fieldName ) + .arg( interval ) ); + } + else if ( field.type() == QVariant::DateTime ) + { + conditions.push_back( QStringLiteral( R"raw("%1" = to_datetime('%2'))raw" ) + .arg( fieldName ) + .arg( interval ) ); + } + else + { + // Guess the type from input + QDateTime dateTime { QDateTime::fromString( interval, Qt::DateFormat::ISODate )}; + if ( dateTime.isValid() ) + { + conditions.push_back( QStringLiteral( R"raw(to_datetime("%1") = to_datetime('%2'))raw" ) + .arg( fieldName ) + .arg( interval ) ); + } + else + { + QDate date { QDate::fromString( interval, Qt::DateFormat::ISODate )}; + if ( date.isValid() ) + { + conditions.push_back( QStringLiteral( R"raw(to_date("%1") = to_date('%2'))raw" ) + .arg( fieldName ) + .arg( interval ) ); + } + else + { + // Nothing done here, log? + } + } + } + } + } + if ( ! conditions.isEmpty() ) + { + expression.setExpression( conditions.join( QStringLiteral( " AND " ) ) ); + } + return expression; +} + json QgsServerApiUtils::layerExtent( const QgsVectorLayer *layer ) { auto extent { layer->extent() }; diff --git a/src/server/qgsserverapiutils.h b/src/server/qgsserverapiutils.h index 0708100e3bf..ed49b1a9e64 100644 --- a/src/server/qgsserverapiutils.h +++ b/src/server/qgsserverapiutils.h @@ -26,6 +26,7 @@ #include "qgsproject.h" #include "qgsserverprojectutils.h" #include "qgsserverapicontext.h" +#include "qgsserverexception.h" #ifdef HAVE_SERVER_PYTHON_PLUGINS #include "qgsaccesscontrol.h" @@ -58,6 +59,44 @@ class SERVER_EXPORT QgsServerApiUtils */ static QgsRectangle parseBbox( const QString &bbox ); + struct TemporalDateInterval + { + QDate begin; + QDate end; + }; + + struct TemporalDateTimeInterval + { + QDateTime begin; + QDateTime end; + }; + + /** + * Parse a date time \a interval and returns a TemporalInterval + * + * \throws QgsServerApiBadRequestException if interval cannot be parsed + */ + static TemporalDateInterval parseTemporalDateInterval( const QString &interval ) SIP_THROW( QgsServerApiBadRequestException ); + static TemporalDateTimeInterval parseTemporalDateTimeInterval( const QString &interval ) SIP_THROW( QgsServerApiBadRequestException ); + +///@cond PRIVATE + template static T parseTemporalInterval( const QString &interval ) SIP_SKIP; +/// @endcond + + + /** + * Parse the \a interval and constructs a (possibily invalid) temporal filter expression for the given \a layer + * + * Syntax: + * + * interval-closed = date-time "/" date-time + * interval-open-start = [".."] "/" date-time + * interval-open-end = date-time "/" [".."] + * interval = interval-closed / interval-open-start / interval-open-end + * datetime = date-time / interval + */ + static QgsExpression temporalFilterExpression( const QgsVectorLayer *layer, const QString &interval ); + /** * layerExtent returns json array with [xMin,yMin,xMax,yMax] CRS84 extent for the given \a layer * FIXME: the OpenAPI swagger docs say that it is inverted axis order: West, north, east, south edges of the spatial extent. diff --git a/src/server/services/wfs3/qgswfs3handlers.cpp b/src/server/services/wfs3/qgswfs3handlers.cpp index 5f175af45d3..60b4489ea8e 100644 --- a/src/server/services/wfs3/qgswfs3handlers.cpp +++ b/src/server/services/wfs3/qgswfs3handlers.cpp @@ -756,6 +756,20 @@ QList QgsWfs3CollectionsItemsHandler::parameters( } ); params.push_back( limit ); + + // datetime + QgsServerQueryStringParameter datetime { QStringLiteral( "datetime" ), false, + QgsServerQueryStringParameter::Type::Integer, + QStringLiteral( "Date time filter" ), + }; + datetime.setCustomValidator( [ = ]( const QgsServerApiContext &, QVariant & value ) -> bool + { + // TODO + return true; + } ); + params.push_back( datetime ); + + // Offset QgsServerQueryStringParameter offset { QStringLiteral( "offset" ), false, QgsServerQueryStringParameter::Type::Integer, @@ -856,7 +870,7 @@ json QgsWfs3CollectionsItemsHandler::schema( const QgsServerApiContext &context { "$ref", "#/components/parameters/resultType" }, { "$ref", "#/components/parameters/bbox" }, { "$ref", "#/components/parameters/bbox-crs" }, - // TODO: {{ "$ref", "#/components/parameters/time" }}, + { "$ref", "#/components/parameters/time" }, } }; @@ -1025,15 +1039,25 @@ void QgsWfs3CollectionsItemsHandler::handleRequest( const QgsServerApiContext &c // so we do our own paging with "offset") const qlonglong offset { params.value( QStringLiteral( "offset" ) ).toLongLong( &ok ) }; - // TODO: make the max limit configurable const qlonglong limit { params.value( QStringLiteral( "limit" ) ).toLongLong( &ok ) }; - // TODO: implement time - const QString time { context.request()->queryParameter( QStringLiteral( "time" ) ) }; - if ( ! time.isEmpty() ) + QString filterExpression; + QStringList expressions; + + /*/ datetime + const QString datetime { context.request()->queryParameter( QStringLiteral( "datetime" ) ) }; + if ( ! datetime.isEmpty() ) { - throw QgsServerApiNotImplementedException( QStringLiteral( "Time filter is not implemented" ) ) ; - } + const QgsExpression timeExpression { QgsServerApiUtils::temporalFilterExpression( mapLayer, datetime ) }; + if ( ! timeExpression.isValid() ) + { + throw QgsServerApiBadRequestException( QStringLiteral( "Invalid datetime filter expression: %1 " ).arg( datetime ) ); + } + else + { + expressions.push_back( timeExpression.expression() ); + } + }*/ // Inputs are valid, process request QgsFeatureRequest featureRequest = filteredRequest( mapLayer, context ); @@ -1044,10 +1068,9 @@ void QgsWfs3CollectionsItemsHandler::handleRequest( const QgsServerApiContext &c featureRequest.setFilterRect( ct.transform( filterRect ) ); } - QString filterExpression; if ( ! attrFilters.isEmpty() ) { - QStringList expressions; + if ( featureRequest.filterExpression() && ! featureRequest.filterExpression()->expression().isEmpty() ) { expressions.push_back( featureRequest.filterExpression()->expression() ); @@ -1066,10 +1089,12 @@ void QgsWfs3CollectionsItemsHandler::handleRequest( const QgsServerApiContext &c expressions.push_back( QStringLiteral( "\"%1\" = '%2'" ).arg( it.key() ).arg( it.value() ) ); } } - filterExpression = expressions.join( QStringLiteral( " AND " ) ); - featureRequest.setFilterExpression( filterExpression ); } + // Join all expression filters + filterExpression = expressions.join( QStringLiteral( " AND " ) ); + featureRequest.setFilterExpression( filterExpression ); + // WFS3 core specs only serves 4326 featureRequest.setDestinationCrs( crs, context.project()->transformContext() ); // Add offset to limit because paging is not supported by QgsFeatureRequest diff --git a/tests/src/python/test_qgsserver_api.py b/tests/src/python/test_qgsserver_api.py index a092ab952a2..8f0681fd74e 100644 --- a/tests/src/python/test_qgsserver_api.py +++ b/tests/src/python/test_qgsserver_api.py @@ -92,7 +92,7 @@ class QgsServerAPIUtilsTest(QgsServerTestBase): def test_append_path(self): path = QgsServerApiUtils.appendMapParameter('/wfs3', QtCore.QUrl('https://www.qgis.org/wfs3?MAP=/some/path')) - self.assertEquals(path, '/wfs3?MAP=/some/path') + self.assertEqual(path, '/wfs3?MAP=/some/path') class API(QgsServerApi): @@ -141,6 +141,15 @@ class QgsServerAPITestBase(QgsServerTestBase): result = bytes(response.body()).decode('utf8') if reference_file.endswith('html') else self.dump(response) path = unitTestDataPath('qgis_server') + '/api/' + reference_file if self.regeregenerate_api_reference: + # Try to change timestamp + try: + content = result.split('\n') + j = ''.join(content[content.index('') + 1:]) + j = json.loads(j) + j['timeStamp'] = '2019-07-05T12:27:07Z' + result = '\n'.join(content[:2]) + '\n' + json.dumps(j, ensure_ascii=False, indent=2) + except: + pass f = open(path.encode('utf8'), 'w+', encoding='utf8') f.write(result) f.close() @@ -462,6 +471,55 @@ class QgsServerAPITest(QgsServerAPITestBase): response = self.compareApi(request, project, 'test_wfs3_collections_items_layer1_with_short_name_eq_tw_star.json') self.assertEqual(response.statusCode(), 200) + def test_wfs3_time_filters(self): + """Test datetime filters""" + + project = QgsProject() + project.read(unitTestDataPath('qgis_server') + '/test_project_api_timefilters.qgs') + request = QgsBufferServerRequest('http://server.qgis.org/wfs3/collections/points/items?name=lus*') + response = self.compareApi(request, project, 'test_wfs3_collections_items_points_eq_lus.json') + self.assertEqual(response.statusCode(), 200) + + # Prepare 3 projects with all three options: "created", "updated", both. + tmpDir = QtCore.QTemporaryDir() + layer = list(project.mapLayers().values())[0] + layer.setIncludeAttributesOapifTemporalFilters(['created']) + created_path = os.path.join(tmpDir.path(), 'test_project_api_timefilters_created.qgs') + project.write(created_path) + layer.setIncludeAttributesOapifTemporalFilters(['updated']) + updated_path = os.path.join(tmpDir.path(), 'test_project_api_timefilters_updated.qgs') + project.write(updated_path) + layer.setIncludeAttributesOapifTemporalFilters(['updated', 'created']) + both_path = os.path.join(tmpDir.path(), 'test_project_api_timefilters_both.qgs') + project.write(both_path) + + # Test data: + #wkt_geom fid name created updated + #Point (7.30355493642693343 44.82162158126364915) 2 bricherasio 2019-05-05 2020-05-05T05:05:05.000 + #Point (7.2500747591236081 44.81342128741047048) 1 luserna 2019-01-01 2020-01-01T10:10:10.000 + + # What to test: + #interval-closed = date-time "/" date-time + #interval-open-start = [".."] "/" date-time + #interval-open-end = date-time "/" [".."] + #interval = interval-closed / interval-open-start / interval-open-end + #datetime = date-time / interval + + def _date_tester(project_path, expected, unexpected): + # Test "created" date field exact + request = QgsBufferServerRequest('http://server.qgis.org/wfs3/collections/points/items?datetime=2019-05-05') + response = QgsBufferServerResponse() + project.read(project_path) + self.server.handleRequest(request, response, project) + body = bytes(response.body()).decode('utf8') + for exp in expected: + self.assertTrue(exp in body) + for unexp in unexpected: + self.assertFalse(unexp in body) + + # Test "created" date field exact + _date_tester(created_path, ['bricherasio'], ['luserna']) + def test_wfs3_excluded_attributes(self): """Test excluded attributes""" project = QgsProject() @@ -706,10 +764,10 @@ class QgsServerOgcAPITest(QgsServerAPITestBase): self.assertEqual(str(ex.exception), 'Template not found: handlerThree.html') # Define a template path - dir = QtCore.QTemporaryDir() - with open(dir.path() + '/handlerThree.html', 'w+') as f: + tmpDir = QtCore.QTemporaryDir() + with open(tmpDir.path() + '/handlerThree.html', 'w+') as f: f.write("Hello world") - h3.templatePathOverride = dir.path() + '/handlerThree.html' + h3.templatePathOverride = tmpDir.path() + '/handlerThree.html' ctx.response().clear() api.executeRequest(ctx) self.assertEqual(bytes(ctx.response().data()), b"Hello world") diff --git a/tests/testdata/qgis_server/api/test_wfs3_api_project.json b/tests/testdata/qgis_server/api/test_wfs3_api_project.json index 114a9ca7286..600624ad340 100644 --- a/tests/testdata/qgis_server/api/test_wfs3_api_project.json +++ b/tests/testdata/qgis_server/api/test_wfs3_api_project.json @@ -1328,5 +1328,6 @@ Content-Type: application/openapi+json;version=3.0 "description": "Access to data (features).", "name": "Features" } - ] + ], + "timeStamp": "2019-07-05T12:27:07Z" } \ No newline at end of file diff --git a/tests/testdata/qgis_server/api/test_wfs3_collections_empty.json b/tests/testdata/qgis_server/api/test_wfs3_collections_empty.json index e4dc5cf2260..4480260b69e 100644 --- a/tests/testdata/qgis_server/api/test_wfs3_collections_empty.json +++ b/tests/testdata/qgis_server/api/test_wfs3_collections_empty.json @@ -19,5 +19,5 @@ Content-Type: application/json "type": "text/html" } ], - "timeStamp": "2019-10-16T13:53:56Z" -} + "timeStamp": "2019-07-05T12:27:07Z" +} \ No newline at end of file diff --git a/tests/testdata/qgis_server/api/test_wfs3_collections_items_exclude_attribute_0.json b/tests/testdata/qgis_server/api/test_wfs3_collections_items_exclude_attribute_0.json index 8f7a56c975f..c762ee07e5a 100644 --- a/tests/testdata/qgis_server/api/test_wfs3_collections_items_exclude_attribute_0.json +++ b/tests/testdata/qgis_server/api/test_wfs3_collections_items_exclude_attribute_0.json @@ -27,6 +27,6 @@ Content-Type: application/geo+json "id": 1, "utf8nameè": "one èé" }, - "timeStamp": "2019-10-16T13:53:57Z", + "timeStamp": "2019-07-05T12:27:07Z", "type": "Feature" -} +} \ No newline at end of file diff --git a/tests/testdata/qgis_server/api/test_wfs3_collections_items_layer1_with_short_name_eq_tw_star.json b/tests/testdata/qgis_server/api/test_wfs3_collections_items_layer1_with_short_name_eq_tw_star.json index 8cec1e45544..4522b3bed5a 100644 --- a/tests/testdata/qgis_server/api/test_wfs3_collections_items_layer1_with_short_name_eq_tw_star.json +++ b/tests/testdata/qgis_server/api/test_wfs3_collections_items_layer1_with_short_name_eq_tw_star.json @@ -35,6 +35,6 @@ Content-Type: application/geo+json ], "numberMatched": 1, "numberReturned": 1, - "timeStamp": "2019-10-16T13:53:57Z", + "timeStamp": "2019-07-05T12:27:07Z", "type": "FeatureCollection" -} +} \ No newline at end of file diff --git a/tests/testdata/qgis_server/api/test_wfs3_collections_items_layer1_with_short_name_eq_two.json b/tests/testdata/qgis_server/api/test_wfs3_collections_items_layer1_with_short_name_eq_two.json index 4d0cafc4504..5dfe35760f3 100644 --- a/tests/testdata/qgis_server/api/test_wfs3_collections_items_layer1_with_short_name_eq_two.json +++ b/tests/testdata/qgis_server/api/test_wfs3_collections_items_layer1_with_short_name_eq_two.json @@ -35,6 +35,6 @@ Content-Type: application/geo+json ], "numberMatched": 1, "numberReturned": 1, - "timeStamp": "2019-10-16T13:53:57Z", + "timeStamp": "2019-07-05T12:27:07Z", "type": "FeatureCollection" -} +} \ No newline at end of file diff --git a/tests/testdata/qgis_server/api/test_wfs3_collections_items_testlayer_èé.json b/tests/testdata/qgis_server/api/test_wfs3_collections_items_testlayer_èé.json index 050e694dc31..d67f41d2b17 100644 --- a/tests/testdata/qgis_server/api/test_wfs3_collections_items_testlayer_èé.json +++ b/tests/testdata/qgis_server/api/test_wfs3_collections_items_testlayer_èé.json @@ -67,6 +67,6 @@ Content-Type: application/geo+json ], "numberMatched": 3, "numberReturned": 3, - "timeStamp": "2019-10-16T13:53:55Z", + "timeStamp": "2019-07-05T12:27:07Z", "type": "FeatureCollection" -} +} \ No newline at end of file diff --git a/tests/testdata/qgis_server/api/test_wfs3_collections_items_testlayer_èé_bbox.json b/tests/testdata/qgis_server/api/test_wfs3_collections_items_testlayer_èé_bbox.json index 6f3c3832d3f..b71a4ff6df8 100644 --- a/tests/testdata/qgis_server/api/test_wfs3_collections_items_testlayer_èé_bbox.json +++ b/tests/testdata/qgis_server/api/test_wfs3_collections_items_testlayer_èé_bbox.json @@ -35,6 +35,6 @@ Content-Type: application/geo+json ], "numberMatched": 1, "numberReturned": 1, - "timeStamp": "2019-10-16T13:53:55Z", + "timeStamp": "2019-07-05T12:27:07Z", "type": "FeatureCollection" -} +} \ No newline at end of file diff --git a/tests/testdata/qgis_server/api/test_wfs3_collections_items_testlayer_èé_bbox_3857.json b/tests/testdata/qgis_server/api/test_wfs3_collections_items_testlayer_èé_bbox_3857.json index 5d577969163..9831ca85c75 100644 --- a/tests/testdata/qgis_server/api/test_wfs3_collections_items_testlayer_èé_bbox_3857.json +++ b/tests/testdata/qgis_server/api/test_wfs3_collections_items_testlayer_èé_bbox_3857.json @@ -51,6 +51,6 @@ Content-Type: application/geo+json ], "numberMatched": 2, "numberReturned": 2, - "timeStamp": "2019-10-16T13:53:55Z", + "timeStamp": "2019-07-05T12:27:07Z", "type": "FeatureCollection" -} +} \ No newline at end of file diff --git a/tests/testdata/qgis_server/api/test_wfs3_collections_items_testlayer_èé_crs_3857.json b/tests/testdata/qgis_server/api/test_wfs3_collections_items_testlayer_èé_crs_3857.json index b7a47703cf6..f97d86a7d2e 100644 --- a/tests/testdata/qgis_server/api/test_wfs3_collections_items_testlayer_èé_crs_3857.json +++ b/tests/testdata/qgis_server/api/test_wfs3_collections_items_testlayer_èé_crs_3857.json @@ -67,6 +67,6 @@ Content-Type: application/geo+json ], "numberMatched": 3, "numberReturned": 3, - "timeStamp": "2019-10-16T13:53:55Z", + "timeStamp": "2019-07-05T12:27:07Z", "type": "FeatureCollection" -} +} \ No newline at end of file diff --git a/tests/testdata/qgis_server/api/test_wfs3_collections_items_testlayer_èé_limit_1.json b/tests/testdata/qgis_server/api/test_wfs3_collections_items_testlayer_èé_limit_1.json index 16cc218a62b..5b2c9d036c5 100644 --- a/tests/testdata/qgis_server/api/test_wfs3_collections_items_testlayer_èé_limit_1.json +++ b/tests/testdata/qgis_server/api/test_wfs3_collections_items_testlayer_èé_limit_1.json @@ -42,6 +42,6 @@ Content-Type: application/geo+json ], "numberMatched": 3, "numberReturned": 1, - "timeStamp": "2019-10-16T13:53:55Z", + "timeStamp": "2019-07-05T12:27:07Z", "type": "FeatureCollection" -} +} \ No newline at end of file diff --git a/tests/testdata/qgis_server/api/test_wfs3_collections_items_testlayer_èé_limit_1_offset_1.json b/tests/testdata/qgis_server/api/test_wfs3_collections_items_testlayer_èé_limit_1_offset_1.json index f0b7e586c35..8b0418a5f8d 100644 --- a/tests/testdata/qgis_server/api/test_wfs3_collections_items_testlayer_èé_limit_1_offset_1.json +++ b/tests/testdata/qgis_server/api/test_wfs3_collections_items_testlayer_èé_limit_1_offset_1.json @@ -49,6 +49,6 @@ Content-Type: application/geo+json ], "numberMatched": 3, "numberReturned": 1, - "timeStamp": "2019-10-16T13:53:55Z", + "timeStamp": "2019-07-05T12:27:07Z", "type": "FeatureCollection" -} +} \ No newline at end of file diff --git a/tests/testdata/qgis_server/api/test_wfs3_collections_project.json b/tests/testdata/qgis_server/api/test_wfs3_collections_project.json index 29d9f30812c..b5fbc79170f 100644 --- a/tests/testdata/qgis_server/api/test_wfs3_collections_project.json +++ b/tests/testdata/qgis_server/api/test_wfs3_collections_project.json @@ -10,19 +10,16 @@ Content-Type: application/json ], "description": "A test vector layer with unicode òà", "extent": { - "spatial": { - "bbox": [ - [ - 8.203459307036344, - 44.90139483904469, - 8.203546993993488, - 44.901482526001836 - ] - ], - "crs": "http://www.opengis.net/def/crs/OGC/1.3/CRS84" - } + "crs": "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + "spatial": [ + [ + 8.203459307036344, + 44.90139483904469, + 8.203546993993488, + 44.901482526001836 + ] + ] }, - "id": "testlayer èé", "links": [ { "href": "http://server.qgis.org/wfs3/collections/testlayer%20%C3%A8%C3%A9/items.json", @@ -37,6 +34,7 @@ Content-Type: application/json "type": "text/html" } ], + "name": "testlayer èé", "title": "A test vector layer èé" }, { @@ -47,19 +45,16 @@ Content-Type: application/json ], "description": "A Layer1 with an abstract", "extent": { - "spatial": { - "bbox": [ - [ - 8.203459307036344, - 44.90139483904469, - 8.203546993993488, - 44.901482526001836 - ] - ], - "crs": "http://www.opengis.net/def/crs/OGC/1.3/CRS84" - } + "crs": "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + "spatial": [ + [ + 8.203459307036344, + 44.90139483904469, + 8.203546993993488, + 44.901482526001836 + ] + ] }, - "id": "layer1_with_short_name", "links": [ { "href": "http://server.qgis.org/wfs3/collections/layer1_with_short_name/items.json", @@ -74,6 +69,7 @@ Content-Type: application/json "type": "text/html" } ], + "name": "layer1_with_short_name", "title": "A Layer1 with a short name" }, { @@ -84,19 +80,16 @@ Content-Type: application/json ], "description": "A test vector layer with unicode òà", "extent": { - "spatial": { - "bbox": [ - [ - 8.203459307036344, - 44.90139483904469, - 8.203546993993488, - 44.901482526001836 - ] - ], - "crs": "http://www.opengis.net/def/crs/OGC/1.3/CRS84" - } + "crs": "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + "spatial": [ + [ + 8.203459307036344, + 44.90139483904469, + 8.203546993993488, + 44.901482526001836 + ] + ] }, - "id": "exclude_attribute", "links": [ { "href": "http://server.qgis.org/wfs3/collections/exclude_attribute/items.json", @@ -111,6 +104,7 @@ Content-Type: application/json "type": "text/html" } ], + "name": "exclude_attribute", "title": "A test vector layer exclude attrs" }, { @@ -121,19 +115,16 @@ Content-Type: application/json ], "description": "A test vector layer with aliases", "extent": { - "spatial": { - "bbox": [ - [ - 8.203459307036344, - 44.90139483904469, - 8.203546993993488, - 44.901482526001836 - ] - ], - "crs": "http://www.opengis.net/def/crs/OGC/1.3/CRS84" - } + "crs": "http://www.opengis.net/def/crs/OGC/1.3/CRS84", + "spatial": [ + [ + 8.203459307036344, + 44.90139483904469, + 8.203546993993488, + 44.901482526001836 + ] + ] }, - "id": "fields_alias", "links": [ { "href": "http://server.qgis.org/wfs3/collections/fields_alias/items.json", @@ -148,6 +139,7 @@ Content-Type: application/json "type": "text/html" } ], + "name": "fields_alias", "title": "A test vector layer with aliases" } ], @@ -170,5 +162,5 @@ Content-Type: application/json "type": "text/html" } ], - "timeStamp": "2019-10-16T13:53:56Z" -} + "timeStamp": "2019-07-05T12:27:07Z" +} \ No newline at end of file diff --git a/tests/testdata/qgis_server/api/test_wfs3_conformance.json b/tests/testdata/qgis_server/api/test_wfs3_conformance.json index 0df5a08d812..fa4696fd4bb 100644 --- a/tests/testdata/qgis_server/api/test_wfs3_conformance.json +++ b/tests/testdata/qgis_server/api/test_wfs3_conformance.json @@ -22,5 +22,5 @@ Content-Type: application/json "type": "text/html" } ], - "timeStamp": "2019-10-16T13:53:57Z" -} + "timeStamp": "2019-07-05T12:27:07Z" +} \ No newline at end of file diff --git a/tests/testdata/qgis_server/api/test_wfs3_landing_page.json b/tests/testdata/qgis_server/api/test_wfs3_landing_page.json index a34d1b61b73..1078492b459 100644 --- a/tests/testdata/qgis_server/api/test_wfs3_landing_page.json +++ b/tests/testdata/qgis_server/api/test_wfs3_landing_page.json @@ -33,5 +33,5 @@ Content-Type: application/json "type": "application/openapi+json;version=3.0" } ], - "timeStamp": "2019-10-16T13:53:57Z" -} + "timeStamp": "2019-07-05T12:27:07Z" +} \ No newline at end of file