From be76a40580a1a704bb84c0d5783f573cc947d50f Mon Sep 17 00:00:00 2001 From: Ioannis Igoumenos Date: Mon, 22 Jul 2024 19:36:51 +0300 Subject: [PATCH] Fix the parent breadcrumb of an External Identity Source Record --- .../src/Model/Table/FileSourcesTable.php | 6 +- app/src/Controller/AppController.php | 371 ++++++++++-------- .../Component/BreadcrumbComponent.php | 10 +- .../ExtIdentitySourceRecordsController.php | 16 +- app/src/Lib/Traits/PrimaryLinkTrait.php | 2 +- .../Table/ExtIdentitySourceRecordsTable.php | 4 +- .../Model/Table/ExternalIdentitiesTable.php | 1 + 7 files changed, 242 insertions(+), 168 deletions(-) diff --git a/app/availableplugins/FileConnector/src/Model/Table/FileSourcesTable.php b/app/availableplugins/FileConnector/src/Model/Table/FileSourcesTable.php index 6861ffc63..c51a21307 100644 --- a/app/availableplugins/FileConnector/src/Model/Table/FileSourcesTable.php +++ b/app/availableplugins/FileConnector/src/Model/Table/FileSourcesTable.php @@ -449,9 +449,11 @@ public function search( while(($data = fgetcsv($handle)) !== false) { // strtolower, previous behavior was full string only so dupe that - $match = array_search(strtolower($searchAttrs['q']), array_map('strtolower', $data)); + $match = collection($data) + ->map(fn($value, $key) => strtolower($value ?? '')) + ->filter(fn($item, $key) => strtolower($searchAttrs['q']) === $item); - if($match !== false) { + if($match->count() > 0) { // $match will be the CSV column that matched, but for now we ignore that // since we just need to know that the row matched somewhere. Note the first // column is always the SORID. diff --git a/app/src/Controller/AppController.php b/app/src/Controller/AppController.php index e190ff1ab..ade7ce791 100644 --- a/app/src/Controller/AppController.php +++ b/app/src/Controller/AppController.php @@ -44,6 +44,7 @@ use Cake\ORM\TableRegistry; use Cake\Utility\Hash; use InvalidArgumentException; +use JsonSchema\Iterator\ObjectIterator; class AppController extends Controller { use \App\Lib\Traits\LabeledLogTrait; @@ -187,12 +188,12 @@ public function getCO(): ?\App\Model\Entity\Co { // We'll return null if no CO, since some contexts may need to know that return $this->cur_co; } - + /** * Get the current CO ID. * + * @return int|null CO ID, or null * @since COmanage Registry v5.0.0 - * @return int CO ID, or null */ public function getCOID(): ?int { @@ -201,6 +202,178 @@ public function getCOID(): ?int { return $cur_co ? $cur_co->id : null; } + /** + * @param string $potentialPrimaryLink + * + * @return Object|bool + * @since COmanage Registry v5.0.0 + */ + + protected function primaryLinkOnGet(string $potentialPrimaryLink): Object|bool + { + // $this->name = Models + $modelsName = $this->name; + + // If this action allows unkeyed, asserted primary link IDs, check the query + // string (e.g.: 'add' or 'index' allow matchgrid_id to be passed in) + $actionParam = $this->request->getParam('action'); + $allowsUnkeyed = $this->$modelsName->allowUnkeyedPrimaryLink($actionParam); + $allowsLookup = $this->$modelsName->allowLookupPrimaryLink($actionParam); + $param = (int)$this->request->getParam('pass.0'); + + if($allowsUnkeyed) { + $query = $this->request->getQuery(); + if($query) { + $this->cur_pl->value = $this->request->getQuery($potentialPrimaryLink); + return false; + } + } + + if(!$allowsLookup || empty($param)) { + return false; + } + + return $this->$modelsName->findPrimaryLink($param); + } + + /** + * @param string $potentialPrimaryLink + * + * @return Object|bool + * @since COmanage Registry v5.0.0 + * + */ + + protected function primaryLinkOnPost(string $potentialPrimaryLink): Object|bool + { + // $this->name = Models + $modelsName = $this->name; + + // Post = add, where we can have a list of objects and nothing in /objects/{id} + // We don't support different primary links across objects, so we throw an error + // if different parent keys are provided. + + $linkValue = null; + + // Data in API format + $reqData = $this->request->getData($modelsName); + + if(!$reqData + // Don't create $reqData if the POST data is also empty + && !empty($this->request->getData())) { + // Data in POST format + $reqData[] = $this->request->getData(); + } + + if(!empty($reqData)) { + foreach($reqData as $rec) { + if(!empty($rec[$potentialPrimaryLink])) { + if(!$linkValue) { + // This is the first record we've seen, use this primary link value + $linkValue = $rec[$potentialPrimaryLink]; + } elseif($linkValue != $rec[$potentialPrimaryLink]) { + // We don't support multiple records with different parents + throw new \InvalidArgumentException(__d('error', 'primary_link.mismatch')); + } + } + + $this->cur_pl->value = $linkValue; + } + } + + // If we didn't find the primary link in the submitted form or API + // request, it might be available via the URL. + + if(!$linkValue + && $this->$modelsName->allowLookupPrimaryLink($this->request->getParam('action'))) { + // Try to map the requested object ID (this is probably a delete, so no attribute in post body) + $param = (int)$this->request->getParam('pass.0'); + + if(!empty($param)) { + return $this->$modelsName->findPrimaryLink($param); + } + } + + return false; + } + + /** + * @return Object|bool + * @since COmanage Registry v5.0.0 + * + */ + + protected function primaryLinkOnPut(): Object|bool + { + // $this->name = Models + $modelsName = $this->name; + $param = (int)$this->request->getParam('pass.0'); + + // Put = edit, so we should look up the parent ID via the object itself + if ( + empty($param) + || + !$this->$modelsName->allowLookupPrimaryLink($this->request->getParam('action')) + ) { + return false; + } + + return $this->$modelsName->findPrimaryLink($param); + } + + /** + * @return void + */ + protected function primaryLinkLookup(): void + { + // $this->name = Models + $modelsName = $this->name; + $availablePrimaryLinks = $this->$modelsName->getPrimaryLinks(); + + foreach($availablePrimaryLinks as $potentialPrimaryLink) { + // $potentialPrimaryLink will be something like 'attribute_collector_id' + // $potentialPrimaryLinkTable will be something like 'CoreEnroller.AttributeCollectors' + $potentialPrimaryLinkTable = $this->$modelsName->getPrimaryLinkTableName($potentialPrimaryLink); + + $cur = match(true) { + $this->request->is('get') => $this->primaryLinkOnGet($potentialPrimaryLink), + ($this->request->is('post') && $this->request->getParam('action') != 'delete') => $this->primaryLinkOnPost($potentialPrimaryLink), + ($this->request->is('put') || $this->request->getParam('action') == 'delete') => $this->primaryLinkOnPut(), + default => false, + }; + + if($cur !== false) { + $this->cur_pl = $cur; + $this->set('vv_primary_link', $this->cur_pl->attr); + break; + } + + if(!empty($this->cur_pl->value)) { + // For looking up values in records here, we want only the attribute + // itself and not the plugin name (used for hacky notation by + // PrimaryLinkTrait::setPrimaryLink(). Note this is a field and not + // a model, but pluginModel() gets us the bit we need. + + // Store the plugin for possible later reference. + $potentialPlugin = str_contains($potentialPrimaryLinkTable, '.') + ? StringUtilities::pluginPlugin($potentialPrimaryLinkTable) + : null; + + // We found a populated primary link. Store the attribute and break the loop. + $this->cur_pl->attr = $potentialPrimaryLink; + if($potentialPlugin) { + $this->cur_pl->plugin = $potentialPlugin; + } + $this->set('vv_primary_link', $this->cur_pl->attr); + break; + } + } + + if(empty($this->cur_pl->value) && !$this->$modelsName->allowEmptyPrimaryLink($this->request->getParam('action'))) { + throw new \RuntimeException(__d('error', 'primary_link')); + } + } + /** * Obtain information about the Standard Object's Primary Link, if set. * The $vv_primary_link view variable is also set. @@ -210,173 +383,57 @@ public function getCOID(): ?int { * @return object Object holding the primary link attribute, and optionally its value * @throws \RuntimeException */ - + public function getPrimaryLink(bool $lookup=false) { // Did we already figure this out? (But only if $lookup) if($lookup && isset($this->cur_pl->value)) { return $this->cur_pl; } - + // $this->name = Models $modelsName = $this->name; - // $modelName = Model - $modelName = \Cake\Utility\Inflector::singularize($modelsName); $this->cur_pl = new \stdClass(); - + + if(!(method_exists($this->$modelsName, 'getPrimaryLinks') + && $this->$modelsName->getPrimaryLinks()) + ) { + return $this->cur_pl; + } + // PrimaryLinkTrait - if(method_exists($this->$modelsName, "getPrimaryLinks") - && $this->$modelsName->getPrimaryLinks()) { - // Some models, in particular MVEAs, can have multiple potential primary - // links. In these cases, only one primary link is valid at a time, so we - // have to look through the available primary links and find one. - - $availablePrimaryLinks = $this->$modelsName->getPrimaryLinks(); - - if($lookup) { - foreach($availablePrimaryLinks as $potentialPrimaryLink) { - // $potentialPrimaryLink will be something like 'attribute_collector_id' - // $potentialPrimaryLinkTable will be something like 'CoreEnroller.AttributeCollectors' - $potentialPrimaryLinkTable = $this->$modelsName->getPrimaryLinkTableName($potentialPrimaryLink); - $potentialPlugin = null; - - // Try to find a value - - if(strstr($potentialPrimaryLinkTable, '.')) { - // For looking up values in records here, we want only the attribute - // itself and not the plugin name (used for hacky notation by - // PrimaryLinkTrait::setPrimaryLink(). Note this is a field and not - // a model, but pluginModel() gets us the bit we need. - - // Store the plugin for possible later reference. - $potentialPlugin = StringUtilities::pluginPlugin($potentialPrimaryLinkTable); - } - - if($this->request->is('get')) { - // If this action allows unkeyed, asserted primary link IDs, check the query - // string (eg: 'add' or 'index' allow matchgrid_id to be passed in) - if($this->$modelsName->allowUnkeyedPrimaryLink($this->request->getParam('action')) - && $this->request->getQuery()) { - $this->cur_pl->value = $this->request->getQuery($potentialPrimaryLink); - } elseif($this->$modelsName->allowLookupPrimaryLink($this->request->getParam('action'))) { - // Try to map the requested object ID - $param = (int)$this->request->getParam('pass.0'); - - if(!empty($param)) { - $this->cur_pl = $this->$modelsName->findPrimaryLink($param); - // Break the loop here since we also have the link attribute, - // which might not be $potentialPrimaryLink - $this->set('vv_primary_link', $this->cur_pl->attr); - break; - } - } - } elseif($this->request->is('post') && $this->request->getParam('action') != 'delete') { - // Post = add, where we can have a list of objects and nothing in /objects/{id} - // We don't support different primary links across objects, so we throw an error - // if different parent keys are provided. - - $linkValue = null; - - // Data in API format - $reqData = $this->request->getData($modelsName); - - if(!$reqData - // Don't create $reqData if the POST data is also empty - && !empty($this->request->getData())) { - // Data in POST format - $reqData[] = $this->request->getData(); - } - - if(!empty($reqData)) { - foreach($reqData as $rec) { - if(!empty($rec[$potentialPrimaryLink])) { - if(!$linkValue) { - // This is the first record we've seen, use this primary link value - $linkValue = $rec[$potentialPrimaryLink]; - } elseif($linkValue != $rec[$potentialPrimaryLink]) { - // We don't support multiple records with different parents - throw new \InvalidArgumentException(__d('error', 'primary_link.mismatch')); - } - } - - $this->cur_pl->value = $linkValue; - } - } - - // If we didn't find the primary link in the submitted form or API - // request, it might be available via the URL. - - if(!$linkValue - && $this->$modelsName->allowLookupPrimaryLink($this->request->getParam('action'))) { - // Try to map the requested object ID (this is probably a delete, so no attribute in post body) - $param = (int)$this->request->getParam('pass.0'); - - if(!empty($param)) { - $this->cur_pl = $this->$modelsName->findPrimaryLink($param); - // Break the loop here since we also have the link attribute, - // which might not be $potentialPrimaryLink - $this->set('vv_primary_link', $this->cur_pl->attr); - break; - } - } - } elseif($this->request->is('put') || $this->request->getParam('action') == 'delete') { - // Put = edit, so we should look up the parent ID via the object itself - if($this->$modelsName->allowLookupPrimaryLink($this->request->getParam('action'))) { - // Try to map the requested object ID (this is probably a delete, so no attribute in post body) - $param = (int)$this->request->getParam('pass.0'); - - if(!empty($param)) { - $this->cur_pl = $this->$modelsName->findPrimaryLink($param); - // Break the loop here since we also have the link attribute, - // which might not be $potentialPrimaryLink - $this->set('vv_primary_link', $this->cur_pl->attr); - break; - } - } - } - - if(!empty($this->cur_pl->value)) { - // We found a populated primary link. Store the attribute and break the loop. - $this->cur_pl->attr = $potentialPrimaryLink; - if($potentialPlugin) { - $this->cur_pl->plugin = $potentialPlugin; - } - $this->set('vv_primary_link', $this->cur_pl->attr); - break; - } - } - - if(empty($this->cur_pl->value) - && !$this->$modelsName->allowEmptyPrimaryLink($this->request->getParam('action'))) { - throw new \RuntimeException(__d('error', 'primary_link')); + // Some models, in particular MVEAs, can have multiple potential primary + // links. In these cases, only one primary link is valid at a time, so we + // have to look through the available primary links and find one. + + if($lookup) { + $this->primaryLinkLookup(); + } + + if(!empty($this->cur_pl->value)) { + // Look up the link value to find the related entity + + $linkTableName = $this->$modelsName->getPrimaryLinkTableName($this->cur_pl->attr); + $linkTable = $this->getTableLocator()->get($linkTableName); + + $this->set('vv_primary_link_model', $linkTableName); + + try { + $plObj = $linkTable->findById($this->cur_pl->value)->firstOrFail(); + + $this->set('vv_primary_link_obj', $plObj); + + // While we're here, note the CO since we'll probably need it soon + if(!empty($plObj->co_id)) { + $this->cur_pl->co_id = $plObj->co_id; + } elseif(method_exists($linkTable, 'findCoForRecord')) { + $this->cur_pl->co_id = $linkTable->findCoForRecord((int)$this->cur_pl->value); } } - - if(!empty($this->cur_pl->value)) { - // Look up the link value to find the related entity - - $linkTableName = $this->$modelsName->getPrimaryLinkTableName($this->cur_pl->attr); - $linkTable = $this->getTableLocator()->get($linkTableName); - - $this->set('vv_primary_link_model', $linkTableName); - - try { - $plObj = $linkTable->findById($this->cur_pl->value)->firstOrFail(); - - $this->set('vv_primary_link_obj', $plObj); - - // While we're here, note the CO since we'll probably need it soon - if(!empty($plObj->co_id)) { - $this->cur_pl->co_id = $plObj->co_id; - } elseif(method_exists($linkTable, "findCoForRecord")) { - $this->cur_pl->co_id = $linkTable->findCoForRecord((int)$this->cur_pl->value); - } - } - catch(RecordNotFoundException $e) { - $this->llog('error', "Could not find value '" . $this->cur_pl->value . "' for primary link object " . $linkTableName); - // Mask this with a generic UnauthorizedException - throw new UnauthorizedException(__d('error', 'perm')); - } + catch(RecordNotFoundException $e) { + $this->llog('error', "Could not find value '" . $this->cur_pl->value . "' for primary link object " . $linkTableName); + // Mask this with a generic UnauthorizedException + throw new UnauthorizedException(__d('error', 'perm')); } } diff --git a/app/src/Controller/Component/BreadcrumbComponent.php b/app/src/Controller/Component/BreadcrumbComponent.php index ec500e4af..16a3a99d7 100644 --- a/app/src/Controller/Component/BreadcrumbComponent.php +++ b/app/src/Controller/Component/BreadcrumbComponent.php @@ -57,11 +57,11 @@ class BreadcrumbComponent extends Component // Don't render the parent links protected $skipParentPaths = []; // Inject parent links (these render before the index link, if set) - // The parent links are constructed as part of the injectPrimaryLink function. This in the StandardController as well - // as in the StandardPluginController, MVEAController, ProvisioningHistoryRecordController, etc. These controllers are - // a descendant from the StandardController we will calculate the Parents twice. In order to avoid duplicates the - // injectParents table has to be an associative array. The uniqueness of the key will preserve the uniqueness of the parent - // path while the order of firing will create the correct breadcumb path order + // The parent links are constructed as part of the injectPrimaryLink function, as well as in the StandardController and + // in the StandardPluginController, MVEAController, ProvisioningHistoryRecordController, etc. These controllers are + // a descendant from the StandardController we will calculate the Parents twice. To avoid duplicates, the + // injectParents table has to be an associative array. The uniqueness of the key will preserve the uniqueness of + // the parent path while the order of firing will create the correct breadcrumb path order protected $injectParents = []; // Inject title links (immediately before the title breadcrumb) protected $injectTitleLinks = []; diff --git a/app/src/Controller/ExtIdentitySourceRecordsController.php b/app/src/Controller/ExtIdentitySourceRecordsController.php index 5f07920d1..7f24d4fd1 100644 --- a/app/src/Controller/ExtIdentitySourceRecordsController.php +++ b/app/src/Controller/ExtIdentitySourceRecordsController.php @@ -33,10 +33,24 @@ use Cake\Log\Log; use Cake\ORM\TableRegistry; -class ExtIdentitySourceRecordsController extends StandardController { +class ExtIdentitySourceRecordsController extends MVEAController { public $paginate = [ 'order' => [ 'ExtIdentitySourceRecords.id' => 'asc' ] ]; + + /** + * Perform Controller initialization. + * + * @since COmanage Registry v5.0.0 + */ + + public function initialize(): void + { + parent::initialize(); + + // Configure breadcrumb rendering + $this->Breadcrumb->skipParents(['/^\/ext-identity-source-records/']); + } } \ No newline at end of file diff --git a/app/src/Lib/Traits/PrimaryLinkTrait.php b/app/src/Lib/Traits/PrimaryLinkTrait.php index f0360853d..41e4751e6 100644 --- a/app/src/Lib/Traits/PrimaryLinkTrait.php +++ b/app/src/Lib/Traits/PrimaryLinkTrait.php @@ -464,7 +464,7 @@ public function setCurCoId(int $coId) { } /** - * Set the primary link attribute. Several formats are acceepted: + * Set the primary link attribute. Several formats are accepted: * * 1: ('person_id'): Primary link is to People * 2: (['person_id', 'group_id']): Primary link can be to People _or_ Groups diff --git a/app/src/Model/Table/ExtIdentitySourceRecordsTable.php b/app/src/Model/Table/ExtIdentitySourceRecordsTable.php index b14d55211..7a81c2aac 100644 --- a/app/src/Model/Table/ExtIdentitySourceRecordsTable.php +++ b/app/src/Model/Table/ExtIdentitySourceRecordsTable.php @@ -71,11 +71,11 @@ public function initialize(array $config): void { $this->setDisplayField('source_key'); - $this->setPrimaryLink(['external_identity_source_id']); + $this->setPrimaryLink(['external_identity_source_id', 'external_identity_id']); $this->setRequiresCO(true); // These are required for the link to work from the Artifacts page - $this->setAllowUnkeyedPrimaryCO(['index']); + $this->setAllowUnkeyedPrimaryCO(['index', 'view']); $this->setAllowEmptyPrimaryLink(['index']); $this->setIndexContains([ diff --git a/app/src/Model/Table/ExternalIdentitiesTable.php b/app/src/Model/Table/ExternalIdentitiesTable.php index 62a7cf945..9f945a039 100644 --- a/app/src/Model/Table/ExternalIdentitiesTable.php +++ b/app/src/Model/Table/ExternalIdentitiesTable.php @@ -117,6 +117,7 @@ public function initialize(array $config): void { 'Addresses', 'AdHocAttributes', 'EmailAddresses', + 'ExtIdentitySourceRecords' => ['ExternalIdentitySources'], 'Identifiers', 'Names', //'ExternalIdentityRoles',