diff --git a/app/resources/locales/en_US/error.po b/app/resources/locales/en_US/error.po index b86ddf222..ac016e359 100644 --- a/app/resources/locales/en_US/error.po +++ b/app/resources/locales/en_US/error.po @@ -163,6 +163,9 @@ msgstr "No identifier of type \"{0}\" found" msgid "IdentifierAssignments.type.none" msgstr "No identifier type specified" +msgid "Identifiers.exists" +msgstr "Identifier already exists on {0} ID {1}" + msgid "Identifiers.login" msgstr "Only Identifiers attached to a Person may be flagged for login" diff --git a/app/resources/locales/en_US/field.po b/app/resources/locales/en_US/field.po index d2ceb620f..2849fe1eb 100644 --- a/app/resources/locales/en_US/field.po +++ b/app/resources/locales/en_US/field.po @@ -679,6 +679,12 @@ msgstr "Telephone Number" msgid "TelephoneNumbers.number.ext" msgstr "x" +msgid "Types.case_insensitive" +msgstr "Case Insensitive" + +msgid "Types.case_insensitive.desc" +msgstr "If ticked, uniqueness checks for this Identifier Type will be case insensitive" + msgid "Types.edupersonaffiliation.desc" # XXX update link to PE wiki? msgstr "Map the extended affiliation to this eduPersonAffiliation, see eduPersonAffiliation and Extended Affiliations" diff --git a/app/src/Model/Table/ExtIdentitySourceRecordsTable.php b/app/src/Model/Table/ExtIdentitySourceRecordsTable.php index c8356f2f4..b832d6009 100644 --- a/app/src/Model/Table/ExtIdentitySourceRecordsTable.php +++ b/app/src/Model/Table/ExtIdentitySourceRecordsTable.php @@ -87,7 +87,7 @@ public function initialize(array $config): void { $this->setRequiresCO(true); // These are required for the link to work from the Artifacts page - $this->setAllowUnkeyedPrimaryCO(['index', 'view']); + $this->setAllowUnkeyedPrimaryCO(['index']); $this->setAllowEmptyPrimaryLink(['index']); $this->setIndexContains([ diff --git a/app/src/Model/Table/IdentifiersTable.php b/app/src/Model/Table/IdentifiersTable.php index 8d2f45978..fa5f54cc1 100644 --- a/app/src/Model/Table/IdentifiersTable.php +++ b/app/src/Model/Table/IdentifiersTable.php @@ -31,6 +31,7 @@ use Cake\Event\EventInterface; use Cake\ORM\Query; +use Cake\ORM\RulesChecker; use Cake\ORM\Table; use Cake\Validation\Validator; use \App\Lib\Enum\SuspendableStatusEnum; @@ -40,6 +41,7 @@ class IdentifiersTable extends Table { use \App\Lib\Traits\ChangelogBehaviorTrait; use \App\Lib\Traits\CoLinkTrait; use \App\Lib\Traits\HistoryTrait; + use \App\Lib\Traits\LabeledLogTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\ProvisionableTrait; @@ -172,6 +174,24 @@ public function beforeMarshal(EventInterface $event, \ArrayObject $data, \ArrayO } } + /** + * Define business rules. + * + * @since COmanage Registry v5.0.0 + * @param RulesChecker $rules RulesChecker object + * @return RulesChecker + */ + + public function buildRules(RulesChecker $rules): RulesChecker { + // AR-Identifier-2 An Identifier must be unique for its Type and Entity (Person + // or Group) within the CO. + $rules->add([$this, 'ruleUniqueIdentifier'], + 'uniqueIdentifier', + ['errorField' => 'identifier']); + + return $rules; + } + /** * Callback after model save. * @@ -243,6 +263,63 @@ public function lookupPersonByLogin(int $coId, string $identifier): int { return $id->person_id; } + /** + * Application Rule to determine if an Identifier is already in use. + * + * @since COmanage Registry v5.0.0 + * @param Entity $entity Entity to be validated + * @param array $options Application rule options + * @return boolean true if the Rule check passes, false otherwise + */ + + public function ruleUniqueIdentifier($entity, $options) { + // Uniqueness constraints only apply to People and Groups + + // In v4 we created a txn to ensure consistency, but it looks like Cake actually + // starts a transaction, so it appears we don't actially need to do that here. + + if(!empty($entity->person_id) || !empty($entity->group_id)) { + if($entity->isNew() + || $entity->isDirty('identifier') + || $entity->isDirty('type_id')) { + // We need the Type configuration to see if uniqueness is case insensitive + $type = $this->Types->get($entity->type_id); + + // Note we specifically do NOT check status, since a Suspended Identifier + // will still prevent duplicate assignment. (AR-Identifier-3) + $whereClause = [ + 'type_id' => $entity->type_id + ]; + + if(isset($type->case_insensitive) && $type->case_insensitive) { + $whereClause['LOWER(identifier)'] = strtolower($entity->identifier); + } else { + $whereClause['identifier'] = $entity->identifier; + } + + $identifier = $this->find() + ->where($whereClause) + ->epilog('FOR UPDATE') + ->first(); + + if(!empty($identifier)) { + $inusect = !empty($identifier->person_id) ? __d('controller', 'People', 1) : __d('controller', 'Groups', 1); + $inuseid = !empty($identifier->person_id) ? $identifier->person_id : $identifier->group_id; + + // If we fail in the middle of Identifier Assignment this returned message + // will get lost/superceded by a rollback error + $this->llog('rule', "AR-Identifier-2 Identifier " . $identifier->identifier . " is already in use on $inusect ID $inuseid"); + + return __d('error', + 'Identifiers.exists', + [$inusect, $inuseid]); + } + } + } + + return true; + } + /** * Perform a keyword search. * diff --git a/app/src/Model/Table/TypesTable.php b/app/src/Model/Table/TypesTable.php index 7af7e94a5..a47f7532f 100644 --- a/app/src/Model/Table/TypesTable.php +++ b/app/src/Model/Table/TypesTable.php @@ -405,6 +405,11 @@ public function validationDefault(Validator $validator): Validator { ]); $validator->allowEmptyString('edupersonaffiliation'); + $validator->add('case_insensitive', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('case_insensitive'); + $validator->add('status', [ 'content' => ['rule' => ['inList', SuspendableStatusEnum::getConstValues()]] ]); diff --git a/app/templates/People/fields.inc b/app/templates/People/fields.inc index 016c50412..d479e5c2a 100644 --- a/app/templates/People/fields.inc +++ b/app/templates/People/fields.inc @@ -48,7 +48,8 @@ if($vv_action == 'add') { 'arguments' => [ 'fieldName' => 'names.0.type_id', 'fieldOptions' => [ - 'default' => $vv_default_name_type + 'default' => $vv_default_name_type, + 'required' => true ], 'fieldType' => 'string' ]]); diff --git a/app/templates/Types/fields.inc b/app/templates/Types/fields.inc index d235049c1..f90568fd6 100644 --- a/app/templates/Types/fields.inc +++ b/app/templates/Types/fields.inc @@ -33,10 +33,14 @@ var attr = document.getElementById('attribute').value; // Handle page interaction - if(attr == 'PersonRoles.affiliation_type') { + if(attr == 'Identifiers.type') { + hideFields(['edupersonaffiliation'], isPageLoad); + showFields(['case-insensitive'], isPageLoad); + } else if(attr == 'PersonRoles.affiliation_type') { + hideFields(['case-insensitive'], isPageLoad); showFields(['edupersonaffiliation'], isPageLoad); } else { - hideFields(['edupersonaffiliation'], isPageLoad); + hideFields(['case-insensitive', 'edupersonaffiliation'], isPageLoad); } } @@ -60,6 +64,7 @@ if($vv_action == 'add' || $vv_action == 'edit') { foreach (['display_name', 'value', 'status', + 'case_insensitive', 'edupersonaffiliation' ] as $field) { print $this->element('form/listItem', [