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>