diff --git a/app/resources/locales/en_US/enumeration.po b/app/resources/locales/en_US/enumeration.po index 23ba920c9..207110da6 100644 --- a/app/resources/locales/en_US/enumeration.po +++ b/app/resources/locales/en_US/enumeration.po @@ -90,8 +90,8 @@ msgstr "Pending Activation" msgid "ExternalIdentityStatusEnum.S" msgstr "Suspended" -msgid "ExternalIdentityStatusEnum.XP" -msgstr "Expired" +msgid "ExternalIdentityStatusEnum.DX" +msgstr "Deleted" msgid "GroupTypeEnum.MA" msgstr "Active Members" diff --git a/app/resources/locales/en_US/error.po b/app/resources/locales/en_US/error.po index 50c8cfe47..530055559 100644 --- a/app/resources/locales/en_US/error.po +++ b/app/resources/locales/en_US/error.po @@ -229,6 +229,10 @@ msgstr "Page number must be an integer" msgid "perm" msgstr "Permission Denied" +msgid "PersonRoles.valid_from.after" +msgstr "Valid From date must be earlier than Valid Through date" + + msgid "Plugins.inactive" msgstr "The plugin {0} is not active" diff --git a/app/resources/locales/en_US/result.po b/app/resources/locales/en_US/result.po index 6cd04094d..8c9a97080 100644 --- a/app/resources/locales/en_US/result.po +++ b/app/resources/locales/en_US/result.po @@ -48,6 +48,9 @@ msgstr "{0} {1} Deleted: {2}" msgid "edited.mvea" msgstr "{0} {1} Edited: {2}" +msgid "ExternalIdentities.status.recalculated" +msgstr "External Identity status recalculated from {0} to {1}" + msgid "ExternalIdentitySources.synced" msgstr "External Identity Source sync complete" @@ -108,9 +111,21 @@ msgstr "Started via JobCommand by {0} (uid {1})" msgid "People.added.pipeline" msgstr "Created new Person via Pipeline {0} ({1}) using Source {2} ({3}) Key {4}" +msgid "People.status.recalculated" +msgstr "Person status recalculated from {0} to {1}" + +msgid "PersonRoles.status.recalculated" +msgstr "Person Role status recalculated from {0} to {1}" + +msgid "Pipelines.complete" +msgstr "Pipeline {0} complete for EIS {1} source key {2}" + msgid "Pipelines.ei.added" msgstr "Created new External Identity via Pipeline {0} ({1}) using Source {2} ({3}) Key {4}" +msgid "Pipelines.started" +msgstr "Pipeline {0} started for EIS {1} source key {2}" + msgid "saved" msgstr "Saved" diff --git a/app/src/Controller/ApiV2Controller.php b/app/src/Controller/ApiV2Controller.php index e5a39ccb1..e40f30841 100644 --- a/app/src/Controller/ApiV2Controller.php +++ b/app/src/Controller/ApiV2Controller.php @@ -97,7 +97,7 @@ public function add() { $results[] = ['id' => $obj->id]; // Trigger provisioning, letting errors bubble up (AR-GMR-5) - if(method_exists($table, "requestProvisioning")) { + if(method_exists($this->modelsName, "requestProvisioning")) { $this->llog('rule', "AR-GMR-5 Requesting provisioning for $modelsName " . $obj->id); $table->requestProvisioning(id: $obj->id, context: ProvisioningContextEnum::Automatic); } diff --git a/app/src/Controller/AppController.php b/app/src/Controller/AppController.php index f8714ae4d..4e49f846f 100644 --- a/app/src/Controller/AppController.php +++ b/app/src/Controller/AppController.php @@ -33,6 +33,7 @@ use App\Lib\Events\ChangelogEventListener; use App\Lib\Events\CoIdEventListener; use App\Lib\Events\RuleBuilderEventListener; +use App\Lib\Util\StringUtilities; use Cake\Controller\Controller; use Cake\Core\Configure; use Cake\Datasource\Exception; @@ -219,6 +220,20 @@ public function getPrimaryLink(bool $lookup=false) { if($lookup) { foreach($availablePrimaryLinks as $potentialPrimaryLink) { // Try to find a value + + if(strstr($potentialPrimaryLink, '.')) { + // 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($potentialPrimaryLink); + + // We clobber $potentialPrimaryLink to avoid rewriting a bunch of code, + // but probably we should rewrite it. + $potentialPrimaryLink = StringUtilities::pluginModel($potentialPrimaryLink); + } if($this->request->is('get')) { // If this action allows unkeyed, asserted primary link IDs, check the query @@ -269,7 +284,13 @@ public function getPrimaryLink(bool $lookup=false) { $this->cur_pl->value = $linkValue; } - } elseif($this->$modelsName->allowLookupPrimaryLink($this->request->getParam('action'))) { + } + + // 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'); @@ -439,40 +460,48 @@ protected function setCO() { // Nothing to do... return; } - - // $this->name = Models, unless we're in an API call - $modelsName = $this->name; - - $attrs = $this->request->getAttributes(); - - // Unlike Match, where the Matchgrid is embedded in the request API URL, - // Registry API calls are more similar to UI calls, where we may or may - // not be able to find the CO ID directly in the URL. - if($this->request->is('restful') - && !empty($attrs['params']['model'])) { - $modelsName = \Cake\Utility\Inflector::camelize($attrs['params']['model']); - $this->$modelsName = TableRegistry::getTableLocator()->get($modelsName); - } - - if(!method_exists($this->$modelsName, "requiresCO") - || !$this->$modelsName->requiresCO()) { - // Nothing to do, CO not required by this model/controller - return; - } - - // Not all models have CO as their primary link. This will also - // trigger setting of the viewVar for breadcrumbs and anything else. - $link = $this->getPrimaryLink(true); - + // Try to find the requested CO $coid = null; - // getPrimaryLink has already done our work - if($link->attr == 'co_id') { - $coid = $link->value; - } else { - if(!empty($link->co_id)) { - $coid = $link->co_id; + if(method_exists($this, 'calculateRequestedCOID')) { + // This controller implements special logic + + $coid = $this->calculateRequestedCOID(); + } + + if(!$coid) { + // $this->name = Models, unless we're in an API call + $modelsName = $this->name; + + $attrs = $this->request->getAttributes(); + + // Unlike Match, where the Matchgrid is embedded in the request API URL, + // Registry API calls are more similar to UI calls, where we may or may + // not be able to find the CO ID directly in the URL. + if($this->request->is('restful') + && !empty($attrs['params']['model'])) { + $modelsName = \Cake\Utility\Inflector::camelize($attrs['params']['model']); + $this->$modelsName = TableRegistry::getTableLocator()->get($modelsName); + } + + if(!method_exists($this->$modelsName, "requiresCO") + || !$this->$modelsName->requiresCO()) { + // Nothing to do, CO not required by this model/controller + return; + } + + // Not all models have CO as their primary link. This will also + // trigger setting of the viewVar for breadcrumbs and anything else. + $link = $this->getPrimaryLink(true); + + // getPrimaryLink has already done our work + if($link->attr == 'co_id') { + $coid = $link->value; + } else { + if(!empty($link->co_id)) { + $coid = $link->co_id; + } } } @@ -499,17 +528,21 @@ protected function setCO() { throw new \InvalidArgumentException(__d('error', 'inactive', [__d('controller', 'Cos', [1]), $coid])); } - // We store the CO ID in Configuration to facilitate its access from - // model contexts such as validation where passing the value via the - // Controller is not particularly feasible. + if(!empty($modelsName) && !empty($this->$modelsName)) { + // We store the CO ID in Configuration to facilitate its access from + // model contexts such as validation where passing the value via the + // Controller is not particularly feasible. Note that for API calls + // $modelsName may not be set, so (eg) StandardApiController does + // something similar. - // This only works for the current model, not related models. If/when we - // need to support relatedmodels, we could have setCurCoId() cascade the - // CO to any of its related models that require it, or use the event - // listener approach commented out below. - if(method_exists($this->$modelsName, "acceptsCoId") - && $this->$modelsName->acceptsCoId()) { - $this->$modelsName->setCurCoId((int)$coid); + // This only works for the current model, not related models. If/when we + // need to support relatedmodels, we could have setCurCoId() cascade the + // CO to any of its related models that require it, or use the event + // listener approach commented out below. + if(method_exists($this->$modelsName, "acceptsCoId") + && $this->$modelsName->acceptsCoId()) { + $this->$modelsName->setCurCoId((int)$coid); + } /* This doesn't work for the current model since it has already been initialized, but it could be an option for related models later... diff --git a/app/src/Controller/Component/BreadcrumbComponent.php b/app/src/Controller/Component/BreadcrumbComponent.php index 01c2db4e0..d78e27d22 100644 --- a/app/src/Controller/Component/BreadcrumbComponent.php +++ b/app/src/Controller/Component/BreadcrumbComponent.php @@ -32,6 +32,7 @@ use \Cake\Controller\Component; use \Cake\Event\EventInterface; use \Cake\ORM\TableRegistry; +use \Cake\Utility\Inflector; use \App\Lib\Util\StringUtilities; class BreadcrumbComponent extends Component @@ -179,11 +180,7 @@ public function injectPrimaryLink(object $link) { $modelsName = StringUtilities::foreignKeyToClassName($link->attr); $contain = []; - - if($modelsName == 'People' || $modelsName == 'ExternalIdentities') { - // We need the Primary Name to render it - $contain[] = 'PrimaryName'; - } + $primaryName = null; $linkTable = TableRegistry::getTableLocator()->get($modelsName); $linkObj = $linkTable->get($link->value, ['contain' => $contain]); @@ -203,8 +200,15 @@ public function injectPrimaryLink(object $link) { $label = $linkObj->$displayField; - if(!empty($linkObj->primary_name)) { - $label = $linkObj->primary_name->full_name; + if($modelsName == 'People' || $modelsName == 'ExternalIdentities') { + // We need the Primary Name (or first name found) to render it + + $Names = TableRegistry::getTableLocator()->get('Names'); + + // This will throw an error on failure + $primaryName = $Names->primaryName($linkObj->id, Inflector::underscore(Inflector::singularize($modelsName))); + + $label = $primaryName->full_name; } // If we don't have a visible label use the record ID diff --git a/app/src/Controller/Component/RegistryAuthComponent.php b/app/src/Controller/Component/RegistryAuthComponent.php index c88f073b5..5f7df4a13 100644 --- a/app/src/Controller/Component/RegistryAuthComponent.php +++ b/app/src/Controller/Component/RegistryAuthComponent.php @@ -130,6 +130,41 @@ public function beforeFilter(EventInterface $event) { // Perform authorization check + // Controllers can handle their own authn and/or authz, as indicated + // by implementing the willHandleAuth() function. This applies to both + // regular and API requests. + + $controllerAuthz = false; + + if(method_exists($controller, 'willHandleAuth')) { + // The Controller might handle its own authn/z + + $mode = $controller->willHandleAuth($event); + + switch($mode) { + case 'authz': + // The controller will handle authorization, but we still need + // to make sure we have an authenticated user + $controllerAuthz = true; + break; + case 'open': + // The current request is open/public, no auth required + return true; + break; + case 'no': + // The controller will not do either authn or authz, so apply + // standard behavior + break; + case 'yes': + // The controller will handle both authn and authz, simply return + return true; + break; + default: + throw new \InvalidArgumentException("Unknown willHandleAuth return value $mode"); + break; + } + } + // Do we have an authenticated user session? // Note we don't stuff anything into the session anymore, the only attribute @@ -146,9 +181,22 @@ public function beforeFilter(EventInterface $event) { try { if($this->authenticateApiUser()) { - if($this->calculatePermission(action: $request->getParam('action'), id: $id)) { + $authok = false; + + if($controllerAuthz) { + // Don't merge these if statements together! We want to hand off + // to the controller to determine if authz was met, and if not redirect + // appropriately. We _don't_ want to call our own calculatePermission(). + if($controller->calculatePermission()) { + // Controller asserts authorization successful + $authok = true; + } + } elseif($this->calculatePermission(action: $request->getParam('action'), id: $id)) { // Authorization successful - + $authok = true; + } + + if($authok) { $AuthenticationEvents = TableRegistry::getTableLocator()->get('AuthenticationEvents'); $AuthenticationEvents->record(identifier: $this->authenticatedUser, @@ -182,12 +230,6 @@ public function beforeFilter(EventInterface $event) { } } else { // Certain requests do not require authentication - - // XXX is this too broad, or are all Pages permitted? Also, should this move - // into Controller::isAuthorized? - if($controller->getName() == 'Pages') { - return true; - } if(!empty($auth['external']['user'])) { // We have a valid username that is *authenticated* for the current request. @@ -196,7 +238,14 @@ public function beforeFilter(EventInterface $event) { $controller->set('vv_user', ['username' => $auth['external']['user']]); $this->authenticatedUser = $auth['external']['user']; - if($this->calculatePermission($request->getParam('action'), $id)) { + if($controllerAuthz) { + // Don't merge these if statements together! We want to hand off + // to the controller to determine if authz was met, and if not redirect + // appropriately. We _don't_ want to call our own calculatePermission(). + if($controller->calculatePermission()) { + return true; + } + } elseif($this->calculatePermission($request->getParam('action'), $id)) { // Authorization successful return true; } diff --git a/app/src/Controller/DashboardsController.php b/app/src/Controller/DashboardsController.php index 0593c028d..89ec5c8f7 100644 --- a/app/src/Controller/DashboardsController.php +++ b/app/src/Controller/DashboardsController.php @@ -76,14 +76,14 @@ public function artifacts() { // could be delegated to (eg) a COU Admin at some point... $artifactMenuItems = [ - __d('controller', 'Jobs', [99]) => [ + __d('controller', 'ExtIdentitySourceRecords', [99]) => [ 'icon' => 'assignment', - 'controller' => 'jobs', + 'controller' => 'ext_identity_source_records', 'action' => 'index' ], - __d('controller', 'ExternalIdentitySourceRecords', [99]) => [ + __d('controller', 'Jobs', [99]) => [ 'icon' => 'assignment', - 'controller' => 'external_identity_source_records', + 'controller' => 'jobs', 'action' => 'index' ] ]; diff --git a/app/src/Controller/ExternalIdentitiesController.php b/app/src/Controller/ExternalIdentitiesController.php index edd72dd86..441f526e3 100644 --- a/app/src/Controller/ExternalIdentitiesController.php +++ b/app/src/Controller/ExternalIdentitiesController.php @@ -38,11 +38,11 @@ class ExternalIdentitiesController extends MVEAController { public $paginate = [ 'order' => [ - 'PrimaryName.family' => 'asc' + 'Name.family' => 'asc' ], 'sortableFields' => [ - 'PrimaryName.given', - 'PrimaryName.family' + 'Names.given', + 'Names.family' ] ]; } \ No newline at end of file diff --git a/app/src/Controller/ExternalIdentitySourcesController.php b/app/src/Controller/ExternalIdentitySourcesController.php new file mode 100644 index 000000000..53ab3bcc9 --- /dev/null +++ b/app/src/Controller/ExternalIdentitySourcesController.php @@ -0,0 +1,182 @@ + [ + 'ExternalIdentitySources.description' => 'asc' + ] + ]; + + /** + * Callback run prior to the request action. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Cake Event + * @return \Cake\Http\Response HTTP Response + */ + + public function beforeFilter(\Cake\Event\EventInterface $event) { + if(!$this->request->is('restful')) { + // Provide additional hints to BreadcrumbsComponent. This needs to be here + // and not in beforeRender because the component beforeRender will run first. + + if(in_array($this->request->getParam('action'), ['retrieve', 'search'])) { + $eis = $this->ExternalIdentitySources->get($this->request->getParam('pass')[0]); + + $this->Breadcrumb->injectTitleLink($this->ExternalIdentitySources, $eis); + + if($this->request->getParam('action') == 'retrieve') { + $this->Breadcrumb->injectTitleLink( + table: $this->ExternalIdentitySources, + entity: $eis, + action: 'search', + label: __d('operation', 'ExternalIdentitySources.search') + ); + + $this->set('vv_eis', $eis); + } + } + } + + return parent::beforeFilter($event); + } + + /** + * Calculate the redirect for this request. + * + * @since COmanage Registry v5.0.0 + * @param Entity $entity Subject entity + * @return array Redirect + */ + + public function calculateRedirectTarget($entity): array { + // We should only be called for sync, after which we want to redirect to a URL like + // /registry/external-identity-sources/retrieve/3?source_key=foo + + return [ + 'controller' => 'ExternalIdentitySources', + 'action' => 'retrieve', + $this->request->getParam('pass.0'), + '?' => [ + 'source_key' => $this->request->getQuery('source_key') + ] + ]; + } + + /** + * Retrieve a record from an External Identity Source backend. + * + * @since COmanage Registry v5.0.0 + * @param string $id External Identity Source to search + */ + + public function retrieve(string $id) { + try { + $source_key = $this->request->getQuery('source_key'); + + $this->set('vv_eis_record', $this->ExternalIdentitySources->retrieve((int)$id, $source_key)); + + $this->set('vv_external_identity_record', $this->ExternalIdentitySources + ->ExtIdentitySourceRecords + ->find() + ->where(['ExtIdentitySourceRecords.source_key' => $source_key, + 'ExtIdentitySourceRecords.external_identity_source_id' => $id]) + ->contain(['ExternalIdentities']) + ->first()); + + $this->set('vv_title', __d('operation', 'view.a', [$source_key])); + } + catch(\Exception $e) { + $this->Flash->error($e->getMessage()); + + return $this->generateRedirect(null); + } + } + + /** + * Perform a search against an External Identity Source backend. + * + * @since COmanage Registry v5.0.0 + * @param string $id External Identity Source to search + */ + + public function search(string $id) { + if($this->request->is('post')) { + try { + // The search attributes are backend specific, pass them as an array + $search = $this->request->getData('search'); + + $matches = $this->ExternalIdentitySources->search((int)$id, $search); + + $this->set('vv_search_results', $matches); + } + catch(\Exception $e) { + $this->Flash->error($e->getMessage()); + } + } + + // Obtain the searchable attributes and pass to the view + + $this->set('vv_search_attrs', $this->ExternalIdentitySources->searchableAttributes((int)$id)); + + $this->set('vv_title', __d('operation', 'ExternalIdentitySources.search')); + } + + /** + * Perform an External Identity sync. + * + * @since COmanage Registry v5.0.0 + * @param string $id External Identity Source ID + */ + + public function sync(string $id) { + try { + $source_key = $this->request->getQuery('source_key'); + + $this->ExternalIdentitySources->sync((int)$id, $source_key); + $this->set('vv_eis_record', $this->ExternalIdentitySources->retrieve((int)$id, $source_key)); + + $this->set('vv_title', __d('operation', 'view.a', [$source_key])); + + $this->Flash->success(__d('result', 'ExternalIdentitySources.synced')); + } + catch(\Exception $e) { + $this->Flash->error($e->getMessage()); + } + + return $this->generateRedirect(null); + } +} \ No newline at end of file diff --git a/app/src/Controller/MVEAController.php b/app/src/Controller/MVEAController.php index 36030e407..aedda6a3c 100644 --- a/app/src/Controller/MVEAController.php +++ b/app/src/Controller/MVEAController.php @@ -92,7 +92,7 @@ public function beforeFilter(\Cake\Event\EventInterface $event) { $externalIdentity = $ExternalIdentity->findById($eiId)->firstOrFail(); - // What's the primary name for the Extarnal Identity? + // What's the primary name for the External Identity? The first name found... $this->set('vv_ei_name', $Names->primaryName($externalIdentity->id, 'external_identity')); $this->set('vv_ei_id', $externalIdentity->id); diff --git a/app/src/Controller/PagesController.php b/app/src/Controller/PagesController.php index 6dc732847..3f0d510cc 100644 --- a/app/src/Controller/PagesController.php +++ b/app/src/Controller/PagesController.php @@ -87,4 +87,16 @@ public function display(...$path): ?Response return $this->render(); } + + /** + * Indicate whether this Controller will handle some or all authnz. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Cake event, ie: from beforeFilter + * @return string "no", "open", "authz", or "yes" + */ + + function willHandleAuth(\Cake\Event\EventInterface $event): string { + return "open"; + } } diff --git a/app/src/Controller/PersonRolesController.php b/app/src/Controller/PersonRolesController.php index e83b88489..95aeb67fb 100644 --- a/app/src/Controller/PersonRolesController.php +++ b/app/src/Controller/PersonRolesController.php @@ -42,4 +42,56 @@ class PersonRolesController extends MVEAController { 'PersonRoles.title' => 'asc' ] ]; + + // Cache the personStatus on add/edit actions, in order to render a flash message + protected $cachedPerson = null; + + /** + * Callback run prior to the request action. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Cake Event + */ + + public function beforeFilter(\Cake\Event\EventInterface $event) { + if(!$this->request->is('restful') + && ($this->request->is('post') || $this->request->is('put')) + && in_array($this->request->getParam('action'), ['add', 'edit'])) { + // Cache the current Person so to see if status was recalculated + $this->cachedPerson = $this->PersonRoles->People->get($this->request->getData('person_id')); + } + + return parent::beforeFilter($event); + } + + /** + * Set supplemental Flash messages. + * + * @since COmanage Registry v5.0.0 + */ + + public function setSupplementalFlash($entity) { + // If we auto-recalculated the Person Role status, set a Flash message + $autoStatus = $this->PersonRoles->getAutoStatus(); + + if(!empty($autoStatus)) { + $this->Flash->information(__d('result', + 'PersonRoles.status.recalculated', + [__d('enumeration', 'StatusEnum.'.$autoStatus['from']), + __d('enumeration', 'StatusEnum.'.$autoStatus['to'])])); + } + + if(!empty($this->cachedPerson)) { + // See if we have a new Person status value, and if so set a Flash message + + $person = $this->PersonRoles->People->get($this->cachedPerson->id); + + if($this->cachedPerson->status != $person->status) { + $this->Flash->information(__d('result', + 'People.status.recalculated', + [__d('enumeration', 'StatusEnum.'.$this->cachedPerson->status), + __d('enumeration', 'StatusEnum.'.$person->status)])); + } + } + } } \ No newline at end of file diff --git a/app/src/Controller/StandardController.php b/app/src/Controller/StandardController.php index 509027140..a6bc19b6d 100644 --- a/app/src/Controller/StandardController.php +++ b/app/src/Controller/StandardController.php @@ -60,6 +60,11 @@ public function add() { if($table->save($obj)) { $this->Flash->success(__d('result', 'saved')); + + // Give the controller an opportunity to set additional Flash messages + if(method_exists($this, "setSupplementalFlash")) { + $this->setSupplementalFlash($obj); + } // Trigger provisioning, letting errors bubble up (AR-GMR-5) if(method_exists($table, "requestProvisioning")) { @@ -123,6 +128,30 @@ public function add() { $this->render('/Standard/add-edit-view'); } + /** + * Callback run prior to the request action. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Cake Event + * @return \Cake\Http\Response HTTP Response + */ + + public function beforeFilter(\Cake\Event\EventInterface $event) { + if(!$this->request->is('restful')) { + // Provide additional hints to BreadcrumbsComponent. This needs to be here + // and not in beforeRender because the component beforeRender will run first. + + $primaryLink = $this->getPrimaryLink(true); + + if(!empty($primaryLink->attr) && $primaryLink->attr != 'co_id') { + // eg: EnrollmentFlowSteps -> EnrollmentFlow, JobHistoryRecords -> Job, etc + $this->Breadcrumb->injectPrimaryLink($primaryLink); + } + } + + return parent::beforeFilter($event); + } + /** * Standard operations before the view is rendered. * @@ -340,6 +369,11 @@ public function edit(string $id) { if($table->save($saveObj)) { $this->Flash->success(__d('result', 'saved')); + // Give the controller an opportunity to set additional Flash messages + if(method_exists($this, "setSupplementalFlash")) { + $this->setSupplementalFlash($obj); + } + // Trigger provisioning, letting errors bubble up (AR-GMR-5) if(method_exists($table, "requestProvisioning")) { $this->llog('rule', "AR-GMR-5 Requesting provisioning for $modelsName " . $obj->id); @@ -423,7 +457,7 @@ public function generateRedirect($entity) { $redirectGoal = 'index'; } } - + if($redirectGoal == 'self' && $entity && in_array($this->request->getParam('action'), ['add', 'edit'])) { @@ -443,7 +477,7 @@ public function generateRedirect($entity) { ]; } elseif($redirectGoal == 'pluggableLink' || $redirectGoal == 'primaryLink') { // pluggableLink and primaryLink do basically the same thing, except that - // pluggableLink moves from a plugin to core so we need to drop the plugin + // pluggableLink checks for special handling of the 'plugin' parameter $link = $this->getPrimaryLink(true); if(!empty($link->attr) && !empty($link->value)) { @@ -454,9 +488,15 @@ public function generateRedirect($entity) { ]; if($redirectGoal == 'pluggableLink') { - $redirect['plugin'] = null; + // If the primary link points to a plugin, we want to redirect + // into that plugin, otherwise the core code + $redirect['plugin'] = $link->plugin ?? null; } } + } elseif($redirectGoal == 'special') { + // The controller will implement a special calculation + + $redirect = $this->calculateRedirectTarget($entity); } else { // Default is to redirect to the index view $redirect = ['action' => 'index']; diff --git a/app/src/Lib/Enum/ActionEnum.php b/app/src/Lib/Enum/ActionEnum.php index 293e9f9f2..eae92c5b2 100644 --- a/app/src/Lib/Enum/ActionEnum.php +++ b/app/src/Lib/Enum/ActionEnum.php @@ -32,19 +32,22 @@ class ActionEnum extends StandardEnum { // Codes beginning with 'X' (eg: 'XABC') are reserved for local use // Codes beginning with a lowercase 'p' (eg: 'pABC') are reserved for plugin use - const CommentAdded = 'CMNT'; - const GroupAdded = 'ACGR'; - const GroupDeleted = 'DCGR'; - const GroupEdited = 'ECGR'; - const GroupMemberAdded = 'ACGM'; - const GroupMemberDeleted = 'DCGM'; - const GroupMemberEdited = 'ECGM'; - const GroupOwnerAdded = 'ACGO'; - const GroupOwnerDeleted = 'DCGO'; - const IdentifierAutoAssigned = 'AIDA'; - const MVEAAdded = 'AMVE'; - const MVEADeleted = 'DMVE'; - const MVEAEdited = 'EMVE'; - const NamePrimary = 'PNAM'; - const PersonAddedPipeline = 'ACPL'; + const CommentAdded = 'CMNT'; + const GroupAdded = 'ACGR'; + const GroupDeleted = 'DCGR'; + const GroupEdited = 'ECGR'; + const GroupMemberAdded = 'ACGM'; + const GroupMemberDeleted = 'DCGM'; + const GroupMemberEdited = 'ECGM'; + const GroupOwnerAdded = 'ACGO'; + const GroupOwnerDeleted = 'DCGO'; + const IdentifierAutoAssigned = 'AIDA'; + const MVEAAdded = 'AMVE'; + const MVEADeleted = 'DMVE'; + const MVEAEdited = 'EMVE'; + const NamePrimary = 'PNAM'; + const PersonAddedPipeline = 'ACPL'; + const PersonPipelineComplete = 'CCPL'; + const PersonPipelineStarted = 'SCPL'; + const PersonStatusRecalculated = 'RCPS'; } \ No newline at end of file diff --git a/app/src/Lib/Enum/ExternalIdentityStatusEnum.php b/app/src/Lib/Enum/ExternalIdentityStatusEnum.php index a6cf8a472..9052667d2 100644 --- a/app/src/Lib/Enum/ExternalIdentityStatusEnum.php +++ b/app/src/Lib/Enum/ExternalIdentityStatusEnum.php @@ -32,9 +32,44 @@ class ExternalIdentityStatusEnum extends StandardEnum { const Active = 'A'; const Archived = 'D'; + const Deleted = 'X'; const Duplicate = 'D2'; - const Expired = 'XP'; const GracePeriod = 'GP'; - const PendingActivation = 'PS'; const Suspended = 'S'; + + /** + * Map a status value to its "preference" or "rank" for status recalculation. + * + * @since COmanage Registry v5.0.0 + * @param string $status ExternalIdentityStatusEnum + * @return int Preference Rank (larger numbers are more preferred) + * @throws InvalidArgumentException + */ + + public static function rank(string $status): int { + // This is basically a subset of StatusEnum::rank(). + + $statusRanks = array( + // Active statuses are most preferred + self::Active => 14, + self::GracePeriod => 13, + + // Next come expired statuses, since there may be provisioned skeletal records + // that need to be maintained + self::Suspended => 12, + + // Finally, we generally don't want Deleted or Duplicate unless all roles are deleted or duplicates + self::Archived => 2, + // "Deleted" is managed by Registry, not the EIS backend, but we'll basically treat + // it the same as Archived + self::Deleted => 2, + self::Duplicate => 1 + ); + + if(!isset($statusRanks[$status])) { + throw new \InvalidArgumentException("Invalid status $status"); + } + + return $statusRanks[$status]; + } } \ No newline at end of file diff --git a/app/src/Lib/Enum/StatusEnum.php b/app/src/Lib/Enum/StatusEnum.php index 931531da3..4664329f7 100644 --- a/app/src/Lib/Enum/StatusEnum.php +++ b/app/src/Lib/Enum/StatusEnum.php @@ -47,4 +47,63 @@ class StatusEnum extends StandardEnum { const PendingConfirmation = 'PC'; const Suspended = 'S'; const Declined = 'X'; + + /** + * Map a status value to its "preference" or "rank" for status recalculation. + * + * @since COmanage Registry v5.0.0 + * @param string $status StatusEnum + * @return int Preference Rank (larger numbers are more preferred) + * @throws InvalidArgumentException + */ + + public static function rank(string $status): int { + // We rank status by "preference". More "preferred" statuses rank higher. + // To facilitate comparison, we'll convert the status to an integer value. + // Most preferred numbers are larger so we can say things like + // Active > Expired. + + // Note a similar chart is defined in ExternalIdentityStatusEnum. + + $statusRanks = array( + // Active statuses are most preferred + self::Active => 15, + self::GracePeriod => 14, + + // Next come expired statuses, since there may be provisioned skeletal records + // that need to be maintained + self::Suspended => 13, + self::Expired => 12, + + // Then invitation statuses + self::Approved => 11, + self::PendingApproval => 10, + self::Confirmed => 9, + self::PendingConfirmation => 8, + self::Invited => 7, + self::PendingActivation => 6, + self::Pending => 5, // It's not clear this is used for anything + + // Denied and Declined are below expired since other roles are more likely to have been used + self::Denied => 4, + self::Declined => 3, + + // Finally, we generally don't want Archived or Duplicate unless all roles are deleted or duplicates + self::Archived => 2, + self::Duplicate => 1 + ); + + if($status == self::Locked) { + // Locked status should only apply to the Person and not Person Roles, so it + // shouldn't be valid for ranking. + + throw new \InvalidArgumentException("Cannot calculate Rank for Locked status"); + } + + if(!isset($statusRanks[$status])) { + throw new \InvalidArgumentException("Invalid status $status"); + } + + return $statusRanks[$status]; + } } \ No newline at end of file diff --git a/app/src/Lib/Traits/PrimaryLinkTrait.php b/app/src/Lib/Traits/PrimaryLinkTrait.php index cbe3ffd3b..7c2ba6f68 100644 --- a/app/src/Lib/Traits/PrimaryLinkTrait.php +++ b/app/src/Lib/Traits/PrimaryLinkTrait.php @@ -191,10 +191,18 @@ public function findPrimaryLink(int $id, bool $archived=false) { // should be set. Return the first one we find. foreach(array_keys($this->primaryLinks) as $plKey) { if(!empty($obj->$plKey)) { + // If this Primary Link points to a plugin, add a hint for the callter + $plugin = null; + + if(strstr($this->primaryLinks[$plKey], '.')) { + $plugin = \App\Lib\Util\StringUtilities::pluginPlugin($this->primaryLinks[$plKey]); + } + return (object)[ - 'attr' => $plKey, - 'value' => $obj->$plKey, - 'co_id' => $this->calculateCoForRecord($obj) + 'plugin' => $plugin, + 'attr' => $plKey, + 'value' => $obj->$plKey, + 'co_id' => $this->calculateCoForRecord($obj) ]; } } @@ -450,7 +458,6 @@ public function setAllowUnkeyedPrimaryLink(array $actions) { * @param int $coId CO ID */ - public function setCurCoId(int $coId) { $this->curCoId = $coId; } @@ -479,12 +486,15 @@ public function setPrimaryLink($fields) { if(preg_match('/^(.*)\.(.*?)_id$/', $field, $f)) { // Modified plugin notation match $t = $f[1] . "." . \Cake\Utility\Inflector::camelize(\Cake\Utility\Inflector::pluralize($f[2])); + // We need the key to be the field name, not Plugin.field + $this->primaryLinks[$f[2]."_id"] = $t; } elseif(preg_match('/^(.*?)_id$/', $field, $f)) { // Standard foreign key match $t = \Cake\Utility\Inflector::camelize(\Cake\Utility\Inflector::pluralize($f[1])); + $this->primaryLinks[$field] = $t; + } else { + $this->primaryLinks[$field] = null; } - - $this->primaryLinks[$field] = $t; } } @@ -492,13 +502,13 @@ public function setPrimaryLink($fields) { * Set the redirect goal for this table. * * @since COmanage Registry v5.0.0 - * @param string $goal Redirect goal ('index', 'pluggableLink', 'primaryLink', 'self') + * @param string $goal Redirect goal ('index', 'pluggableLink', 'primaryLink', 'self', 'special') * @param string $action Action to set goal for ('*' for default) * @throws InvalidArgumentException */ public function setRedirectGoal(string $goal, string $action='*') { - if(!in_array($goal, ['index', 'pluggableLink', 'primaryLink', 'self'])) { + if(!in_array($goal, ['index', 'pluggableLink', 'primaryLink', 'self', 'special'])) { throw new \InvalidArgumentException(__d('error', 'invalid', [$goal])); } diff --git a/app/src/Lib/Traits/ValidationTrait.php b/app/src/Lib/Traits/ValidationTrait.php index fdad31de5..2f987761b 100644 --- a/app/src/Lib/Traits/ValidationTrait.php +++ b/app/src/Lib/Traits/ValidationTrait.php @@ -69,26 +69,33 @@ public function registerPrimaryKeyValidation(Validator $validator, array $primar * Register validation rules for the provided field, as a string. * * @since COmanage Registry v5.0.0 - * @param Validator $validator Cake Validator - * @param TableSchemaInterface $schema Cake Schema - * @param string $field Field name - * @param bool $required Whether this field is required - * @param string $prefix Require the value to start with $prefix + * @param Validator $validator Cake Validator + * @param TableSchemaInterface $schema Cake Schema + * @param string $field Field name + * @param bool $required Whether this field is required + * @param string $prefix Require the value to start with $prefix + * @param bool $validateInput Whether to appli the validateInput rule * @return Validator Cake Validator */ - public function registerStringValidation(Validator $validator, - TableSchemaInterface $schema, - string $field, - bool $required, - string $prefix = ''): Validator { + public function registerStringValidation( + Validator $validator, + TableSchemaInterface $schema, + string $field, + bool $required, + string $prefix = '', + bool $validateInput = true + ): Validator { $rules = [ 'size' => ['rule' => ['validateMaxLength', ['column' => $schema->getColumn($field)]], - 'provider' => 'table'], - 'filter' => ['rule' => ['validateInput'], 'provider' => 'table'] ]; + if($validateInput) { + $rules['filter'] = ['rule' => ['validateInput'], + 'provider' => 'table']; + } + if(!empty($prefix)) { $rules['prefix'] = [ 'rule' => ['validatePrefix'], @@ -245,7 +252,7 @@ public function validateInput($value, array $context) { if(strlen($value) != strcspn($value, $invalid)) { // Mismatch, implying bad input - return __d('error', 'input.invalid'); + return __d('error', 'input.invalid.2'); } // We require at least one non-whitespace character (CO-1551) diff --git a/app/src/Lib/Util/StringUtilities.php b/app/src/Lib/Util/StringUtilities.php index 945456eb9..b4f959ee4 100644 --- a/app/src/Lib/Util/StringUtilities.php +++ b/app/src/Lib/Util/StringUtilities.php @@ -154,7 +154,7 @@ public static function localizeController(string $controllerName, ?string $plugi } else { // Standard localization - return __d('controller', $modelsName, [$plural ? 99 : 1]); + return __d('controller', $controllerName, [$plural ? 99 : 1]); } } diff --git a/app/src/Model/Behavior/ChangelogBehavior.php b/app/src/Model/Behavior/ChangelogBehavior.php index b70729f0a..be2afabee 100644 --- a/app/src/Model/Behavior/ChangelogBehavior.php +++ b/app/src/Model/Behavior/ChangelogBehavior.php @@ -183,10 +183,16 @@ public function beforeSave(\Cake\Event\Event $event, \Cake\Datasource\EntityInte // We also increment the revision on the entity, not the archive $entity->revision++; - // Cake 3 doesn't have callbacks=false, so we use the archive flag so we + // Cake 3+ doesn't have callbacks=false, so we use the archive flag so we // don't recurse indefinitely. We also skip validation in case (eg) validation // rules changed since the original record was created. - $subject->saveOrFail($archive, ['checkRules' => false, 'archive' => false]); + $subject->saveOrFail($archive, [ + 'checkRules' => false, + 'archive' => false, + // We don't want to save associated models by default since + // it will rekey them to the new archive copy. + 'associated' => false + ]); return; } diff --git a/app/src/Model/Table/AddressesTable.php b/app/src/Model/Table/AddressesTable.php index 6f84cabf1..f42adb038 100644 --- a/app/src/Model/Table/AddressesTable.php +++ b/app/src/Model/Table/AddressesTable.php @@ -94,6 +94,7 @@ public function initialize(array $config): void { $this->setPrimaryLink(['external_identity_id', 'external_identity_role_id', 'person_id', 'person_role_id']); $this->setRequiresCO(true); + // Models that AcceptCoId should be expicitly added to StandardApiController::initialize() $this->setAcceptsCoId(true); $this->setRedirectGoal('self'); $this->setAllowLookupPrimaryLink(['unfreeze']); diff --git a/app/src/Model/Table/ExternalIdentitiesTable.php b/app/src/Model/Table/ExternalIdentitiesTable.php index de6eceba8..7eb69122c 100644 --- a/app/src/Model/Table/ExternalIdentitiesTable.php +++ b/app/src/Model/Table/ExternalIdentitiesTable.php @@ -65,9 +65,10 @@ public function initialize(array $config): void { // Define associations $this->belongsTo('People'); - $this->hasOne('PrimaryName') - ->setClassName('Names') - ->setConditions(['PrimaryName.primary_name' => true]); +// External Identities do not have Primary Names +// $this->hasOne('PrimaryName') +// ->setClassName('Names'); +// ->setConditions(['PrimaryName.primary_name' => true]); $this->hasMany('Names') ->setDependent(true) ->setCascadeCallbacks(true); @@ -111,9 +112,7 @@ public function initialize(array $config): void { $this->setRequiresCO(true); $this->setRedirectGoal('self'); -// XXX does some of this stuff really belong in the controller? $this->setEditContains([ - 'PrimaryName', 'Addresses', 'AdHocAttributes', 'EmailAddresses', @@ -125,9 +124,9 @@ public function initialize(array $config): void { 'Urls' ]); - $this->setIndexContains(['PrimaryName']); + $this->setIndexContains(['Names']); + $this->setViewContains([ - 'PrimaryName', 'Addresses', 'AdHocAttributes', 'EmailAddresses', @@ -164,6 +163,33 @@ public function initialize(array $config): void { ]); } + /** + * Callback before model delete. + * + * @since COmanage Registry v5.0.0 + * @param CakeEventEvent $event The beforeDelete event + * @param $entity Entity + * @param ArrayObject $options Options + * @return boolean True on success + */ + + public function beforeDelete(\Cake\Event\Event $event, $entity, \ArrayObject $options) { + // If we were only dealing with hard delete, we wouldn't need implementedEvents() + // below, because ChangelogBehavior ignores hard deletes. + + // Manually delete any names, since the validation rules will fail on cascade. + // Since this isn't a hard delete we can't use deleteAll since we need + // ChangelogBehavior to fire. + + $names = $this->Names->find()->where(['external_identity_id' => $entity->id])->all(); + + foreach($names as $n) { + $this->Names->delete($n, ['checkRules' => false]); + } + + return true; + } + /** * Table specific logic to generate a display field. * @@ -173,11 +199,25 @@ public function initialize(array $config): void { */ public function generateDisplayField(\App\Model\Entity\ExternalIdentity $entity): string { - if(empty($entity->primary_name)) { - throw new \InvalidArgumentException(__d('error', 'Names.primary_name')); - } - - return $entity->primary_name->full_name; + return $entity->names[0]->full_name; + } + + /** + * Define the table's implemented events. + * + * @since COmanage Registry v5.0.0 + */ + + public function implementedEvents(): array { + $events = parent::implementedEvents(); + + // We need to adjust our beforeDelete priority to run before ChangelogBehavior's. + $events['Model.beforeDelete'] = [ + 'callable' => 'beforeDelete', + 'priority' => 1 + ]; + + return $events; } /** @@ -196,6 +236,61 @@ public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasour return true; } + /** + * Recalculate External Identity status based on External Identity Roles status. + * + * @since COmanage Registry v5.0.0 + * @param int $id External Identity ID + * @return string New External Identity status + */ + + public function recalculateStatus(int $id): ?string { + $newStatus = null; + + // Start by pulling the roles for this External Identity, along with the EI record + + $externalIdentity = $this->get($id, ['contain' => 'ExternalIdentityRoles']); + + if(!empty($externalIdentity->external_identity_roles)) { + foreach($externalIdentity->external_identity_roles as $role) { + if(!$newStatus) { + // This is the first role, just set the new status to it + + $newStatus = $role->status; + } else { + // Check if this role's status is more preferable than the current status + + if(ExternalIdentityStatusEnum::rank($role->status) > ExternalIdentityStatusEnum::rank($newStatus)) { + $newStatus = $role->status; + } + } + } + } + + if($newStatus) { + if($newStatus != $externalIdentity->status) { + // Update the External Identity status + $oldStatus = $externalIdentity->status; + $externalIdentity->status = $newStatus; + $this->save($externalIdentity); + + // Record history + $this->recordHistory( + entity: $externalIdentity, + action: ActionEnum::PersonStatusRecalculated, + comment: __d('result', + 'ExternalIdentities.status.recalculated', + [__d('enumeration', 'ExternalIdentityStatusEnum.'.$oldStatus), + __d('enumeration', 'ExternalIdentityStatusEnum.'.$newStatus)]) + ); + } + // else nothing to do, status is unchanged + } + // else no roles, leave status unchanged + + return $newStatus; + } + /** * Set validation rules. * diff --git a/app/src/Model/Table/ExternalIdentityRolesTable.php b/app/src/Model/Table/ExternalIdentityRolesTable.php index b54eae789..86132973c 100644 --- a/app/src/Model/Table/ExternalIdentityRolesTable.php +++ b/app/src/Model/Table/ExternalIdentityRolesTable.php @@ -108,7 +108,7 @@ public function initialize(array $config): void { ], 'affiliationTypes' => [ 'type' => 'type', - 'attribute' => 'PersonRoles.affiliation' + 'attribute' => 'PersonRoles.affiliation_type' ] ]); @@ -117,13 +117,13 @@ public function initialize(array $config): void { // See also CFM-126 // XXX need to add couAdmin, eventually 'entity' => [ - 'delete' => ['platformAdmin', 'coAdmin'], - 'edit' => ['platformAdmin', 'coAdmin'], + 'delete' => false, + 'edit' => false, 'view' => ['platformAdmin', 'coAdmin'] ], // Actions that operate over a table (ie: do not require an $id) 'table' => [ - 'add' => ['platformAdmin', 'coAdmin'], + 'add' => false, 'index' => ['platformAdmin', 'coAdmin'] ] ]); @@ -213,6 +213,10 @@ public function implementedEvents(): array { public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasource\EntityInterface $entity, \ArrayObject $options): bool { if(!$entity->deleted) { $this->recordHistory($entity); + + if($entity->isDirty('status')) { + $this->ExternalIdentities->recalculateStatus($entity->external_identity_id); + } } return true; diff --git a/app/src/Model/Table/ExternalIdentitySourcesTable.php b/app/src/Model/Table/ExternalIdentitySourcesTable.php index 4cbabaa28..3bfa4ab2c 100644 --- a/app/src/Model/Table/ExternalIdentitySourcesTable.php +++ b/app/src/Model/Table/ExternalIdentitySourcesTable.php @@ -78,6 +78,8 @@ public function initialize(array $config): void { $this->setPrimaryLink(['co_id']); $this->setRequiresCO(true); + // We need to calculate the redirect URL for sync ourselves (in the controller) + $this->setRedirectGoal('special', 'sync'); $this->setAllowLookupPrimaryLink(['retrieve', 'search', 'sync']); $this->setAutoViewVars([ @@ -132,7 +134,12 @@ public function retrieve(int $id, string $source_key): array { $pModel = StringUtilities::pluginModel($source->plugin); - return $this->$pModel->retrieve($source, $source_key); + $record = $this->$pModel->retrieve($source, $source_key); + + // Inject the source key so every backend doesn't have to do this + $record['entity_data']['source_key'] = $source_key; + + return $record; } /** diff --git a/app/src/Model/Table/HistoryRecordsTable.php b/app/src/Model/Table/HistoryRecordsTable.php index fa9325f7a..a18144b0e 100644 --- a/app/src/Model/Table/HistoryRecordsTable.php +++ b/app/src/Model/Table/HistoryRecordsTable.php @@ -29,6 +29,7 @@ namespace App\Model\Table; +use Cake\Event\EventInterface; use Cake\ORM\Table; use Cake\Validation\Validator; @@ -113,6 +114,25 @@ public function initialize(array $config): void { ]); } + /** + * Perform actions while marshaling data, before validation. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Event + * @param ArrayObject $data Object data, in array format + * @param ArrayObject $options Entity save options + */ + + public function beforeMarshal(EventInterface $event, \ArrayObject $data, \ArrayObject $options) + { + if(!empty($data['comment'])) { + // Truncate the comment to fit the column width + $column = $this->getSchema()->getColumn('comment'); + + $data['comment'] = substr($data['comment'], 0, $column['length']); + } + } + /** * Table specific logic to generate a display field. * @@ -219,17 +239,11 @@ public function validationDefault(Validator $validator): Validator { $this->registerPrimaryKeyValidation($validator, $this->getPrimaryLinks()); - $validator->add('action', [ - 'length' => ['rule' => ['validateMaxLength', ['column' => $schema->getColumn('action')]], - 'provider' => 'table'], - ]); - $validator->notEmptyString('action'); - - $validator->add('comment', [ - 'length' => ['rule' => ['validateMaxLength', ['column' => $schema->getColumn('comment')]], - 'provider' => 'table'], - ]); - $validator->notEmptyString('comment'); + $this->registerStringValidation($validator, $schema, 'action', true); + + // We disable validateInput for the comment field since changesToString likes to + // include > characters. + $this->registerStringValidation($validator, $schema, 'comment', required: true, validateInput: false); return $validator; } diff --git a/app/src/Model/Table/IdentifierAssignmentsTable.php b/app/src/Model/Table/IdentifierAssignmentsTable.php index 3f7adc7a2..0e520b602 100644 --- a/app/src/Model/Table/IdentifierAssignmentsTable.php +++ b/app/src/Model/Table/IdentifierAssignmentsTable.php @@ -214,8 +214,6 @@ public function assign( $this->llog('trace', "New Identifier '".$ia->description."' assigned (".$ret['assigned'][$ia->description].") for $entityType $entityId"); $this->attachIdentifier($ia, $entity, $ret['assigned'][$ia->description]); - - $cxn->commit(); } catch(\Exception $e) { $this->llog('debug', "Identifier '".$ia->description."' assignment failed for $entityType $entityId: " . $e->getMessage()); @@ -225,8 +223,12 @@ public function assign( } else { $this->llog('trace', "Identifier '".$ia->description."' already assigned for $entityType $entityId"); $ret['already'][$ia->description] = true; // XXX maybe return the identifier? - $cxn->rollback(); + // We can't rollback here because it will cause parent transactions + // (eg: Pipelines) to fail +// $cxn->rollback(); } + + $cxn->commit(); } // Trigger provisioning, letting errors bubble up (AR-GMR-5) diff --git a/app/src/Model/Table/JobHistoryRecordsTable.php b/app/src/Model/Table/JobHistoryRecordsTable.php index ced38a86f..3453fed49 100644 --- a/app/src/Model/Table/JobHistoryRecordsTable.php +++ b/app/src/Model/Table/JobHistoryRecordsTable.php @@ -96,6 +96,25 @@ public function initialize(array $config): void { ]); } + /** + * Perform actions while marshaling data, before validation. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Event + * @param ArrayObject $data Object data, in array format + * @param ArrayObject $options Entity save options + */ + + public function beforeMarshal(EventInterface $event, \ArrayObject $data, \ArrayObject $options) + { + if(!empty($data['comment'])) { + // Truncate the comment to fit the column width + $column = $this->getSchema()->getColumn('comment'); + + $data['comment'] = substr($data['comment'], 0, $column['length']); + } + } + /** * Table specific logic to generate a display field. * @@ -173,11 +192,7 @@ public function validationDefault(Validator $validator): Validator { ]); $validator->allowEmptyString('record_key'); - $validator->add('comment', [ - 'length' => ['rule' => ['validateMaxLength', ['column' => $schema->getColumn('comment')]], - 'provider' => 'table'], - ]); - $validator->notEmptyString('comment'); + $this->registerStringValidation($validator, $schema, 'comment', true); $validator->add('status', [ 'content' => ['rule' => ['inList', JobStatusEnum::getConstValues()]] diff --git a/app/src/Model/Table/NamesTable.php b/app/src/Model/Table/NamesTable.php index 809b215e4..69fcfc3cb 100644 --- a/app/src/Model/Table/NamesTable.php +++ b/app/src/Model/Table/NamesTable.php @@ -99,6 +99,7 @@ public function initialize(array $config): void { $this->setPrimaryLink(['external_identity_id', 'person_id']); $this->setAllowLookupPrimaryLink(['primary', 'unfreeze']); $this->setRequiresCO(true); + // Models that AcceptCoId should be expicitly added to StandardApiController::initialize() $this->setAcceptsCoId(true); $this->setRedirectGoal('self'); @@ -145,7 +146,7 @@ public function beforeMarshal(EventInterface $event, \ArrayObject $data, \ArrayO { if(!empty($data['source_name_id'])) { // Source records may not assert primary name on the Person copy. -// XXX this implies an EIS name cannot be a primary name - document as an AR +// XXX this implies an EIS name cannot be a primary name - document as an AR $data['primary_name'] = false; } } @@ -235,10 +236,20 @@ public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasour */ public function primaryName(int $id, string $recordType='person') { - return $this->find() - ->where([$recordType.'_id' => $id, - 'primary_name' => true]) - ->firstOrFail(); + if($recordType == 'person') { + // Return the Primary Name + + return $this->find() + ->where(['person_id' => $id, + 'primary_name' => true]) + ->firstOrFail(); + } else { + // Return the first name, whatever it is + + return $this->find() + ->where(['external_identity_id' => $id]) + ->firstOrFail(); + } } /** diff --git a/app/src/Model/Table/PeopleTable.php b/app/src/Model/Table/PeopleTable.php index 02f837963..98b44a90a 100644 --- a/app/src/Model/Table/PeopleTable.php +++ b/app/src/Model/Table/PeopleTable.php @@ -33,6 +33,7 @@ use Cake\ORM\RulesChecker; use Cake\ORM\Table; use Cake\Validation\Validator; +use \App\Lib\Enum\ActionEnum; use \App\Lib\Enum\GroupTypeEnum; use \App\Lib\Enum\StatusEnum; use \App\Lib\Enum\SuspendableStatusEnum; @@ -396,7 +397,6 @@ public function marshalProvisioningData(int $id): array { 'AdHocAttributes', 'EmailAddresses' => [ 'Types' ], 'ExternalIdentities' => [ - 'PrimaryName' => [ 'Types' ], 'Addresses' => [ 'Types' ], 'AdHocAttributes', 'EmailAddresses' => [ 'Types' ], @@ -525,6 +525,73 @@ public function marshalProvisioningData(int $id): array { return $ret; } + /** + * Recalculate Person status based on Person Roles status. + * + * @since COmanage Registry v5.0.0 + * @param int $id Person ID + * @return string New Person status + */ + + public function recalculateStatus(int $id): ?string { + $newStatus = null; + + // Start by pulling the roles for this person, along with the Person record + + $person = $this->get($id, ['contain' => 'PersonRoles']); + + if(!empty($person->person_roles)) { + foreach($person->person_roles as $role) { + if(!$newStatus) { + // This is the first role, just set the new status to it + + $newStatus = $role->status; + } else { + // Check if this role's status is more preferable than the current status + + if(StatusEnum::rank($role->status) > StatusEnum::rank($newStatus)) { + $newStatus = $role->status; + } + } + } + } + + if($newStatus) { + if($newStatus != $person->status) { + // Locked status cannot be recalculated. This isn't an error, per se. + if($person->status == StatusEnum::Locked) { + $this->llog('trace', 'Not recalculating Person " . $person->id . " status since the record is locked'); + return $curStatus; + } + + // Update the Person status + $oldStatus = $person->status; + $person->status = $newStatus; + $this->save($person); + + // Record history + $this->recordHistory( + entity: $person, + action: ActionEnum::PersonStatusRecalculated, + comment: __d('result', + 'People.status.recalculated', + [__d('enumeration', 'StatusEnum.'.$oldStatus), + __d('enumeration', 'StatusEnum.'.$newStatus)]) + ); + + // We shouldn't need to manually trigger provisioning here since we'll typically + // be called via PersonRole::afterSave(), which will be called by some other + // context (StandardController, Pipelines, etc) that will manage provisioning + // after the PersonRole save (to the calling context's perspective) is finished. +// $this->requestProvisioning(id: $obj->id, context: ProvisioningContextEnum::Automatic); + } + // else nothing to do, status is unchanged + } + // else no roles, leave status unchanged + + return $newStatus; + } + /** * Reconcile memberships in CO members groups based on the Person entity. * diff --git a/app/src/Model/Table/PersonRolesTable.php b/app/src/Model/Table/PersonRolesTable.php index 97035061c..ac797fee5 100644 --- a/app/src/Model/Table/PersonRolesTable.php +++ b/app/src/Model/Table/PersonRolesTable.php @@ -29,6 +29,8 @@ namespace App\Model\Table; +use Cake\Event\EventInterface; +use \Cake\I18n\FrozenTime; use Cake\ORM\Query; use Cake\ORM\RulesChecker; use Cake\ORM\Table; @@ -66,6 +68,9 @@ class PersonRolesTable extends Table { 'student' ] ]; + + // Cache status info if we automatically recalculated the status in beforeMarshal + protected $autoStatus = null; /** * Perform Cake Model initialization. @@ -178,7 +183,107 @@ public function initialize(array $config): void { ] ]); } - + + /** + * Callback before data is marshaled into an entity. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event beforeMarshal event + * @param ArrayObject $data Entity data + * @param ArrayObject $options Callback options + */ + + public function beforeMarshal(EventInterface $event, \ArrayObject $data, \ArrayObject $options) + { + // Perform validity date/status reconciliation. status should always be set, + // but we'll check for it just in case. + if(!empty($data['status'])) { + // Note that $data['id'] will _not_ be set, even for updates, so we can't directly + // reference the Person Role ID in log records + + // AR-PersonRole-4 A Person Role with a Valid From date in the future and a status + // of Active, Expired, or Grace Period will be given a status of Pending Activation, + // unless the Person Role is frozen. A Person Role in Pending Activation status with + // a valid from date in the past will be given a status of Active. + + if(!empty($data['valid_from'])) { + $validFrom = new FrozenTime($data['valid_from']); + + if($validFrom->isPast() + && $data['status'] == StatusEnum::PendingActivation) { + if(empty($data['frozen']) || !$data['frozen']) { + $this->autoStatus = [ 'from' => $data['status'], 'to' => StatusEnum::Active ]; + $this->llog('rule', "AR-PersonRole-4 Updating status on Person Role for Person " . $data['person_id'] . " from Pending Activation to Active"); + $data['status'] = StatusEnum::Active; + } else { + $this->llog('trace', 'Not recalculating status on Person Role for Person ' . $data['person_id'] . ' since the record is frozen'); + } + } elseif($validFrom->isFuture() + && in_array($data['status'], [StatusEnum::Active, + StatusEnum::Expired, + StatusEnum::GracePeriod])) { + if(empty($data['frozen']) || !$data['frozen']) { + $this->autoStatus = [ 'from' => $data['status'], 'to' => StatusEnum::PendingActivation ]; + $this->llog('rule', "AR-PersonRole-4 Updating status on Person Role for Person " . $data['person_id'] . " from " . $data['status'] . " to Pending Activation"); + $data['status'] = StatusEnum::PendingActivation; + } else { + $this->llog('trace', 'Not recalculating status on Person Role for Person ' . $data['person_id'] . ' since the record is frozen'); + } + } + } + + // AR-PersonRole-5 A Person Role with a Valid Through date in the past and a status + // of Active, Grace Period, or Pending Activation will be given a status of Expired, + // unless the Person Role is frozen. A Person Role in Expired status with a valid + // from date in the future will be given a status of Active. + + if(!empty($data['valid_through'])) { + $validThrough = new FrozenTime($data['valid_through']); + + if($validThrough->isFuture() + && $data['status'] == StatusEnum::Expired) { + if(empty($data['frozen']) || !$data['frozen']) { + $this->autoStatus = [ 'from' => $data['status'], 'to' => StatusEnum::Active ]; + $this->llog('rule', "AR-PersonRole-5 Updating status on Person Role for Person " . $data['person_id'] . " from Expired to Active"); + $data['status'] = StatusEnum::Active; + } else { + $this->llog('trace', 'Not recalculating status on Person Role for Person ' . $data['person_id'] . ' since the record is frozen'); + } + } elseif($validThrough->isPast() + && in_array($data['status'], [StatusEnum::Active, + StatusEnum::GracePeriod, + StatusEnum::PendingActivation])) { + if(empty($data['frozen']) || !$data['frozen']) { + $this->autoStatus = [ 'from' => $data['status'], 'to' => StatusEnum::Expired ]; + $this->llog('rule', "AR-PersonRole-5 Updating status on Person Role for Person " . $data['person_id'] . " from " . $data['status'] . " to Pending Expired"); + $data['status'] = StatusEnum::Expired; + } else { + $this->llog('trace', 'Not recalculating status on Person Role for Person ' . $data['person_id'] . ' since the record is frozen'); + } + } + } + } + } + + /** + * Define business rules. + * + * @since COmanage Registry v5.0.0 + * @param RulesChecker $rules RulesChecker object + * @return RulesChecker + */ + + public function buildRules(RulesChecker $rules): RulesChecker { + // AR-PersonRole-6 If both valid from and valid through dates are provided for + // a Person Role, the valid from date must be earlier than the valid through date. + + $rules->add([$this, 'ruleDatesSequential'], + 'datesSequential', + ['errorField' => 'valid_from']); + + return $rules; + } + /** * Table specific logic to generate a display field. * @@ -199,6 +304,17 @@ public function generateDisplayField(\App\Model\Entity\PersonRole $entity): stri return (string)$entity->id; } + /** + * Get information related to automatic status recalculation. + * + * @since COmanage Registry v5.0.0 + * @return array 'from': Old status, 'to': New status + */ + + public function getAutoStatus(): ?array { + return $this->autoStatus; + } + /** * Obtain an iterator for the set of Members in the specified COU. * @@ -300,6 +416,10 @@ public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasour $this->reconcileCouMembersGroupMemberships($entity); + if($entity->isDirty('status')) { + $this->People->recalculateStatus($entity->person_id); + } + return true; } @@ -397,6 +517,30 @@ public function reconcileCouMembersGroupMemberships(\Cake\Datasource\EntityInter } } + /** + * Application Rule to determine if validity dates are sequential + * + * @param Entity $entity Entity to be validated + * @param array $options Application rule options + * + * @return bool|string true if the Rule check passes, false otherwise + * @since COmanage Registry v5.0.0 + */ + + public function ruleDatesSequential($entity, array $options): bool|string { + // This rule only applies if both valid_from and valid_through are set. + + if(!empty($entity->valid_from) && !empty($entity->valid_through)) { + $diff = $entity->valid_from->diff($entity->valid_through); + + if($diff->invert) { + return __d('error', 'PersonRoles.valid_from.after'); + } + } + + return true; + } + /** * Perform a keyword search. * diff --git a/app/src/Model/Table/PipelinesTable.php b/app/src/Model/Table/PipelinesTable.php index 77264c9eb..0239fd466 100644 --- a/app/src/Model/Table/PipelinesTable.php +++ b/app/src/Model/Table/PipelinesTable.php @@ -43,6 +43,7 @@ use \App\Model\Entity\Pipeline; use \App\Lib\Enum\ActionEnum; use \App\Lib\Enum\DeletedRoleStatusEnum; +use \App\Lib\Enum\ExternalIdentityStatusEnum; use \App\Lib\Enum\MatchStrategyEnum; use \App\Lib\Enum\ProvisioningContextEnum; use \App\Lib\Enum\StatusEnum; @@ -194,6 +195,8 @@ protected function correlateRecordKeys( // By finding a matching ID patchEntity() will know not to update the related // model. If an attribute changes in the Backend record, we won't match it // here and the old value will be deleted while the new value will be added. + // (We do still need to handle some metadata for new records, though, in particular + // foreign keys.) // Start with the ID of the External Identity itself. $ret['id'] = $externalIdentity->id; @@ -224,7 +227,9 @@ protected function correlateRecordKeys( } } } + } + if(!empty($ret[$m])) { // And make sure each mapped Backend record has a parent record ID. // We do this separately to catch any new records. foreach(array_keys($ret[$m]) as $i) { @@ -234,21 +239,49 @@ protected function correlateRecordKeys( } // Now map any External Identity Roles. We can use the role_key to help here. - if(!empty($externalIdentity->external_identity_roles) + + if(!empty($externalIdentity->external_identity_roles) && !empty($ret['external_identity_roles'])) { foreach($externalIdentity->external_identity_roles as $roleentity) { foreach($ret['external_identity_roles'] as $i => $rdata) { if($roleentity->role_key == $rdata['role_key']) { - // Insert the record ID + // Insert the record ID for existing records (updates) $ret['external_identity_roles'][$i]['id'] = $roleentity->id; + + // While we're here, work with any related models + foreach([ + // related models need EntityMetaTrait + 'ad_hoc_attributes', + 'addresses', + 'telephone_numbers' + ] as $m) { + if(!empty($ret['external_identity_roles'][$i][$m])) { + if(!empty($roleentity->$m)) { + // There is at least one associated model of this type on the + // External Identity Role, and in the mapped Backend data + foreach($roleentity->$m as $rentity) { + // Check all mapped records for the same model + foreach($ret['external_identity_roles'][$i][$m] as $j => $mdata) { + if(!isset($ret['external_identity_roles'][$i][$m][$j]['id']) // We saw this one already + && $rentity->isProbablyThisArray($mdata)) { + // Insert the record ID + $ret['external_identity_roles'][$i][$m][$j]['id'] = $rentity->id; + break; // We can exit the inner loop, but not the outer ones + } + } + } + } + + // Insert the parent record ID, separately to catch any new records + foreach(array_keys($ret['external_identity_roles'][$i][$m]) as $j) { + $ret['external_identity_roles'][$i][$m][$j]['external_identity_role_id'] = $roleentity->id; + } + } + } + break; // We can exit the inner loop, but not the outer one } } - - // Insert the parent record ID, again separately to catch any new records. - foreach(array_keys($ret['external_identity_roles']) as $i) { - $ret['external_identity_roles'][$i]['external_identity_id'] = $externalIdentity->id; - } } // And finally any related models for the External Identity. We can skip this @@ -256,38 +289,12 @@ protected function correlateRecordKeys( // therefore not have existing keys). For deleted Roles, when the Role itself // is deleted the associated models will also be deleted (as dependencies) so // we don't need to facilitate that here. + } - foreach($externalIdentity->external_identity_roles as $roleentity) { - foreach($ret['external_identity_roles'] as $i => $rdata) { - foreach([ - // related models need EntityMetaTrait - 'ad_hoc_attributes', - 'addresses', - 'telephone_numbers' - ] as $m) { - if(!empty($roleentity->$m) - && !empty($ret['external_identity_roles'][$m])) { - // There is at least one associated model of this type on the - // External Identity Role, and in the mapped Backend data - foreach($roleentity->$m as $rentity) { - // Check all mapped records for the same model - foreach($ret['external_identity_roles'][$m] as $i => $mdata) { - if(!isset($ret['external_identity_roles'][$m][$i]['id']) // We saw this one already - && $rentity->isProbablyThisArray($mdata)) { - // Insert the record ID - $ret['external_identity_roles'][$m][$i]['id'] = $rentity->id; - break; // We can exit the inner loop, but not the outer ones - } - } - - // Insert the parent record ID, separately to catch any new records - foreach(array_keys($ret['external_identity_roles'][$m]) as $i) { - $ret['external_identity_roles'][$m][$i]['external_identity_role_id'] = $roleentity->id; - } - } - } - } - } + if(!empty($ret['external_identity_roles'])) { + // Insert the parent record ID, again separately to catch any new records. + foreach(array_keys($ret['external_identity_roles']) as $i) { + $ret['external_identity_roles'][$i]['external_identity_id'] = $externalIdentity->id; } } @@ -341,9 +348,9 @@ protected function createPersonFromEIS( // Force this to be the primary name just in case it wasn't set $newPerson['names'][0]['primary_name'] = true; - $entity = $this->Cos->People->newEntity($newPerson); + $entity = $this->Cos->People->newEntity($newPerson, ['associated' => 'Names']); - $this->Cos->People->saveOrFail($entity); + $this->Cos->People->saveOrFail($entity, ['associated' => 'Names']); $this->Cos->People->recordHistory( entity: $entity, @@ -360,6 +367,49 @@ protected function createPersonFromEIS( return $entity; } + /** + * Copy the data from an entity and filter metadata, returning an array + * suitable for creating a new entity. Related models are also removed. + * + * @since COmanage Registry v5.0.0 + * @param Entity $entity Entity to copy + * @return array Array of filtered entity data + */ + + protected function duplicateFilterEntityData($entity): array { + // There's some overlap with TableMetaTrait::filterMetadataFields... + + $newdata = $entity->toArray(); + + // This list is a combination of eliminating fields that create + // noise in change detection for History creation, as well as + // functional attributes that cause problems if set (eg: frozen). + unset( + $newdata['id'], + $newdata['external_identity_id'], + $newdata['external_identity_role_id'], + $newdata['actor_identifier'], + $newdata['created'], + $newdata['deleted'], + $newdata['frozen'], + $newdata['full_name'], + // XXX we temporarily filter manager and sponsor identifiers because + // we haven't yet implemented support for mapping them + $newdata['manager_identifier'], + $newdata['sponsor_identifier'], + $newdata['modified'], + $newdata['primary_name'], + $newdata['revision'], + $newdata['role_key'], + // We don't want status for the External Identity, and we handle it + // specially for External Identity Roles + $newdata['status'] + ); + + // This will remove anything that isn't stringy + return array_filter($newdata, 'is_scalar'); + } + /** * Execute the specified Pipeline on the provided EIS data. * @@ -400,7 +450,7 @@ public function execute( $this->llog('trace', "Record for EIS $eisId source key " . $eisBackendRecord['source_key'] . " is unchanged, stopping Pipeline"); $cxn->commit(); - $return; + return; } // (2) Match against an existing Person or create a new Person, in @@ -411,7 +461,16 @@ public function execute( $eisRecord['record'], $eisBackendRecord['entity_data'] ); - + + // We can't record the start history until we have a Person entity + $this->Cos->People->ExternalIdentities->recordHistory( + entity: $person, + action: ActionEnum::PersonPipelineStarted, + comment: __d('result', + 'Pipelines.started', + [$id, $eisId, $eisBackendRecord['source_key']]) + ); + // (3) Create or update an External Identity based on the sync strategy // and the backend attributes $externalIdentity = $this->syncExternalIdentity( @@ -443,12 +502,14 @@ public function execute( ); // (6) Update Person Status - + // - We no longer need to do anything here since status recalculation + // happens automatically +/* $person = $this->updatePersonStatus( $pipeline, $externalIdentity, $person - ); + );*/ // (7) Provision @@ -457,6 +518,14 @@ public function execute( context: ProvisioningContextEnum::Automatic ); + $this->Cos->People->ExternalIdentities->recordHistory( + entity: $person, + action: ActionEnum::PersonPipelineComplete, + comment: __d('result', + 'Pipelines.complete', + [$id, $eisId, $eisBackendRecord['source_key']]) + ); + $this->llog('trace', "Pipeline $id complete for EIS $eisId source key " . $eisBackendRecord['source_key']); $cxn->commit(); @@ -470,42 +539,6 @@ public function execute( } } - /** - * Copy the data from an entity and filter metadata, returning an array - * suitable for creating a new entity. Related models are also removed. - * - * @since COmanage Registry v5.0.0 - * @param Entity $entity Entity to copy - * @return array Array of filtered entity data - */ - - protected function duplicateFilterEntityData($entity): array { - // There's some overlap with TableMetaTrait::filterMetadataFields... - - $newdata = $entity->toArray(); - - // This list is a combination of eliminating fields that create - // noise in change detection for History creation, as well as - // functional attributes that cause problems if set (eg: frozen). - unset( - $newdata['id'], - $newdata['external_identity_id'], - $newdata['actor_identifier'], - $newdata['created'], - $newdata['deleted'], - $newdata['frozen'], - $newdata['full_name'], - $newdata['modified'], - $newdata['primary_name'], - $newdata['revision'], - $newdata['role_key'], - $newdata['status'] - ); - - // This will remove anything that isn't stringy - return array_filter($newdata, 'is_scalar'); - } - /** * Pipeline step to create or update the External Identity Source Record. * @@ -607,8 +640,7 @@ protected function mapAttributesToCO( $ret = [ // Make sure source_key is a string - 'source_key' => (string)$eisAttributes['source_key'], - 'status' => StatusEnum::Active + 'source_key' => (string)$eisAttributes['source_key'] ]; if(!empty($eisAttributes['date_of_birth'])) { @@ -683,16 +715,28 @@ protected function mapAttributesToCO( 'PersonRoles.affiliation_type', $val ); + } elseif($attr == 'status') { + // Generally we'll let validation and recalcuation handle status, + // but if for some reason the backend asserts Deleted (which is used + // internally as a sync status, and so is not permitted to be asserted + // by the backend) we'll just convert it to Archived rather than futz + // around with context specific validation rules. + + // Strictly speaking this is not an Application Rule since backends + // shouldn't assert Deleted status so we don't need to document a + // behavior for what happens when they do. + + $rolecopy['status'] = + $val == ExternalIdentityStatusEnum::Deleted + ? ExternalIdentityStatusEnum::Archived + : $val; } else { -// XXX need to add sponsor/manager mapping CFM-33 +// XXX need to add sponsor/manager mapping CFM-33; remove from duplicateFilterEntityData // Just copy the attribute $rolecopy[$attr] = $val; } } -// XXX need to revisit status management CFM-344 - $rolecopy['status'] = StatusEnum::Active; - // If no affiliation type was provided by the backend, // use the Pipeline's configuration if(empty($rolecopy['affiliation_type_id'])) { @@ -719,7 +763,7 @@ protected function mapAttributesToCO( $attr['type'] ); unset($copy['type']); - $ret[$m][] = $copy; + $rolecopy[$m][] = $copy; } catch(\Exception $e) { $this->llog('error', "Failed to map $attr type \"" . $attr['type'] . "\" to a valid Type ID for EIS role record " . $role['role_key'] . ", skipping"); @@ -879,6 +923,9 @@ protected function syncExternalIdentity( // and try to correlate its record keys. $mapped = $this->correlateRecordKeys($externalIdentity, $mapped); + // Track any new entities so we don't immediately delete them + $newEntities = []; + // To avoid complications with patching, we work with individual records, // not associated models. $externalIdentityEntity = $this->Cos->People->ExternalIdentities->get( @@ -940,11 +987,12 @@ protected function syncExternalIdentity( $aentity, // We only need to filter out related models for // ExternalIdentityRoles since the others don't have them - array_filter($arecord, 'is_scalar') + array_filter($arecord, 'is_scalar'), + ['associated' => []] ); if($aentity->isDirty()) { - $this->Cos->People->ExternalIdentities->$model->saveOrFail($aentity); + $this->Cos->People->ExternalIdentities->$model->saveOrFail($aentity, ['associated' => false]); $this->llog('trace', "Updated $model " . $aentity->id . " for External Identity " . $externalIdentityEntity->id); } @@ -994,7 +1042,11 @@ protected function syncExternalIdentity( $newentity = $this->Cos->People->ExternalIdentities->$model->$eirmodel->newEntity($aeirrecord); $this->Cos->People->ExternalIdentities->$model->$eirmodel->saveOrFail($newentity); - this->llog('trace', "Added $eirmodel " . $newentity->id . " for $model " . $aentity->id); + $this->llog('trace', "Added $eirmodel " . $newentity->id . " for $model " . $aentity->id); + + // Inject the new entity so syncPerson sees it + $externalIdentity->$model->$eirmodel[] = $newentity; + $newEntities[$amodel][] = $newentity->id; } } } @@ -1004,7 +1056,11 @@ protected function syncExternalIdentity( if(!empty($aentity->$aeirmodel)) { foreach($aentity->$aeirmodel as $aeirentity) { - $found = Hash::extract($arecord[$aeirmodel], '{n}[id='.$aeirentity->id.']'); + $found = false; + + if(!empty($arecord[$aeirmodel])) { + $found = Hash::extract($arecord[$aeirmodel], '{n}[id='.$aeirentity->id.']'); + } if(!$found) { $this->llog('trace', "Deleted $eirmodel " . $aeirentity->id . " for $model " . $aentity->id); @@ -1027,10 +1083,21 @@ protected function syncExternalIdentity( // $arecord should include the associated models, so we don't need // to do any special handling for them. - $newentity = $this->Cos->People->ExternalIdentities->$model->newEntity($arecord); + $newentity = $this->Cos->People->ExternalIdentities->$model->newEntity( + $arecord, + ['associated' => ['Addresses', 'AdHocAttributes', 'TelephoneNumbers']] + ); + + $this->Cos->People->ExternalIdentities->$model->saveOrFail( + $newentity, + ['associated' => ['Addresses', 'AdHocAttributes', 'TelephoneNumbers']] + ); - $this->Cos->People->ExternalIdentities->$model->saveOrFail($newentity); $this->llog('trace', "Added $model " . $newentity->id . " for External Identity " . $externalIdentityEntity->id); + + // Inject the new entity so syncPerson sees it + $externalIdentity->$amodel[] = $newentity; + $newEntities[$amodel][] = $newentity->id; } } } @@ -1045,7 +1112,19 @@ protected function syncExternalIdentity( if(!empty($externalIdentity->$amodel)) { foreach($externalIdentity->$amodel as $aentity) { - $found = Hash::extract($mapped[$amodel], '{n}[id='.$aentity->id.']'); + $found = false; + + if(!empty($mapped[$amodel])) { + // Is this an existing entity in the mapped data? + $found = (bool)Hash::extract($mapped[$amodel], '{n}[id='.$aentity->id.']'); + + if(!$found + && !empty($newEntities[$amodel]) + && in_array($aentity->id, $newEntities[$amodel])) { + // This is a new entity we just added + $found = true; + } + } if(!$found) { if($model == 'ExternalIdentityRoles') { @@ -1064,14 +1143,36 @@ protected function syncExternalIdentity( $prole = $this->Cos->People->PersonRoles->find() ->where(['PersonRoles.source_external_identity_role_id' => $aentity->id]) + ->contain(['AdHocAttributes', 'Addresses', 'TelephoneNumbers']) ->first(); if(!empty($prole)) { - // Update the status in accordance with the Pipeline configuratino - $this->llog('trace', "Updating status on PersonRole " . $prole->id . " to " . $pipeline->sync_status_on_delete . " following deletion of source ExternalIdentityRole " . $aentity->id); + if(isset($prole->frozen) && $prole->frozen) { + $this->llog('trace', "Refusing to update frozen Person Role " . $prole->id . " from deleted External Identity Role " . $aentity->id); + } else { + // Update the status in accordance with the Pipeline configuration + $this->llog('trace', "Updating status on PersonRole " . $prole->id . " to " . $pipeline->sync_status_on_delete . " following deletion of source ExternalIdentityRole " . $aentity->id); + + $prole->status = $pipeline->sync_status_on_delete; + $this->Cos->People->PersonRoles->saveOrFail($prole); + + // Delete the MVEAs associated with this Person Role. We do this here + // rather than in syncPerson since we're doing all the other work here. + foreach([ + 'Addresses', + 'AdHocAttributes', + 'TelephoneNumbers' + ] as $eirmodel) { + $aeirmodel = Inflector::underscore($eirmodel); - $prole->status = $pipeline->sync_status_on_delete; - $this->Cos->People->PersonRoles->saveOrFail($prole); + if(!empty($prole->$aeirmodel)) { + foreach($prole->$aeirmodel as $aeirentity) { + $this->llog('trace', "Deleted $aeirmodel " . $aeirentity->id . " for Person Role " . $prole->id); + $this->Cos->People->PersonRoles->$eirmodel->deleteOrFail($aeirentity); + } + } + } + } } } @@ -1167,6 +1268,11 @@ protected function syncPerson( // There is an existing record, update it (if it changed) _unless_ // the attribute record is frozen. + if($model == 'Names' && $found->primary_name) { + // Preserve the primary name flag, if set + $newdata['primary_name'] = true; + } + $this->Cos->People->$model->patchEntity($found, $newdata); if($found->isDirty()) { @@ -1228,7 +1334,8 @@ protected function syncPerson( $seenRoleIds = []; if(!empty($externalIdentity->external_identity_roles)) { - $sourcefk = 'source_external_identity_role_id'; + // $sourcefk = 'source_external_identity_role_id' + $sourcefk = $this->Cos->People->PersonRoles->sourceForeignKey(); // Pull the current Person Roles $curentities = $this->Cos->People->PersonRoles @@ -1237,6 +1344,7 @@ protected function syncPerson( 'PersonRoles.person_id' => $person->id, "PersonRoles.$sourcefk IS NOT" => null ]) + ->contain(['AdHocAttributes', 'Addresses', 'TelephoneNumbers']) ->all(); foreach($externalIdentity->external_identity_roles as $eirentity) { @@ -1261,7 +1369,19 @@ protected function syncPerson( // duplicateFilterEntityData() will remove status, but we need to // set it back (if asserted) or set a default (if not). - $newdata['status'] = $eirentity->status ?? StatusEnum::Pending; + if(!empty($eirentity->status)) { + if($eirentity->status == ExternalIdentityStatusEnum::Archived) { + // The EI Role was flagged as Archived, update the Person Role to + // the status configured in the Pipeline. In this scenario, we don't + // otherwise remove associated MVEAs. + $newdata['status'] = $pipeline->sync_status_on_delete; + } else { + $newdata['status'] = $eirentity->status; + } + } else { + // Default to Active status for this Role (subject to validity date recalculation) + $newdata['status'] = StatusEnum::Active; + } // Do we have a corresponding record on the Person? $found = $curentities->firstMatch([$sourcefk => $eirentity->id]); @@ -1270,13 +1390,13 @@ protected function syncPerson( // There is an existing record, update it (if it changed) _unless_ // the role record is frozen. - $this->Cos->People->PersonRoles->patchEntity($found, $newdata); + $this->Cos->People->PersonRoles->patchEntity($found, $newdata, ['associated' => []]); if($found->isDirty()) { if(isset($found->frozen) && $found->frozen) { - $this->llog('trace', "Refusing to update frozen $model " . $found->id . " to Person from External Identity " . $externalIdentity->id); + $this->llog('trace', "Refusing to update frozen Person Role " . $found->id . " to Person from External Identity " . $externalIdentity->id); } else { - $this->Cos->People->PersonRoles->saveOrFail($found); + $this->Cos->People->PersonRoles->saveOrFail($found, ['associated' => false]); $this->llog('trace', "Updated PersonRole " . $found->id . " to Person from External Identity " . $externalIdentity->id); } } @@ -1284,12 +1404,95 @@ protected function syncPerson( // Default the new attribute to not frozen $newdata['frozen'] = false; - $newentity = $this->Cos->People->PersonRoles->newEntity($newdata); - $this->Cos->People->PersonRoles->saveOrFail($newentity); + $newentity = $this->Cos->People->PersonRoles->newEntity($newdata, ['associated' => []]); + $this->Cos->People->PersonRoles->saveOrFail($newentity, ['associated' => false]); $this->llog('trace', "Added PersonRole " . $newentity->id . " to Person from External Identity " . $externalIdentity->id); } + // Now handle related models + + foreach([ + 'ad_hoc_attributes' => 'AdHocAttributes', + 'addresses' => 'Addresses', + 'telephone_numbers' => 'TelephoneNumbers' + ] as $m => $t) { + $seenRelatedModelIds = []; + + if(!empty($eirentity->$m)) { + foreach($eirentity->$m as $relatedEntity) { + // Convert the related entity to an array and filter it + $newdata = $this->duplicateFilterEntityData($relatedEntity); + + // Insert foreign keys + $rsourcefk = $this->Cos->People->PersonRoles->$t->sourceForeignKey(); + $newdata[$rsourcefk] = $relatedEntity->id; + $newdata['person_role_id'] = $found->id ?? $newentity->id; + + // See if we have a correponding Person Role entity, but only if + // we're working with an existing Person Role + + $relatedFound = null; + + if(!empty($found->$m)) { + $relatedFound = Hash::extract($found->$m, '{n}['.$rsourcefk.'='.$relatedEntity->id.']'); + + if($relatedFound) { + // Hash returns an array, but we want the first object in it + + $relatedFound = $relatedFound[0]; + + // There is an existing record, update it (if it changed) _unless_ + // the record is frozen + + $this->Cos->People->PersonRoles->$t->patchEntity($relatedFound, $newdata, ['associated' => []]); + + if($relatedFound->isDirty()) { + if(isset($relatedFound->frozen) && $relatedFound->frozen) { + $this->llog('trace', "Refusing to update frozen $t " . $relatedFound->id . " to Person Role from External Identity Role $t " . $relatedEntity->id); + } else { + $this->Cos->People->PersonRoles->$t->saveOrFail($relatedFound, ['associated' => false]); + $this->llog('trace', "Updated $t " . $relatedFound->id . " to Person Role from External Identity Role $t " . $relatedEntity->id); + } + } + + $seenRelatedModelIds[] = $relatedFound->id; + } + } + + // We need to use empty() because Hash might return an empty array + if(empty($relatedFound)) { + // We have a new related entity on an existing Person Role, or a new + // Person Role (and therefore all related entities are new) + + // Default the new attribute to not frozen + $newdata['frozen'] = false; + + $newentity = $this->Cos->People->PersonRoles->$t->newEntity($newdata, ['associated' => []]); + $this->Cos->People->PersonRoles->$t->saveOrFail($newentity, ['associated' => false]); + + $this->llog('trace', "Added PersonRole $t " . $newentity->id . " to Person Role from External Identity Role $t " . $relatedEntity->id); + + $seenRelatedModelIds[] = $newentity->id; + } + } + } + + // Delete any related models we didn't see in the source EI Role + if(!empty($found->$m)) { + foreach($found->$m as $curRelatedEntity) { + if(!in_array($curRelatedEntity->id, $seenRelatedModelIds)) { + if(isset($curRelatedEntity->frozen) && $curRelatedEntity->frozen) { + $this->llog('trace', "Refusing to delete frozen $t " . $curRelatedEntity->id . " from Person Role $t " . $relatedEntity->id); + } else { + $this->llog('trace', "Deleted $t " . $curRelatedEntity->id . " for Person Role " . $found->id); + $this->Cos->People->PersonRoles->$t->deleteOrFail($curRelatedEntity); + } + } + } + } + } + $seenRoleIds[] = $eirentity->id; } @@ -1298,13 +1501,24 @@ protected function syncPerson( // to be applied, and also allows us to reactive a role if it comes back // with the same Role Key. + // Under what circumstances would we have a Person Role with a foreign key + // to an EI Role, but we didn't see that EI Role when walking the loop, + // above? + // - If the backend changed the status to Suspended or Archived, the EIR + // would still be valid, and we would see it above. + // - If the backend deleted the role entirely, syncExternalIdentity would + // notice, and explicitly change the PersonRole status to $delete_status + // while ExternalIdentityRoles::beforeDelete would update the PR foreign + // key to no longer point to the source EIR, so we wouldn't see the PR + // at all. + // - A manually deleted EIR would behave similarly. +/* if(!empty($curentities->person_roles)) { foreach($curentities->person_roles as $currole) { - if(!in_array($seenRoleIds, $curentities->currole->id)) { - // XXX want to delete person role $curentities->currole->id CFM-33 + if(!in_array($currole->id, $seenRoleIds)) { } } - } + }*/ } return $person; @@ -1318,7 +1532,7 @@ protected function syncPerson( * @param ExternalIdentity $externalIdentity External Identity * @param Person $person Person * @return Person Person - */ + * protected function updatePersonStatus( Pipeline $pipeline, @@ -1336,7 +1550,7 @@ protected function updatePersonStatus( } return $person; - } + }*/ /** * Set validation rules. diff --git a/app/src/Model/Table/PluginsTable.php b/app/src/Model/Table/PluginsTable.php index 977e8543c..e9c69c419 100644 --- a/app/src/Model/Table/PluginsTable.php +++ b/app/src/Model/Table/PluginsTable.php @@ -211,7 +211,8 @@ public function deactivate(int $id): bool { */ public function findActive(Query $query): Query { - return $query->where(['Plugins.status' => SuspendableStatusEnum::Active]); + return $query->where(['Plugins.status' => SuspendableStatusEnum::Active]) + ->order(['plugin' => 'ASC']); } /** @@ -440,7 +441,7 @@ public function syncPluginRegistry() { $dh = opendir($cfg['path']); while(($d = readdir($dh)) !== false) { - if($d == "." || $d == "..") { + if($d == "." || $d == ".." || $d == ".DS_Store") { continue; } diff --git a/app/src/Model/Table/TelephoneNumbersTable.php b/app/src/Model/Table/TelephoneNumbersTable.php index c18ccbe7d..78f3013fc 100644 --- a/app/src/Model/Table/TelephoneNumbersTable.php +++ b/app/src/Model/Table/TelephoneNumbersTable.php @@ -94,6 +94,7 @@ public function initialize(array $config): void { $this->setPrimaryLink(['external_identity_id', 'external_identity_role_id', 'person_id', 'person_role_id']); $this->setRequiresCO(true); + // Models that AcceptCoId should be expicitly added to StandardApiController::initialize() $this->setAcceptsCoId(true); $this->setRedirectGoal('self'); $this->setAllowLookupPrimaryLink(['unfreeze']); diff --git a/app/src/Model/Table/TypesTable.php b/app/src/Model/Table/TypesTable.php index 532d7e5a9..7af7e94a5 100644 --- a/app/src/Model/Table/TypesTable.php +++ b/app/src/Model/Table/TypesTable.php @@ -106,7 +106,6 @@ public function initialize(array $config): void { $this->hasMany('Pronouns'); $this->hasMany('TelephoneNumbers'); $this->hasMany('Urls'); -// XXX add other MVEA models $this->setDisplayField('display_name'); diff --git a/app/src/View/Helper/FieldHelper.php b/app/src/View/Helper/FieldHelper.php index 5fe65f799..961eaf862 100644 --- a/app/src/View/Helper/FieldHelper.php +++ b/app/src/View/Helper/FieldHelper.php @@ -95,7 +95,8 @@ public function control(string $fieldName, string $beforeField = '', string $afterField = '', string $prefix = '', - bool $labelIsTextOnly = false): string { + bool $labelIsTextOnly = false, + string $controlType = null): string { $coptions = $options; $coptions['label'] = false; $coptions['readonly'] = @@ -111,8 +112,8 @@ public function control(string $fieldName, // Get the field type from the map of fields (e.g. 'boolean', 'string', 'timestamp') $fieldMap = $this->getView()->get('vv_field_types'); - $fieldType = $fieldMap[$fieldName]; - + $fieldType = $controlType ?: $fieldMap[$fieldName]; + // Remove prefix from field value if(!empty($prefix) && !empty($this->getView()->get('vv_obj')->$fieldName)) { $vv_obj = $this->getView()->get('vv_obj'); @@ -510,7 +511,6 @@ protected function formNameDiv(string $fieldName, string $labelText=null, string * @return string Source HTML */ - // XXX docblock - emit control for MVEA that has a source_foo_id public function sourceControl($entity): string { // eg: Identifiers $modelName = StringUtilities::entityToClassName($entity); diff --git a/app/templates/ExternalIdentities/columns.inc b/app/templates/ExternalIdentities/columns.inc index 6a516d069..0283f8f16 100644 --- a/app/templates/ExternalIdentities/columns.inc +++ b/app/templates/ExternalIdentities/columns.inc @@ -31,10 +31,10 @@ $indexColumns = [ 'name' => [ 'type' => 'link', 'action' => 'view', - 'model' => 'primary_name', + 'model' => 'names', 'field' => 'full_name', // XXX see comments in the controller about sorting on given vs family - 'sortable' => 'PrimaryName.family' +// 'sortable' => 'PrimaryName.family' ] ]; diff --git a/app/templates/ExternalIdentitySources/fields.inc b/app/templates/ExternalIdentitySources/fields.inc index c9f6d8019..cd1ef8fc5 100644 --- a/app/templates/ExternalIdentitySources/fields.inc +++ b/app/templates/ExternalIdentitySources/fields.inc @@ -36,4 +36,6 @@ if($vv_action == 'add' || $vv_action == 'edit') { print $this->Field->control('plugin'); print $this->Field->control('pipeline_id'); + + print $this->Field->control('sor_label'); } diff --git a/app/templates/Standard/add-edit-view.php b/app/templates/Standard/add-edit-view.php index 39fc8818e..34110943d 100644 --- a/app/templates/Standard/add-edit-view.php +++ b/app/templates/Standard/add-edit-view.php @@ -100,10 +100,15 @@ $perm = false; if(!empty($t['link']['controller'])) { - // We're linking into a related model + // We're linking into a related model, which may or may not be in a plugin $linkModel = \Cake\Utility\Inflector::camelize($t['link']['controller']); + if(!empty($t['link']['plugin'])) { + $linkModel = \Cake\Utility\Inflector::camelize($t['link']['plugin']) + . "." . $linkModel; + } + if(isset($vv_permissions[$linkModel][ $t['link']['action'] ])) { $perm = $vv_permissions[$linkModel][ $t['link']['action'] ]; } diff --git a/app/templates/Standard/index.php b/app/templates/Standard/index.php index 216ef4c81..3eed39cd6 100644 --- a/app/templates/Standard/index.php +++ b/app/templates/Standard/index.php @@ -49,8 +49,11 @@ // Our default link actions, in order of preference, unless the column config overrides it $linkActions = ['edit', 'view']; +// $vv_template_path will be set for plugins +$templatePath = $vv_template_path ?? ROOT . DS . "templates" . DS . $modelsName; + // Read the index configuration ($indexColumns) and the associated actions for this model -$incFile = ROOT . DS . "templates" . DS . $modelsName . DS . "columns.inc"; +$incFile = $templatePath . DS . "columns.inc"; if(!is_readable($incFile)) { throw new \InvalidArgumentException("$incFile is not readable"); } @@ -110,7 +113,13 @@ 'action' => 'add', '?' => $linkFilter ], - 'label' => __d('operation', 'add.a', __d('controller', $modelsName, [1])), + 'label' => __d( + 'operation', + 'add.a', + \App\Lib\Util\StringUtilities::localizeController( + controllerName: $modelsName, + pluginName: $this->getPlugin(), + plural: false)) ]; } @@ -465,7 +474,11 @@ } } else { if(!empty($entity->$m->$f)) { + // HasOne $label = $entity->$m->$f . $suffix; + } elseif(!empty($entity->$m[0]->$f)) { + // HasMany, pick the first + $label = $entity->$m[0]->$f . $suffix; } } }