From afd5ab2fb89b617d5101d01cb7f0c0e4d1a2e0a9 Mon Sep 17 00:00:00 2001 From: Benn Oshrin Date: Mon, 31 Jul 2023 11:13:15 -0400 Subject: [PATCH] Initial implementation of Identifier Assignment (CFM-57) --- app/composer.json | 6 +- app/config/schema/schema.json | 26 +- app/plugins/CoreAssigner/README.md | 11 + app/plugins/CoreAssigner/composer.json | 24 + app/plugins/CoreAssigner/phpunit.xml.dist | 30 + .../resources/locales/en_US/core_assigner.po | 74 +++ .../src/Controller/AppController.php | 10 + .../Controller/FormatAssignersController.php | 40 ++ .../CoreAssigner/src/CoreAssignerPlugin.php | 93 +++ .../src/Lib/Enum/CollisionModeEnum.php | 37 ++ .../src/Lib/Enum/PermittedCharactersEnum.php | 67 +++ .../src/Model/Entity/FormatAssigner.php | 49 ++ .../Model/Entity/FormatAssignerSequence.php | 49 ++ .../Table/FormatAssignerSequencesTable.php | 154 +++++ .../src/Model/Table/FormatAssignersTable.php | 548 ++++++++++++++++++ .../CoreAssigner/src/config/plugin.json | 38 ++ .../templates/FormatAssigners/fields.inc | 64 ++ app/plugins/CoreAssigner/tests/bootstrap.php | 55 ++ app/plugins/CoreAssigner/tests/schema.sql | 1 + app/plugins/CoreAssigner/webroot/.gitkeep | 0 .../src/Model/Table/SqlServersTable.php | 1 + app/resources/locales/en_US/controller.po | 3 + app/resources/locales/en_US/enumeration.po | 9 + app/resources/locales/en_US/error.po | 15 + app/resources/locales/en_US/field.po | 21 +- app/resources/locales/en_US/operation.po | 3 + app/resources/locales/en_US/result.po | 9 + app/src/Controller/AppController.php | 7 +- app/src/Controller/DashboardsController.php | 5 + .../IdentifierAssignmentsController.php | 80 +++ app/src/Controller/StandardController.php | 17 +- app/src/Lib/Enum/ActionEnum.php | 27 +- .../Enum/IdentifierAssignmentContextEnum.php | 38 ++ app/src/Lib/Traits/HistoryTrait.php | 8 +- app/src/Lib/Traits/PrimaryLinkTrait.php | 97 ++-- app/src/Lib/Traits/ProvisionableTrait.php | 7 +- app/src/Lib/Util/SchemaManager.php | 6 +- app/src/Lib/Util/StringUtilities.php | 14 +- app/src/Model/Entity/IdentifierAssignment.php | 42 ++ app/src/Model/Table/AdHocAttributesTable.php | 11 +- app/src/Model/Table/AddressesTable.php | 13 +- app/src/Model/Table/EmailAddressesTable.php | 11 +- app/src/Model/Table/GroupsTable.php | 3 +- .../Table/IdentifierAssignmentsTable.php | 507 ++++++++++++++++ app/src/Model/Table/IdentifiersTable.php | 12 +- app/src/Model/Table/NamesTable.php | 11 +- app/src/Model/Table/PeopleTable.php | 1 + app/src/Model/Table/PluginsTable.php | 4 +- app/src/Model/Table/PronounsTable.php | 11 +- app/src/Model/Table/TelephoneNumbersTable.php | 11 +- app/src/Model/Table/UrlsTable.php | 11 +- app/templates/Groups/fields-nav.inc | 33 +- .../IdentifierAssignments/columns.inc | 64 ++ .../IdentifierAssignments/fields.inc | 100 ++++ app/templates/People/fields-nav.inc | 13 + app/vendor/cakephp-plugins.php | 1 + app/vendor/composer/autoload_psr4.php | 2 + app/vendor/composer/autoload_static.php | 10 + 58 files changed, 2449 insertions(+), 165 deletions(-) create mode 100644 app/plugins/CoreAssigner/README.md create mode 100644 app/plugins/CoreAssigner/composer.json create mode 100644 app/plugins/CoreAssigner/phpunit.xml.dist create mode 100644 app/plugins/CoreAssigner/resources/locales/en_US/core_assigner.po create mode 100644 app/plugins/CoreAssigner/src/Controller/AppController.php create mode 100644 app/plugins/CoreAssigner/src/Controller/FormatAssignersController.php create mode 100644 app/plugins/CoreAssigner/src/CoreAssignerPlugin.php create mode 100644 app/plugins/CoreAssigner/src/Lib/Enum/CollisionModeEnum.php create mode 100644 app/plugins/CoreAssigner/src/Lib/Enum/PermittedCharactersEnum.php create mode 100644 app/plugins/CoreAssigner/src/Model/Entity/FormatAssigner.php create mode 100644 app/plugins/CoreAssigner/src/Model/Entity/FormatAssignerSequence.php create mode 100644 app/plugins/CoreAssigner/src/Model/Table/FormatAssignerSequencesTable.php create mode 100644 app/plugins/CoreAssigner/src/Model/Table/FormatAssignersTable.php create mode 100644 app/plugins/CoreAssigner/src/config/plugin.json create mode 100644 app/plugins/CoreAssigner/templates/FormatAssigners/fields.inc create mode 100644 app/plugins/CoreAssigner/tests/bootstrap.php create mode 100644 app/plugins/CoreAssigner/tests/schema.sql create mode 100644 app/plugins/CoreAssigner/webroot/.gitkeep create mode 100644 app/src/Controller/IdentifierAssignmentsController.php create mode 100644 app/src/Lib/Enum/IdentifierAssignmentContextEnum.php create mode 100644 app/src/Model/Entity/IdentifierAssignment.php create mode 100644 app/src/Model/Table/IdentifierAssignmentsTable.php create mode 100644 app/templates/IdentifierAssignments/columns.inc create mode 100644 app/templates/IdentifierAssignments/fields.inc diff --git a/app/composer.json b/app/composer.json index 6c29c7fbb..913c590aa 100644 --- a/app/composer.json +++ b/app/composer.json @@ -32,7 +32,8 @@ "App\\": "src/", "CoreServer\\": "plugins/CoreServer/src/", "FileConnector\\": "availableplugins/FileConnector/src/", - "SqlConnector\\": "availableplugins/SqlConnector/src/" + "SqlConnector\\": "availableplugins/SqlConnector/src/", + "CoreAssigner\\": "plugins/CoreAssigner/src/" } }, "autoload-dev": { @@ -41,7 +42,8 @@ "Cake\\Test\\": "vendor/cakephp/cakephp/tests/", "CoreServer\\Test\\": "plugins/CoreServer/tests/", "FileConnector\\Test\\": "availableplugins/FileConnector/tests/", - "SqlConnector\\Test\\": "availableplugins/SqlConnector/tests/" + "SqlConnector\\Test\\": "availableplugins/SqlConnector/tests/", + "CoreAssigner\\Test\\": "plugins/CoreAssigner/tests/" } }, "scripts": { diff --git a/app/config/schema/schema.json b/app/config/schema/schema.json index 652a772d7..7e9227acb 100644 --- a/app/config/schema/schema.json +++ b/app/config/schema/schema.json @@ -11,12 +11,14 @@ "columns": { "co_id": { "type": "integer", "foreignkey": { "table": "cos", "column": "id" }, "notnull": true }, "comment": { "type": "string", "size": 256 }, + "context": { "type": "string", "size": 2 }, "cou_id": { "type": "integer", "foreignkey": { "table": "cous", "column": "id" } }, "description": { "type": "string", "size": 128 }, "external_identity_id": { "type": "integer", "foreignkey": { "table": "external_identities", "column": "id" } }, "external_identity_role_id": { "type": "integer", "foreignkey": { "table": "external_identity_roles", "column": "id" } }, "group_id": { "type": "integer", "foreignkey": { "table": "groups", "column": "id" } }, "id": { "type": "integer", "autoincrement": true, "primarykey": true }, + "identifier_assignment_id": { "type": "integer", "foreignkey": { "table": "identifier_assignments", "column": "id" }, "notnull": true }, "language": { "type": "string", "size": 16 }, "name": { "type": "string", "size": 128, "notnull": true }, "ordr": { "type": "integer" }, @@ -171,8 +173,7 @@ "cous_i1": { "columns": [ "co_id" ] }, "cous_i2": { "columns": [ "name" ] }, "cous_i3": { "columns": [ "co_id", "name" ] }, - "cous_i4": { "needed": false, "columns": [ "parent_id" ] - } + "cous_i4": { "needed": false, "columns": [ "parent_id" ] } } }, @@ -578,6 +579,27 @@ "indexes": { "servers_i1": { "columns": [ "co_id" ] } } + }, + + "identifier_assignments": { + "columns": { + "id": {}, + "co_id": {}, + "description": {}, + "plugin": {}, + "status": {}, + "context": {}, + "group_id": {}, + "identifier_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" }, "notnull": false }, + "login": { "type": "boolean" }, + "email_address_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" }, "notnull": false }, + "ordr": {} + }, + "indexes": { + "identifier_assignments_i1": { "columns": [ "co_id" ] }, + "identifier_assignments_i2": { "needed": false, "columns": [ "email_address_type_id" ] }, + "identifier_assignments_i3": { "needed": false, "columns": [ "identifier_type_id" ] } + } } }, diff --git a/app/plugins/CoreAssigner/README.md b/app/plugins/CoreAssigner/README.md new file mode 100644 index 000000000..23bd1ab90 --- /dev/null +++ b/app/plugins/CoreAssigner/README.md @@ -0,0 +1,11 @@ +# CoreAssigner plugin for CakePHP + +## Installation + +You can install this plugin into your CakePHP application using [composer](https://getcomposer.org). + +The recommended way to install composer packages is: + +``` +composer require your-name-here/core-assigner +``` diff --git a/app/plugins/CoreAssigner/composer.json b/app/plugins/CoreAssigner/composer.json new file mode 100644 index 000000000..627cf1916 --- /dev/null +++ b/app/plugins/CoreAssigner/composer.json @@ -0,0 +1,24 @@ +{ + "name": "your-name-here/core-assigner", + "description": "CoreAssigner plugin for CakePHP", + "type": "cakephp-plugin", + "license": "MIT", + "require": { + "php": ">=7.2", + "cakephp/cakephp": "4.4.*" + }, + "require-dev": { + "phpunit/phpunit": "^8.5 || ^9.3" + }, + "autoload": { + "psr-4": { + "CoreAssigner\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "CoreAssigner\\Test\\": "tests/", + "Cake\\Test\\": "vendor/cakephp/cakephp/tests/" + } + } +} diff --git a/app/plugins/CoreAssigner/phpunit.xml.dist b/app/plugins/CoreAssigner/phpunit.xml.dist new file mode 100644 index 000000000..96d774cf4 --- /dev/null +++ b/app/plugins/CoreAssigner/phpunit.xml.dist @@ -0,0 +1,30 @@ + + + + + + + + + + + tests/TestCase/ + + + + + + + + + + + src/ + + + diff --git a/app/plugins/CoreAssigner/resources/locales/en_US/core_assigner.po b/app/plugins/CoreAssigner/resources/locales/en_US/core_assigner.po new file mode 100644 index 000000000..0e3112e84 --- /dev/null +++ b/app/plugins/CoreAssigner/resources/locales/en_US/core_assigner.po @@ -0,0 +1,74 @@ +# COmanage Registry Localizations (core_assigner domain) +# +# Portions licensed to the University Corporation for Advanced Internet +# Development, Inc. ("UCAID") under one or more contributor license agreements. +# See the NOTICE file distributed with this work for additional information +# regarding copyright ownership. +# +# UCAID licenses this file to you under the Apache License, Version 2.0 +# (the "License"); you may not use this file except in compliance with the +# License. You may obtain a copy of the License at: +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# @link https://www.internet2.edu/comanage COmanage Project +# @package registry-plugins +# @since COmanage Registry v5.0.0 +# @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + +msgid "controller.FormatAssigners" +msgstr "{0,plural,=1{Format Assigner} other{Format Assigners}}" + +msgid "enumeration.CollisionModeEnum.R" +msgstr "Random" + +msgid "enumeration.CollisionModeEnum.S" +msgstr "Sequential" + +msgid "enumeration.PermittedCharactersEnum.AN" +msgstr "AlphaNumeric Only" + +msgid "enumeration.PermittedCharactersEnum.AD" +msgstr "AlphaNumeric and Dot, Dash, Underscore" + +msgid "enumeration.PermittedCharactersEnum.AQ" +msgstr "AlphaNumeric and Dot, Dash, Underscore, Apostrophe" + +msgid "enumeration.PermittedCharactersEnum.AL" +msgstr "Any" + +msgid "field.FormatAssigners.collision_mode" +msgstr "Collision Mode" + +msgid "field.FormatAssigners.collision_mode.desc" +msgstr "How to assign collision numbers when required" + +msgid "field.FormatAssigners.format" +msgstr "Format" + +msgid "field.FormatAssigners.format.desc" +msgstr "See the documentation for details" + +msgid "field.FormatAssigners.maximum" +msgstr "Maximum Collision Value" + +msgid "field.FormatAssigners.maximum.desc" +msgstr "The maximum value for randomly generated collision numbers" + +msgid "field.FormatAssigners.minimum" +msgstr "Minimum Collision Value" + +msgid "field.FormatAssigners.minimum.desc" +msgstr "The minimum value for randomly generated collision numbers, or the starting value for sequences" + +msgid "field.FormatAssigners.permitted_characters" +msgstr "Permitted Characters" + +msgid "field.FormatAssigners.permitted_characters.desc" +msgstr "When substituting parameters in a format, only permit these characters to be used" diff --git a/app/plugins/CoreAssigner/src/Controller/AppController.php b/app/plugins/CoreAssigner/src/Controller/AppController.php new file mode 100644 index 000000000..5f6437e52 --- /dev/null +++ b/app/plugins/CoreAssigner/src/Controller/AppController.php @@ -0,0 +1,10 @@ + [ + 'FormatAssigners.format' => 'asc' + ] + ]; +} diff --git a/app/plugins/CoreAssigner/src/CoreAssignerPlugin.php b/app/plugins/CoreAssigner/src/CoreAssignerPlugin.php new file mode 100644 index 000000000..fcf1f19dc --- /dev/null +++ b/app/plugins/CoreAssigner/src/CoreAssignerPlugin.php @@ -0,0 +1,93 @@ +plugin( + 'CoreAssigner', + ['path' => '/core-assigner'], + function (RouteBuilder $builder) { + // Add custom routes here + + $builder->fallbacks(); + } + ); + parent::routes($routes); + } + + /** + * Add middleware for the plugin. + * + * @param \Cake\Http\MiddlewareQueue $middlewareQueue The middleware queue to update. + * @return \Cake\Http\MiddlewareQueue + */ + public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue + { + // Add your middlewares here + + return $middlewareQueue; + } + + /** + * Add commands for the plugin. + * + * @param \Cake\Console\CommandCollection $commands The command collection to update. + * @return \Cake\Console\CommandCollection + */ + public function console(CommandCollection $commands): CommandCollection + { + // Add your commands here + + $commands = parent::console($commands); + + return $commands; + } + + /** + * Register application container services. + * + * @param \Cake\Core\ContainerInterface $container The Container to update. + * @return void + * @link https://book.cakephp.org/4/en/development/dependency-injection.html#dependency-injection + */ + public function services(ContainerInterface $container): void + { + // Add your services here + } +} diff --git a/app/plugins/CoreAssigner/src/Lib/Enum/CollisionModeEnum.php b/app/plugins/CoreAssigner/src/Lib/Enum/CollisionModeEnum.php new file mode 100644 index 000000000..1c107f8fe --- /dev/null +++ b/app/plugins/CoreAssigner/src/Lib/Enum/CollisionModeEnum.php @@ -0,0 +1,37 @@ + + */ + protected $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/CoreAssigner/src/Model/Entity/FormatAssignerSequence.php b/app/plugins/CoreAssigner/src/Model/Entity/FormatAssignerSequence.php new file mode 100644 index 000000000..de4e99e81 --- /dev/null +++ b/app/plugins/CoreAssigner/src/Model/Entity/FormatAssignerSequence.php @@ -0,0 +1,49 @@ + + */ + protected $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/CoreAssigner/src/Model/Table/FormatAssignerSequencesTable.php b/app/plugins/CoreAssigner/src/Model/Table/FormatAssignerSequencesTable.php new file mode 100644 index 000000000..af501fab1 --- /dev/null +++ b/app/plugins/CoreAssigner/src/Model/Table/FormatAssignerSequencesTable.php @@ -0,0 +1,154 @@ +addBehavior('Timestamp'); + + // This is sort of a hybrid of configuration and artifact... + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('CoreAssigner.FormatAssigners'); + + $this->setDisplayField('affix'); + + $this->setPrimaryLink('CoreAssigner.format_assigner_id'); + $this->setRequiresCO(true); + } + + /** + * Obtain the next sequence number for the specified identifier assignment. + * NOTE: This method should be called from within a transaction + * + * @since COmanage Registry v5.0.0 + * @param int $formatAssignerId Format Assigner ID + * @param string $affix Affix to obtain a sequence number for + * @param int $start Initial value to return if sequence not yet started + * @return int Next sequence + */ + + public function next( + int $formatAssignerId, + string $affix, + int $start + ): int { + // We're basically implementing sequences. We don't actually use sequences + // because dynamically creating sequences is a recipe for platform dependent + // coding. + + $newCount = 1; + + if($start && $start > -1) { + $newCount = $start; + } + + // Get the current value for this affix. We need to use FOR UPDATE in case + // another process is trying to assign the same sequence number at the same time. + + $cur = $this->find() + ->where([ + 'FormatAssignerSequences.format_assigner_id' => $formatAssignerId, + 'FormatAssignerSequences.affix' => $affix + ]) + ->epilog('FOR UPDATE') + ->first(); + + if(!empty($cur)) { + // Increment an existing counter + + $newCount = $cur->last + 1; + + $cur->last = $newCount; + + $this->save($cur); + } else { + // Start a new counter + + $seq = $this->newEntity([ + 'format_assigner_id' => $formatAssignerId, + 'affix' => $affix, + 'start' => $newCount + ]); + + $this->save($seq); + } + + return $newCount; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('format_assigner_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('format_assigner_id'); + + $this->registerStringValidation($validator, $schema, 'affix', true); + + $validator->add('last', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('last'); + + return $validator; + } +} diff --git a/app/plugins/CoreAssigner/src/Model/Table/FormatAssignersTable.php b/app/plugins/CoreAssigner/src/Model/Table/FormatAssignersTable.php new file mode 100644 index 000000000..67dd1aa74 --- /dev/null +++ b/app/plugins/CoreAssigner/src/Model/Table/FormatAssignersTable.php @@ -0,0 +1,548 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + // Timestamp behavior handles created/modified updates + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('IdentifierAssignments'); + + $this->hasMany('CoreAssigner.FormatAssignerSequences') + ->setDependent(true) + ->setCascadeCallbacks(true); + + $this->setDisplayField('format'); + + $this->setPrimaryLink('identifier_assignment_id'); + $this->setRequiresCO(true); + + $this->setAutoViewVars([ + 'collisionModes' => [ + 'type' => 'enum', + 'class' => 'CoreAssigner.CollisionModeEnum' + ], + 'permittedCharacters' => [ + 'type' => 'enum', + 'class' => 'CoreAssigner.PermittedCharactersEnum' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, // Delete the pluggable object instead + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ +// XXX do we need to fix this for other plugin entry point models? + 'add' => false, // This is added by the parent model + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Assign an identifier. + * + * @since COmanage Registry v5.0.0 + * @param IdentifierAssignment $ia Identifier Assignment describing the requested configuration + * @param object $entity The entity (Person, Group, Department) to assign an Identifier for + * @return string The newly proposed Identifier + * @throws InvalidArgumentException + * @throws RuntimeException + */ + + public function assign($ia, $entity): string { + // Generate the new identifier. This requires several steps. First, substitute + // non-collision number parameters to generate our base. If substituteParameters() + // fails, it'll throw an Exception that we let bubble up. + $base = $this->substituteParameters( + $entity, + // If no format is specified, default to "(#)". + $ia->format_assigner->format ?? "(#)", + $ia->format_assigner->permitted_characters + ); + + // Now that we've got our base, loop until we get a unique identifier. + // We try a maximum of 10 (0 through 9) times, and track identifiers we've + // seen already. + + $tested = []; + $ret = null; + + // PAR-FormatAssigner-1 A maximum of 10 attempts will be made to assign an Identifier. + for($i = 0;$i < 10;$i++) { + $sequenced = $this->selectSequences( + $base, + $i, + $ia->format_assigner->permitted_characters + ); + + // There may or may not be a collision number format. If so, we should end + // up with a unique candidate (though for random it's possible we won't). + $candidate = $this->assignCollisionNumber( + $ia->format_assigner->id, + $sequenced, + $ia->format_assigner->collision_mode, + $ia->format_assigner->minimum, + $ia->format_assigner->maximum + ); + + if(!in_array($candidate, $tested) + // Also check that we didn't get an empty string + && trim($candidate) != false) { + // We have a new candidate (ie: one that wasn't generated on a previous loop), + // so let's see if it is already in use. + + try { + $this->IdentifierAssignments->checkAvailability( + (!empty($ia->identifier_type_id) ? 'Identifiers' : 'EmailAddresses'), + (!empty($ia->identifier_type_id) ? $ia->identifier_type_id : $ia->email_address_type_id), + $candidate, + $entity + ); + + $ret = $candidate; + } + catch(\OverflowException $e) { + // The Identifier we generated is in use, try again + $this->llog('trace', "Generated candidate identifier $candidate, but it is already in use"); + } + + if($ret) { + break; + } + + // else try the next one + $tested[] = $candidate; + } + } + + if(!$ret) { + throw new \RuntimeException(__d('error', 'IdentifierAssignments.failed')); + } + + return $ret; + } + + /** + * Assign a collision number if the current identifier segment accepts one. + * + * @since COmanage Registry v5.0.0 + * @param int $formatAssignerId Format Assigner ID + * @param string $sequenced Sequenced string as returned by selectSequences() + * @param CollisionModeEnum $collisionMode Collision number assignment mode + * @param int $min Minimum number to assign + * @param int $max Maximum number to assign (for Random mode only) + * @return string Candidate string, possibly with a collision number assigned + * @throws InvalidArgumentException + */ + + protected function assignCollisionNumber( + int $formatAssignerId, + string $sequenced, + string $collisionMode, + int $min, + ?int $max=null + ): string { + // We expect $sequenced to be %s and not %d in order to be able to ensure + // a specific width (ie: padded and/or truncated). This also makes sense in that + // identifiers are really strings, not numbers. + + $matches = []; + + if(preg_match('/\%[0-9.]*s/', $sequenced, $matches)) { + switch($collisionMode) { + case CollisionModeEnum::Random: + // Simply pick a number between $min and $max. + + $lmax = $max; + + if(!$max) { + // We have to be a bit careful with min and max vs mt_rand(). substituteParameters() + // will generate something like (%05.5s). If no explicit $max is configured by the + // admin, we used mt_getrandmax. However, that could generate a string like 172500398. + // We take the first (eg) 5 digits, which are "17250". If $min is 20000, we'll + // incorrectly assign a collision number outside the permitted range (CO-1933). + + // Pull the width out of the string + $width = (int)rtrim(ltrim(strstr($matches[0], '.'), "."), "s"); + + // And calculate a new max + $lmax = (10 ** $width) - 1; + } + + $n = random_int($min, $lmax); + return sprintf($sequenced, $n); + break; + case CollisionModeEnum::Sequential: + return sprintf($sequenced, $this->FormatAssignerSequences->next( + formatAssignerId: $formatAssignerId, + affix: $sequenced, + start: $min)); + break; + default: + throw new InvalidArgumentException(__d('error', 'unknown', $algorithm)); + break; + } + } else { + // Nothing to do, just return the same string + + return $sequenced; + } + } + + /** + * Select the sequenced segments to be processed for the given iteration. + * + * @since COmanage Registry v5.0.0 + * @param string $base Base string as returned by substituteParameters + * @param int $iteration Iteration number (between 0 and 9) + * @param PermittedCharactersEnum $permitted Acceptable characters for substituted parameters + * @return string Format with sequenced segments selected + */ + + protected function selectSequences( + string $base, + int $iteration, + string $permitted + ): string { + $sequenced = ""; + + // Loop through the string + for($j = 0;$j < strlen($base);$j++) { + switch($base[$j]) { + case '\\': + // Copy the next character directly + if($j+1 < strlen($base)) { + $j++; + $sequenced .= $base[$j]; + } + break; + case '[': + // Sequenced segment + + // Single Use segments are only incorporated into the specified iteration, + // vs Additive segments that are incorporated into all subsequent ones as well. + $singleuse = false; + + if($j+1 < strlen($base) && $base[$j+1] == '=') { + $singleuse = true; + $j++; + } + + if($j+3 < strlen($base)) { + $j++; + + if(($singleuse && ($base[$j] == $iteration)) + || + (!$singleuse && ($base[$j] <= $iteration))) { + // This segment is now in effect, copy until we see a close bracket + // (and jump past the ':') + $j += 2; + + // Assemble the text for this segment. If after parameter substitution + // we end up with no permitted characters, skip this segment + + $segtext = ""; + + while($base[$j] != ']') { + $segtext .= $base[$j]; + $j++; + } + + if(strlen($segtext) > 0 + && preg_match('/'. PermittedCharactersEnum::getPermittedCharacters($permitted) . '/', $segtext)) { + $sequenced .= $segtext; + } + } else { + // Move to end of segment, we're not using this one yet + + while($base[$j] != ']') { + $j++; + } + } + } + break; + default: + // Just copy this character + $sequenced .= $base[$j]; + break; + } + } + + return $sequenced; + } + + /** + * Perform parameter substitution on an identifier format to generate the base + * string used in identifier assignment. + * + * @since COmanage Registry v5.0.0 + * @param EntityInterface $entity Entity to assign Identifier for + * @param string $format Identifier assignment format + * @param PermittedCharactersEnum $permitted Acceptable characters for substituted parameters + * @return string Identifier with paramaters substituted + * @throws RuntimeException + */ + + protected function substituteParameters( + $entity, + string $format, + string $permitted + ): string { + $base = ""; + + // For random letter generation ('h', 'r', 'R') + $randomCharSet = array( + 'h' => "0123456789abcdef", + 'l' => "abcdefghijkmnopqrstuvwxyz", // Note no "l" + 'L' => "ACDEFGHIJKLMNPQPTUVWXYZ" // Note no "B", "O", or "S" (similar to 8,0,5) + ); + + // Loop through the format string + for($i = 0;$i < strlen($format);$i++) { + switch($format[$i]) { + case '\\': + // Copy the next character directly + if($i+1 < strlen($format)) { + $i++; + $base .= $format[$i]; + } + break; + case '(': + // Parameter to substitute + if($i+2 < strlen($format)) { + // Move past '(' + $i++; + + $width = ""; + + // Check if the next character is a width specifier + if($format[$i+1] == ':') { + // Don't advance $i yet since we still need it, so use $j instead + for($j = $i+2;$j < strlen($format);$j++) { + if($format[$j] != ')') { + $width .= $format[$j]; + } else { + break; + } + } + } + + // Do the actual parameter replacement, blocking out characters that aren't permitted + + $charregex = '/'. PermittedCharactersEnum::getPermittedCharacters(enum: $permitted, invert: true) . '/'; + + switch($format[$i]) { + case 'f': + $base .= sprintf("%.".$width."s", + preg_replace($charregex, '', strtolower($entity->primary_name->family))); + break; + case 'F': + $base .= sprintf("%.".$width."s", + preg_replace($charregex, '', $entity->primary_name->family)); + break; + case 'g': + $base .= sprintf("%.".$width."s", + preg_replace($charregex, '', strtolower($entity->primary_name->given))); + break; + case 'G': + $base .= sprintf("%.".$width."s", + preg_replace($charregex, '', $entity->primary_name->given)); + break; + // Note 'h' is defined with 'l', below + // case 'h': + case 'I': + // We skip the next character (a slash) and then continue reading + // until we get to a close parenthesis + $identifierType = ""; + + $i+=2; + + while($format[$i] != ')' && $i < strlen($format)) { + $identifierType .= $format[$i]; + $i++; + } + + // Rewind one character because we're going to advance past it + // again below. + $i--; + + if($identifierType == "") { + throw new \RuntimeException(__d('error', 'IdentifierAssignments.type.none')); + } + + // If we find more than one identifier of the same type, we + // arbitrarily pick the first. We should be able to use Hash + // to do this, but our type label appears to be one level too + // deep. (The alternative would be to use Types->getTypeId but + // then that adds another database call.) + + $id = null; + + foreach($entity->identifiers as $idx) { + if($idx->type->value == $identifierType) { + $id = $idx->identifier; + break; + } + } + + if(empty($id)) { + throw new \RuntimeException(__d('error', 'IdentifierAssignments.type.notfound', $identifierType)); + } + + $base .= sprintf("%.".$width."s", + preg_replace($charregex, '', $id)); + break; + case 'h': + case 'l': + case 'L': + for($j = 0;$j < ($width != "" ? $width : 1);$j++) { + $base .= $randomCharSet[ $format[$i] ][ mt_rand(0, strlen($randomCharSet[ $format[$i] ])-1) ]; + } + break; + case 'm': + $base .= sprintf("%.".$width."s", + preg_replace($charregex, '', strtolower( $entity->primary_name->middle))); + break; + case 'M': + $base .= sprintf("%.".$width."s", + preg_replace($charregex, '', $entity->primary_name->middle)); + break; + case 'n': + $base .= sprintf("%.".$width."s", + preg_replace($charregex, '', strtolower($entity->name))); + break; + case 'N': + $base .= sprintf("%.".$width."s", + preg_replace($charregex, '', $entity->name)); + break; + case '#': + // Convert the collision number parameter to a sprintf style specification, + // left padded with 0s. Note that assignCollisionNumber expects %s, not %d. + $base .= "%" . ($width != "" ? ("0" . $width . "." . $width) : "") . "s"; + break; + } + + // Move past the width specifier + if($width != "") { + $i += strlen($width) + 1; + } + + // Move past the ')' + $i++; + } + break; + default: + // Just copy this character + $base .= $format[$i]; + break; + } + } + + return $base; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('identifier_assignment_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('identifier_assignment_id'); + + $this->registerStringValidation($validator, $schema, 'format', true); + + $validator->add('minimum', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('minimum'); + + $validator->add('maximum', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('maximum'); + + $validator->add('collision_mode', [ + 'content' => ['rule' => ['inList', CollisionModeEnum::getConstValues()]] + ]); + $validator->notEmptyString('collision_mode'); + + $validator->add('permitted_characters', [ + 'content' => ['rule' => ['inList', PermittedCharactersEnum::getConstValues()]] + ]); + $validator->notEmptyString('permitted_characters'); + + return $validator; + } +} diff --git a/app/plugins/CoreAssigner/src/config/plugin.json b/app/plugins/CoreAssigner/src/config/plugin.json new file mode 100644 index 000000000..66155266a --- /dev/null +++ b/app/plugins/CoreAssigner/src/config/plugin.json @@ -0,0 +1,38 @@ +{ + "types": { + "assigner": [ + "FormatAssigners" + ] + }, + "schema": { + "tables": { + "format_assigners": { + "columns": { + "id": {}, + "identifier_assignment_id": {}, + "format": { "type": "string", "size": 256 }, + "minimum": { "type": "integer" }, + "maximum": { "type": "integer" }, + "collision_mode": { "type": "string", "size": 2 }, + "permitted_characters": { "type": "string", "size": 2 } + }, + "indexes": { + "format_assigners_i1": { "columns": [ "identifier_assignment_id" ]} + } + }, + "format_assigner_sequences": { + "columns": { + "id": {}, + "format_assigner_id": { "type": "integer", "foreignkey": { "table": "format_assigners", "column": "id" } }, + "affix": { "type": "string", "size": 256 }, + "last": { "type": "integer" } + }, + "indexes": { + "format_assigner_sequences_i1": { "columns": [ "format_assigner_id", "affix" ], "unique": true }, + "format_assigner_sequences_i2": { "needed": false, "columns": [ "format_assigner_id" ] } + }, + "changelog": false + } + } + } +} \ No newline at end of file diff --git a/app/plugins/CoreAssigner/templates/FormatAssigners/fields.inc b/app/plugins/CoreAssigner/templates/FormatAssigners/fields.inc new file mode 100644 index 000000000..2de0ec752 --- /dev/null +++ b/app/plugins/CoreAssigner/templates/FormatAssigners/fields.inc @@ -0,0 +1,64 @@ + + +Field->control('format'); + + print $this->Field->control( + fieldName: 'collision_mode', + options: [ + 'onChange' => 'javascript:fields_update_gadgets();' + ] + ); + + print $this->Field->control('permitted_characters'); + + print $this->Field->control('minimum'); + + print $this->Field->control('maximum'); +} diff --git a/app/plugins/CoreAssigner/tests/bootstrap.php b/app/plugins/CoreAssigner/tests/bootstrap.php new file mode 100644 index 000000000..2985f3a43 --- /dev/null +++ b/app/plugins/CoreAssigner/tests/bootstrap.php @@ -0,0 +1,55 @@ +loadSqlFiles('tests/schema.sql', 'test'); diff --git a/app/plugins/CoreAssigner/tests/schema.sql b/app/plugins/CoreAssigner/tests/schema.sql new file mode 100644 index 000000000..20f50320d --- /dev/null +++ b/app/plugins/CoreAssigner/tests/schema.sql @@ -0,0 +1 @@ +-- Test database schema for CoreAssigner diff --git a/app/plugins/CoreAssigner/webroot/.gitkeep b/app/plugins/CoreAssigner/webroot/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/app/plugins/CoreServer/src/Model/Table/SqlServersTable.php b/app/plugins/CoreServer/src/Model/Table/SqlServersTable.php index fc52131a3..205453b8e 100644 --- a/app/plugins/CoreServer/src/Model/Table/SqlServersTable.php +++ b/app/plugins/CoreServer/src/Model/Table/SqlServersTable.php @@ -44,6 +44,7 @@ class SqlServersTable extends Table { use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\TableMetaTrait; + use \App\Lib\Traits\ValidationTrait; /** * Perform Cake Model initialization. diff --git a/app/resources/locales/en_US/controller.po b/app/resources/locales/en_US/controller.po index 1e3d2425d..8e7e2a3ae 100644 --- a/app/resources/locales/en_US/controller.po +++ b/app/resources/locales/en_US/controller.po @@ -72,6 +72,9 @@ msgstr "{0,plural,=1{Group} other{Groups}}" msgid "HistoryRecords" msgstr "{0,plural,=1{History Record} other{History Records}}" +msgid "IdentifierAssignments" +msgstr "{0,plural,=1{Identifier Assignment} other{Identifier Assignments}}" + msgid "Identifiers" msgstr "{0,plural,=1{Identifier} other{Identifiers}}" diff --git a/app/resources/locales/en_US/enumeration.po b/app/resources/locales/en_US/enumeration.po index 7634d6b41..676a12464 100644 --- a/app/resources/locales/en_US/enumeration.po +++ b/app/resources/locales/en_US/enumeration.po @@ -72,6 +72,15 @@ msgstr "All Members" msgid "GroupTypeEnum.S" msgstr "Standard" +msgid "IdentifierAssignmentContextEnum.CD" +msgstr "Department" + +msgid "IdentifierAssignmentContextEnum.CG" +msgstr "Group" + +msgid "IdentifierAssignmentContextEnum.CP" +msgstr "Person" + msgid "JobStatusEnum.A" msgstr "Assigned" diff --git a/app/resources/locales/en_US/error.po b/app/resources/locales/en_US/error.po index 7fed7b33b..7129e16e9 100644 --- a/app/resources/locales/en_US/error.po +++ b/app/resources/locales/en_US/error.po @@ -121,6 +121,21 @@ msgstr "Group cannot be nested into itself" msgid "Groups.nested" msgstr "Group is nested or has nestings, and cannot be suspended or deleted" +msgid "IdentifierAssignments.exists" +msgstr "The identifier \"{0}\" is already in use" + +msgid "IdentifierAssignments.failed" +msgstr "Failed to find a unique identifier to assign" + +msgid "IdentifierAssignments.type" +msgstr "Exactly one Email Address Type or one Identifier Type must be specified" + +msgid "IdentifierAssignments.type.notfound" +msgstr "No identifier of type \"{0}\" found" + +msgid "IdentifierAssignments.type.none" +msgstr "No identifier type specified" + 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 cf47654fd..e5fbe143d 100644 --- a/app/resources/locales/en_US/field.po +++ b/app/resources/locales/en_US/field.po @@ -50,6 +50,9 @@ msgstr "Attribute" msgid "comment" msgstr "Comment" +msgid "context" +msgstr "Context" + msgid "country" msgstr "Country" @@ -122,6 +125,9 @@ msgstr "(Dr, Hon, etc)" msgid "identifier" msgstr "Identifier" +msgid "format" +msgstr "format" + msgid "full_name" msgstr "Full Name" @@ -321,9 +327,6 @@ msgstr "Limit Global Search Scope" msgid "CoSettings.search_global_limited_models.desc" msgstr "If true, Global Search will only search Names, Email Addresses, and Identifiers. This may result in faster searches for larger deployments." -msgid "format" -msgstr "format" - msgid "GroupMembers.source" msgstr "Membership Source" @@ -363,6 +366,18 @@ msgstr "Open" msgid "Groups.open.desc" msgstr "Open groups may be self-joined by any Person in the CO" +msgid "IdentifierAssignments.email_address_type_id" +msgstr "Email Address Type" + +msgid "IdentifierAssignments.email_address_type_id.desc" +msgstr "Exactly one of Email Address or Identifier Type must be set" + +msgid "IdentifierAssignments.identifier_type_id" +msgstr "Identifier Type" + +msgid "IdentifierAssignments.identifier_type_id.desc" +msgstr "Exactly one of Email Address or Identifier Type must be set" + msgid "JobHistoryRecords.record_key" msgstr "Record Key" diff --git a/app/resources/locales/en_US/operation.po b/app/resources/locales/en_US/operation.po index e01e38929..2268a90d6 100644 --- a/app/resources/locales/en_US/operation.po +++ b/app/resources/locales/en_US/operation.po @@ -96,6 +96,9 @@ msgstr "First" msgid "go" msgstr "Go" +msgid "identifiers.assign" +msgstr "Assign Identifiers" + msgid "last" msgstr "Last" diff --git a/app/resources/locales/en_US/result.po b/app/resources/locales/en_US/result.po index db9d5fbdc..0404d29d4 100644 --- a/app/resources/locales/en_US/result.po +++ b/app/resources/locales/en_US/result.po @@ -81,6 +81,15 @@ msgstr "Added {0} as an owner of group {1}" msgid "GroupOwners.deleted" msgstr "Removed {0} as an owner of group {1}" +msgid "IdentifierAssignments.assigned.already" +msgstr "Identifiers Already Assigned ({0})" + +msgid "IdentifierAssignments.history" +msgstr "Identifier Auto Assigned: {0} ({1}, {2})" + +msgid "IdentifierAssignments.assigned.ok" +msgstr "Identifiers Assigned ({0})" + msgid "Names.primary_name" msgstr "Primary Name Updated" diff --git a/app/src/Controller/AppController.php b/app/src/Controller/AppController.php index 58c231870..d29da07e6 100644 --- a/app/src/Controller/AppController.php +++ b/app/src/Controller/AppController.php @@ -341,16 +341,17 @@ public function getPrimaryLink(bool $lookup=false) { * Get the redirect goal for this table. * * @since COmanage Registry v5.0.0 - * @return string Redirect goal + * @param string $action Action + * @return string Redirect goal */ - protected function getRedirectGoal(): ?string { + protected function getRedirectGoal(string $action): ?string { // $this->name = Models $modelsName = $this->name; // PrimaryLinkTrait if(method_exists($this->$modelsName, "getRedirectGoal")) { - return $this->$modelsName->getRedirectGoal(); + return $this->$modelsName->getRedirectGoal($this->request->getParam('action')); } return 'index'; diff --git a/app/src/Controller/DashboardsController.php b/app/src/Controller/DashboardsController.php index 5e074eb98..12562df3d 100644 --- a/app/src/Controller/DashboardsController.php +++ b/app/src/Controller/DashboardsController.php @@ -118,6 +118,11 @@ public function configuration() { 'action' => 'index' ], // XXX External Identity Sources should use "cloud_download" for the icon + __d('controller', 'IdentifierAssignments', [99]) => [ + 'icon' => 'badge', + 'controller' => 'identifier_assignments', + 'action' => 'index' + ], __d('controller', 'ProvisioningTargets', [99]) => [ 'icon' => 'cloud_upload', 'controller' => 'provisioning_targets', diff --git a/app/src/Controller/IdentifierAssignmentsController.php b/app/src/Controller/IdentifierAssignmentsController.php new file mode 100644 index 000000000..d7d528e31 --- /dev/null +++ b/app/src/Controller/IdentifierAssignmentsController.php @@ -0,0 +1,80 @@ + [ + 'IdentifierAssignments.description' => 'asc' + ] + ]; + + /** + * Assign Identifiers. + * + * @since COmanage Registry v5.0.0 + */ + + public function assign() { + $link = $this->getPrimaryLink(true); + + try { + $results = $this->IdentifierAssignments->assign( + entityType: StringUtilities::foreignKeyToClassName($link->attr), + entityId: (int)$link->value + ); + + if(!empty($results)) { + // We could get multiple types of results from different Identifier Assignments + + if(!empty($results['assigned'])) { + $this->Flash->success(__d('result', 'IdentifierAssignments.assigned.ok', implode(',', array_keys($results['assigned'])))); + } + + if(!empty($results['errors'])) { + $this->Flash->error(implode(',', $results['errors'])); + } + + if(!empty($results['already'])) { + $this->Flash->information(__d('result', 'IdentifierAssignments.assigned.already', implode(',', array_keys($results['already'])))); + } + } + } + catch(\Exception $e) { + $this->Flash->error($e->getMessage()); + } + + $this->generateRedirect(null); + } +} \ No newline at end of file diff --git a/app/src/Controller/StandardController.php b/app/src/Controller/StandardController.php index fb3ffce55..f6bc44c90 100644 --- a/app/src/Controller/StandardController.php +++ b/app/src/Controller/StandardController.php @@ -407,7 +407,7 @@ public function generateRedirect(?int $id) { $redirect = []; // By default we return to the index, but we'll also accept "self" or "primaryLink". - $redirectGoal = $this->getRedirectGoal(); + $redirectGoal = $this->getRedirectGoal($this->request->getParam('action')); if(!$redirectGoal) { // Our default behavior is index unless we're in a plugin context @@ -635,10 +635,17 @@ protected function populateAutoViewVars(object $obj=null) { // Inject configuration. Since we're only ever looking at the types // table, inject the current CO along with the requested attribute $avv['model'] = 'Types'; - $avv['where'] = [ - 'attribute' => $avv['attribute'], - 'status' => SuspendableStatusEnum::Active - ]; + if(is_array($avv['attribute'])) { + $avv['where'] = [ + 'attribute IN' => $avv['attribute'], + 'status' => SuspendableStatusEnum::Active + ]; + } else { + $avv['where'] = [ + 'attribute' => $avv['attribute'], + 'status' => SuspendableStatusEnum::Active + ]; + } // fall through case 'auxiliary': // XXX add list as in match? diff --git a/app/src/Lib/Enum/ActionEnum.php b/app/src/Lib/Enum/ActionEnum.php index ec0c5d309..8255d88df 100644 --- a/app/src/Lib/Enum/ActionEnum.php +++ b/app/src/Lib/Enum/ActionEnum.php @@ -32,17 +32,18 @@ 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 MVEAAdded = 'AMVE'; - const MVEADeleted = 'DMVE'; - const MVEAEdited = 'EMVE'; - const NamePrimary = 'PNAM'; + 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'; } \ No newline at end of file diff --git a/app/src/Lib/Enum/IdentifierAssignmentContextEnum.php b/app/src/Lib/Enum/IdentifierAssignmentContextEnum.php new file mode 100644 index 000000000..ac303bd36 --- /dev/null +++ b/app/src/Lib/Enum/IdentifierAssignmentContextEnum.php @@ -0,0 +1,38 @@ +isNew() || $entity->deleted) { // Generate a changeset of non-empty fields foreach($diffFields as $field) { - if(!is_string($field)) { + if($field != 'type_id' && !is_string($field)) { // This is a related model, skip continue; } $newValue = $entity->get($field); - - if(!empty($newValue) && is_string($newValue)) { + + if(!empty($newValue)) { if($field == 'type_id') { $newValue = $Types->getTypeLabel((int)$newValue); } - $changeSet[] = $field . ": " . $newValue; + $changeSet[] = $field . ": " . (string)$newValue; } } } else { diff --git a/app/src/Lib/Traits/PrimaryLinkTrait.php b/app/src/Lib/Traits/PrimaryLinkTrait.php index 3e4f4b800..cbe3ffd3b 100644 --- a/app/src/Lib/Traits/PrimaryLinkTrait.php +++ b/app/src/Lib/Traits/PrimaryLinkTrait.php @@ -48,9 +48,10 @@ trait PrimaryLinkTrait { // Actions where the primary link can be obtained by looking up the record ID private $lookupActions = ['delete', 'edit', 'canvas', 'view']; - // Where to redirect on add or edit, can be 'self', 'index', 'pluggableLink', or 'primaryLink' - // We use null to mean "index unless we're in a plugin context, in which case pluggableLink" - private $redirectGoal = null; + // Where to redirect on add or edit, can be 'self', 'index', 'pluggableLink', or 'primaryLink'. + // We use null to mean "index unless we're in a plugin context, in which case pluggableLink". + // This array is keyed on the action (or "*" for default). + private $redirectGoal = ['*' => null]; // Accept the current CO ID? private $acceptCoId = false; @@ -120,12 +121,22 @@ public function calculateCoForRecord(EntityInterface $entity, bool $original=fal } } else { foreach($this->primaryLinks as $linkField => $linkTable) { - if(!empty($entity->$linkField)) { + $lf = $linkField; + + if(strstr($linkField, '.')) { + // Modified plugin notation ("CoreAssigners.format_assigner_id"), + // we just need the fieldname, not the full string. + + $bits = explode(".", $linkField, 2); + $lf = $bits[1]; + } + + if(!empty($entity->$lf)) { // Use this field. Recursively ask the primaryLink until we get an answer. $LinkTable = TableRegistry::getTableLocator()->get($linkTable); - - $linkValue = ($original ? $entity->getOriginal($linkField) : $entity->get($linkField)); - + + $linkValue = ($original ? $entity->getOriginal($lf) : $entity->get($lf)); + return $LinkTable->findCoForRecord($linkValue); } } @@ -164,20 +175,27 @@ public function findFilterPrimaryLink(\Cake\ORM\Query $query, array $options) { } /** - * Find the Primary Link for an entity. + * Find the Primary Link associated with the requested object ID. * * @since COmanage Registry v5.0.0 - * @param Entity $entity Entity - * @return Entity Primary Link (as an Entity) + * @param int $id Object ID + * @param bool $archived Whether to retrieve archived (deleted) records + * @return Entity Primary Link (as an Entity) * @throws \InvalidArgumentException */ - public function findPrimaryLinkEntity($entity) { + public function findPrimaryLink(int $id, bool $archived=false) { + $obj = $this->get($id, ['archived' => $archived]); //->firstOrFail(); + + // We might have multiple primary link keys (eg for MVEAs), but only one + // should be set. Return the first one we find. foreach(array_keys($this->primaryLinks) as $plKey) { - if(!empty($entity->$plKey)) { - $LinkTable = TableRegistry::getTableLocator()->get($this->primaryLinks[$plKey]); - - return $LinkTable->findById($entity->$plKey)->firstOrFail(); + if(!empty($obj->$plKey)) { + return (object)[ + 'attr' => $plKey, + 'value' => $obj->$plKey, + 'co_id' => $this->calculateCoForRecord($obj) + ]; } } @@ -185,26 +203,20 @@ public function findPrimaryLinkEntity($entity) { } /** - * Find the Primary Link associated with the requested object ID. + * Find the Primary Link for an entity. * * @since COmanage Registry v5.0.0 - * @param int $id Object ID - * @return Entity Primary Link (as an Entity) + * @param Entity $entity Entity + * @return Entity Primary Link (as an Entity) * @throws \InvalidArgumentException */ - public function findPrimaryLink(int $id) { - $obj = $this->findById($id)->firstOrFail(); - - // We might have multiple primary link keys (eg for MVEAs), but only one - // should be set. Return the first one we find. + public function findPrimaryLinkEntity($entity) { foreach(array_keys($this->primaryLinks) as $plKey) { - if(!empty($obj->$plKey)) { - return (object)[ - 'attr' => $plKey, - 'value' => $obj->$plKey, - 'co_id' => $this->calculateCoForRecord($obj) - ]; + if(!empty($entity->$plKey)) { + $LinkTable = TableRegistry::getTableLocator()->get($this->primaryLinks[$plKey]); + + return $LinkTable->findById($entity->$plKey)->firstOrFail(); } } @@ -238,11 +250,12 @@ public function getPrimaryLinkTableName(string $primaryLink): string { * Obtain this table's redirect goal. * * @since COmanage Registry v5.0.0 - * @return string Redirect goal + * @param string $action Action + * @return string Redirect goal */ - public function getRedirectGoal(): ?string { - return $this->redirectGoal; + public function getRedirectGoal(string $action): ?string { + return $this->redirectGoal[$action] ?? $this->redirectGoal['*']; } /** @@ -457,8 +470,17 @@ public function setPrimaryLink($fields) { foreach($fields as $field) { $t = null; - // Calculate the table name for future reference - if(preg_match('/^(.*?)_id$/', $field, $f)) { + // Calculate the table name for future reference. This could just be + // a simple reference ("person_id" => "People") or it could be in + // plugin notation ("CoreAssigner.format_assigner_id" => "CoreAssigner.FormatAssigners"). + // Note the plugin notation isn't exactly standard (Plugin.field doesn't make sense + // except that we inflect it to something that does). + + if(preg_match('/^(.*)\.(.*?)_id$/', $field, $f)) { + // Modified plugin notation match + $t = $f[1] . "." . \Cake\Utility\Inflector::camelize(\Cake\Utility\Inflector::pluralize($f[2])); + } elseif(preg_match('/^(.*?)_id$/', $field, $f)) { + // Standard foreign key match $t = \Cake\Utility\Inflector::camelize(\Cake\Utility\Inflector::pluralize($f[1])); } @@ -470,15 +492,16 @@ 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') + * @param string $action Action to set goal for ('*' for default) * @throws InvalidArgumentException */ - public function setRedirectGoal(string $goal) { + public function setRedirectGoal(string $goal, string $action='*') { if(!in_array($goal, ['index', 'pluggableLink', 'primaryLink', 'self'])) { throw new \InvalidArgumentException(__d('error', 'invalid', [$goal])); } - $this->redirectGoal = $goal; + $this->redirectGoal[$action] = $goal; } } diff --git a/app/src/Lib/Traits/ProvisionableTrait.php b/app/src/Lib/Traits/ProvisionableTrait.php index 349a33146..367dfbec6 100644 --- a/app/src/Lib/Traits/ProvisionableTrait.php +++ b/app/src/Lib/Traits/ProvisionableTrait.php @@ -54,6 +54,8 @@ public function requestProvisioning( string $context, ?int $provisioningTargetId=null) { if(method_exists($this, 'marshalProvisioningData')) { + // The model specific marshalProvisioningData implementations are expected + // to properly handle deleted records. $data = $this->marshalProvisioningData($id); // Invocation of the plugins is handled by the Pluggable table @@ -69,8 +71,11 @@ public function requestProvisioning( // This is a secondary model, eg Names. We need to figure out the primary model // and then request provisioning on that one instead. - $primaryLink = $this->findPrimaryLink($id); + // We need to explicitly look at archived records here. A deleted record + // may point to a valid primary object. + $primaryLink = $this->findPrimaryLink(id: $id, archived: true); + $parentTableName = StringUtilities::foreignKeyToClassName($primaryLink->attr); $this->$parentTableName->requestProvisioning( diff --git a/app/src/Lib/Util/SchemaManager.php b/app/src/Lib/Util/SchemaManager.php index 36647ed06..5534723cb 100644 --- a/app/src/Lib/Util/SchemaManager.php +++ b/app/src/Lib/Util/SchemaManager.php @@ -259,7 +259,11 @@ protected function processSchema( $flags = []; $options = []; - $table->addIndex($iCfg->columns, $iName, $flags, $options); + if(isset($iCfg->unique) && $iCfg->unique) { + $table->addUniqueConstraint($iCfg->columns, $iName, $flags, $options); + } else { + $table->addIndex($iCfg->columns, $iName, $flags, $options); + } } } diff --git a/app/src/Lib/Util/StringUtilities.php b/app/src/Lib/Util/StringUtilities.php index 7fd786286..0b3e47aca 100644 --- a/app/src/Lib/Util/StringUtilities.php +++ b/app/src/Lib/Util/StringUtilities.php @@ -32,6 +32,18 @@ use \Cake\Utility\Inflector; class StringUtilities { + /** + * Determine the foreign key name to point to a Cake Class Name (eg: foo_id for Foo). + * + * @since COmanage Registry v5.0.0 + * @param string $className Class Name + * @return string Foreign key name + */ + + public static function classNameToForeignKey(string $className): string { + return Inflector::underscore(Inflector::singularize($className)) . "_id"; + } + /** * Construct the Column human-readable key * @@ -186,7 +198,7 @@ public static function tableToEntityName($table): string { * Determine the foreign key name to point to a Cake Entity (eg: foo_id for FooTable). * * @since COmanage Registry v5.0.0 - * @param Entity $entity Entity + * @param Table $table Table * @return string Foreign key name */ diff --git a/app/src/Model/Entity/IdentifierAssignment.php b/app/src/Model/Entity/IdentifierAssignment.php new file mode 100644 index 000000000..aef39cae9 --- /dev/null +++ b/app/src/Model/Entity/IdentifierAssignment.php @@ -0,0 +1,42 @@ + true, + 'id' => false, + 'slug' => false, + ]; +} \ No newline at end of file diff --git a/app/src/Model/Table/AdHocAttributesTable.php b/app/src/Model/Table/AdHocAttributesTable.php index d194d8a80..f1becd357 100644 --- a/app/src/Model/Table/AdHocAttributesTable.php +++ b/app/src/Model/Table/AdHocAttributesTable.php @@ -53,16 +53,6 @@ public function getLayout(): string { return "iframe"; } - /** - * Provide the default redirect goal - * - * @since COmanage Registry v5.0.0 - * @return string Type of redirect - */ - public function getRedirectGoal(): string { - return "self"; - } - /** * Perform Cake Model initialization. * @@ -88,6 +78,7 @@ public function initialize(array $config): void { $this->setPrimaryLink(['external_identity_id', 'external_identity_role_id', 'person_id', 'person_role_id']); $this->setRequiresCO(true); + $this->setRedirectGoal('self'); $this->setPermissions([ // Actions that operate over an entity (ie: require an $id) diff --git a/app/src/Model/Table/AddressesTable.php b/app/src/Model/Table/AddressesTable.php index a49b0b39e..254b39df3 100644 --- a/app/src/Model/Table/AddressesTable.php +++ b/app/src/Model/Table/AddressesTable.php @@ -67,17 +67,7 @@ class AddressesTable extends Table { public function getLayout(): string { return "iframe"; } - - /** - * Provide the default redirect goal - * - * @since COmanage Registry v5.0.0 - * @return string Type of redirect - */ - public function getRedirectGoal(): string { - return "self"; - } - + /** * Perform Cake Model initialization. * @@ -105,6 +95,7 @@ public function initialize(array $config): void { $this->setPrimaryLink(['external_identity_id', 'external_identity_role_id', 'person_id', 'person_role_id']); $this->setRequiresCO(true); $this->setAcceptsCoId(true); + $this->setRedirectGoal('self'); $this->setAutoViewVars([ 'languages' => [ diff --git a/app/src/Model/Table/EmailAddressesTable.php b/app/src/Model/Table/EmailAddressesTable.php index 52adb0068..a1e872c37 100644 --- a/app/src/Model/Table/EmailAddressesTable.php +++ b/app/src/Model/Table/EmailAddressesTable.php @@ -69,16 +69,6 @@ public function getLayout(): string { return "iframe"; } - /** - * Provide the default redirect goal - * - * @since COmanage Registry v5.0.0 - * @return string Type of redirect - */ - public function getRedirectGoal(): string { - return "self"; - } - /** * Perform Cake Model initialization. * @@ -104,6 +94,7 @@ public function initialize(array $config): void { $this->setPrimaryLink(['external_identity_id', 'person_id']); $this->setAllowLookupPrimaryLink(['primary']); $this->setRequiresCO(true); + $this->setRedirectGoal('self'); $this->setAutoViewVars([ 'types' => [ diff --git a/app/src/Model/Table/GroupsTable.php b/app/src/Model/Table/GroupsTable.php index a975a46f7..bc1005ced 100644 --- a/app/src/Model/Table/GroupsTable.php +++ b/app/src/Model/Table/GroupsTable.php @@ -98,7 +98,7 @@ public function initialize(array $config): void { $this->setPrimaryLink('co_id'); $this->setAllowLookupPrimaryLink(['provision', 'reconcile']); $this->setRequiresCO(true); - + $this->setEditContains([ 'Identifiers' ]); @@ -141,6 +141,7 @@ public function initialize(array $config): void { 'GroupNestings', 'GroupOwners', 'HistoryRecords', + 'IdentifierAssignments', 'Identifiers', 'ProvisioningTargets' ] diff --git a/app/src/Model/Table/IdentifierAssignmentsTable.php b/app/src/Model/Table/IdentifierAssignmentsTable.php new file mode 100644 index 000000000..3f7adc7a2 --- /dev/null +++ b/app/src/Model/Table/IdentifierAssignmentsTable.php @@ -0,0 +1,507 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Orderable'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('Cos'); + $this->belongsTo('Groups'); + $this->belongsTo('EmailAddressTypes') + ->setClassName('Types') + ->setForeignKey('email_address_type_id') + ->setProperty('email_address_type'); + $this->belongsTo('IdentifierTypes') + ->setClassName('Types') + ->setForeignKey('identifier_type_id') + ->setProperty('identifier_type'); + + $this->setPluginRelations(); + + $this->setDisplayField('description'); + + $this->setPrimaryLink(['co_id', 'group_id', 'person_id']); + $this->setRequiresCO(true); + $this->setAllowUnkeyedPrimaryLink(['assign']); + $this->setRedirectGoal(action: 'assign', goal: 'primaryLink'); + + $this->setAutoViewVars([ + 'contexts' => [ + 'type' => 'enum', + 'class' => 'IdentifierAssignmentContextEnum' + ], + 'emailAddressTypes' => [ + 'type' => 'type', + 'attribute' => ['EmailAddresses.type'] + ], + 'groups' => [ + 'type' => 'select', + 'model' => 'Groups' + ], + 'identifierTypes' => [ + 'type' => 'type', + 'attribute' => ['Identifiers.type'] + ], + 'plugins' => [ + 'type' => 'plugin', + 'pluginType' => 'assigner' + ], + 'statuses' => [ + 'type' => 'enum', + 'class' => 'SuspendableStatusEnum' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'configure' => ['platformAdmin', 'coAdmin'], + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'assign' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Assign Identifiers for an Entity. + * + * @since COmanage Registry v5.0.0 +* XXX Document params + */ + + public function assign( + string $entityType, + int $entityId, + bool $provision=true, +// XXX CFM-76 HistoryRecords don't seem to do anything with actorPersonId yet +// Also need to update StandardController or something for regular requests + int $actorPersonId=null + ): array { + $ret = [ + 'already' => [], + 'assigned' => [], + 'errors' => [] + ]; + + // Pull the entity, which we'll use to map to the CO and also to pass to + // other functions. + + $contains = ['Identifiers' => 'Types']; + + if($entityType == 'People') { + $contains[] = 'PrimaryName'; + } + + $EntityTable = TableRegistry::getTableLocator()->get($entityType); + + // Map the entity to its CO, which we'll need to pull the + // Identifier Assignment configuration. + + $coId = $EntityTable->findCoForRecord($entityId); + + $context = ($entityType == 'Groups' + ? IdentifierAssignmentContextEnum::Group + : IdentifierAssignmentContextEnum::Person); + + $ias = $this->find() + ->where([ + 'IdentifierAssignments.co_id' => $coId, + 'IdentifierAssignments.status' => SuspendableStatusEnum::Active, + 'IdentifierAssignments.context' => $context + ]) + ->order(['IdentifierAssignments.ordr' => 'ASC']) + ->contain($this->getPluginRelations()) + ->all(); + + foreach($ias as $ia) { +// XXX CFM-57 If not group eligible skip this (but log that we skipped it) + // We'll create a transaction for each Identifier Assignment + + $cxn = $this->getConnection(); + $cxn->begin(); + + // We pull the entity at the start of each loop to reload any + // identifiers that were generated on the previous loop and therefore + // might be used in a subsequent assignment. (It might be slightly + // more efficient to manually track the generated identifier, but + // this should be less brittle if we add support for another model + // alongside Identifiers and EmailAddresses.) + + $entity = $EntityTable->get($entityId, ['contain' => $contains]); + + // Check if there is already an identifier of this type + + if(!$this->assigned($ia, $entity)) { + // Request a new Identifier + + try { + $Plugin = TableRegistry::getTableLocator()->get($ia->plugin); + + // The plugin is expected to throw InvalidArgumentException on + // unsupported context, or RuntimeException on some other error. + $ret['assigned'][$ia->description] = $Plugin->assign($ia, $entity); + + $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()); + $ret['errors'][$ia->description] = $e->getMessage(); + $cxn->rollback(); + } + } else { + $this->llog('trace', "Identifier '".$ia->description."' already assigned for $entityType $entityId"); + $ret['already'][$ia->description] = true; // XXX maybe return the identifier? + $cxn->rollback(); + } + } + + // Trigger provisioning, letting errors bubble up (AR-GMR-5) + if(method_exists($EntityTable, "requestProvisioning")) { + $this->llog('rule', "AR-GMR-5 Requesting provisioning for $entityType " . $entity->id); + $EntityTable->requestProvisioning(id: $entity->id, context: ProvisioningContextEnum::Automatic); + } + + return $ret; + } + + /** + * Determine if an identifier of a given type is already assigned to an entity. + * Suspended identifiers are considered assigned. + * + * IMPORTANT: This function should be called within a transaction to ensure + * actions taken based on availability are atomic. + * + * @since COmanage Registry v5.0.0 + * @param IdentifierAssignment $ia Identifier Assignment + * @param EntityInterface $entity Entity + * @return bool True if an identifier of the specified type is already assigned, false otherwise + */ + + public function assigned($ia, $entity): bool { + $fk = StringUtilities::entityToForeignKey($entity); + + $className = !empty($ia->email_address_type_id) + ? 'EmailAddresses' + : 'Identifiers'; + + $typeId = !empty($ia->email_address_type_id) + ? $ia->email_address_type_id + : $ia->identifier_type_id; + $EntityTable = TableRegistry::getTableLocator()->get($className); + + $count = $EntityTable->find() + ->where([ + $className . '.' . $fk => $entity->id, + $className . '.type_id' => $typeId + ]) + ->epilog('FOR UPDATE') +// We can't use aggregate functions with FOR UPDATE +// ->count() + ->all(); + + return (bool)($count->count()); + } + + /** + * Attach a newly generated Identifier (or Email Address) to the entity + * for which it was generated. + * + * @since COmanage Registry v5.0.0 + * @param IdentifierAssignment $ia Identifier Assignment + * @param EntityInterface $entity Subject Entity + * @param string $identifier Identifier (or Email Address) + * @return int Newly created entity ID + */ + + public function attachIdentifier($ia, $entity, string $identifier): int { + // eg: person_id, group_id + $fk = StringUtilities::entityToForeignKey($entity); + $entityClassName = StringUtilities::entityToClassName($entity); + + // Are we attaching an Identifier or an Email Address? + $targetClassName = !empty($ia->email_address_type_id) + ? 'EmailAddresses' + : 'Identifiers'; + + $TargetEntityTable = TableRegistry::getTableLocator()->get($targetClassName); + + $newRecord = []; + + if($targetClassName == 'EmailAddresses') { + $newRecord = [ + 'mail' => $identifier, + 'type_id' => $ia->email_address_type_id, + // AR-IdentifierAssignment-3 EmailAddresses generated via Identifier Assignment are considered verified + 'verified' => true + ]; + } else { + $newRecord = [ + 'identifier' => $identifier, + 'type_id' => $ia->identifier_type_id, + 'status' => SuspendableStatusEnum::Active, + 'login' => $ia->login + ]; + } + + // Add in the foreign key + $newRecord[$fk] = $entity->id; + + $newEntity = $TargetEntityTable->newEntity($newRecord); + + $TargetEntityTable->saveOrFail($newEntity); + + $Types = TableRegistry::getTableLocator()->get('Types'); + + $typeLabel = $Types->getTypeLabel((int)$newRecord['type_id']); + + // We can actually pass either $newEntity or $entity to recordHistory() + // with basically the same effect, but passing $entity will result in + // fewer lookups to get the foreign keys required to record history. + $TargetEntityTable->$entityClassName->recordHistory( + entity: $entity, + action: ActionEnum::IdentifierAutoAssigned, + comment: __d('result', "IdentifierAssignments.history", [$identifier, $typeLabel, $ia->description]) + ); + + return $newEntity->id; + } + + /** + * Define business rules. + * + * @since COmanage Registry v5.0.0 + * @param RulesChecker $rules RulesChecker object + * @return RulesChecker + */ + + public function buildRules(RulesChecker $rules): RulesChecker { + // AR-IdentifierAssignment-1 An IdentifierAssignment must apply to either an + // Identifier or an EmailAddress, but not both. + + $rules->add([$this, 'ruleWhichType'], + 'targetType', + ['errorField' => 'identifier_type_id']); + + return $rules; + } + + /** + * Check if an identifier or email address is available for use, ie + * if it is not defined (regardless of status) within the same CO. + * + * IMPORTANT: This function should be called within a transaction to ensure + * actions taken based on availability are atomic. + * + * @since COmanage Registry v5.0.0 + * @param string $className Class name ("Identifiers" or "EmailAddresses") + * @param int $typeId Type ID + * @param string $candidate Candidate identifier or email address + * @param EntityInterface $entity Entity to check availability for + * @return bool True if the candidate is already in use + * @throws OverflowException If $candidate is already in use + */ + + public function checkAvailability( + string $className, + int $typeId, + string $candidate, + $entity + ): bool { + $fieldName = ($className == 'EmailAddresses' ? 'mail' : 'identifier'); + $foreignKey = StringUtilities::entityToForeignKey($entity); + + $Table = TableRegistry::getTableLocator()->get($className); + + // In order to allow ensure that another process doesn't perform the same + // availability check while we're running, we need to lock the appropriate + // tables/rows at read time. We do this with FOR UPDATE. + + $r = $Table->find() + ->where([ + // AR-Identifier-Assignment-2 Availability checks for newly + // generated Identifiers and EmailAddresses are case insensitive. +// XXX CFM-306 This really requires a case insensitive index, but DBAL doesn't support +// those because it's a "database specific" thing. It might be possible to do this +// by overriding the schema manager... +// https://www.doctrine-project.org/projects/doctrine-dbal/en/current/reference/schema-manager.html#overriding-the-schema-manager + 'LOWER('.$className.'.'.$fieldName.')' => strtolower($candidate), + // Because type_ids are specific to a CO, we effectively + // constrain the search within a CO by typeId + $className.'.type_id' => $typeId, + // Only consider records where the foreign key of the same type + // is not null. (eg: We don't consider an Identifier assigned to + // a Group to be taken if we're assigning for a Person.) + $className.'.'.$foreignKey.' IS NOT NULL', + ]) + ->epilog('FOR UPDATE') +// We can't use aggregate functions with FOR UPDATE +// ->count() + ->all(); + + if($r->count() > 0) { + throw new \OverflowException(__d('error', 'IdentifierAssignments.exists', $candidate)); + } + +// XXX CFM-309: Once Identifier Validators are a thing call them here +// (see v4 AppModel::checkAvailability) + + return true; + } + + /** + * Application Rule to determine if an appropriate target type is selected. + * + * @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 ruleWhichType($entity, $options) { + if(!$entity->email_address_type_id + && !$entity->identifier_type_id) { + // No type was set + return(__d('error', 'IdentifierAssignments.type')); + } elseif($entity->email_address_type_id + && $entity->identifier_type_id) { + // Both types were set + return(__d('error', 'IdentifierAssignments.type')); + } + + return true; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('co_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('co_id'); + + $this->registerStringValidation($validator, $schema, 'description', false); + + $validator->add('status', [ + 'content' => ['rule' => ['inList', SuspendableStatusEnum::getConstValues()]] + ]); + $validator->notEmptyString('status'); + + $this->registerStringValidation($validator, $schema, 'plugin', false); + + $validator->add('context', [ + 'content' => ['rule' => ['inList', IdentifierAssignmentContextEnum::getConstValues()]] + ]); + $validator->notEmptyString('context'); + + $validator->add('group_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('group_id'); + + $validator->add('identifier_type_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + // See AR-IdentifierAssignment-1 + $validator->allowEmptyString('identifier_type_id'); + + $validator->add('login', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('login'); + + $validator->add('email_address_type_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + // See AR-IdentifierAssignment-1 + $validator->allowEmptyString('email_address_type_id'); + + $validator->add('ordr', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('ordr'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/IdentifiersTable.php b/app/src/Model/Table/IdentifiersTable.php index e89ae4a97..cf291eafb 100644 --- a/app/src/Model/Table/IdentifiersTable.php +++ b/app/src/Model/Table/IdentifiersTable.php @@ -81,16 +81,6 @@ public function getLayout(): string { return "iframe"; } - /** - * Provide the default redirect goal - * - * @since COmanage Registry v5.0.0 - * @return string Type of redirect - */ - public function getRedirectGoal(): string { - return "self"; - } - /** * Perform Cake Model initialization. * @@ -116,8 +106,8 @@ public function initialize(array $config): void { $this->setDisplayField('identifier'); $this->setPrimaryLink(['external_identity_id', 'group_id', 'person_id']); - $this->setAllowLookupPrimaryLink(['primary']); $this->setRequiresCO(true); + $this->setRedirectGoal('self'); $this->setAutoViewVars([ 'types' => [ diff --git a/app/src/Model/Table/NamesTable.php b/app/src/Model/Table/NamesTable.php index b0e5cace3..30cc37c70 100644 --- a/app/src/Model/Table/NamesTable.php +++ b/app/src/Model/Table/NamesTable.php @@ -72,16 +72,6 @@ class NamesTable extends Table { public function getLayout(): string { return "iframe"; } - - /** - * Provide the default redirect goal - * - * @since COmanage Registry v5.0.0 - * @return string Type of redirect - */ - public function getRedirectGoal(): string { - return "self"; - } /** * Perform Cake Model initialization. @@ -109,6 +99,7 @@ public function initialize(array $config): void { $this->setAllowLookupPrimaryLink(['primary']); $this->setRequiresCO(true); $this->setAcceptsCoId(true); + $this->setRedirectGoal('self'); $this->setAutoViewVars([ 'languages' => [ diff --git a/app/src/Model/Table/PeopleTable.php b/app/src/Model/Table/PeopleTable.php index 2f52a217d..910982195 100644 --- a/app/src/Model/Table/PeopleTable.php +++ b/app/src/Model/Table/PeopleTable.php @@ -176,6 +176,7 @@ public function initialize(array $config): void { 'EmailAddresses', 'ExternalIdentities', 'HistoryRecords', + 'IdentifierAssignments', 'Identifiers', 'PersonRoles', 'ProvisioningTargets', diff --git a/app/src/Model/Table/PluginsTable.php b/app/src/Model/Table/PluginsTable.php index de90a5a6e..977e8543c 100644 --- a/app/src/Model/Table/PluginsTable.php +++ b/app/src/Model/Table/PluginsTable.php @@ -401,7 +401,9 @@ public function ruleInUse($entity, array $options): string|bool { // exception and anyway just returning a single error will be sufficient // for now - return __d('error', 'Plugins.inuse', [count($r), $type, $r->first()->name, $r->first()->co_id]); + $displayField = $table->getDisplayField(); + + return __d('error', 'Plugins.inuse', [count($r), $type, $r->first()->$displayField, $r->first()->co_id]); } } } diff --git a/app/src/Model/Table/PronounsTable.php b/app/src/Model/Table/PronounsTable.php index c1a74a567..5cd0f78c7 100644 --- a/app/src/Model/Table/PronounsTable.php +++ b/app/src/Model/Table/PronounsTable.php @@ -64,16 +64,6 @@ public function getLayout(): string { return "iframe"; } - /** - * Provide the default redirect goal - * - * @since COmanage Registry v5.0.0 - * @return string Type of redirect - */ - public function getRedirectGoal(): string { - return "self"; - } - /** * Perform Cake Model initialization. * @@ -98,6 +88,7 @@ public function initialize(array $config): void { $this->setPrimaryLink(['external_identity_id', 'person_id']); $this->setRequiresCO(true); + $this->setRedirectGoal('self'); $this->setAutoViewVars([ 'languages' => [ diff --git a/app/src/Model/Table/TelephoneNumbersTable.php b/app/src/Model/Table/TelephoneNumbersTable.php index 0854d7ed2..f43329d05 100644 --- a/app/src/Model/Table/TelephoneNumbersTable.php +++ b/app/src/Model/Table/TelephoneNumbersTable.php @@ -68,16 +68,6 @@ public function getLayout(): string { return "iframe"; } - /** - * Provide the default redirect goal - * - * @since COmanage Registry v5.0.0 - * @return string Type of redirect - */ - public function getRedirectGoal(): string { - return "self"; - } - /** * Perform Cake Model initialization. * @@ -105,6 +95,7 @@ public function initialize(array $config): void { $this->setPrimaryLink(['external_identity_id', 'external_identity_role_id', 'person_id', 'person_role_id']); $this->setRequiresCO(true); $this->setAcceptsCoId(true); + $this->setRedirectGoal('self'); $this->setAutoViewVars([ 'types' => [ diff --git a/app/src/Model/Table/UrlsTable.php b/app/src/Model/Table/UrlsTable.php index 46f41c23f..9729aa0bd 100644 --- a/app/src/Model/Table/UrlsTable.php +++ b/app/src/Model/Table/UrlsTable.php @@ -64,16 +64,6 @@ public function getLayout(): string { return "iframe"; } - /** - * Provide the default redirect goal - * - * @since COmanage Registry v5.0.0 - * @return string Type of redirect - */ - public function getRedirectGoal(): string { - return "self"; - } - /** * Perform Cake Model initialization. * @@ -98,6 +88,7 @@ public function initialize(array $config): void { $this->setPrimaryLink(['external_identity_id', 'person_id']); $this->setRequiresCO(true); + $this->setRedirectGoal('self'); $this->setAutoViewVars([ 'types' => [ diff --git a/app/templates/Groups/fields-nav.inc b/app/templates/Groups/fields-nav.inc index cab3c9729..355c896fe 100644 --- a/app/templates/Groups/fields-nav.inc +++ b/app/templates/Groups/fields-nav.inc @@ -37,12 +37,15 @@ $mveasEntityType = "group"; // XXX: if CFM-218 (Make fields.inc configuration only) is accepted, move the contents of this file into fields.inc $topLinks = [ [ - 'icon' => 'sync', + 'icon' => 'history', 'order' => 'Default', - 'label' => __d('operation', 'reconcile'), + 'label' => __d('controller', 'HistoryRecords', [99]), 'link' => [ - 'action' => 'reconcile', - $vv_obj->id + 'controller' => 'history_records', + 'action' => 'index', + '?' => [ + 'group_id' => $vv_obj->id + ] ], 'class' => '' ], @@ -60,13 +63,25 @@ $topLinks = [ 'class' => '' ], [ - 'icon' => 'history', + 'icon' => 'badge', 'order' => 'Default', - 'label' => __d('controller', 'HistoryRecords', [99]), + 'label' => __d('operation', 'identifiers.assign'), 'link' => [ - 'controller' => 'history_records', - 'action' => 'index', - '?' => ['group_id' => $vv_obj->id] + 'controller' => 'identifier_assignments', + 'action' => 'assign', + '?' => [ + 'group_id' => $vv_obj->id + ] + ], + 'class' => '' + ], + [ + 'icon' => 'sync', + 'order' => 'Default', + 'label' => __d('operation', 'reconcile'), + 'link' => [ + 'action' => 'reconcile', + $vv_obj->id ], 'class' => '' ] diff --git a/app/templates/IdentifierAssignments/columns.inc b/app/templates/IdentifierAssignments/columns.inc new file mode 100644 index 000000000..ed843c2b5 --- /dev/null +++ b/app/templates/IdentifierAssignments/columns.inc @@ -0,0 +1,64 @@ + [ + 'type' => 'link', + 'sortable' => true + ], + 'plugin' => [ + 'type' => 'echo', + 'sortable' => true + ], + 'context' => [ + 'type' => 'enum', + 'class' => 'IdentifierAssignmentContextEnum' + ], + 'ordr' => [ + 'type' => 'echo', + 'sortable' => true + ], + 'status' => [ + 'type' => 'enum', + 'class' => 'StatusEnum', + 'sortable' => true + ] +]; + +// $rowActions appear as row-level menu items in the index view gear icon +$rowActions = [ + [ + 'action' => 'configure', + 'label' => __d('operation', 'configure.plugin'), + 'icon' => 'electrical_services' + ] +]; + +// TODO: develop $bulkActions. For now, use a placeholder. +$bulkActions = [ + 'delete' => true +]; \ No newline at end of file diff --git a/app/templates/IdentifierAssignments/fields.inc b/app/templates/IdentifierAssignments/fields.inc new file mode 100644 index 000000000..9fa5e4717 --- /dev/null +++ b/app/templates/IdentifierAssignments/fields.inc @@ -0,0 +1,100 @@ + + +Field->control('description'); + + print $this->Field->control('status'); + + print $this->Field->control('plugin'); + + print $this->Field->control( + fieldName: 'context', + options: [ + 'onChange' => 'javascript:fields_update_gadgets();' + ] + ); + + print $this->Field->control('group_id'); + + print $this->Field->control( + fieldName: 'identifier_type_id', + options: [ + 'onChange' => 'javascript:reset_type("email-address-type-id")' + ] + ); + + print $this->Field->control('login'); + + print $this->Field->control( + fieldName: 'email_address_type_id', + options: [ + 'onChange' => 'javascript:reset_type("identifier-type-id")' + ] + ); + + print $this->Field->control('ordr'); +} \ No newline at end of file diff --git a/app/templates/People/fields-nav.inc b/app/templates/People/fields-nav.inc index e19d930a0..96ede675b 100644 --- a/app/templates/People/fields-nav.inc +++ b/app/templates/People/fields-nav.inc @@ -68,6 +68,19 @@ $topLinks = [ ] ], 'class' => '' + ], + [ + 'icon' => 'badge', + 'order' => 'Default', + 'label' => __d('operation', 'identifiers.assign'), + 'link' => [ + 'controller' => 'identifier_assignments', + 'action' => 'assign', + '?' => [ + 'person_id' => $vv_obj->id + ] + ], + 'class' => '' ] ]; diff --git a/app/vendor/cakephp-plugins.php b/app/vendor/cakephp-plugins.php index 5b33c03b7..e0831e061 100644 --- a/app/vendor/cakephp-plugins.php +++ b/app/vendor/cakephp-plugins.php @@ -5,6 +5,7 @@ 'plugins' => [ 'Bake' => $baseDir . '/vendor/cakephp/bake/', 'Cake/TwigView' => $baseDir . '/vendor/cakephp/twig-view/', + 'CoreAssigner' => $baseDir . '/plugins/CoreAssigner/', 'CoreServer' => $baseDir . '/plugins/CoreServer/', 'DebugKit' => $baseDir . '/vendor/cakephp/debug_kit/', 'Migrations' => $baseDir . '/vendor/cakephp/migrations/', diff --git a/app/vendor/composer/autoload_psr4.php b/app/vendor/composer/autoload_psr4.php index 1fd70561a..10ec936a9 100644 --- a/app/vendor/composer/autoload_psr4.php +++ b/app/vendor/composer/autoload_psr4.php @@ -65,6 +65,8 @@ 'CoreServer\\' => array($baseDir . '/plugins/CoreServer/src'), 'CoreReport\\Test\\' => array($baseDir . '/availableplugins/CoreReport/tests'), 'CoreReport\\' => array($baseDir . '/availableplugins/CoreReport/src'), + 'CoreAssigner\\Test\\' => array($baseDir . '/plugins/CoreAssigner/tests'), + 'CoreAssigner\\' => array($baseDir . '/plugins/CoreAssigner/src'), 'Composer\\XdebugHandler\\' => array($vendorDir . '/composer/xdebug-handler/src'), 'Composer\\Spdx\\' => array($vendorDir . '/composer/spdx-licenses/src'), 'Composer\\Semver\\' => array($vendorDir . '/composer/semver/src'), diff --git a/app/vendor/composer/autoload_static.php b/app/vendor/composer/autoload_static.php index b446bd07a..3be2be931 100644 --- a/app/vendor/composer/autoload_static.php +++ b/app/vendor/composer/autoload_static.php @@ -134,6 +134,8 @@ class ComposerStaticInitb25f76eec921984aa94dcf4015a4846e 'CoreServer\\' => 11, 'CoreReport\\Test\\' => 16, 'CoreReport\\' => 11, + 'CoreAssigner\\Test\\' => 18, + 'CoreAssigner\\' => 13, 'Composer\\XdebugHandler\\' => 23, 'Composer\\Spdx\\' => 14, 'Composer\\Semver\\' => 16, @@ -400,6 +402,14 @@ class ComposerStaticInitb25f76eec921984aa94dcf4015a4846e array ( 0 => __DIR__ . '/../..' . '/availableplugins/CoreReport/src', ), + 'CoreAssigner\\Test\\' => + array ( + 0 => __DIR__ . '/../..' . '/plugins/CoreAssigner/tests', + ), + 'CoreAssigner\\' => + array ( + 0 => __DIR__ . '/../..' . '/plugins/CoreAssigner/src', + ), 'Composer\\XdebugHandler\\' => array ( 0 => __DIR__ . '/..' . '/composer/xdebug-handler/src',