From 2f093eaf4ec44182d78af020d2d9810287e86eda Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos <ioigoume@gmail.com> Date: Tue, 16 Apr 2024 14:03:18 +0300 Subject: [PATCH] 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 --- app/src/Controller/StandardController.php | 4 +- app/src/Lib/Traits/IndexQueryTrait.php | 92 +++++++++++++-------- app/src/Lib/Traits/SearchFilterTrait.php | 99 ++++++++++++++++------- app/src/Lib/Traits/TableMetaTrait.php | 19 ++++- app/src/Model/Table/CousTable.php | 31 ++++++- app/src/Model/Table/GroupMembersTable.php | 18 ++++- app/src/Model/Table/GroupsTable.php | 5 +- app/src/Model/Table/PeopleTable.php | 8 +- app/templates/element/filter.php | 2 +- 9 files changed, 198 insertions(+), 80 deletions(-) diff --git a/app/src/Controller/StandardController.php b/app/src/Controller/StandardController.php index 9e2df306d..696db82bb 100644 --- a/app/src/Controller/StandardController.php +++ b/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'); diff --git a/app/src/Lib/Traits/IndexQueryTrait.php b/app/src/Lib/Traits/IndexQueryTrait.php index 90912a94d..e94394dd2 100644 --- a/app/src/Lib/Traits/IndexQueryTrait.php +++ b/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 diff --git a/app/src/Lib/Traits/SearchFilterTrait.php b/app/src/Lib/Traits/SearchFilterTrait.php index cbe4fba65..e8d449e66 100644 --- a/app/src/Lib/Traits/SearchFilterTrait.php +++ b/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)) }; } diff --git a/app/src/Lib/Traits/TableMetaTrait.php b/app/src/Lib/Traits/TableMetaTrait.php index c2a80da6f..2a41883d0 100644 --- a/app/src/Lib/Traits/TableMetaTrait.php +++ b/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; } } diff --git a/app/src/Model/Table/CousTable.php b/app/src/Model/Table/CousTable.php index b340696a5..78dada52d 100644 --- a/app/src/Model/Table/CousTable.php +++ b/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. diff --git a/app/src/Model/Table/GroupMembersTable.php b/app/src/Model/Table/GroupMembersTable.php index b93f803b9..a35fdd5ef 100644 --- a/app/src/Model/Table/GroupMembersTable.php +++ b/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 + ], + ]); } /** diff --git a/app/src/Model/Table/GroupsTable.php b/app/src/Model/Table/GroupsTable.php index c7038af5f..01f351f25 100644 --- a/app/src/Model/Table/GroupsTable.php +++ b/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 ] diff --git a/app/src/Model/Table/PeopleTable.php b/app/src/Model/Table/PeopleTable.php index 6d13c9aba..e39220db6 100644 --- a/app/src/Model/Table/PeopleTable.php +++ b/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 ], diff --git a/app/templates/element/filter.php b/app/templates/element/filter.php index f01116d52..d7a6226d9 100644 --- a/app/templates/element/filter.php +++ b/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>