Skip to content

Commit

Permalink
Merge pull request #4559 from nboisteault/filter_by_multiple_users_or…
Browse files Browse the repository at this point in the history
…_groups

Improve user/group filter to allow multiple users/groups
  • Loading branch information
mdouchin authored Aug 23, 2024
2 parents 5906e03 + 0c1f319 commit 829b42c
Show file tree
Hide file tree
Showing 15 changed files with 1,298 additions and 488 deletions.
39 changes: 14 additions & 25 deletions lizmap/modules/lizmap/lib/Form/QgisForm.php
Original file line number Diff line number Diff line change
Expand Up @@ -1794,41 +1794,30 @@ protected function filterDataByLogin($layername)
return null;
}

// Optionnaly add a filter parameter
$lproj = $this->layer->getProject();
$loginFilteredConfig = $lproj->getLoginFilteredConfig($layername);
// Optionally add a filter parameter
$layerProject = $this->layer->getProject();
$loginFilteredConfig = $layerProject->getLoginFilteredConfig($layername);

if ($loginFilteredConfig) {
// Get filter type
$type = 'groups';
$attribute = $loginFilteredConfig->filterAttribute;

// check filter type
if (property_exists($loginFilteredConfig, 'filterPrivate')
&& $loginFilteredConfig->filterPrivate == 'True') {
&& $loginFilteredConfig->filterPrivate == 'True') {
$type = 'login';
}

// Check if a user is authenticated
$isConnected = $this->appContext->userIsConnected();
$cnx = $this->appContext->getDbConnection($this->layer->getId());
if ($isConnected) {
$user = $this->appContext->getUserSession();
$login = $user->login;
if ($type == 'login') {
$where = ' "'.$attribute."\" IN ( '".$login."' , 'all' )";
} else {
$userGroups = $this->appContext->aclUserPublicGroupsId();
// Set XML Filter if getFeature request
$flatGroups = implode("' , '", $userGroups);
$where = ' "'.$attribute."\" IN ( '".$flatGroups."' , 'all' )";
}
} else {
// The user is not authenticated: only show data with attribute = 'all'
$where = ' "'.$attribute.'" = '.$cnx->quote('all');
// Filter attribute
$attribute = $loginFilteredConfig->filterAttribute;

// SQL filter
$layerFilter = $layerProject->getLoginFilter($layername);
if (empty($layerFilter)) {
return null;
}

// Set filter when multiple layers concerned
return array(
'where' => $where,
'where' => $layerFilter['filter'],
'type' => $type,
'attribute' => $attribute,
);
Expand Down
87 changes: 82 additions & 5 deletions lizmap/modules/lizmap/lib/Project/Project.php
Original file line number Diff line number Diff line change
Expand Up @@ -1223,10 +1223,19 @@ public function getLoginFilteredConfig($layerName, $edition = false)
* Get login filters, get expressions for layers based on login filtered
* config.
*
* NOTE: We could delegate this completely to the lizmap_server plugin
* for all requests. The only request needed to have the SQL filter
* is the WFS GetFeature request, if the layer is a PostgreSQL layer.
* It this particular case, we could use a similar approach
* that the one use with qgisVectorLayer::requestPolygonFilter
* which calls the server plugin with SERVICE=Lizmap&REQUEST=GetSubsetString
*
* @param string[] $layers : layers' name list
* @param bool $edition : get login filters for edition
*
* @return array
* @return array Array containing layers names as key and filter configuration
* and SQL filters as values. Array might be empty if no filter
* is configured for the layer.
*/
public function getLoginFilters($layers, $edition = false)
{
Expand Down Expand Up @@ -1258,22 +1267,90 @@ public function getLoginFilters($layers, $edition = false)
// attribute to filter
$attribute = $loginFilteredConfig->filterAttribute;

// Quoted attribute with double-quotes
$cnx = $this->appContext->getDbConnection();
$quotedField = $cnx->encloseName($attribute);

// Get QGIS vector layer provider
/** @var null|\qgisVectorLayer $qgisLayer The QGIS vector layer instance */
$qgisLayer = $this->qgis->getLayer($layerByTypeName->id, $this);
$provider = 'unknown';
if ($qgisLayer) {
$provider = $qgisLayer->getProvider();
}

// default no user connected
$filter = "\"{$attribute}\" = 'all'";
$filter = "{$quotedField} = 'all'";

// For PostgreSQL layers, allow multiple values in the filter field
// E.g. "groupe_a,other_group"
if ($provider == 'postgres'
&& property_exists($loginFilteredConfig, 'allow_multiple_acl_values')
&& $loginFilteredConfig->allow_multiple_acl_values
) {
$filter .= " OR {$quotedField} LIKE 'all,%'";
$filter .= " OR {$quotedField} LIKE '%,all'";
$filter .= " OR {$quotedField} LIKE '%,all,%'";
}

// A user is connected
if ($this->appContext->userIsConnected()) {
// Get the user
$user = $this->appContext->getUserSession();
$login = $user->login;

// List of values for expression
$values = array();
if (property_exists($loginFilteredConfig, 'filterPrivate')
&& $this->optionToBoolean($loginFilteredConfig->filterPrivate)
) {
$filter = "\"{$attribute}\" IN ( '".$login."' , 'all' )";
// If filter is private use user_login
$values[] = $login;
} else {
// Else use user groups
$userGroups = $this->appContext->aclUserPublicGroupsId();
$flatGroups = implode("' , '", $userGroups);
$filter = "\"{$attribute}\" IN ( '".$flatGroups."' , 'all' )";
$values = $userGroups;
}

// Add all to values
$values[] = 'all';
$allValuesFilters = array();

// For each value (group, all, login, etc.), create a filter
// combining all the possibility: equality & LIKE
foreach ($values as $value) {
$valueFilters = array();
// Quote the value with single quotes
$quotedValue = $cnx->quote($value);

// equality
$valueFilters[] = "{$quotedField} = {$quotedValue}";

// For PostgreSQL layers, allow multiple values in the filter field
// E.g. "groupe_a,other_group"
if ($provider == 'postgres'
&& property_exists($loginFilteredConfig, 'allow_multiple_acl_values')
&& $loginFilteredConfig->allow_multiple_acl_values
) {
// begins with value & comma
$quotedLikeValue = $cnx->quote("{$value},%");
$valueFilters[] = "{$quotedField} LIKE {$quotedLikeValue}";

// ends with comma & value
$quotedLikeValue = $cnx->quote("%,{$value}");
$valueFilters[] = "{$quotedField} LIKE {$quotedLikeValue}";

// value between two commas
$quotedLikeValue = $cnx->quote("%,{$value},%");
$valueFilters[] = "{$quotedField} LIKE {$quotedLikeValue}";
}

// Build the filter for this value
$allValuesFilters[] = implode(' OR ', $valueFilters);
}

// Build filter for all values
$filter = implode(' OR ', $allValuesFilters);
}

$filters[$layerName] = array_merge(
Expand Down
4 changes: 2 additions & 2 deletions lizmap/modules/lizmap/lib/Request/WFSRequest.php
Original file line number Diff line number Diff line change
Expand Up @@ -643,13 +643,13 @@ protected function parseExpFilter($cnx, $params)
if (strpos($validFilter, '$id') !== false) {
$key = $this->datasource->key;
if (count(explode(',', $key)) == 1) {
return ' AND '.str_replace('$id ', $cnx->encloseName($key).' ', $validFilter);
return ' AND ( '.str_replace('$id ', $cnx->encloseName($key).' ', $validFilter).' ) ';
}

return false;
}

return ' AND '.$validFilter;
return ' AND ( '.$validFilter.' ) ';
}

return '';
Expand Down
10 changes: 10 additions & 0 deletions tests/end2end/cypress/integration/requests-metadata-ghaction.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ describe('Request JSON metadata', function () {
"__anonymous",
"admins",
"group_a",
"group_b",
"publishers"
].sort()
);
Expand All @@ -49,6 +50,7 @@ describe('Request JSON metadata', function () {
"__anonymous",
"admins",
"group_a",
"group_b",
"publishers"
].sort()
);
Expand All @@ -72,6 +74,9 @@ describe('Request JSON metadata', function () {
"group_a": {
"label": "group_a"
},
"group_b": {
"label": "group_b"
},
"intranet": {
"label": "Intranet demos group"
},
Expand Down Expand Up @@ -145,6 +150,7 @@ describe('Request JSON metadata', function () {
"__anonymous",
"admins",
"group_a",
"group_b",
"publishers"
].sort()
);
Expand All @@ -153,6 +159,7 @@ describe('Request JSON metadata', function () {
"__anonymous",
"admins",
"group_a",
"group_b",
"publishers"
].sort()
);
Expand All @@ -167,6 +174,9 @@ describe('Request JSON metadata', function () {
"group_a": {
"label": "group_a"
},
"group_b": {
"label": "group_b"
},
"intranet": {
"label": "Intranet demos group"
},
Expand Down
99 changes: 75 additions & 24 deletions tests/end2end/playwright/filter-layer-by-user.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,17 +12,34 @@ test.describe('Filter layer data by user - not connected', () => {
await page.locator('#dock-close').click();
});

test('GetMap', async ({ page }) => {
// Hide all elements but #newOlMap and its children
await page.$eval("*", el => el.style.visibility = 'hidden');
await page.$eval("#newOlMap, #newOlMap *", el => el.style.visibility = 'visible');
// DISABLED BECAUSE IT IS NOT RELIABLE, MAINTAINABLE AND CAUSES HEADACHE ;-)
// Instead, we use a WMS GetFeatureInfo in JSON format below
// test('GetMap', async ({ page }) => {
// // Hide all elements but #newOlMap and its children
// await page.$eval("*", el => el.style.visibility = 'hidden');
// await page.$eval("#newOlMap, #newOlMap *", el => el.style.visibility = 'visible');

// expect(await page.locator('#newOlMap').screenshot()).toMatchSnapshot('map_not_connected.png', {
// maxDiffPixels: 500
// });
// });

test('WMS GetFeatureInfo JSON', async ({ page }) => {

const getFeatureInfo = await page.evaluate(async () => {
return await fetch("/index.php/lizmap/service?repository=testsrepository&project=filter_layer_by_user&SERVICE=WMS&REQUEST=GetFeatureInfo&VERSION=1.3.0&CRS=EPSG%3A2154&INFO_FORMAT=application%2Fjson&QUERY_LAYERS=green_filter_layer_by_user_edition_only%2Cblue_filter_layer_by_user%2Cred_layer_with_no_filter&LAYERS=green_filter_layer_by_user_edition_only%2Cblue_filter_layer_by_user%2Cred_layer_with_no_filter&STYLE=default%2Cdefault%2Cdefault&FEATURE_COUNT=10&FILTER=green_filter_layer_by_user_edition_only:\"gid\" > 0")
.then(r => r.ok ? r.json() : Promise.reject(r))
})

// check features
expect(getFeatureInfo.features).toHaveLength(4)
// check a specific feature
const feature = getFeatureInfo.features[0]
expect(feature.id).not.toBeUndefined()

expect(await page.locator('#newOlMap').screenshot()).toMatchSnapshot('map_not_connected.png', {
maxDiffPixels: 500
});
});

test('Popup', async ({ page }) => {
test('Popup with map click', async ({ page }) => {
let getFeatureInfoRequestPromise = page.waitForRequest(request => request.method() === 'POST' && request.postData()?.includes('GetFeatureInfo') === true);

// blue_filter_layer_by_user
Expand Down Expand Up @@ -133,17 +150,34 @@ test.describe('Filter layer data by user - user in group a', () => {
await page.locator('#dock-close').click();
});

test('GetMap', async ({ page }) => {
// Hide all elements but #newOlMap and its children
await page.$eval("*", el => el.style.visibility = 'hidden');
await page.$eval("#newOlMap, #newOlMap *", el => el.style.visibility = 'visible');
// DISABLED BECAUSE IT IS NOT RELIABLE, MAINTAINABLE AND CAUSES HEADACHE ;-)
// Instead, we use a WMS GetFeatureInfo in JSON format below
// test('GetMap', async ({ page }) => {
// // Hide all elements but #newOlMap and its children
// await page.$eval("*", el => el.style.visibility = 'hidden');
// await page.$eval("#newOlMap, #newOlMap *", el => el.style.visibility = 'visible');

// expect(await page.locator('#newOlMap').screenshot()).toMatchSnapshot('map_connected_as_user_in_group_a.png', {
// maxDiffPixels: 500
// });
// });

test('WMS GetFeatureInfo JSON', async ({ page }) => {

const getFeatureInfo = await page.evaluate(async () => {
return await fetch("/index.php/lizmap/service?repository=testsrepository&project=filter_layer_by_user&SERVICE=WMS&REQUEST=GetFeatureInfo&VERSION=1.3.0&CRS=EPSG%3A2154&INFO_FORMAT=application%2Fjson&QUERY_LAYERS=green_filter_layer_by_user_edition_only%2Cblue_filter_layer_by_user%2Cred_layer_with_no_filter&LAYERS=green_filter_layer_by_user_edition_only%2Cblue_filter_layer_by_user%2Cred_layer_with_no_filter&STYLE=default%2Cdefault%2Cdefault&FEATURE_COUNT=10&FILTER=green_filter_layer_by_user_edition_only:\"gid\" > 0")
.then(r => r.ok ? r.json() : Promise.reject(r))
})

// check features
expect(getFeatureInfo.features).toHaveLength(5)
// check a specific feature
const feature = getFeatureInfo.features[0]
expect(feature.id).not.toBeUndefined()

expect(await page.locator('#newOlMap').screenshot()).toMatchSnapshot('map_connected_as_user_in_group_a.png', {
maxDiffPixels: 500
});
});

test('Popup', async ({ page }) => {
test('Popup with map click', async ({ page }) => {
let getFeatureInfoRequestPromise = page.waitForRequest(request => request.method() === 'POST' && request.postData()?.includes('GetFeatureInfo') === true);

// blue_filter_layer_by_user
Expand Down Expand Up @@ -258,17 +292,34 @@ test.describe('Filter layer data by user - admin', () => {
await page.locator('#dock-close').click();
});

test('GetMap', async ({ page }) => {
// Hide all elements but #newOlMap and its children
await page.$eval("*", el => el.style.visibility = 'hidden');
await page.$eval("#newOlMap, #newOlMap *", el => el.style.visibility = 'visible');
// DISABLED BECAUSE IT IS NOT RELIABLE, MAINTAINABLE AND CAUSES HEADACHE ;-)
// Instead, we use a WMS GetFeatureInfo in JSON format below
// test('GetMap', async ({ page }) => {
// // Hide all elements but #newOlMap and its children
// await page.$eval("*", el => el.style.visibility = 'hidden');
// await page.$eval("#newOlMap, #newOlMap *", el => el.style.visibility = 'visible');

// expect(await page.locator('#newOlMap').screenshot()).toMatchSnapshot('map_connected_as_admin.png', {
// maxDiffPixels: 500
// });
// });

test('WMS GetFeatureInfo JSON', async ({ page }) => {

const getFeatureInfo = await page.evaluate(async () => {
return await fetch("/index.php/lizmap/service?repository=testsrepository&project=filter_layer_by_user&SERVICE=WMS&REQUEST=GetFeatureInfo&VERSION=1.3.0&CRS=EPSG%3A2154&INFO_FORMAT=application%2Fjson&QUERY_LAYERS=green_filter_layer_by_user_edition_only%2Cblue_filter_layer_by_user%2Cred_layer_with_no_filter&LAYERS=green_filter_layer_by_user_edition_only%2Cblue_filter_layer_by_user%2Cred_layer_with_no_filter&STYLE=default%2Cdefault%2Cdefault&FEATURE_COUNT=10&FILTER=green_filter_layer_by_user_edition_only:\"gid\" > 0")
.then(r => r.ok ? r.json() : Promise.reject(r))
})

// check features
expect(getFeatureInfo.features).toHaveLength(7)
// check a specific feature
const feature = getFeatureInfo.features[0]
expect(feature.id).not.toBeUndefined()

expect(await page.locator('#newOlMap').screenshot()).toMatchSnapshot('map_connected_as_admin.png', {
maxDiffPixels: 500
});
});

test('Popup', async ({ page }) => {
test('Popup with map click', async ({ page }) => {
let getFeatureInfoRequestPromise = page.waitForRequest(request => request.method() === 'POST' && request.postData()?.includes('GetFeatureInfo') === true);

// blue_filter_layer_by_user
Expand Down
Loading

2 comments on commit 829b42c

@3liz-bot
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The latest weekly run of end2end "playwright" tests failed with this latest commit on the branch release_3_6 😣

CC @nboisteault and @Gustry, please have a look to the logs. Maybe it's a false positive ?

Visit https://github.com/3liz/lizmap-web-client/actions/runs/10552966709

@3liz-bot
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The latest weekly run of end2end "playwright" tests failed with this latest commit on the branch release_3_6 😣

CC @nboisteault and @Gustry, please have a look to the logs. Maybe it's a false positive ?

Visit https://github.com/3liz/lizmap-web-client/actions/runs/10552966709

Please sign in to comment.