Skip to content

Commit

Permalink
Browse files Browse the repository at this point in the history
CFM-291_Ability_to_filter_members (#181)
* Add GroupMembers filter

* Fixed COU filter by parent

* Add missing function parameters

* fix date calculations

* fix date calculations
Ioannis committed Apr 16, 2024
1 parent e7c9d76 commit 2f093ea
Showing 9 changed files with 198 additions and 80 deletions.
4 changes: 1 addition & 3 deletions app/src/Controller/StandardController.php
@@ -719,9 +719,7 @@ protected function populateAutoViewVars(object $obj=null) {
$modelsName = $this->name;
// $table = the actual table object
$table = $this->$modelsName;
// XXX We assume that all models that load the Tree behavior will
// implement a potentialParents method
$this->set($vvar, $table->potentialParents($this->getCOID()));
$this->set($vvar, $table->getParents($this->getCOID()));
break;
case 'plugin':
$PluginTable = $this->getTableLocator()->get('Plugins');
92 changes: 57 additions & 35 deletions app/src/Lib/Traits/IndexQueryTrait.php
@@ -57,45 +57,65 @@ public function constructGetIndexContains(Query $query): object {
$containClause = $table->getIndexContains();
}

// Examples:
// 1. GET https://example.com/registry-pe/api/v2/people?co_id=2&limit=10&extended=PrimaryName,EmailAddresses,Identifiers
// 2. GET https://example.com/registry-pe/api/v2/people?co_id=2&limit=10&extended=on
// 2. GET https://example.com/registry-pe/api/v2/people?co_id=2&limit=10&extended=all
// 1. GET https://example.com/registry-pe/api/v2/people?co_id=2&limit=10
if($this->request->is('restful')|| $this->request->is('ajax')) {
// Restfull and ajax do not include the IndexContains by default.
$containClause = [];
// Set the extended query param to `on` in order to fetch the indexContains
if(
$this->request->getQuery('extended') &&
filter_var($this->request->getQuery('extended'), FILTER_VALIDATE_BOOLEAN)
) {
$containClause = $table->getIndexContains();
} elseif(
$this->request->getQuery('extended') &&
$this->request->getQuery('extended') === 'all'
) {
// Get all the associated models
$associations = $table->associations();
foreach($associations->getIterator() as $a) {
$containClause = $this->containClauseFromQueryParams();
}

return empty($containClause) ? $query : $query->contain($containClause);
}


/**
* Construct the Contain Clause from the query parameters of an AJAX or REST call
*
* Examples:
* 1. GET https://example.com/registry-pe/api/v2/people?co_id=2&limit=10&extended=PrimaryName,EmailAddresses,Identifiers
* 2. GET https://example.com/registry-pe/api/v2/people?co_id=2&limit=10&extended=on
* 3. GET https://example.com/registry-pe/api/v2/people?co_id=2&limit=10&extended=all
* 4. GET https://example.com/registry-pe/api/v2/people?co_id=2&limit=10
*
* @return array Contain Clause
* @since COmanage Registry v5.0.0
*/
public function containClauseFromQueryParams(): array
{
// $this->name = Models
$modelsName = $this->name;
// $table = the actual table object
$table = $this->$modelsName;

// Restfull and ajax do not include the IndexContains by default.
$containClause = [];
// Set the extended query param to `on` in order to fetch the indexContains
if(
$this->request->getQuery('extended') &&
filter_var($this->request->getQuery('extended'), FILTER_VALIDATE_BOOLEAN)
) {
$containClause = $table->getIndexContains();
} elseif(
$this->request->getQuery('extended') &&
$this->request->getQuery('extended') === 'all'
) {
// Get all the associated models
$associations = $table->associations();
foreach($associations->getIterator() as $a) {
$containClause[] = $a->getName();
}
} elseif (
$this->request->getQuery('extended')
&& \is_string($this->request->getQuery('extended'))
) {
// Get ONLY the associated models requested
$associations = $table->associations();
$containQueryList = str_getcsv($this->request->getQuery('extended'));
foreach($associations->getIterator() as $a) {
if(\in_array($a->getName(), $containQueryList, true)) {
$containClause[] = $a->getName();
}
} elseif (
$this->request->getQuery('extended')
&& \is_string($this->request->getQuery('extended'))
) {
// Get ONLY the associated models requested
$associations = $table->associations();
$containQueryList = str_getcsv($this->request->getQuery('extended'));
foreach($associations->getIterator() as $a) {
if(\in_array($a->getName(), $containQueryList, true)) {
$containClause[] = $a->getName();
}
}
}
}

return empty($containClause) ? $query : $query->contain($containClause);
return $containClause;
}

/**
@@ -115,7 +135,7 @@ public function getIndexQuery(bool $pickerMode = false, array $requestParams = [
// PrimaryLinkTrait
$link = $this->getPrimaryLink(true);
// Initialize the Query Object
$query = $table->find($this->paginate['finder'] ?? 'all');
$query = $table->find();
// Get a pointer to my expressions list
$newexp = $query->newExpr();
// The searchable attributes can have an AND or an OR conjunction. The first one is used from the filtering block
@@ -136,7 +156,8 @@ public function getIndexQuery(bool $pickerMode = false, array $requestParams = [
// Attributes to search for
if(method_exists($table, 'getSearchableAttributes')) {
$searchableAttributes = $table->getSearchableAttributes($this->name,
$this->viewBuilder()->getVar('vv_tz'));
$this->viewBuilder()
->getVar('vv_tz'));

if(!empty($searchableAttributes)) {
$this->set('vv_searchable_attributes', $searchableAttributes);
@@ -165,6 +186,7 @@ public function getIndexQuery(bool $pickerMode = false, array $requestParams = [
}

// Filter results that will occur from the searchable attributes
// TODO: Move to its own function
if($pickerMode) {
// Get only the active People
// XXX Perhaps we need to make this a configuration
99 changes: 71 additions & 28 deletions app/src/Lib/Traits/SearchFilterTrait.php
@@ -29,6 +29,7 @@

namespace App\Lib\Traits;

use Bake\Utility\Model\AssociationFilter;
use Cake\Database\Expression\QueryExpression;
use Cake\Http\ServerRequest;
use Cake\ORM\Query;
@@ -60,21 +61,49 @@ public function addJoins(Query $query, string $attribute, ServerRequest $request
return $query;
}

$changelog_fk = strtolower(Inflector::underscore($this->searchFilters[$attribute]['model'])) . '_id';
$fk = strtolower(Inflector::underscore(Inflector::singularize($this->_alias))) . '_id';
$mtable_name = Inflector::tableize(Inflector::pluralize($this->searchFilters[$attribute]['model']));
$mtable_alias = Inflector::pluralize($this->searchFilters[$attribute]['model']);

return $query->join([$mtable_alias => [
'table' => $mtable_name,
'conditions' => [
$mtable_alias . '.' . $fk . '=' . $this->_alias . '.id',
// XXX Moved to changelong Behavior
// $mtable_alias . '.' . 'deleted IS NOT TRUE',
// $mtable_alias . '.' . $changelog_fk . ' IS NULL'
],
'type' => $joinType
]]);
$parentTable = $this->_alias;
$joinAssociations = [];
// Iterate over the dot notation and add the joins in the correct order
// People.Names
foreach (explode('.', $this->searchFilters[$attribute]['model']) as $associationsdModel) {
$mtable_name = Inflector::tableize(Inflector::pluralize($associationsdModel));
$mtable_alias = Inflector::pluralize($associationsdModel);

$AssociationFilter = new AssociationFilter();
$associatedModel = $AssociationFilter->filterAssociations($this->fetchTable($associationsdModel));
$relation = null;
$conditions = [];
if(isset($associatedModel['HasOne'])
&& !empty($associatedModel['HasOne'][$parentTable])
) {
$relation = $associatedModel['HasOne'][$parentTable];
$conditions[] = $relation['alias'] . '.' . $relation['foreignKey'] . '=' . $mtable_alias . '.id';
} elseif(isset($associatedModel['HasMany'])
&& !empty($associatedModel['HasMany'][$parentTable])
) {
$relation = $associatedModel['HasMany'][$parentTable];
$conditions[] = $relation['alias'] . '.' . $relation['foreignKey'] . '=' . $mtable_alias . '.id';
} elseif(isset($associatedModel['BelongsTo'])
&& !empty($associatedModel['BelongsTo'][$parentTable])
) {
$relation = $associatedModel['BelongsTo'][$parentTable];
$conditions[] = $relation['alias'] . '.id' . '=' . $mtable_alias . '.' . $relation['foreignKey'];
}

$joinAssociations[$mtable_alias] = [
'table' => $mtable_name,
'conditions' => $conditions,
'type' => $joinType
];

$parentTable = $associationsdModel;
}


return $query->join($joinAssociations);
// XXX We can not use the inenerJoinWith since it applies EagerLoading and includes all the fields which
// causes problems
// return $query->innerJoinWith($this->searchFilters[$attribute]['model']);
}

/**
@@ -93,14 +122,19 @@ public function constructDateComparisonClause(QueryExpression $exp, string $attr
if (empty($dates[0]) && empty($dates[1])) {
return $exp;
}
// The starts_at is non-empty. So the data should be greater than the starts_at date
if (!empty($dates[0]) && empty($dates[1])) {
return $exp->gte("'" . FrozenTime::parse($dates[0]) . "'", $attributeWithModelPrefix);
}
// The ends_at is non-empty. So the data should be less than the ends_at date
if (!empty($dates[1])
&& empty($dates[0])) {
return $exp->lte("'" . FrozenTime::parse($dates[1]) . "'", $attributeWithModelPrefix);

// The starts_at is empty or the ends_at is empty
if (
(!empty($dates[0]) && empty($dates[1]))
||
(empty($dates[0]) && !empty($dates[1]))
) {
$date = empty($dates[0]) ? $dates[1] : $dates[0];
if(str_contains($attributeWithModelPrefix, 'valid_from')) {
return $exp->gte($attributeWithModelPrefix, FrozenTime::parse($date));
} elseif(str_contains($attributeWithModelPrefix, 'valid_through')) {
return $exp->lte($attributeWithModelPrefix, FrozenTime::parse($date));
}
}

return $exp->between($attributeWithModelPrefix, "'" . $dates[0] . "'", "'" . $dates[1] . "'");
@@ -125,19 +159,28 @@ public function expressionsConstructor(Query $query, QueryExpression $exp, strin
}

// Prepend the Model name to the attribute
$attributeWithModelPrefix = isset($this->searchFilters[$attribute]['model']) ?
Inflector::pluralize($this->searchFilters[$attribute]['model']) . '.' . $attribute :
$this->_alias . '.' . $attribute;
$modelPrefix = $this->_alias;
if(isset($this->searchFilters[$attribute]['model'])) {
$associationNamesPath = explode('.', $this->searchFilters[$attribute]['model']);
$modelPrefix = Inflector::pluralize(end($associationNamesPath));
}

$attributeWithModelPrefix = $modelPrefix . '.' . $attribute;


$search = $q;
// Use the `lower` function to apply uniformity for the search
$lower = $query->func()->lower([$attributeWithModelPrefix => 'identifier']);

// XXX Strings and Enums are not treated the same. Enums require an exact match but strings
// are partially/non-case sensitive matched
return match ($this->searchFilters[$attribute]['type']) {
'string' => $exp->like($lower, strtolower('%' . $search . '%')),
'integer', 'boolean' => $exp->add([$attributeWithModelPrefix => $search]),
'integer',
'boolean',
'parent' => $exp->add([$attributeWithModelPrefix => $search]),
'date' => $exp->add([$attributeWithModelPrefix => FrozenTime::parseDate($search, 'y-M-d')]),
'timestamp' => $this->constructDateComparisonClause($search),
'timestamp' => $this->constructDateComparisonClause($exp, $attributeWithModelPrefix, $search),
default => $exp->eq($lower, strtolower($search))
};
}
19 changes: 15 additions & 4 deletions app/src/Lib/Traits/TableMetaTrait.php
@@ -73,7 +73,6 @@ protected function filterMetadataFields() {
'revision',
'lft', // XXX For now i skip lft.rght column for tree structures
'rght',
// 'parent_id', // todo: We need to filter using the parent_id. This should be an enumerator and should apply for all the models that use TreeBehavior
'api_key',
// XXX maybe replace this with a regex, source_*_id?
'source_ad_hoc_attribute_id',
@@ -90,12 +89,24 @@ protected function filterMetadataFields() {

$newa = array();
foreach($coltype as $clmn => $type) {
if(in_array($clmn, $meta_fields,true)) {
// XXX We need to check if the type is an enum or plain string. The enum is a string
// but during filtering we do not use like but eq
// If required we can treat enum types as string types

$fType = $type;
// XXX Cakephp Inflector's camel-case function returns a Pascal case string while the variable function
// returns a camel-case string
$viewVarsKey = Inflector::variable(Inflector::pluralize($clmn));
if(isset($this->getAutoViewVars()[$viewVarsKey]['type'])) {
$fType = $this->getAutoViewVars()[$viewVarsKey]['type'];
}

if(\in_array($clmn, $meta_fields, true)) {
// Move the value to metadata
$newa['meta'][$clmn] = $type;
$newa['meta'][$clmn] = $fType;
} else {
// Just copy the value
$newa[$clmn] = $type;
$newa[$clmn] = $fType;
}
}

31 changes: 29 additions & 2 deletions app/src/Model/Table/CousTable.php
@@ -29,6 +29,8 @@

namespace App\Model\Table;

use App\Lib\Enum\StatusEnum;
use Cake\Database\Expression\QueryExpression;
use Cake\ORM\Query;
use Cake\ORM\RulesChecker;
use Cake\ORM\Table;
@@ -92,8 +94,9 @@ public function initialize(array $config): void {
$this->setRequiresCO(true);

$this->setAutoViewVars([
'parent_ids' => [
'type' => 'parent'
'parentIds' => [
'type' => 'parent' // Even though the type is parent we refer to the parent_id
// which is an integer
]
]);

@@ -132,6 +135,30 @@ public function buildRules(RulesChecker $rules): RulesChecker {

return $rules;
}

/**
* Get the Parent COU list(Suitable for dropdown)
*
* @param int $coId CO ID
*
* @return array List of [id, name] Parent COUs
* @since COmanage Registry v5.0.0
*/
public function getParents(int $coId): array
{
$subquery = $this->find();
$subquery = $subquery->where(['co_id' => $coId])
->where(fn(QueryExpression $exp, Query $subquery) => $exp->isNotNull('parent_id'))
->select(['parent_id'])
->distinct();

$query = $this->find('list')
->where(fn(QueryExpression $exp, Query $query) => $exp->in('id', $subquery))
->distinct()
->select(['id', 'name']);
$results = $query->toArray();
return $results;
}

/**
* Callback after model save.
18 changes: 17 additions & 1 deletion app/src/Model/Table/GroupMembersTable.php
@@ -52,7 +52,8 @@ class GroupMembersTable extends Table {
use \App\Lib\Traits\QueryModificationTrait;
use \App\Lib\Traits\TableMetaTrait;
use \App\Lib\Traits\ValidationTrait;

use \App\Lib\Traits\SearchFilterTrait;

/**
* Provide the default layout
*
@@ -127,6 +128,21 @@ public function initialize(array $config): void {
'index' => ['platformAdmin', 'coAdmin']
]
]);

$this->setFilterConfig([
'family' => [
'type' => 'relatedModel',
'model' => 'People.Names',
'active' => true,
'order' => 2
],
'given' => [
'type' => 'relatedModel',
'model' => 'People.Names',
'active' => true,
'order' => 1
],
]);
}

/**
5 changes: 3 additions & 2 deletions app/src/Model/Table/GroupsTable.php
@@ -133,7 +133,8 @@ public function initialize(array $config): void {
// For a regular group, the Owners Group
'OwnersGroup'
]);


// XXX Also used by SearchBlocks
$this->setAutoViewVars([
'statuses' => [
'type' => 'enum',
@@ -148,7 +149,7 @@ public function initialize(array $config): void {
$this->setFilterConfig([
'identifier' => [
'type' => 'relatedModel',
'model' => 'Identifier',
'model' => 'Identifiers',
'active' => true,
'order' => 4
]
8 changes: 4 additions & 4 deletions app/src/Model/Table/PeopleTable.php
@@ -165,25 +165,25 @@ public function initialize(array $config): void {
$this->setFilterConfig([
'family' => [
'type' => 'relatedModel',
'model' => 'Name',
'model' => 'Names',
'active' => true,
'order' => 2
],
'given' => [
'type' => 'relatedModel',
'model' => 'Name',
'model' => 'Names',
'active' => true,
'order' => 1
],
'mail' => [
'type' => 'relatedModel',
'model' => 'EmailAddress',
'model' => 'EmailAddresses',
'active' => true,
'order' => 3
],
'identifier' => [
'type' => 'relatedModel',
'model' => 'Identifier',
'model' => 'Identifiers',
'active' => true,
'order' => 4
],
2 changes: 1 addition & 1 deletion app/templates/element/filter.php
@@ -89,7 +89,7 @@
$hasActiveFilters = false;
?>

<div id="<?= $modelName . ucfirst($this->request->getParam('action')); ?>Search" class="top-filters">
<div id="<?= $modelName . ucfirst($this->request->getParam('action')) ?>Search" class="top-filters">
<fieldset>
<legend id="top-filters-toggle">
<em class="material-icons top-filters-search-icon" aria-hidden="true">search</em>

0 comments on commit 2f093ea

Please sign in to comment.