From 3295da6185dff9d78dde440d865add20f8478f85 Mon Sep 17 00:00:00 2001 From: Alessandro Pasotti Date: Thu, 7 Nov 2019 18:12:09 +0100 Subject: [PATCH] Server OAPIF: handle PUT transactions --- src/server/services/wfs3/qgswfs3handlers.cpp | 188 ++++- src/server/services/wfs3/qgswfs3handlers.h | 9 +- tests/src/python/test_qgsserver_api.py | 88 ++ .../qgis_server/test_project_api_editing.qgs | 763 +++++++++--------- 4 files changed, 642 insertions(+), 406 deletions(-) diff --git a/src/server/services/wfs3/qgswfs3handlers.cpp b/src/server/services/wfs3/qgswfs3handlers.cpp index 4df4bd74e56..18ab0578991 100644 --- a/src/server/services/wfs3/qgswfs3handlers.cpp +++ b/src/server/services/wfs3/qgswfs3handlers.cpp @@ -1101,6 +1101,8 @@ void QgsWfs3CollectionsItemsHandler::handleRequest( const QgsServerApiContext &c switch ( context.request()->method() ) { + // ////////////////////////////////////////////////////////////// + // Retrieve features case QgsServerRequest::Method::GetMethod: { // Validate inputs @@ -1343,14 +1345,15 @@ void QgsWfs3CollectionsItemsHandler::handleRequest( const QgsServerApiContext &c write( data, context, htmlMetadata ); break; } - + // ////////////////////////////////////////////////////////////// + // Create a new feature case QgsServerRequest::Method::PostMethod: { // First: check permissions const QStringList wfstInsertLayerIds = QgsServerProjectUtils::wfstInsertLayerIds( *context.project() ); if ( ! wfstInsertLayerIds.contains( mapLayer->id() ) || ! mapLayer->dataProvider()->capabilities().testFlag( QgsVectorDataProvider::Capability::AddFeatures ) ) { - throw QgsServerApiPermissionDeniedException( QStringLiteral( "Layer %1 is not editable" ).arg( mapLayer->name() ) ); + throw QgsServerApiPermissionDeniedException( QStringLiteral( "Features cannot be added to layer '%1'" ).arg( mapLayer->name() ) ); } #ifdef HAVE_SERVER_PYTHON_PLUGINS @@ -1452,7 +1455,6 @@ void QgsWfs3CollectionsItemsHandler::handleRequest( const QgsServerApiContext &c } feat.setId( FID_NULL ); - // TODO: handle CRS QgsFeatureList featuresToAdd; featuresToAdd.append( feat ); if ( ! mapLayer->dataProvider()->addFeatures( featuresToAdd ) ) @@ -1482,7 +1484,7 @@ void QgsWfs3CollectionsItemsHandler::handleRequest( const QgsServerApiContext &c } break; } - + // Error default: { throw QgsServerApiNotImplementedException( QStringLiteral( "%1 method is not implemented." ) @@ -1519,10 +1521,19 @@ void QgsWfs3CollectionsFeatureHandler::handleRequest( const QgsServerApiContext checkLayerIsAccessible( mapLayer, context ); const std::string title { mapLayer->title().isEmpty() ? mapLayer->name().toStdString() : mapLayer->title().toStdString() }; - const QString featureId { match.captured( QStringLiteral( "featureId" ) ) }; - // GET - if ( context.request()->method() == QgsServerRequest::Method::GetMethod ) + // Retrieve feature from storage + const QString featureId { match.captured( QStringLiteral( "featureId" ) ) }; + QgsFeatureRequest featureRequest = filteredRequest( mapLayer, context ); + featureRequest.setFilterFid( featureId.toLongLong() ); + QgsFeature feature; + QgsFeatureIterator it { mapLayer->getFeatures( featureRequest ) }; + if ( ! it.nextFeature( feature ) && feature.isValid() ) + { + QgsServerApiInternalServerError( QStringLiteral( "Invalid feature [%1]" ).arg( featureId ) ); + } + + auto doGet = [ & ]( ) { #ifdef HAVE_SERVER_PYTHON_PLUGINS QgsAccessControl *accessControl = context.serverInterface()->accessControls(); @@ -1535,15 +1546,6 @@ void QgsWfs3CollectionsFeatureHandler::handleRequest( const QgsServerApiContext } #endif - QgsFeatureRequest featureRequest = filteredRequest( mapLayer, context ); - featureRequest.setFilterFid( featureId.toLongLong() ); - QgsFeature feature; - QgsFeatureIterator it { mapLayer->getFeatures( featureRequest ) }; - if ( ! it.nextFeature( feature ) && feature.isValid() ) - { - QgsServerApiInternalServerError( QStringLiteral( "Invalid feature [%1]" ).arg( featureId ) ); - } - QgsJsonExporter exporter { mapLayer }; exporter.setAttributes( featureRequest.subsetOfAttributes() ); exporter.setAttributeDisplayName( true ); @@ -1565,12 +1567,156 @@ void QgsWfs3CollectionsFeatureHandler::handleRequest( const QgsServerApiContext { "navigation", navigation } }; write( data, context, htmlMetadata ); - } - else + }; + + switch ( context.request()->method() ) { - throw QgsServerApiNotImplementedException( QStringLiteral( "%1 method is not implemented." ) - .arg( QgsServerRequest::methodToString( context.request()->method() ) ) ); - } + // ////////////////////////////////////////////////////////////// + // Retrieve a single feature + case QgsServerRequest::Method::GetMethod: + { + doGet(); + break; + } + // ////////////////////////////////////////////////////////////// + // Replace feature, use PATCH for partial updates + // TODO: factor with POST, that uses mostly the same code + case QgsServerRequest::Method::PutMethod: + { + // First: check permissions + const QStringList wfstUpdateLayerIds = QgsServerProjectUtils::wfstUpdateLayerIds( *context.project() ); + if ( ! wfstUpdateLayerIds.contains( mapLayer->id() ) || + ! mapLayer->dataProvider()->capabilities().testFlag( QgsVectorDataProvider::Capability::ChangeGeometries ) || + ! mapLayer->dataProvider()->capabilities().testFlag( QgsVectorDataProvider::Capability::ChangeAttributeValues ) ) + { + throw QgsServerApiPermissionDeniedException( QStringLiteral( "Features in layer '%1' cannot be changed" ).arg( mapLayer->name() ) ); + } + +#ifdef HAVE_SERVER_PYTHON_PLUGINS + + // get access controls + QgsAccessControl *accessControl = context.serverInterface()->accessControls(); + if ( accessControl && !accessControl->layerUpdatePermission( mapLayer ) ) + { + throw QgsServerApiPermissionDeniedException( QStringLiteral( "No ACL permissions to change features on layer '%1'" ).arg( mapLayer->name() ) ); + } + + //scoped pointer to restore all original layer filters (subsetStrings) when pointer goes out of scope + //there's LOTS of potential exit paths here, so we avoid having to restore the filters manually + std::unique_ptr< QgsOWSServerFilterRestorer > filterRestorer( new QgsOWSServerFilterRestorer() ); + if ( accessControl ) + { + QgsOWSServerFilterRestorer::applyAccessControlLayerFilters( accessControl, mapLayer, filterRestorer->originalFilters() ); + } + +#endif + try + { + // Parse + json postData = json::parse( context.request()->data() ); + // Process data: extract geometry (because we need to process attributes in a much more complex way) + const QgsFeatureList features = QgsOgrUtils::stringToFeatureList( context.request()->data(), mapLayer->fields(), QTextCodec::codecForName( "UTF-8" ) ); + if ( features.isEmpty() ) + { + throw QgsServerApiBadRequestException( QStringLiteral( "Posted body contains no feature" ) ); + } + + QgsFeature feat = features.first(); + if ( ! feat.isValid() ) + { + throw QgsServerApiInternalServerError( QStringLiteral( "Feature is not valid" ) ); + } + + QgsChangedAttributesMap changedAttributes; + QgsAttributeMap changedMap; + QgsGeometryMap changedGeometries; + + // Transform geometry + if ( mapLayer->crs() != QgsCoordinateReferenceSystem::fromEpsgId( 4326 ) ) + { + QgsGeometry geom { feat.geometry() }; + try + { + geom.transform( QgsCoordinateTransform( QgsCoordinateReferenceSystem::fromEpsgId( 4326 ), mapLayer->crs(), context.project()->transformContext() ) ); + } + catch ( QgsCsException & ) + { + throw QgsServerApiInternalServerError( QStringLiteral( "Geometry could not be transformed to destination CRS" ) ); + } + changedGeometries.insert( feature.id(), geom ); + } + + // Process attributes + try + { + const auto authorizedFields { publishedFields( mapLayer, context ) }; + QStringList authorizedFieldNames; + for ( const auto &f : authorizedFields ) + { + authorizedFieldNames.push_back( f.name() ); + } + const QVariantMap properties { QgsJsonUtils::parseJson( postData["properties"].dump( ) ).toMap( ) }; + const QgsFields fields = mapLayer->fields(); + int fieldIndex = 0; + for ( const auto &field : fields ) + { + if ( ! properties.value( field.name() ).isNull() ) + { + if ( ! authorizedFieldNames.contains( field.name() ) ) + { + throw QgsServerApiBadRequestException( QStringLiteral( "Feature field %1 is not allowed" ).arg( field.name() ) ); + } + else + { + QVariant value { properties.value( field.name() ) }; + // Convert blobs + if ( ! properties.value( field.name() ).isNull() && static_cast( field.type() ) == QMetaType::QByteArray ) + { + value = QByteArray::fromBase64( value.toByteArray() ); + } + changedMap.insert( fieldIndex, value ); + } + } + else + { + changedMap.insert( fieldIndex, QVariant( ) ); + } + fieldIndex++; + } + if ( ! changedMap.isEmpty() ) + { + changedAttributes.insert( feature.id(), changedMap ); + } + } + catch ( json::exception & ) + { + throw QgsServerApiBadRequestException( QStringLiteral( "Feature properties are not valid" ) ); + } + + // TODO: raise if nothing to change? + + if ( ! mapLayer->dataProvider()->changeFeatures( changedAttributes, changedGeometries ) ) + { + throw QgsServerApiInternalServerError( QStringLiteral( "Error adding feature to collection" ) ); + } + + // Now we need to send the updated feature to the client + feature = mapLayer->getFeature( feature.id() ); + doGet(); + + } + catch ( json::exception &ex ) + { + throw QgsServerApiBadRequestException( QStringLiteral( "JSON parse error: %1" ).arg( ex.what( ) ) ); + } + break; + } + default: + { + throw QgsServerApiNotImplementedException( QStringLiteral( "%1 method is not implemented." ) + .arg( QgsServerRequest::methodToString( context.request()->method() ) ) ); + } + } // end switch } json QgsWfs3CollectionsFeatureHandler::schema( const QgsServerApiContext &context ) const diff --git a/src/server/services/wfs3/qgswfs3handlers.h b/src/server/services/wfs3/qgswfs3handlers.h index 29c79875db1..ffed9aa3ad0 100644 --- a/src/server/services/wfs3/qgswfs3handlers.h +++ b/src/server/services/wfs3/qgswfs3handlers.h @@ -23,11 +23,12 @@ class QgsFeatureRequest; class QgsServerOgcApi; +class QgsFeature; /** * The QgsWfs3AbstractItemsHandler class provides some * functionality which is common to the handlers that - * return items. + * return or process items. */ class QgsWfs3AbstractItemsHandler: public QgsServerOgcApiHandler { @@ -54,9 +55,9 @@ class QgsWfs3AbstractItemsHandler: public QgsServerOgcApiHandler /** * Returns a filtered list of fields containing only fields published for WFS and plugin filters applied. - * @param layer the vector layer - * @param context the server api context - * @return QgsFields list with filters applied + * \param layer the vector layer + * \param context the server api context + * \return QgsFields list with filters applied */ QgsFields publishedFields( const QgsVectorLayer *layer, const QgsServerApiContext &context ) const; diff --git a/tests/src/python/test_qgsserver_api.py b/tests/src/python/test_qgsserver_api.py index fc0e66dcfe4..4e62db713a4 100644 --- a/tests/src/python/test_qgsserver_api.py +++ b/tests/src/python/test_qgsserver_api.py @@ -592,6 +592,94 @@ class QgsServerAPITest(QgsServerAPITestBase): self.assertEqual(bytes(feature.attribute('blob_1')), b"test") self.assertEqual(re.sub(r'\.\d+', '', feature.geometry().asWkt().upper()), 'MULTIPOINT ((806732 5592286))') + def test_wfs3_collection_items_put(self): + """Test WFS3 API items PUT""" + + tmpDir = QtCore.QTemporaryDir() + shutil.copy(unitTestDataPath('qgis_server') + '/test_project_api_editing.qgs', tmpDir.path() + '/test_project_api_editing.qgs') + shutil.copy(unitTestDataPath('qgis_server') + '/test_project_api_editing.gpkg', tmpDir.path() + '/test_project_api_editing.gpkg') + + project = QgsProject() + project.read(tmpDir.path() + '/test_project_api_editing.qgs') + + # Project layers with different permissions + insert_layer = r'test%20layer%20èé%203857%20published%20insert' + update_layer = r'test%20layer%20èé%203857%20published%20update' + delete_layer = r'test%20layer%20èé%203857%20published%20delete' + unpublished_layer = r'test%20layer%203857%20èé%20unpublished' + hidden_text_1_layer = r'test%20layer%203857%20èé%20unpublished' + + # Invalid request + data = b'not json!' + request = QgsBufferServerRequest('http://server.qgis.org/wfs3/collections/%s/items/1' % update_layer, + QgsBufferServerRequest.PutMethod, + {'Content-Type': 'application/geo+json'}, + data + ) + response = QgsBufferServerResponse() + self.server.handleRequest(request, response, project) + self.assertEqual(response.statusCode(), 400) + self.assertTrue('[{"code":"Bad request error","description":"JSON parse error' in bytes(response.body()).decode('utf8')) + + # Valid request: change feature with ID 1 + data = """{ + "geometry": { + "coordinates": [[ + 7.247, + 44.814 + ]], + "type": "MultiPoint" + }, + "properties": { + "text_1": "Text 1", + "text_2": "Text 2", + "int_1": 123, + "float_1": 12345.678, + "datetime_1": "2019-11-07T12:34:56", + "date_1": "2019-11-07", + "blob_1": "dGVzdA==", + "bool_1": true + }, + "type": "Feature" + }""".encode('utf8') + + # Unauthorized layer + request = QgsBufferServerRequest('http://server.qgis.org/wfs3/collections/%s/items/1' % insert_layer, + QgsBufferServerRequest.PutMethod, + {'Content-Type': 'application/geo+json'}, + data + ) + response = QgsBufferServerResponse() + self.server.handleRequest(request, response, project) + self.assertEqual(response.statusCode(), 403) + + # Authorized layer + request = QgsBufferServerRequest('http://server.qgis.org/wfs3/collections/%s/items/1' % update_layer, + QgsBufferServerRequest.PutMethod, + {'Content-Type': 'application/geo+json'}, + data + ) + response = QgsBufferServerResponse() + self.server.handleRequest(request, response, project) + self.assertEqual(response.statusCode(), 200) + j = json.loads(bytes(response.body()).decode('utf8')) + self.assertEqual(j['properties']['text_1'], 'Text 1') + self.assertEqual(j['properties']['text_2'], 'Text 2') + self.assertEqual(j['properties']['int_1'], 123) + self.assertEqual(j['properties']['float_1'], 12345.678) + self.assertEqual(j['properties']['bool_1'], True) + self.assertEqual(j['properties']['blob_1'], "dGVzdA==") + self.assertEqual(j['geometry']['coordinates'], [[7.247, 44.814]]) + + feature = project.mapLayersByName('test layer èé 3857 published update')[0].getFeature(1) + self.assertEqual(feature.attribute('text_1'), 'Text 1') + self.assertEqual(feature.attribute('text_2'), 'Text 2') + self.assertEqual(feature.attribute('int_1'), 123) + self.assertEqual(feature.attribute('float_1'), 12345.678) + self.assertEqual(feature.attribute('bool_1'), True) + self.assertEqual(bytes(feature.attribute('blob_1')), b"test") + self.assertEqual(re.sub(r'\.\d+', '', feature.geometry().asWkt().upper()), 'MULTIPOINT ((806732 5592286))') + def test_wfs3_field_filters(self): """Test field filters""" project = QgsProject() diff --git a/tests/testdata/qgis_server/test_project_api_editing.qgs b/tests/testdata/qgis_server/test_project_api_editing.qgs index 1da97be9c9a..1eeb175af4f 100644 --- a/tests/testdata/qgis_server/test_project_api_editing.qgs +++ b/tests/testdata/qgis_server/test_project_api_editing.qgs @@ -1,5 +1,5 @@ - + QGIS Test Project API Editing @@ -19,40 +19,40 @@ - + - + - + - + - + - test_layer_èé_3857_60451b88_fd50_456b_860d_5240a3948ce4 - test_layer_èé_3857_published_insert_0263cb9c_cac5_4cc1_98fe_cb11b0ef2857 - test_layer_èé_3857_unpublished_e2dfb7b6_196b_4faa_9af5_0e5df1f9b362 - test_layer_èé_3857_published_delete_7a6552d6_2780_4039_9550_09c60631f909 - test_layer_èé_3857_published_update_4dad13eb_4069_4b30_9acb_da604e74fd90 + test_layer_èé_3857_published_insert_60451b88_fd50_456b_860d_5240a3948ce4 + test_layer_èé_3857_unpublished_0263cb9c_cac5_4cc1_98fe_cb11b0ef2857 + test_layer_èé_3857_published_delete_e2dfb7b6_196b_4faa_9af5_0e5df1f9b362 + test_layer_èé_3857_published_update_7a6552d6_2780_4039_9550_09c60631f909 + test_layer_èé_3857_published_hidden_4dad13eb_4069_4b30_9acb_da604e74fd90 - + - - - - - + + + + + - + degrees 7.19737040250652527 @@ -78,42 +78,42 @@ - + - + - + - + - + - + - 804542.375 - 5593348 - 804542.5 - 5593349 + 804542 + 5593350 + 804542 + 5593350 - test_layer_èé_3857_60451b88_fd50_456b_860d_5240a3948ce4 + test_layer_èé_3857_published_insert_60451b88_fd50_456b_860d_5240a3948ce4 ./test_project_api_editing.gpkg|layername=test layer èé 3857 @@ -159,11 +159,11 @@ - false + true - + @@ -187,10 +187,10 @@ 1 1 - + - - + + @@ -211,9 +211,9 @@ @@ -230,21 +230,22 @@ 0 0 1 - - + + + - + - + @@ -327,54 +328,54 @@ - - - - - - - - - + + + + + + + + + - - - - - - - - - + + + + + + + + + - - - - - - - - - + + + + + + + + + - + - @@ -431,14 +432,14 @@ def my_form_open(dialog, layer, feature): fid - + - 804542.375 - 5593348 - 804542.5 - 5593349 + 804542 + 5593350 + 804542 + 5593350 - test_layer_èé_3857_published_delete_7a6552d6_2780_4039_9550_09c60631f909 + test_layer_èé_3857_published_update_7a6552d6_2780_4039_9550_09c60631f909 ./test_project_api_editing.gpkg|layername=test layer èé 3857 @@ -484,11 +485,11 @@ def my_form_open(dialog, layer, feature): - false + true - + @@ -512,10 +513,10 @@ def my_form_open(dialog, layer, feature): 1 1 - + - - + + @@ -536,9 +537,9 @@ def my_form_open(dialog, layer, feature): @@ -555,22 +556,22 @@ def my_form_open(dialog, layer, feature): 0 0 1 - - + + - + - + - + @@ -653,54 +654,54 @@ def my_form_open(dialog, layer, feature): - - - - - - - - - + + + + + + + + + - - - - - - - - - + + + + + + + + + - - - - - - - - - + + + + + + + + + - + - @@ -757,14 +758,14 @@ def my_form_open(dialog, layer, feature): fid - + - 804542.375 - 5593348 - 804542.5 - 5593349 + 804542 + 5593350 + 804542 + 5593350 - test_layer_èé_3857_published_insert_0263cb9c_cac5_4cc1_98fe_cb11b0ef2857 + test_layer_èé_3857_unpublished_0263cb9c_cac5_4cc1_98fe_cb11b0ef2857 ./test_project_api_editing.gpkg|layername=test layer èé 3857 @@ -810,11 +811,11 @@ def my_form_open(dialog, layer, feature): - false + true - + @@ -838,10 +839,10 @@ def my_form_open(dialog, layer, feature): 1 1 - + - - + + @@ -862,9 +863,9 @@ def my_form_open(dialog, layer, feature): @@ -881,22 +882,22 @@ def my_form_open(dialog, layer, feature): 0 0 1 - - + + - + - + - + @@ -979,54 +980,54 @@ def my_form_open(dialog, layer, feature): - - - - - - - - - + + + + + + + + + - - - - - - - - - + + + + + + + + + - - - - - - - - - + + + + + + + + + - + - @@ -1083,14 +1084,14 @@ def my_form_open(dialog, layer, feature): fid - + - 804542.375 - 5593348 - 804542.5 - 5593349 + 804542 + 5593350 + 804542 + 5593350 - test_layer_èé_3857_published_update_4dad13eb_4069_4b30_9acb_da604e74fd90 + test_layer_èé_3857_published_hidden_4dad13eb_4069_4b30_9acb_da604e74fd90 ./test_project_api_editing.gpkg|layername=test layer èé 3857 @@ -1136,11 +1137,11 @@ def my_form_open(dialog, layer, feature): - false + true - + @@ -1164,10 +1165,10 @@ def my_form_open(dialog, layer, feature): 1 1 - + - - + + @@ -1188,9 +1189,9 @@ def my_form_open(dialog, layer, feature): @@ -1207,22 +1208,22 @@ def my_form_open(dialog, layer, feature): 0 0 1 - - + + - + - + - + @@ -1307,54 +1308,54 @@ def my_form_open(dialog, layer, feature): text_2 - - - - - - - - - + + + + + + + + + - - - - - - - - - + + + + + + + + + - - - - - - - - - + + + + + + + + + - + - @@ -1411,14 +1412,14 @@ def my_form_open(dialog, layer, feature): fid - + - 804542.375 - 5593348 - 804542.5 - 5593349 + 804542 + 5593350 + 804542 + 5593350 - test_layer_èé_3857_unpublished_e2dfb7b6_196b_4faa_9af5_0e5df1f9b362 + test_layer_èé_3857_published_delete_e2dfb7b6_196b_4faa_9af5_0e5df1f9b362 ./test_project_api_editing.gpkg|layername=test layer èé 3857 @@ -1464,11 +1465,11 @@ def my_form_open(dialog, layer, feature): - false + true - + @@ -1492,10 +1493,10 @@ def my_form_open(dialog, layer, feature): 1 1 - + - - + + @@ -1516,9 +1517,9 @@ def my_form_open(dialog, layer, feature): @@ -1535,22 +1536,22 @@ def my_form_open(dialog, layer, feature): 0 0 1 - - + + - + - + - + @@ -1633,54 +1634,54 @@ def my_form_open(dialog, layer, feature): - - - - - - - - - + + + + + + + + + - - - - - - - - - + + + + + + + + + - - - - - - - - - + + + + + + + + + - + - @@ -1739,11 +1740,11 @@ def my_form_open(dialog, layer, feature): - - - - - + + + + + @@ -1832,17 +1833,17 @@ def my_form_open(dialog, layer, feature): - test_layer_èé_3857_60451b88_fd50_456b_860d_5240a3948ce4 - test_layer_èé_3857_published_delete_7a6552d6_2780_4039_9550_09c60631f909 - test_layer_èé_3857_published_update_4dad13eb_4069_4b30_9acb_da604e74fd90 - test_layer_èé_3857_unpublished_e2dfb7b6_196b_4faa_9af5_0e5df1f9b362 + test_layer_èé_3857_published_insert_60451b88_fd50_456b_860d_5240a3948ce4 + test_layer_èé_3857_published_update_7a6552d6_2780_4039_9550_09c60631f909 + test_layer_èé_3857_published_hidden_4dad13eb_4069_4b30_9acb_da604e74fd90 + test_layer_èé_3857_published_delete_e2dfb7b6_196b_4faa_9af5_0e5df1f9b362 8 - 8 - 8 - 8 - 8 + 8 + 8 + 8 + 8 8 8 8 @@ -1850,16 +1851,16 @@ def my_form_open(dialog, layer, feature): - test_layer_èé_3857_published_update_4dad13eb_4069_4b30_9acb_da604e74fd90 - test_layer_èé_3857_unpublished_e2dfb7b6_196b_4faa_9af5_0e5df1f9b362 + test_layer_èé_3857_published_hidden_4dad13eb_4069_4b30_9acb_da604e74fd90 + test_layer_èé_3857_published_delete_e2dfb7b6_196b_4faa_9af5_0e5df1f9b362 - test_layer_èé_3857_60451b88_fd50_456b_860d_5240a3948ce4 - test_layer_èé_3857_published_update_4dad13eb_4069_4b30_9acb_da604e74fd90 + test_layer_èé_3857_published_insert_60451b88_fd50_456b_860d_5240a3948ce4 + test_layer_èé_3857_published_hidden_4dad13eb_4069_4b30_9acb_da604e74fd90 - test_layer_èé_3857_published_delete_7a6552d6_2780_4039_9550_09c60631f909 - test_layer_èé_3857_published_update_4dad13eb_4069_4b30_9acb_da604e74fd90 + test_layer_èé_3857_published_update_7a6552d6_2780_4039_9550_09c60631f909 + test_layer_èé_3857_published_hidden_4dad13eb_4069_4b30_9acb_da604e74fd90 @@ -1942,12 +1943,12 @@ def my_form_open(dialog, layer, feature): - - - + + + - - + + @@ -1961,28 +1962,28 @@ def my_form_open(dialog, layer, feature): - + - - + + @@ -1996,9 +1997,9 @@ def my_form_open(dialog, layer, feature): @@ -2006,25 +2007,25 @@ def my_form_open(dialog, layer, feature): - + - + - + - - + + @@ -2043,17 +2044,17 @@ def my_form_open(dialog, layer, feature): - - + + @@ -2074,9 +2075,9 @@ def my_form_open(dialog, layer, feature): @@ -2086,9 +2087,9 @@ def my_form_open(dialog, layer, feature): @@ -2097,25 +2098,25 @@ def my_form_open(dialog, layer, feature): - + - + - + - - + + @@ -2134,17 +2135,17 @@ def my_form_open(dialog, layer, feature): - - + + @@ -2165,9 +2166,9 @@ def my_form_open(dialog, layer, feature): @@ -2177,9 +2178,9 @@ def my_form_open(dialog, layer, feature): @@ -2189,7 +2190,7 @@ def my_form_open(dialog, layer, feature): - +