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',