From f13295c6cbbc2e0f4bcee49de4643715478f0253 Mon Sep 17 00:00:00 2001 From: Benn Oshrin Date: Sat, 18 May 2024 19:23:59 -0400 Subject: [PATCH] Initial implementation of AssignerJob and ProvisionerJob (CFM-169) --- .../src/Model/Table/SqlProvisionersTable.php | 16 +- .../SqlConnector/src/config/plugin.json | 51 +--- app/composer.json | 6 +- .../src/Model/Table/SqlAssignersTable.php | 3 +- app/plugins/CoreJob/README.md | 11 + app/plugins/CoreJob/composer.json | 24 ++ app/plugins/CoreJob/phpunit.xml.dist | 30 ++ .../resources/locales/en_US/core_job.po | 68 +++++ .../CoreJob/src/Controller/AppController.php | 10 + app/plugins/CoreJob/src/CoreJobPlugin.php | 93 +++++++ .../CoreJob/src/Lib/Jobs/AssignerJob.php | 259 ++++++++++++++++++ .../CoreJob/src/Lib/Jobs/ProvisionerJob.php | 258 +++++++++++++++++ app/plugins/CoreJob/src/config/plugin.json | 8 + app/plugins/CoreJob/tests/bootstrap.php | 55 ++++ app/plugins/CoreJob/tests/schema.sql | 1 + app/plugins/CoreJob/webroot/.gitkeep | 0 app/resources/locales/en_US/error.po | 3 + app/resources/locales/en_US/operation.po | 6 + app/resources/locales/en_US/result.po | 6 + .../IdentifierAssignmentsController.php | 77 ++++-- .../ProvisioningTargetsController.php | 35 +++ app/src/Lib/Traits/PluggableModelTrait.php | 2 +- app/src/Lib/Util/PaginatedSqlIterator.php | 4 +- app/src/Lib/Util/TableUtilities.php | 61 +++++ app/src/Model/Entity/ProvisioningTarget.php | 12 + app/src/Model/Table/EmailAddressesTable.php | 1 + app/src/Model/Table/GroupsTable.php | 5 +- .../Table/IdentifierAssignmentsTable.php | 37 ++- .../Model/Table/JobHistoryRecordsTable.php | 1 + app/src/Model/Table/JobsTable.php | 43 ++- app/src/Model/Table/PeopleTable.php | 3 +- .../Model/Table/ProvisioningTargetsTable.php | 10 +- .../IdentifierAssignments/columns.inc | 12 + app/templates/Jobs/fields.inc | 16 +- app/templates/ProvisioningTargets/columns.inc | 6 + app/vendor/cakephp-plugins.php | 1 + app/vendor/composer/autoload_psr4.php | 2 + app/vendor/composer/autoload_static.php | 10 + 38 files changed, 1136 insertions(+), 110 deletions(-) create mode 100644 app/plugins/CoreJob/README.md create mode 100644 app/plugins/CoreJob/composer.json create mode 100644 app/plugins/CoreJob/phpunit.xml.dist create mode 100644 app/plugins/CoreJob/resources/locales/en_US/core_job.po create mode 100644 app/plugins/CoreJob/src/Controller/AppController.php create mode 100644 app/plugins/CoreJob/src/CoreJobPlugin.php create mode 100644 app/plugins/CoreJob/src/Lib/Jobs/AssignerJob.php create mode 100644 app/plugins/CoreJob/src/Lib/Jobs/ProvisionerJob.php create mode 100644 app/plugins/CoreJob/src/config/plugin.json create mode 100644 app/plugins/CoreJob/tests/bootstrap.php create mode 100644 app/plugins/CoreJob/tests/schema.sql create mode 100644 app/plugins/CoreJob/webroot/.gitkeep create mode 100644 app/src/Lib/Util/TableUtilities.php diff --git a/app/availableplugins/SqlConnector/src/Model/Table/SqlProvisionersTable.php b/app/availableplugins/SqlConnector/src/Model/Table/SqlProvisionersTable.php index 3fff9f39b..b8902a411 100644 --- a/app/availableplugins/SqlConnector/src/Model/Table/SqlProvisionersTable.php +++ b/app/availableplugins/SqlConnector/src/Model/Table/SqlProvisionersTable.php @@ -40,6 +40,7 @@ use App\Lib\Enum\ProvisioningStatusEnum; use App\Lib\Util\SchemaManager; use App\Lib\Util\StringUtilities; +use App\Lib\Util\TableUtilities; class SqlProvisionersTable extends Table { use \App\Lib\Traits\AutoViewVarsTrait; @@ -64,7 +65,8 @@ class SqlProvisionersTable extends Table { 'AdHocAttributes', 'Addresses', 'EmailAddresses', - 'ExternalIdentities', +// External Identities are not provisionable +// 'ExternalIdentities', 'GroupMembers', 'Identifiers', 'Names', @@ -108,7 +110,7 @@ class SqlProvisionersTable extends Table { 'source_table' => 'email_addresses', 'related' => [] ], - 'ExternalIdentities' => [ +/* 'ExternalIdentities' => [ 'table' => 'external_identities', 'name' => 'SpExternalIdentities', 'source' => 'ExternalIdentities', @@ -135,7 +137,7 @@ class SqlProvisionersTable extends Table { 'Addresses', 'TelephoneNumbers' ] - ], + ],*/ 'GroupMembers' => [ 'table' => 'group_members', 'name' => 'SpGroupMembers', @@ -412,7 +414,7 @@ protected function syncEntity( 'connection' => ConnectionManager::get($dataSource) ]; - $SpTable = TableRegistry::getTableLocator()->get(alias: $mconfig['name'], options: $options); + $SpTable = TableUtilities::getTableFromRegistry(alias: $mconfig['name'], options: $options); try { $curEntity = $SpTable->get($data->id); @@ -568,7 +570,7 @@ public function syncReferenceData(int $id, string $dataSource='targetdb') { 'connection' => ConnectionManager::get($dataSource) ]; - $SpTable = TableRegistry::getTableLocator()->get(alias: $m['name'], options: $options); + $SpTable = TableUtilities::getTableFromRegistry(alias: $m['name'], options: $options); // Next get the source table model @@ -660,7 +662,7 @@ protected function syncRelatedEntities( 'connection' => ConnectionManager::get($dataSource) ]; - $SpTable = TableRegistry::getTableLocator()->get(alias: $mconfig['name'], options: $options); + $SpTable = TableUtilities::getTableFromRegistry(alias: $mconfig['name'], options: $options); // We have the source values, but we need to convert them to arrays // for patchEntities @@ -744,7 +746,7 @@ protected function syncRelatedEntities( 'connection' => ConnectionManager::get($dataSource) ]; - $SubTable = TableRegistry::getTableLocator()->get(alias: $subconfig['name'], options: $options); + $SubTable = TableUtilities::getTableFromRegistry(alias: $subconfig['name'], options: $options); foreach($toDelete as $d) { // We shouldn't get here if either $parentKey or $d->id is null... diff --git a/app/availableplugins/SqlConnector/src/config/plugin.json b/app/availableplugins/SqlConnector/src/config/plugin.json index 04a3c185c..bbed1e07a 100644 --- a/app/availableplugins/SqlConnector/src/config/plugin.json +++ b/app/availableplugins/SqlConnector/src/config/plugin.json @@ -93,41 +93,6 @@ }, "changelog": false }, - - "external_identities": { - "columns": { - "id": {}, - "person_id": { "notnull": true }, - "status": {}, - "date_of_birth": { "type": "date" } - }, - "indexes": { - "external_identities_i1": { "columns": [ "person_id" ] } - }, - "changelog": false - }, - - "external_identity_roles": { - "columns": { - "id": {}, - "external_identity_id": { "notnull": true }, - "status": {}, - "ordr": {}, - "affiliation_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, - "title": { "type": "string", "size": 128 }, - "organization": { "type": "string", "size": 128 }, - "department": { "type": "string", "size": 128 }, - "manager_identifier": { "type": "string", "size": 512 }, - "sponsor_identifier": { "type": "string", "size": 512 }, - "valid_from": {}, - "valid_through": {} - }, - "indexes": { - "external_identity_roles_i1": { "columns": [ "external_identity_id" ] }, - "external_identity_roles_i2": { "columns": [ "affiliation_type_id" ] } - }, - "changelog": false - }, "groups": { "columns": { @@ -153,7 +118,7 @@ }, "indexes": { }, - "mvea": [ "person", "person_role", "external_identity", "external_identity_role" ], + "mvea": [ "person", "person_role" ], "changelog": false }, @@ -173,7 +138,7 @@ "indexes": { "addresses_i1": { "columns": [ "type_id" ] } }, - "mvea": [ "person", "person_role", "external_identity", "external_identity_role" ], + "mvea": [ "person", "person_role" ], "changelog": false }, @@ -188,7 +153,7 @@ "indexes": { "email_addresses_i3": { "columns": [ "type_id" ] } }, - "mvea": [ "person", "external_identity" ], + "mvea": [ "person" ], "changelog": false }, @@ -203,7 +168,7 @@ "indexes": { "identifiers_i3": { "columns": [ "type_id" ] } }, - "mvea": [ "person", "external_identity", "group" ], + "mvea": [ "person", "group" ], "changelog": false }, @@ -223,7 +188,7 @@ "indexes": { "names_i1": { "columns": [ "type_id" ] } }, - "mvea": [ "person", "external_identity" ], + "mvea": [ "person" ], "changelog": false }, @@ -237,7 +202,7 @@ "indexes": { "pronouns_i1": { "columns": [ "type_id" ] } }, - "mvea": [ "person", "external_identity" ], + "mvea": [ "person" ], "changelog": false }, @@ -254,7 +219,7 @@ "indexes": { "telephone_numbers_i1": { "columns": [ "type_id" ] } }, - "mvea": [ "person", "person_role", "external_identity", "external_identity_role" ], + "mvea": [ "person", "person_role" ], "changelog": false }, @@ -268,7 +233,7 @@ "indexes": { "urls_i1": { "columns": [ "type_id" ] } }, - "mvea": [ "person", "external_identity" ], + "mvea": [ "person" ], "changelog": false }, diff --git a/app/composer.json b/app/composer.json index b47f40050..1122a447f 100644 --- a/app/composer.json +++ b/app/composer.json @@ -34,7 +34,8 @@ "CoreAssigner\\": "plugins/CoreAssigner/src/", "CoreServer\\": "plugins/CoreServer/src/", "FileConnector\\": "availableplugins/FileConnector/src/", - "SqlConnector\\": "availableplugins/SqlConnector/src/" + "SqlConnector\\": "availableplugins/SqlConnector/src/", + "CoreJob\\": "plugins/CoreJob/src/" } }, "autoload-dev": { @@ -45,7 +46,8 @@ "CoreAssigner\\Test\\": "plugins/CoreAssigner/tests/", "CoreServer\\Test\\": "plugins/CoreServer/tests/", "FileConnector\\Test\\": "availableplugins/FileConnector/tests/", - "SqlConnector\\Test\\": "availableplugins/SqlConnector/tests/" + "SqlConnector\\Test\\": "availableplugins/SqlConnector/tests/", + "CoreJob\\Test\\": "plugins/CoreJob/tests/" } }, "scripts": { diff --git a/app/plugins/CoreAssigner/src/Model/Table/SqlAssignersTable.php b/app/plugins/CoreAssigner/src/Model/Table/SqlAssignersTable.php index 3ac0a5b25..f0777f53b 100644 --- a/app/plugins/CoreAssigner/src/Model/Table/SqlAssignersTable.php +++ b/app/plugins/CoreAssigner/src/Model/Table/SqlAssignersTable.php @@ -36,6 +36,7 @@ use Cake\ORM\TableRegistry; use Cake\Utility\Hash; use Cake\Validation\Validator; +use App\Lib\Util\TableUtilities; class SqlAssignersTable extends Table { use \App\Lib\Traits\AutoViewVarsTrait; @@ -128,7 +129,7 @@ public function assign($ia, $entity): string { 'connection' => ConnectionManager::get('sqlassigner') ]; - $SourceTable = TableRegistry::getTableLocator()->get( + $SourceTable = TableUtilities::getTableFromRegistry( alias: 'SourceIdentifiers', options: $options ); diff --git a/app/plugins/CoreJob/README.md b/app/plugins/CoreJob/README.md new file mode 100644 index 000000000..05ed9ffe0 --- /dev/null +++ b/app/plugins/CoreJob/README.md @@ -0,0 +1,11 @@ +# CoreJob 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-job +``` diff --git a/app/plugins/CoreJob/composer.json b/app/plugins/CoreJob/composer.json new file mode 100644 index 000000000..d1fbeadce --- /dev/null +++ b/app/plugins/CoreJob/composer.json @@ -0,0 +1,24 @@ +{ + "name": "your-name-here/core-job", + "description": "CoreJob plugin for CakePHP", + "type": "cakephp-plugin", + "license": "MIT", + "require": { + "php": ">=7.2", + "cakephp/cakephp": "4.5.*" + }, + "require-dev": { + "phpunit/phpunit": "^8.5 || ^9.3" + }, + "autoload": { + "psr-4": { + "CoreJob\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "CoreJob\\Test\\": "tests/", + "Cake\\Test\\": "vendor/cakephp/cakephp/tests/" + } + } +} diff --git a/app/plugins/CoreJob/phpunit.xml.dist b/app/plugins/CoreJob/phpunit.xml.dist new file mode 100644 index 000000000..fe025c5e9 --- /dev/null +++ b/app/plugins/CoreJob/phpunit.xml.dist @@ -0,0 +1,30 @@ + + + + + + + + + + + tests/TestCase/ + + + + + + + + + + + src/ + + + diff --git a/app/plugins/CoreJob/resources/locales/en_US/core_job.po b/app/plugins/CoreJob/resources/locales/en_US/core_job.po new file mode 100644 index 000000000..4839f6e49 --- /dev/null +++ b/app/plugins/CoreJob/resources/locales/en_US/core_job.po @@ -0,0 +1,68 @@ +# COmanage Registry Localizations (core_job 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 +# @since COmanage Registry v5.0.0 +# @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + +msgid "error.co_id" +msgstr "Requested {0} entity {1} is not in CO {2}" + +msgid "opt.assigner.context" +msgstr "Identifier Assignment context" + +msgid "opt.entities" +msgstr "Comma separated list of entity IDs to process" + +msgid "opt.provisioner.model" +msgstr "Model to provision" + +msgid "opt.provisioner.provisioning_target_id" +msgstr "Provisioning Target ID" + +msgid "Assigner.cancel_summary" +msgstr "Job canceled after reviewing {0} entities and assigning {1} Identifier(s)" + +msgid "Assigner.error.assign" +msgstr "Error assigning {0}: {1}" + +msgid "Assigner.finish_summary" +msgstr "Reviewed {0} entities and assigned {1} Identifier(s)" + +msgid "Assigner.result.assigned" +msgstr "Assigned {0}: {1}" + +msgid "Assigner.start_summary" +msgstr "Assigning all Identifiers in context {0} for CO {1} ({2} entities)" + +msgid "Provisioner.cancel_summary" +msgstr "Job canceled after provisioning {0} entities ({1} errors)" + +msgid "Provisioner.error.status" +msgstr "Provisioner is disabled" + +msgid "Provisioner.finish_summary" +msgstr "Reprovisioned {0} entities ({1} errors)" + +msgid "Provisioner.result.provisioned" +msgstr "Reprovisioned" + +msgid "Provisioner.start_summary" +msgstr "Reprovisioning {0} {1} entities for Provisioning Target {2}" diff --git a/app/plugins/CoreJob/src/Controller/AppController.php b/app/plugins/CoreJob/src/Controller/AppController.php new file mode 100644 index 000000000..dfd146246 --- /dev/null +++ b/app/plugins/CoreJob/src/Controller/AppController.php @@ -0,0 +1,10 @@ +plugin( + 'CoreJob', + ['path' => '/core-job'], + 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/CoreJob/src/Lib/Jobs/AssignerJob.php b/app/plugins/CoreJob/src/Lib/Jobs/AssignerJob.php new file mode 100644 index 000000000..49f86b619 --- /dev/null +++ b/app/plugins/CoreJob/src/Lib/Jobs/AssignerJob.php @@ -0,0 +1,259 @@ + [ + 'help' => __d('core_job', 'opt.assigner.context'), + 'type' => 'select', + 'choices' => ['Groups', 'People'], + 'required' => true + ], + 'entities' => [ + 'help' => __d('core_job', 'opt.entities'), + 'type' => 'string', + 'required' => false + ] + ]; + } + + /** + * Process Identifier Assignment for an entity. + * + * @since COmanage Registry v5.0.0 + * @param JobsTable $JobsTable Jobs Table + * @param JobHistoryRecordsTable $JobHistoryRecordsTable JobHistoryRecords Table + * @param IdentifierAssignmentsTable $IdentifierAssignments IdentifierAssignments Table + * @param Job $job Job entity + * @param string $context Context (People, Groups) + * @param int $entityId Entity ID to process + * @param int $count Total entity count + * @param &int $processed Number of entities processed so far + * @param &int $lastPct Last percent update + * @param &int $assigned Number of new Identifiers assigned + * @return bool True if processing should continue, false otherwise + */ + + protected function processEntity( + \App\Model\Table\JobsTable $JobsTable, + \App\Model\Table\JobHistoryRecordsTable $JobHistoryRecordsTable, + \App\Model\Table\IdentifierAssignmentsTable $IdentifierAssignments, + \App\Model\Entity\Job $job, + string $context, + int $entityId, + int $count, + int &$processed, + int &$lastPct, + int &$assigned + ): bool { + $result = $IdentifierAssignments->assign( + entityType: $context, + entityId: $entityId, + provision: true, + // actorPersonId: null -- do we have this available? + ); + + $processed++; + + // Note IdentifierAssignmentsTable already does logging of whether Identifiers were + // assigned or not, so we don't need to do anything more here (except record + // appropriate JobHistory, which we do for newly assigned and errorss). + + if(!empty($result['assigned'])) { + foreach($result['assigned'] as $ia => $msg) { + $JobHistoryRecordsTable->record( + jobId: $job->id, + recordKey: (string)$entityId, + comment: __d('core_job', 'Assigner.result.assigned', [$ia, $msg]), + status: JobStatusEnum::Complete + ); + + $assigned++; + } + } + + if(!empty($result['errors'])) { + foreach($result['errors'] as $ia => $msg) { + $JobHistoryRecordsTable->record( + jobId: $job->id, + recordKey: (string)$entityId, + comment: __d('core_job', 'Assigner.error.assign', [$ia, $msg]), + status: JobStatusEnum::Failed + ); + } + } + + // Check to see if the Job was canceled, or update the percent complete + if($JobsTable->isCanceled($job->id)) { + // The Job was already marked Canceled, but we can optionally add a History Record + $JobHistoryRecordsTable->record( + jobId: $job->id, + recordKey: "", + comment: __d('core_job', 'Assigner.finish_summary', [$count, $assigned]), + status: JobStatusEnum::Canceled + ); + + return false; + } else { + // Maybe update % complete + + $newPct = (int)round(($processed * 100) / $count); + + if($newPct > $lastPct) { + $JobsTable->setPercentComplete(job: $job, percent: $newPct); + $lastPct = $newPct; + } + } + + return true; + } + + /** + * Run the requested Job. + * + * @since COmanage Registry v5.0.0 + * @param JobsTable $JobsTable Jobs table, for updating the Job status + * @param JobHistoryRecordsTable $JobHistoryRecordsTable Job History Records table, for recording additional history + * @param Job $job Job entity + * @param array $parameters Parameters for this Job + */ + + public function run( + \App\Model\Table\JobsTable $JobsTable, + \App\Model\Table\JobHistoryRecordsTable $JobHistoryRecordsTable, + \App\Model\Entity\Job $job, + array $parameters + ) { + // Before we get started, figure out what we're doing + + $context = $parameters['context']; + + $count = 0; // Number of available entities (eg: People) + $processed = 0; // Number of entities processed so far + $lastPct = 0; // Last integer percent done, used for updating Percent Complete + $assigned = 0; // Number of _Identifiers_ (not entities) newly assigned + + // IdentifierAssignmentsTable will cache the IdentifierAssignment configuration, + // but each assignment action still generates a bunch of database calls. + $IdentifierAssignments = TableRegistry::getTableLocator()->get('IdentifierAssignments'); + // ie: People or Groups + $EntityTable = TableRegistry::getTableLocator()->get($context); + + if(!empty($parameters['entities'])) { + // We have one or more explicitly specified entities to process + + $ids = explode(',', $parameters['entities']); + + $count = count($ids); + + $JobsTable->start(job: $job, summary: __d('core_job', 'Assigner.start_summary', [$context, $job->co_id, $count])); + + foreach($ids as $id) { + // First make sure $id is in $job->co_id + + $entityCoId = $EntityTable->calculateCoId((int)$id); + + if($entityCoId == $job->co_id) { + if(!$this->processEntity( + $JobsTable, + $JobHistoryRecordsTable, + $IdentifierAssignments, + $job, + $context, + (int)$id, + $count, + $processed, + $lastPct, + $assigned + )) { + break; + } + } else { + $JobHistoryRecordsTable->record( + jobId: $job->id, + recordKey: (string)$entity->id, + comment: __d('core_job', 'error.co_id', [$context, $id, $job->co_id]), + status: JobStatusEnum::Failed + ); + } + } + } else { + // We'll end up pulling entities twice, once here as part of getMembers using + // PaginatedSqlIterator, and again because assign() needs additional information. + // We could just pull a list of IDs here, but that's still basically the same + // number of queries. We could pull the additional information (contain()) that + // assign() needs here, but then we'd have that logic in both places. Running + // Identifier Assignments for everyone is a relatively uncommon action, so it's + // probably OK if it's more expensive than it needs to be to keep the code simpler. + + $iterator = $EntityTable->getMembers($job->co_id); + + // We use this for logging, but it shouldn't be used for iterating as it + // could change during iteration + $count = $iterator->count(); + + $JobsTable->start(job: $job, summary: __d('core_job', 'Assigner.start_summary', [$context, $job->co_id, $count])); + + foreach($iterator as $k => $entity) { + if(!$this->processEntity( + $JobsTable, + $JobHistoryRecordsTable, + $IdentifierAssignments, + $job, + $context, + $entity->id, + $count, + $processed, + $lastPct, + $assigned + )) { + break; + } + } + } + + $JobsTable->finish(job: $job, summary: __d('core_job', 'Assigner.finish_summary', [$count, $assigned])); + } +} \ No newline at end of file diff --git a/app/plugins/CoreJob/src/Lib/Jobs/ProvisionerJob.php b/app/plugins/CoreJob/src/Lib/Jobs/ProvisionerJob.php new file mode 100644 index 000000000..d7ff2356d --- /dev/null +++ b/app/plugins/CoreJob/src/Lib/Jobs/ProvisionerJob.php @@ -0,0 +1,258 @@ + [ + 'help' => __d('core_job', 'opt.entities'), + 'type' => 'string', + 'required' => false + ], + 'model' => [ + 'help' => __d('core_job', 'opt.provisioner.model'), + 'type' => 'select', + 'choices' => ['Groups', 'People'], + 'required' => true + ], + 'provisioning_target_id' => [ + 'help' => __d('core_job', 'opt.provisioner.provisioning_target_id'), + 'type' => 'integer', + 'required' => true + ] + ]; + } + + /** + * Process Reprovisioning for an entity. + * + * @since COmanage Registry v5.0.0 + * @param JobsTable $JobsTable Jobs Table + * @param JobHistoryRecordsTable $JobHistoryRecordsTable JobHistoryRecords Table + * @param EntityTable $EntityTable Entity Table (PeopleTable, etc) + * @param Job $job Job entity + * @param ProvisioningTarget $target Provisioning Target + * @param string $model Model (People, Groups) + * @param int $entityId Entity ID to process + * @param int $count Total entity count + * @param &int $processed Number of entities processed so far + * @param &int $errors Number of errors encountered + * @param &int $lastPct Last percent update + * @return bool True if processing should continue, false otherwise + */ + + protected function processEntity( + \App\Model\Table\JobsTable $JobsTable, + \App\Model\Table\JobHistoryRecordsTable $JobHistoryRecordsTable, + $EntityTable, + \App\Model\Entity\Job $job, + $target, + string $model, + int $entityId, + int $count, + int &$processed, + int &$errors, + int &$lastPct + ): bool { + try { + $EntityTable->requestProvisioning($entityId, $model, $target->id); + + $JobHistoryRecordsTable->record( + jobId: $job->id, + recordKey: (string)$entityId, + comment: __d('core_job', 'Provisioner.result.provisioned'), + status: JobStatusEnum::Complete + ); + } + catch(\Exception $e) { + $JobHistoryRecordsTable->record( + jobId: $job->id, + recordKey: (string)$entityId, + comment: $e->getMessage(), + status: JobStatusEnum::Failed + ); + + $errors++; + } + + $processed++; + + // Check to see if the Job was canceled, or update the percent complete + if($JobsTable->isCanceled($job->id)) { + // The Job was already marked Canceled, but we can optionally add a History Record + $JobHistoryRecordsTable->record( + jobId: $job->id, + recordKey: "", + comment: __d('core_job', 'Provisioner.finish_summary', [$processed, $errors]), + status: JobStatusEnum::Canceled + ); + + return false; + } else { + // Maybe update % complete + + $newPct = (int)round(($processed * 100) / $count); + + if($newPct > $lastPct) { + $JobsTable->setPercentComplete(job: $job, percent: $newPct); + $lastPct = $newPct; + } + } + + return true; + } + + /** + * Run the requested Job. + * + * @since COmanage Registry v5.0.0 + * @param JobsTable $JobsTable Jobs table, for updating the Job status + * @param JobHistoryRecordsTable $JobHistoryRecordsTable Job History Records table, for recording additional history + * @param Job $job Job entity + * @param array $parameters Parameters for this Job + */ + + public function run( + \App\Model\Table\JobsTable $JobsTable, + \App\Model\Table\JobHistoryRecordsTable $JobHistoryRecordsTable, + \App\Model\Entity\Job $job, + array $parameters + ) { + // Before we get started, figure out what we're doing + + $model = $parameters['model']; + + $count = 0; // Number of available entities (eg: People) + $processed = 0; // Number of entities processed so far + $errors = 0; // Number of errors encountered + $lastPct = 0; // Last integer percent done, used for updating Percent Complete + + // IdentifierAssignmentsTable will cache the IdentifierAssignment configuration, + // but each assignment action still generates a bunch of database calls. + $ProvisioningTargets = TableRegistry::getTableLocator()->get('ProvisioningTargets'); + // ie: People or Groups + $EntityTable = TableRegistry::getTableLocator()->get($model); + + $target = $ProvisioningTargets->get($parameters['provisioning_target_id']); + + if($target->co_id != $job->co_id) { + throw new \InvalidArgumentException(__d('core_job', 'error.co_id', ['ProvisioningTarget', $id, $job->co_id])); + } + + if($target->status == ProvisionerModeEnum::Disabled) { + throw new \InvalidArgumentException(__d('core_job', 'Provisioner.error.status')); + } + + if(!empty($parameters['entities'])) { + // We have one or more explicitly specified entities to process + + $ids = explode(',', $parameters['entities']); + + $count = count($ids); + + $JobsTable->start(job: $job, summary: __d('core_job', 'Provisioner.start_summary', [$count, $model, $target->id])); + + foreach($ids as $id) { + // First make sure $id is in $job->co_id + + $entityCoId = $EntityTable->calculateCoId((int)$id); + + if($entityCoId == $job->co_id) { + if(!$this->processEntity( + $JobsTable, + $JobHistoryRecordsTable, + $EntityTable, + $job, + $target, + $model, + (int)$id, + $count, + $processed, + $errors, + $lastPct + )) { + break; + } + } + } + } else { + // We'll end up pulling entities twice, once here as part of getMembers using + // PaginatedSqlIterator, and again because requestProvisioning() needs to + // marshall the provisioning data. We could just pull a list of IDs here, but + // that's still basically the same number of queries. We could pull the + // additional information (contain()) that assign() needs here, but then we'd + // have that logic in both places. Also, this keeps the design consistent with + // AssignerJob. + + $iterator = $EntityTable->getMembers($job->co_id); + + // We use this for logging, but it shouldn't be used for iterating as it + // could change during iteration + $count = $iterator->count(); + + $JobsTable->start(job: $job, summary: __d('core_job', 'Provisioner.start_summary', [$count, $model, $target->id])); + + foreach($iterator as $k => $entity) { + if(!$this->processEntity( + $JobsTable, + $JobHistoryRecordsTable, + $EntityTable, + $job, + $target, + $model, + $entity->id, + $count, + $processed, + $errors, + $lastPct + )) { + break; + } + } + } + + $JobsTable->finish(job: $job, summary: __d('core_job', 'Provisioner.finish_summary', [$processed, $errors])); + } +} \ No newline at end of file diff --git a/app/plugins/CoreJob/src/config/plugin.json b/app/plugins/CoreJob/src/config/plugin.json new file mode 100644 index 000000000..662b33d90 --- /dev/null +++ b/app/plugins/CoreJob/src/config/plugin.json @@ -0,0 +1,8 @@ +{ + "types": { + "job": [ + "AssignerJob", + "ProvisionerJob" + ] + } +} \ No newline at end of file diff --git a/app/plugins/CoreJob/tests/bootstrap.php b/app/plugins/CoreJob/tests/bootstrap.php new file mode 100644 index 000000000..1616b56f3 --- /dev/null +++ b/app/plugins/CoreJob/tests/bootstrap.php @@ -0,0 +1,55 @@ +loadSqlFiles('tests/schema.sql', 'test'); diff --git a/app/plugins/CoreJob/tests/schema.sql b/app/plugins/CoreJob/tests/schema.sql new file mode 100644 index 000000000..59a44c1ef --- /dev/null +++ b/app/plugins/CoreJob/tests/schema.sql @@ -0,0 +1 @@ +-- Test database schema for CoreJob diff --git a/app/plugins/CoreJob/webroot/.gitkeep b/app/plugins/CoreJob/webroot/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/app/resources/locales/en_US/error.po b/app/resources/locales/en_US/error.po index fc6b3686e..fa4c480b6 100644 --- a/app/resources/locales/en_US/error.po +++ b/app/resources/locales/en_US/error.po @@ -205,6 +205,9 @@ msgstr "Invalid parameter" msgid "Jobs.plugin.parameter.required" msgstr "Required parameter not provided" +msgid "Jobs.plugin.parameter.select" +msgstr "Provided value is not a valid choice" + msgid "Jobs.plugin.parameter.type" msgstr "Unknown parameter type {0}" diff --git a/app/resources/locales/en_US/operation.po b/app/resources/locales/en_US/operation.po index a80337754..13e737791 100644 --- a/app/resources/locales/en_US/operation.po +++ b/app/resources/locales/en_US/operation.po @@ -141,6 +141,9 @@ msgstr "First" msgid "go" msgstr "Go" +msgid "IdentifierAssignments.assign.all" +msgstr "Assign Identifiers for All" + msgid "identifiers.assign" msgstr "Assign Identifiers" @@ -195,6 +198,9 @@ msgstr "Are you sure you want to run provisioning?" msgid "provisioning.status" msgstr "Provisioning Status" +msgid "ProvisioningTargets.provision.all" +msgstr "Reprovision All" + msgid "Types.restore" msgstr "Add/Restore Default Types" diff --git a/app/resources/locales/en_US/result.po b/app/resources/locales/en_US/result.po index fd77789d5..c0aa9e347 100644 --- a/app/resources/locales/en_US/result.po +++ b/app/resources/locales/en_US/result.po @@ -102,6 +102,9 @@ msgstr "Identifier Auto Assigned: {0} ({1}, {2})" msgid "IdentifierAssignments.assigned.ok" msgstr "Identifiers Assigned ({0})" +msgid "IdentifierAssignments.queued.ok" +msgstr "Identifiers Assignment for context {0} queued for CO {1}" + msgid "Names.primary_name" msgstr "Primary Name Updated" @@ -157,6 +160,9 @@ msgstr "Created new External Identity via Pipeline {0} ({1}) using Source {2} ({ msgid "Pipelines.started" msgstr "Pipeline {0} ({1}) started for EIS {2} ({3}) source key {4}" +msgid "ProvisioningTargets.queued.ok" +msgstr "Reprovisioning for {0} queued for {1} ({2})" + msgid "removed" msgstr "removed" diff --git a/app/src/Controller/IdentifierAssignmentsController.php b/app/src/Controller/IdentifierAssignmentsController.php index d7d528e31..64e010cd7 100644 --- a/app/src/Controller/IdentifierAssignmentsController.php +++ b/app/src/Controller/IdentifierAssignmentsController.php @@ -31,6 +31,7 @@ // XXX not doing anything with Log yet use Cake\Log\Log; +use Cake\ORM\TableRegistry; use \App\Lib\Util\StringUtilities; class IdentifierAssignmentsController extends StandardPluggableController { @@ -48,33 +49,65 @@ class IdentifierAssignmentsController extends StandardPluggableController { 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($link->attr == 'co_id') { + // We've been asked to assign Identifiers for all entities within a CO, + // which we do by queuing a job. + + $JobTable = TableRegistry::getTableLocator()->get("Jobs"); + + try { + $contexts = ['People', 'Groups']; + + foreach($contexts as $context) { + $JobTable->register( + coId: (int)$link->value, + plugin: 'CoreJob.AssignerJob', + parameters: ['context' => $context], + registerSummary: __d('result', 'IdentifierAssignments.queued.ok', [$context, $link->value]) + ); } - if(!empty($results['already'])) { - $this->Flash->information(__d('result', 'IdentifierAssignments.assigned.already', implode(',', array_keys($results['already'])))); + $this->Flash->success(__d('result', 'IdentifierAssignments.queued.ok', [implode(', ', $contexts), $link->value])); + } + catch(\Exception $e) { + $this->Flash->error($e->getMessage()); + } + + // We need to explicitly set the redirect since generateRedirect() will miscalculate + return $this->redirect([ + 'action' => 'index', + '?' => ['co_id' => $link->value] + ]); + } else { + // We're assigning Identifiers for a single entity + 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()); - } + catch(\Exception $e) { + $this->Flash->error($e->getMessage()); + } - $this->generateRedirect(null); + $this->generateRedirect(null); + } } } \ No newline at end of file diff --git a/app/src/Controller/ProvisioningTargetsController.php b/app/src/Controller/ProvisioningTargetsController.php index 27633787f..26542e44b 100644 --- a/app/src/Controller/ProvisioningTargetsController.php +++ b/app/src/Controller/ProvisioningTargetsController.php @@ -77,6 +77,41 @@ public function beforeFilter(\Cake\Event\EventInterface $event) { return parent::beforeFilter($event); } + /** + * Register a (re)provisioning job. + * + * @since COmanage Registry v5.0.0 + */ + + public function reprovision(string $id) { + // We call this "reprovision" and not "provision" to avoid conflicts with + // StandardController::provision. + + $JobTable = TableRegistry::getTableLocator()->get("Jobs"); + + try { + $target = $this->ProvisioningTargets->get((int)$id); + + $models = ['People', 'Groups']; + + foreach($models as $model) { + $JobTable->register( + coId: $this->getCOID(), + plugin: 'CoreJob.ProvisionerJob', + parameters: ['model' => $model, 'provisioning_target_id' => $id], + registerSummary: __d('result', 'ProvisioningTargets.queued.ok', [$model, $target->description, $target->id]) + ); + } + + $this->Flash->success(__d('result', 'ProvisioningTargets.queued.ok', [implode(', ', $models), $target->description, $target->id])); + } + catch(\Exception $e) { + $this->Flash->error($e->getMessage()); + } + + return $this->generateRedirect($target ?? null); + } + /** * Generate a status index. * diff --git a/app/src/Lib/Traits/PluggableModelTrait.php b/app/src/Lib/Traits/PluggableModelTrait.php index 5d413e979..10a5c1a52 100644 --- a/app/src/Lib/Traits/PluggableModelTrait.php +++ b/app/src/Lib/Traits/PluggableModelTrait.php @@ -151,7 +151,7 @@ protected function setPluginRelations() { // This plugin is not valid. We could filter this in the find() using a // where() clause, but checking here allows us to emit a warning. - $this->llog('error', "Ignoring invalid plugin found in " . $this->getTable() . " record " . $m->id); + $this->llog('error', "Ignoring invalid plugin '" . $m->plugin . "' found in " . $this->getTable() . " record " . $m->id); continue; } diff --git a/app/src/Lib/Util/PaginatedSqlIterator.php b/app/src/Lib/Util/PaginatedSqlIterator.php index f39b3ad8c..10f790ec9 100644 --- a/app/src/Lib/Util/PaginatedSqlIterator.php +++ b/app/src/Lib/Util/PaginatedSqlIterator.php @@ -93,7 +93,7 @@ public function count(bool $refresh=false): int { $this->loadCount(); } - return $this->initialCount; + return $this->count; } /** @@ -120,7 +120,7 @@ protected function loadCount(): void { $query = $query->where($this->conditions); } - $this->initialCount = $query->count(); + $this->count = $query->count(); } /** diff --git a/app/src/Lib/Util/TableUtilities.php b/app/src/Lib/Util/TableUtilities.php new file mode 100644 index 000000000..b0753967a --- /dev/null +++ b/app/src/Lib/Util/TableUtilities.php @@ -0,0 +1,61 @@ +exists($alias)) { + return $Locator->get($alias); + } else { + return $Locator->get($alias, $options); + } + } +} \ No newline at end of file diff --git a/app/src/Model/Entity/ProvisioningTarget.php b/app/src/Model/Entity/ProvisioningTarget.php index 09dae98f7..1efceee83 100644 --- a/app/src/Model/Entity/ProvisioningTarget.php +++ b/app/src/Model/Entity/ProvisioningTarget.php @@ -30,6 +30,7 @@ namespace App\Model\Entity; use Cake\ORM\Entity; +use \App\Lib\Enum\ProvisionerModeEnum; class ProvisioningTarget extends Entity { use \App\Lib\Traits\ReadOnlyEntityTrait; @@ -39,4 +40,15 @@ class ProvisioningTarget extends Entity { 'id' => false, 'slug' => false, ]; + + /** + * Determine if this entity is Active. + * + * @since COmanage Registry v5.0.0 + * @return bool True if the record is active, false otherwise. + */ + + public function isActive(): bool { + return $this->status != ProvisionerModeEnum::Disabled; + } } \ No newline at end of file diff --git a/app/src/Model/Table/EmailAddressesTable.php b/app/src/Model/Table/EmailAddressesTable.php index 81d0334bc..d2c25bad3 100644 --- a/app/src/Model/Table/EmailAddressesTable.php +++ b/app/src/Model/Table/EmailAddressesTable.php @@ -43,6 +43,7 @@ class EmailAddressesTable extends Table { use \App\Lib\Traits\ChangelogBehaviorTrait; use \App\Lib\Traits\CoLinkTrait; use \App\Lib\Traits\HistoryTrait; + use \App\Lib\Traits\LabeledLogTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\ProvisionableTrait; diff --git a/app/src/Model/Table/GroupsTable.php b/app/src/Model/Table/GroupsTable.php index 4326cea6f..3350ab7a1 100644 --- a/app/src/Model/Table/GroupsTable.php +++ b/app/src/Model/Table/GroupsTable.php @@ -606,7 +606,7 @@ public function marshalProvisioningData(int $id): array { // Provisioning Eligibility is // - Deleted if the changelog deleted flag is true - // - Eligible if the status is Active + // - Eligible if the status is Active and the group type is not Owners // - Ineligible otherwise $ret['eligibility'] = ProvisioningEligibilityEnum::Ineligible; @@ -620,7 +620,8 @@ public function marshalProvisioningData(int $id): array { // but we leave the Identifiers in place. $ret['data']->group_members = []; - } elseif($ret['data']->status == SuspendableStatusEnum::Active) { + } elseif($ret['data']->status == SuspendableStatusEnum::Active + && !$ret['data']->isOwners()) { $ret['eligibility'] = ProvisioningEligibilityEnum::Eligible; // For Eligible, we still need to remove Group Memberships that are diff --git a/app/src/Model/Table/IdentifierAssignmentsTable.php b/app/src/Model/Table/IdentifierAssignmentsTable.php index d7865b8df..ecbb30586 100644 --- a/app/src/Model/Table/IdentifierAssignmentsTable.php +++ b/app/src/Model/Table/IdentifierAssignmentsTable.php @@ -51,6 +51,11 @@ class IdentifierAssignmentsTable extends Table { use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\ValidationTrait; + // Cache of Identifier Assignments, by CO ID and Context. Intended primarily + // for use with AssignerJob, which might call assign() hundreds or thousands + // of times, but for the same set of Identifier Assignments. + private $iacache = null; + /** * Perform Cake Model initialization. * @@ -140,6 +145,9 @@ public function initialize(array $config): void { * @param int $entityId Entity ID * @param bool $provision Whether or not to run provisioners after assignment * @param int $actorPersonId Person ID of Actor assigning identifiers + * @return array 'already': Identifiers already assigned, keyed by IA description + * 'assigned': Identifiers newly assigned, keyed by IA description + * 'errors': Errors, keyed by IA description */ public function assign( @@ -176,15 +184,26 @@ public function assign( ? 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(); + // We cache the Identifier Assignments, in particular for use with + // AssignerJob. + + $ias = null; + + if(!empty($this->iacache[$coId][$context])) { + $ias = $this->iacache[$coId][$context]; + } else { + $ias = $this->find() + ->where([ + 'IdentifierAssignments.co_id' => $coId, + 'IdentifierAssignments.status' => SuspendableStatusEnum::Active, + 'IdentifierAssignments.context' => $context + ]) + ->order(['IdentifierAssignments.ordr' => 'ASC']) + ->contain($this->getPluginRelations()) + ->all(); + + $this->iacache[$coId][$context] = $ias; + } foreach($ias as $ia) { // XXX CFM-57 If not group eligible skip this (but log that we skipped it) diff --git a/app/src/Model/Table/JobHistoryRecordsTable.php b/app/src/Model/Table/JobHistoryRecordsTable.php index 3453fed49..8cecb64d9 100644 --- a/app/src/Model/Table/JobHistoryRecordsTable.php +++ b/app/src/Model/Table/JobHistoryRecordsTable.php @@ -29,6 +29,7 @@ namespace App\Model\Table; +use Cake\Event\EventInterface; use Cake\ORM\Table; use Cake\Validation\Validator; use App\Lib\Enum\JobStatusEnum; diff --git a/app/src/Model/Table/JobsTable.php b/app/src/Model/Table/JobsTable.php index 6b1cce01b..f6be504fe 100644 --- a/app/src/Model/Table/JobsTable.php +++ b/app/src/Model/Table/JobsTable.php @@ -88,6 +88,10 @@ public function initialize(array $config): void { $this->setAllowLookupPrimaryLink(['cancel']); $this->setAutoViewVars([ + 'plugins' => [ + 'type' => 'plugin', + 'pluginType' => 'job' + ], 'statuses' => [ 'type' => 'enum', 'class' => 'JobStatusEnum' @@ -108,7 +112,7 @@ public function initialize(array $config): void { ], // Actions that operate over a table (ie: do not require an $id) 'table' => [ - 'add' => false, // ['platformAdmin', 'coAdmin'], + 'add' => ['platformAdmin', 'coAdmin'], 'index' => ['platformAdmin', 'coAdmin'] ], 'readOnly' => ['cancel'], @@ -127,7 +131,7 @@ public function initialize(array $config): void { * * @since COmanage Registry v5.0.0 * @param Job $job Job to assign - * @throws InvalidArgumentException + * @throws ArgumentException */ public function assign(Job $job) { @@ -286,8 +290,10 @@ public function confirmFinished(int $pid) { public function finish(Job $job, string $summary="", string $result=JobStatusEnum::Complete) { // The Job must be InProgress to be finished, unless we're canceling it + // or we're recording a failure if($job->status != JobStatusEnum::InProgress - && !($result == JobStatusEnum::Canceled && $job->canCancel())) { + && !($result == JobStatusEnum::Canceled && $job->canCancel()) + && $result != JobStatusEnum::Failed) { throw new \InvalidArgumentException( __d('error', 'Jobs.status.invalid', @@ -388,20 +394,30 @@ public function process(Job $job) { ); } - try { - // First create an instance of the Entry Point Model - $pClass = $this->instantiatePluginModel($job->plugin, '\Lib\Jobs'); + // First create an instance of the Entry Point Model + $pClass = $this->instantiatePluginModel($job->plugin, '\Lib\Jobs'); - $JobHistoryRecords = TableRegistry::getTableLocator()->get('JobHistoryRecords'); + $JobHistoryRecords = TableRegistry::getTableLocator()->get('JobHistoryRecords'); - // Maybe set the connection on the JobHistoryTable (if we were run via - // the queue runner). + // Maybe set the connection on the JobHistoryTable (if we were run via + // the queue runner). + try { $cxn = ConnectionManager::get('plugin'); if(!empty($cxn)) { $JobHistoryRecords->setConnection($cxn); } - + } + catch(\Cake\Datasource\Exception\MissingDatasourceConfigException $e) { + // plugin datasource not defined, so we're not in the queue runner + } + catch(\Exception $e) { + $this->finish($job, $e->getMessage(), JobStatusEnum::Failed); + } + + // We need a separate try block here because we want to specially handle + // MissingDatasourceConfigException, above + try { $pClass->run( $this, $JobHistoryRecords, @@ -561,7 +577,7 @@ public function start(Job $job, string $summary="") { __d('error', 'Jobs.status.invalid', [ - $jobs->id, + $job->id, __d('enumeration', 'JobStatusEnum.Assigned'), __d('enumeration', 'JobStatusEnum.InProgress'), __d('enumeration', 'JobStatusEnum.'.$job->status) @@ -612,8 +628,9 @@ protected function validateJobParameters(string $plugin, int $coId, array $param } break; case 'select': - throw new \RuntimeException('not implemented'); -// XXX implement + if(!in_array($val, $pluginParameters[$p]['choices'])) { + $ret[$p] = __d('error', 'Jobs.plugin.parameter.select'); + } break; case 'string': // For now, anything can pass as a string diff --git a/app/src/Model/Table/PeopleTable.php b/app/src/Model/Table/PeopleTable.php index c96b60759..21c449f69 100644 --- a/app/src/Model/Table/PeopleTable.php +++ b/app/src/Model/Table/PeopleTable.php @@ -418,6 +418,7 @@ public function marshalProvisioningData(int $id): array { 'Addresses' => [ 'Types' ], 'AdHocAttributes', 'EmailAddresses' => [ 'Types' ], +/* External Identities are not provisionable 'ExternalIdentities' => [ 'Addresses' => [ 'Types' ], 'AdHocAttributes', @@ -433,7 +434,7 @@ public function marshalProvisioningData(int $id): array { 'Pronouns', 'TelephoneNumbers' => [ 'Types' ], 'Urls' => [ 'Types' ] - ], + ],*/ 'GroupMembers' => [ 'Groups' ], 'Identifiers' => [ 'Types' ], 'Names' => [ 'Types' ], diff --git a/app/src/Model/Table/ProvisioningTargetsTable.php b/app/src/Model/Table/ProvisioningTargetsTable.php index 22f9b91f1..a8395ce97 100644 --- a/app/src/Model/Table/ProvisioningTargetsTable.php +++ b/app/src/Model/Table/ProvisioningTargetsTable.php @@ -86,6 +86,7 @@ public function initialize(array $config): void { $this->setPrimaryLink(['co_id', 'group_id', 'person_id']); $this->setRequiresCO(true); + $this->setAllowLookupPrimaryLink(['reprovision']); $this->setAllowUnkeyedPrimaryLink(['status']); $this->setAutoViewVars([ @@ -106,10 +107,11 @@ public function initialize(array $config): void { $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'] + 'configure' => ['platformAdmin', 'coAdmin'], + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + 'reprovision' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] ], // Actions that operate over a table (ie: do not require an $id) 'table' => [ diff --git a/app/templates/IdentifierAssignments/columns.inc b/app/templates/IdentifierAssignments/columns.inc index e3b8827fd..da7f44414 100644 --- a/app/templates/IdentifierAssignments/columns.inc +++ b/app/templates/IdentifierAssignments/columns.inc @@ -53,6 +53,18 @@ $indexColumns = [ ] ]; +$topLinks = [ + [ + 'icon' => 'add_to_queue', + 'order' => 'Default', + 'label' => __d('operation', 'IdentifierAssignments.assign.all'), + 'link' => [ + 'action' => 'assign' + ], + 'class' => '' + ] +]; + // $rowActions appear as row-level menu items in the index view gear icon $rowActions = [ [ diff --git a/app/templates/Jobs/fields.inc b/app/templates/Jobs/fields.inc index bd6d9f1a7..f18d6e7ff 100644 --- a/app/templates/Jobs/fields.inc +++ b/app/templates/Jobs/fields.inc @@ -1,6 +1,6 @@ element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'plugin', + 'fieldLabel' => __d('controller', 'Jobs', [1]) + ] + ]); +} elseif($vv_action == 'view') { print $this->element('form/listItem', [ 'arguments' => [ 'fieldName' => 'plugin', diff --git a/app/templates/ProvisioningTargets/columns.inc b/app/templates/ProvisioningTargets/columns.inc index 5f4143bbf..3420174fd 100644 --- a/app/templates/ProvisioningTargets/columns.inc +++ b/app/templates/ProvisioningTargets/columns.inc @@ -51,6 +51,12 @@ $rowActions = [ 'action' => 'configure', 'label' => __d('operation', 'configure.plugin'), 'icon' => 'electrical_services' + ], + [ + 'action' => 'reprovision', + 'label' => __d('operation', 'ProvisioningTargets.provision.all'), + 'icon' => 'add_to_queue', + 'if' => 'isActive' ] ]; diff --git a/app/vendor/cakephp-plugins.php b/app/vendor/cakephp-plugins.php index f862280d8..3fd14ac37 100644 --- a/app/vendor/cakephp-plugins.php +++ b/app/vendor/cakephp-plugins.php @@ -7,6 +7,7 @@ 'Cake/TwigView' => $baseDir . '/vendor/cakephp/twig-view/', 'CoreAssigner' => $baseDir . '/plugins/CoreAssigner/', 'CoreEnroller' => $baseDir . '/plugins/CoreEnroller/', + 'CoreJob' => $baseDir . '/plugins/CoreJob/', '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 eaf236030..461bc9bb0 100644 --- a/app/vendor/composer/autoload_psr4.php +++ b/app/vendor/composer/autoload_psr4.php @@ -66,6 +66,8 @@ 'CoreServer\\' => array($baseDir . '/plugins/CoreServer/src'), 'CoreReport\\Test\\' => array($baseDir . '/availableplugins/CoreReport/tests'), 'CoreReport\\' => array($baseDir . '/availableplugins/CoreReport/src'), + 'CoreJob\\Test\\' => array($baseDir . '/plugins/CoreJob/tests'), + 'CoreJob\\' => array($baseDir . '/plugins/CoreJob/src'), 'CoreEnroller\\Test\\' => array($baseDir . '/plugins/CoreEnroller/tests'), 'CoreEnroller\\' => array($baseDir . '/plugins/CoreEnroller/src'), 'CoreAssigner\\Test\\' => array($baseDir . '/plugins/CoreAssigner/tests'), diff --git a/app/vendor/composer/autoload_static.php b/app/vendor/composer/autoload_static.php index 89160fedc..ea99e3f5f 100644 --- a/app/vendor/composer/autoload_static.php +++ b/app/vendor/composer/autoload_static.php @@ -136,6 +136,8 @@ class ComposerStaticInitb25f76eec921984aa94dcf4015a4846e 'CoreServer\\' => 11, 'CoreReport\\Test\\' => 16, 'CoreReport\\' => 11, + 'CoreJob\\Test\\' => 13, + 'CoreJob\\' => 8, 'CoreEnroller\\Test\\' => 18, 'CoreEnroller\\' => 13, 'CoreAssigner\\Test\\' => 18, @@ -412,6 +414,14 @@ class ComposerStaticInitb25f76eec921984aa94dcf4015a4846e array ( 0 => __DIR__ . '/../..' . '/availableplugins/CoreReport/src', ), + 'CoreJob\\Test\\' => + array ( + 0 => __DIR__ . '/../..' . '/plugins/CoreJob/tests', + ), + 'CoreJob\\' => + array ( + 0 => __DIR__ . '/../..' . '/plugins/CoreJob/src', + ), 'CoreEnroller\\Test\\' => array ( 0 => __DIR__ . '/../..' . '/plugins/CoreEnroller/tests',