diff --git a/app/availableplugins/FileConnector/src/Controller/FileSourcesController.php b/app/availableplugins/FileConnector/src/Controller/FileSourcesController.php index bd8407232..0e5371d43 100644 --- a/app/availableplugins/FileConnector/src/Controller/FileSourcesController.php +++ b/app/availableplugins/FileConnector/src/Controller/FileSourcesController.php @@ -30,6 +30,8 @@ namespace FileConnector\Controller; use App\Controller\StandardPluginController; +use Cake\Event\EventInterface; +use Cake\Http\Response; class FileSourcesController extends StandardPluginController { public $paginate = [ @@ -37,4 +39,25 @@ class FileSourcesController extends StandardPluginController { 'FileSources.id' => 'asc' ] ]; + + /** + * Callback run prior to the request render. + * + * @param EventInterface $event Cake Event + * + * @return Response|void + * @since COmanage Registry v5.0.0 + */ + + public function beforeRender(EventInterface $event) { + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->FileSources->ExternalIdentitySources->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->FileSources->ExternalIdentitySources->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->FileSources->ExternalIdentitySources->getPrimaryKey()); + } + + return parent::beforeRender($event); + } } diff --git a/app/availableplugins/FileConnector/src/Model/Table/FileSourcesTable.php b/app/availableplugins/FileConnector/src/Model/Table/FileSourcesTable.php index c51a21307..0e26f851a 100644 --- a/app/availableplugins/FileConnector/src/Model/Table/FileSourcesTable.php +++ b/app/availableplugins/FileConnector/src/Model/Table/FileSourcesTable.php @@ -44,7 +44,8 @@ class FileSourcesTable extends Table { use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\ValidationTrait; - + use \App\Lib\Traits\TabTrait; + // Cache of the field configuration protected $fieldCfg = null; @@ -78,6 +79,23 @@ public function initialize(array $config): void { ] ]); + // All the tabs share the same configuration in the ModelTable file + $this->setTabsConfig( + [ + // Ordered list of Tabs + 'tabs' => ['ExternalIdentitySources', 'FileConnector.FileSources', 'ExternalIdentitySources@action.search'], + // What actions will include the subnavigation header + 'action' => [ + // If a model renders in a subnavigation mode in edit/view mode, it cannot + // render in index mode for the same use case/context + // XXX edit should go first. + 'ExternalIdentitySources' => ['edit', 'view', 'search'], + 'FileConnector.FileSources' => ['edit'], + 'ExternalIdentitySources@action.search' => [], + ], + ] + ); + $this->setPermissions([ // Actions that operate over an entity (ie: require an $id) 'entity' => [ diff --git a/app/availableplugins/FileConnector/templates/FileSources/fields-nav.inc b/app/availableplugins/FileConnector/templates/FileSources/fields-nav.inc deleted file mode 100644 index 4228ce579..000000000 --- a/app/availableplugins/FileConnector/templates/FileSources/fields-nav.inc +++ /dev/null @@ -1,31 +0,0 @@ - 'plugin', - 'active' => 'plugin' - ]; \ No newline at end of file diff --git a/app/composer.json b/app/composer.json index e50a631fd..dada08d3b 100644 --- a/app/composer.json +++ b/app/composer.json @@ -32,6 +32,7 @@ "App\\": "src/", "ApiConnector\\": "availableplugins/ApiConnector/src/", "CoreAssigner\\": "plugins/CoreAssigner/src/", + "CoreEnroller\\": "plugins/CoreEnroller/src/", "CoreServer\\": "plugins/CoreServer/src/", "FileConnector\\": "availableplugins/FileConnector/src/", "PipelineToolkit\\": "availableplugins/PipelineToolkit/src/", @@ -45,6 +46,7 @@ "ApiConnector\\Test\\": "availableplugins/ApiConnector/tests/", "Cake\\Test\\": "vendor/cakephp/cakephp/tests/", "CoreAssigner\\Test\\": "plugins/CoreAssigner/tests/", + "CoreEnroller\\Test\\": "plugins/CoreEnroller/tests/", "CoreServer\\Test\\": "plugins/CoreServer/tests/", "FileConnector\\Test\\": "availableplugins/FileConnector/tests/", "PipelineToolkit\\Test\\": "availableplugins/PipelineToolkit/tests/", diff --git a/app/config/routes.php b/app/config/routes.php index 89664c075..2bf695e7b 100644 --- a/app/config/routes.php +++ b/app/config/routes.php @@ -168,6 +168,20 @@ function (RouteBuilder $builder) { * ...and connect the rest of 'Pages' controller's URLs. */ $builder->connect('/pages/*', ['controller' => 'Pages', 'action' => 'display']); + + /** + * Registry allows URLs of the form /coid/name to render as a Mostly Static Page. + * + * Note this will effectively route any URL of the form /registry/x, where x consists of + * digits, to the Pages controller. We need to filter on digits, or we'll end up taking + * over all controllers as well. (The implication is we can't have a controller whose + * name consists entirely of digits, but we probably shouldn't...) + */ + $builder->connect( + '/{coid}/{name}', + ['controller' => 'Pages', 'action' => 'show' ], + ['coid' => '\d+', 'pass' => ['coid', 'name']] + ); /* * Connect catchall routes for all controllers. diff --git a/app/config/schema/schema.json b/app/config/schema/schema.json index da2844120..21e03464a 100644 --- a/app/config/schema/schema.json +++ b/app/config/schema/schema.json @@ -9,12 +9,14 @@ "comment": "Columns with names matching those defined here will by default inherit these properties", "columns": { + "action": { "type": "string", "size": 4 }, "api_user_id": { "type": "integer", "foreignkey": { "table": "api_users", "column": "id" } }, "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 }, + "enrollment_flow_step_id": { "type": "integer", "foreignkey": { "table": "enrollment_flow_steps", "column": "id" } }, "external_identity_id": { "type": "integer", "foreignkey": { "table": "external_identities", "column": "id" } }, "external_identity_role_id": { "type": "integer", "foreignkey": { "table": "external_identity_roles", "column": "id" } }, "external_identity_source_id": { "type": "integer", "foreignkey": { "table": "external_identity_sources", "column": "id" } }, @@ -23,15 +25,18 @@ "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 }, + "message_template_id": { "type": "integer", "foreignkey": { "table": "message_templates", "column": "id" } }, "name": { "type": "string", "size": 128, "notnull": true }, "ordr": { "type": "integer" }, "person_id": { "type": "integer", "foreignkey": { "table": "people", "column": "id" } }, "person_role_id": { "type": "integer", "foreignkey": { "table": "person_roles", "column": "id" } }, + "petition_id": { "type": "integer", "foreignkey": { "table": "petitions", "column": "id" } }, "plugin": { "type": "string", "size": 80 }, "provisioning_target_id": { "type": "integer", "foreignkey": { "table": "provisioning_targets", "column": "id" }, "notnull": true }, "reference_identifier": { "type": "string", "size": 40 }, "report_id": { "type": "integer", "foreignkey": { "table": "reports", "column": "id" }, "notnull": true }, "server_id": { "type": "integer", "foreignkey": { "table": "servers", "column": "id" }, "notnull": true }, + "sor_label": { "type": "string", "size": 40 }, "status": { "type": "string", "size": 2 }, "type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" }, "notnull": true }, "valid_from": { "type": "datetime" }, @@ -517,8 +522,8 @@ "history_records": { "columns": { "id": {}, - "action": { "type": "string", "size": 4 }, - "comment": { "type": "string", "size": 256 }, + "action": {}, + "comment": {}, "person_id": {}, "person_role_id": {}, "external_identity_id": {}, @@ -594,6 +599,110 @@ } }, + "mostly_static_pages": { + "columns": { + "id": {}, + "co_id": {}, + "name": {}, + "title": { "type": "string", "size": 256 }, + "description": {}, + "status": {}, + "context": {}, + "body": { "type": "text" } + }, + "indexes": { + "mostly_static_pages_i1": { "columns": [ "co_id" ] } + } + }, + + "enrollment_flows": { + "columns": { + "id": {}, + "co_id": {}, + "name": {}, + "status": {}, + "sor_label": {}, + "authz_type": { "type": "string", "size": 2 }, + "authz_cou_id": { "type": "integer", "foreignkey": { "table": "cous", "column": "id" }}, + "authz_group_id": { "type": "integer", "foreignkey": { "table": "groups", "column": "id" }}, + "collect_enrollee_email": { "type": "boolean" }, + "redirect_on_finalize": { "type": "string", "size": 256 } + }, + "indexes": { + "enrollment_flows_i1": { "columns": [ "co_id" ]}, + "enrollment_flows_i2": { "needed": false, "columns": [ "authz_cou_id" ]}, + "enrollment_flows_i3": { "needed": false, "columns": [ "authz_group_id" ]} + } + }, + + "enrollment_flow_steps": { + "columns": { + "id": {}, + "enrollment_flow_id": { "type": "integer", "foreignkey": { "table": "enrollment_flows", "column": "id" }}, + "description": {}, + "status": {}, + "plugin": {}, + "ordr": {}, + "actor_type": { "type": "string", "size": 2 }, + "message_template_id": {}, + "redirect_on_handoff": { "type": "string", "size": 256 } + }, + "indexes": { + "enrollment_flow_steps_i1": { "columns": [ "enrollment_flow_id" ]}, + "enrollment_flow_steps_i2": { "needed": false, "columns": [ "message_template_id" ]} + } + }, + + "petitions": { + "columns": { + "id": {}, + "enrollment_flow_id": { "type": "integer", "foreignkey": { "table": "enrollment_flows", "column": "id" }}, + "status": {}, + "cou_id": {}, + "enrollee_email": { "type": "string", "size": 256 }, + "enrollee_identifier": { "type": "string", "size": 256 }, + "enrollee_person_id": { "type": "integer", "foreignkey": { "table": "people", "column": "id" }}, + "petitioner_identifier": { "type": "string", "size": 256 }, + "petitioner_person_id": { "type": "integer", "foreignkey": { "table": "people", "column": "id" }}, + "token": { "type": "string", "size": 256 } + }, + "indexes": { + "petitions_i1": { "columns": [ "enrollment_flow_id" ]}, + "petitions_i2": { "columns": [ "cou_id" ]}, + "petitions_i3": { "columns": [ "enrollee_person_id" ]}, + "petitions_i4": { "columns": [ "petitioner_person_id" ]} + } + }, + + "petition_step_results": { + "columns": { + "id": {}, + "petition_id": {}, + "enrollment_flow_step_id": {}, + "comment": {} + }, + "indexes": { + "petition_step_results_i1": { "columns": [ "petition_id" ] }, + "petition_step_results_i2": { "needed": false, "columns": [ "enrollment_flow_step_id" ] } + } + }, + + "petition_history_records": { + "columns": { + "id": {}, + "petition_id": {}, + "enrollment_flow_step_id": {}, + "action": {}, + "comment": {}, + "actor_person_id": { "type": "integer", "foreignkey": { "table": "people", "column": "id" } } + }, + "indexes": { + "petition_history_records_i1": { "columns": [ "petition_id" ] }, + "petition_history_records_i2": { "columns": [ "actor_person_id" ] }, + "petition_history_records_i3": { "needed": false, "columns": [ "enrollment_flow_step_id" ] } + } + }, + "jobs": { "columns": { "id": {}, diff --git a/app/plugins/CoreEnroller/composer.json b/app/plugins/CoreEnroller/composer.json new file mode 100644 index 000000000..5bdea63d7 --- /dev/null +++ b/app/plugins/CoreEnroller/composer.json @@ -0,0 +1,24 @@ +{ + "name": "your-name-here/core-enroller", + "description": "CoreEnroller 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": { + "CoreEnroller\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "CoreEnroller\\Test\\": "tests/", + "Cake\\Test\\": "vendor/cakephp/cakephp/tests/" + } + } +} diff --git a/app/plugins/CoreEnroller/phpunit.xml.dist b/app/plugins/CoreEnroller/phpunit.xml.dist new file mode 100644 index 000000000..6e44e4767 --- /dev/null +++ b/app/plugins/CoreEnroller/phpunit.xml.dist @@ -0,0 +1,30 @@ + + + + + + + + + + + tests/TestCase/ + + + + + + + + + + + src/ + + + diff --git a/app/plugins/CoreEnroller/resources/locales/en_US/core_enroller.po b/app/plugins/CoreEnroller/resources/locales/en_US/core_enroller.po new file mode 100644 index 000000000..7b03d6f22 --- /dev/null +++ b/app/plugins/CoreEnroller/resources/locales/en_US/core_enroller.po @@ -0,0 +1,177 @@ +# COmanage Registry Localizations (core_enroller 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.AttributeCollectors" +msgstr "{0,plural,=1{Attribute Collector} other{Attribute Collectors}}" + +msgid "controller.BasicAttributeCollectors" +msgstr "{0,plural,=1{Basic Attribute Collector} other{Basic Attribute Collectors}}" + +msgid "controller.EnrollmentAttributes" +msgstr "{0,plural,=1{Enrollment Attribute} other{Enrollment Attributes}}" + +# These are pseudo-enumerations, only used for fields.inc +msgid "enumeration.DefaultValueValidityType.after" +msgstr "Days After Finalization" + +msgid "error.PetitionAcceptances.exists" +msgstr "An Invitation for this Petition already exists" + +msgid "error.PetitionAcceptances.expired" +msgstr "This Invitation has expired" + +msgid "error.PetitionAcceptances.processed" +msgstr "This Invitation has already been processed" + +msgid "field.BasicAttributeCollectors.affiliation_type_id" +msgstr "Affiliation Type" + +msgid "field.BasicAttributeCollectors.affiliation_type_id.desc" +msgstr "Affiliation assigned to Person Roles created by this Step" + +msgid "field.BasicAttributeCollectors.cou_id.desc" +msgstr "If set, Person Roles created by this Step will be placed in this COU" + +msgid "field.BasicAttributeCollectors.email_address_type_id" +msgstr "Email Address Type" + +msgid "field.BasicAttributeCollectors.email_address_type_id.desc" +msgstr "Type assigned to the Email Address collected by this Step" + +msgid "field.BasicAttributeCollectors.name_type_id" +msgstr "Name Type" + +msgid "field.BasicAttributeCollectors.name_type_id.desc" +msgstr "Type assigned to the Name collected by this Step" + +msgid "field.EnrollmentAttributes.address_required_fields" +msgstr "Required Address Fields" + +msgid "field.EnrollmentAttributes.address_required_fields.desc" +msgstr "Must be at least as restrictive as the CO Setting configuration, or blank to follow the CO Setting" + +msgid "field.EnrollmentAttributes.address_type_id" +msgstr "Address Type" + +msgid "field.EnrollmentAttributes.attribute_language" +msgstr "Attribute Language" + +msgid "field.EnrollmentAttributes.attribute_mvea_parent" +msgstr "Attribute Attaches To" + +msgid "field.EnrollmentAttributes.attribute_tag" +msgstr "Ad Hoc Attribute Tag" + +msgid "field.EnrollmentAttributes.default_value" +msgstr "Default Value" + +msgid "field.EnrollmentAttributes.default_value_affiliation_type_id" +msgstr "Default Affiliation Type" + +msgid "field.EnrollmentAttributes.default_value_cou_id" +msgstr "Default Cou" + +msgid "field.EnrollmentAttributes.default_value_datetime" +msgstr "Default Datetime" + +msgid "field.EnrollmentAttributes.default_value_datetime.desc" +msgstr "On This Date" + +msgid "field.EnrollmentAttributes.default_value_env_name" +msgstr "Environmental Variable for Default Value" + +msgid "field.EnrollmentAttributes.default_value_group_id" +msgstr "Default Group" + +msgid "field.EnrollmentAttributes.default_value_validity_type" +msgstr "Default Validity Date Type" + +msgid "field.EnrollmentAttributes.email_address_type_id" +msgstr "Email Address Type" + +msgid "field.EnrollmentAttributes.identifier_type_id" +msgstr "Identifier Type" + +msgid "field.EnrollmentAttributes.label" +msgstr "Display Label" + +msgid "field.EnrollmentAttributes.name_required_fields" +msgstr "Required Name Fields" + +msgid "field.EnrollmentAttributes.name_required_fields.desc" +msgstr "Must be at least as restrictive as the CO Setting configuration, or blank to follow the CO Setting" + +msgid "field.EnrollmentAttributes.name_type_id" +msgstr "Name Type" + +msgid "field.EnrollmentAttributes.petition_text" +msgstr "Text Field (Petition Use Only)" + +msgid "field.EnrollmentAttributes.petition_textarea" +msgstr "Text Area (Petition Use Only)" + +msgid "field.EnrollmentAttributes.pronoun_type_id" +msgstr "Pronouns Type" + +msgid "field.EnrollmentAttributes.telephone_number_type_id" +msgstr "Telephone Number Type" + +msgid "field.EnrollmentAttributes.url_type_id" +msgstr "URL Type" + +msgid "field.IdentifierCollectors.type_id.desc" +msgstr "The collected Identifiers will be assigned this Type" + +msgid "field.InvitationAccepters.invitation_validity" +msgstr "Invitation Validity" + +msgid "field.InvitationAccepters.invitation_validity.desc" +msgstr "Time in minutes before the invitation expires, default is 1440 (1 day)" + +msgid "field.InvitationAccepters.welcome_message" +msgstr "Welcome Message" + +msgid "result.accept.accepted" +msgstr "Invitation Accepted" + +msgid "result.accept.declined" +msgstr "Invitation Declined" + +msgid "result.attr.saved" +msgstr "Petition Attributes recorded" + +msgid "result.basicattr.finalized" +msgstr "Name, Email Address, and Person Role created during finalization" + +msgid "result.IdentifierCollector.collected" +msgstr "Obtained login Identifier {0}" + +msgid "result.InvitationAccepters.accepted" +msgstr "Invitation Accepted at {0}" + +msgid "result.InvitationAccepters.declined" +msgstr "Invitation Declined at {0}" + +msgid "result.InvitationAccepters.none" +msgstr "No response to invitation yet" \ No newline at end of file diff --git a/app/plugins/CoreEnroller/src/Controller/AppController.php b/app/plugins/CoreEnroller/src/Controller/AppController.php new file mode 100644 index 000000000..7075799f1 --- /dev/null +++ b/app/plugins/CoreEnroller/src/Controller/AppController.php @@ -0,0 +1,10 @@ + [ + 'AttributeCollectors.id' => 'asc' + ] + ]; + + /** + * Callback run prior to the request render. + * + * @param EventInterface $event Cake Event + * + * @return Response|void + * @since COmanage Registry v5.0.0 + */ + + public function beforeRender(EventInterface $event) { + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->AttributeCollectors->EnrollmentFlowSteps->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->AttributeCollectors->EnrollmentFlowSteps->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->AttributeCollectors->EnrollmentFlowSteps->getPrimaryKey()); + } + + return parent::beforeRender($event); + } + + /** + * Dispatch an Enrollment Flow Step. + * + * @since COmanage Registry v5.0.0 + * @param string $id Attribute Collector ID + */ + + public function dispatch(string $id) { + $petition = $this->getPetition(); + + if($this->request->is(['post', 'put'])) { + try { + $this->AttributeCollectors->upsert( + id: (int)$id, + petitionId: $petition->id, + // Remove form metadata from the set of attributes we try to upsert + attributes: array_diff_key($this->request->getData(), ['petition_id' => true, 'token' => true]) + ); + + // On success, indicate the step is completed and generate a redirect + // to the next step + + $link = $this->getPrimaryLink(true); + + return $this->finishStep( + enrollmentFlowStepId: $link->value, + petitionId: $petition->id, + comment: __d('core_enroller', 'result.attr.saved') + ); + } + catch(\Exception $e) { + $this->Flash->error($e->getMessage()); + } + } + + // Fall through and let the form render + + // Pull the configured set of attributes + $this->set('vv_enrollment_attributes', + $this->AttributeCollectors + ->EnrollmentAttributes + ->find() + ->where(['attribute_collector_id' => $id]) + ->all()); + + // We support updating attributes until the Petition is finalized, + // see if there happen to be any values already stored. Note this + // will pull _all_ attributes associated with the Petition, not just + // those associated with this Attribute Collector. + $this->set('vv_petition_attributes', + $this->AttributeCollectors + ->EnrollmentAttributes + ->PetitionAttributes + ->find() + ->where(['petition_id' => $petition->id]) + ->all()); + + $this->render('/Standard/dispatch'); + } + + /** + * Display information about this Step. + * + * @since COmanage Registry v5.0.0 + * @param string $id Attribute Collector ID + */ + + public function display(string $id) { + debug("display something for this petition"); + debug($this->getPetition()); + } +} diff --git a/app/plugins/CoreEnroller/src/Controller/BasicAttributeCollectorsController.php b/app/plugins/CoreEnroller/src/Controller/BasicAttributeCollectorsController.php new file mode 100644 index 000000000..bf3f701f8 --- /dev/null +++ b/app/plugins/CoreEnroller/src/Controller/BasicAttributeCollectorsController.php @@ -0,0 +1,107 @@ + [ + 'BasicAttributeCollectors.id' => 'asc' + ] + ]; + + /** + * Dispatch an Enrollment Flow Step. + * + * @since COmanage Registry v5.1.0 + * @param string $id Attribute Collector ID + */ + + public function dispatch(string $id) { + $petition = $this->getPetition(); + + $CoSettings = TableRegistry::getTableLocator()->get('CoSettings'); + + $settings = $CoSettings->find()->where(['co_id' => $this->getCOID()])->firstOrFail(); + + $this->set('vv_permitted_name_fields', $settings->name_permitted_fields_array()); + $this->set('vv_required_name_fields', $settings->name_required_fields_array()); + + if($this->request->is(['post', 'put'])) { + + try { + $this->BasicAttributeCollectors->upsert( + id: (int)$id, + petitionId: $petition->id, + // Remove form metadata from the set of attributes we pass to upsert + attributes: array_diff_key($this->request->getData(), ['petition_id' => true, 'token' => true]) + ); + + // On success, indicate the step is completed and generate a redirect + // to the next step + + $link = $this->getPrimaryLink(true); + + return $this->finishStep( + enrollmentFlowStepId: $link->value, + petitionId: $petition->id, + comment: __d('core_enroller', 'result.attr.saved') + ); + } + catch(\Exception $e) { + $this->Flash->error($e->getMessage()); + } + } + + // Fall through and let the form render + + $this->render('/Standard/dispatch'); + } + + /** + * Display information about this Step. + * + * @since COmanage Registry v5.1.0 + * @param string $id Attribute Collector ID + */ + + public function display(string $id) { + $petition = $this->getPetition(); + + $this->set('vv_petition_basic_attribute_set', $this->BasicAttributeCollectors + ->PetitionBasicAttributeSets + ->find() + ->where(['PetitionBasicAttributeSets.petition_id' => $petition->id]) + ->firstOrFail()); + } +} diff --git a/app/plugins/CoreEnroller/src/Controller/EnrollmentAttributesController.php b/app/plugins/CoreEnroller/src/Controller/EnrollmentAttributesController.php new file mode 100644 index 000000000..0f423e5e0 --- /dev/null +++ b/app/plugins/CoreEnroller/src/Controller/EnrollmentAttributesController.php @@ -0,0 +1,94 @@ + [ + 'EnrollmentAttributes.ordr' => 'asc' + ] + ]; + + /** + * Callback run prior to the request rendering. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Cake Event + * @return EventInterface + */ + + public function beforeRender(\Cake\Event\EventInterface $event) { + $this->set('vv_supported_attributes', $this->EnrollmentAttributes->supportedAttributes()); + + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->EnrollmentAttributes->AttributeCollectors->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->EnrollmentAttributes->AttributeCollectors->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->EnrollmentAttributes->AttributeCollectors->getPrimaryKey()); + } + + $ret = parent::beforeRender($event); + + $attributes = $this->viewBuilder()->getVar('attributes'); + + // Override the auto-generated title + switch($this->request->getParam('action')) { + case 'add': + $this->set( + 'vv_title', + __d('operation', 'add.a-1', [ + __d('core_enroller', 'controller.EnrollmentAttributes', [1]), + $attributes[$this->request->getQuery('attribute_type')] + ]) + ); + break; + case 'edit': + $vv_obj = $this->viewBuilder()->getVar('vv_obj'); + $this->set( + 'vv_title', + __d('operation', 'edit.a-1', [ + __d('core_enroller', 'controller.EnrollmentAttributes', [1]), + $attributes[ $vv_obj['attribute'] ] + ]) + ); + break; + case 'index': + $this->set('vv_title', __d('core_enroller', 'controller.EnrollmentAttributes', [99])); + break; + } + + return $ret; + } +} diff --git a/app/plugins/CoreEnroller/src/Controller/IdentifierCollectorsController.php b/app/plugins/CoreEnroller/src/Controller/IdentifierCollectorsController.php new file mode 100644 index 000000000..2b33b59c0 --- /dev/null +++ b/app/plugins/CoreEnroller/src/Controller/IdentifierCollectorsController.php @@ -0,0 +1,124 @@ + [ + 'IdentifierCollectors.id' => 'asc' + ] + ]; + + /** + * Dispatch an Enrollment Flow Step. + * + * @since COmanage Registry v5.1.0 + * @param string $id Invitation Accepter ID + */ + + public function dispatch(string $id) { + $petition = $this->getPetition(); + + $cfg = $this->IdentifierCollectors->get($id); + + // Because we're not in the "protected" web server application space, we need to read + // the username from the session (as set via TrafficController) rather than via getenv(). + // This also precludes us from using a variable other than $REMOTE_USER. + + $request = $this->getRequest(); + $session = $request->getSession(); + $username = $session->read('Auth.external.user'); + + $PetitionIdentifiers = TableRegistry::getTableLocator()->get('CoreEnroller.PetitionIdentifiers'); + + try { + $PetitionIdentifiers->record($petition->id, $cfg->enrollment_flow_step_id, $username); + + return $this->finishStep( + enrollmentFlowStepId: $cfg->enrollment_flow_step_id, + petitionId: $petition->id, + comment: __d('core_enroller', 'result.IdentifierCollector.collected', [$username]) + ); + } + catch(\Exception $e) { + $this->Flash->error($e->getMessage()); + } + } + + /** + * Display information about this Step. + * + * @since COmanage Registry v5.1.0 + * @param string $id Invitation Accepters ID + */ + + public function display(string $id) { + $petition = $this->getPetition(); + + $PetitionIdentifiers = TableRegistry::getTableLocator()->get('CoreEnroller.PetitionIdentifiers'); + + $this->set('vv_pi', $PetitionIdentifiers->find()->where(['petition_id' => $petition->id])->first()); + } + + /** + * Indicate whether this Controller will handle some or all authnz. + * + * @since COmanage Registry v5.1.0 + * @param EventInterface $event Cake event, ie: from beforeFilter + * @return string "no", "open", "authz", or "yes" + */ + + public function willHandleAuth(\Cake\Event\EventInterface $event): string { + $request = $this->getRequest(); + $action = $request->getParam('action'); + + if($action == 'dispatch') { + // We need to perform special logic (vs StandardEnrollerController) + // to ensure that web server authentication is triggered. + + // To start, we trigger the parent logic. This will return + // notauth: Some error occurred, we don't want to override this + // authz: No token in use + // yes: Token validated + + $auth = parent::willHandleAuth($event); + + // The only status we need to override is 'yes', since we always want authentication + // to run in order to be able to grab $REMOTE_USER. + + return ($auth == 'yes' ? 'authz' : $auth); + } + + return parent::willHandleAuth($event); + } +} \ No newline at end of file diff --git a/app/plugins/CoreEnroller/src/Controller/InvitationAcceptersController.php b/app/plugins/CoreEnroller/src/Controller/InvitationAcceptersController.php new file mode 100644 index 000000000..3b377d355 --- /dev/null +++ b/app/plugins/CoreEnroller/src/Controller/InvitationAcceptersController.php @@ -0,0 +1,130 @@ + [ + 'InvitationAccepters.id' => 'asc' + ] + ]; + + /** + * Dispatch an Enrollment Flow Step. + * + * @since COmanage Registry v5.1.0 + * @param string $id Invitation Accepter ID + */ + + public function dispatch(string $id) { + $petition = $this->getPetition(); + + $cfg = $this->InvitationAccepters->get($id); + + $this->set('vv_config', $cfg); + + $PetitionAcceptances = TableRegistry::getTableLocator()->get('CoreEnroller.PetitionAcceptances'); + + $pa = $PetitionAcceptances->find()->where(['petition_id' => $petition->id])->first(); + + $this->set('vv_pa', $pa); + + if($this->request->is(['post', 'put'])) { + $data = $this->request->getData(); + + $accepted = (bool)$data['accepted']; + + try { + $PetitionAcceptances->processReply( + $petition->id, + $cfg->enrollment_flow_step_id, + $accepted + ); + + if($accepted) { + // On acceptance, indicate the step is completed and generate a redirect + // to the next step + + $link = $this->getPrimaryLink(true); + + return $this->finishStep( + enrollmentFlowStepId: $link->value, + petitionId: $petition->id, + comment: __d('core_enroller', ($accepted ? 'result.accept.accepted' : 'result.accept.declined')) + ); + } else { + // On decline, set a flash message and redirect to the petition complete landing + $this->Flash->success(__d('core_enroller', 'result.accept.declined')); + + $coId = $this->getCOID(); + + return $this->redirect("/$coId/petition-complete"); + } + } + catch(\Exception $e) { + $this->Flash->error($e->getMessage()); + } + } else { + // Record that the invitation was viewed + $PetitionHistoryRecords = TableRegistry::getTableLocator()->get('PetitionHistoryRecords'); + + $PetitionHistoryRecords->record( + petitionId: $petition->id, + enrollmentFlowStepId: $cfg->enrollment_flow_step_id, + action: PetitionActionEnum::InvitationViewed, + comment: __d('result', 'Petitions.viewed.inv') + // actorPersonId + ); + } + + // Fall through and let the form render*/ + + $this->render('/Standard/dispatch'); + } + + /** + * Display information about this Step. + * + * @since COmanage Registry v5.1.0 + * @param string $id Invitation Accepters ID + */ + + public function display(string $id) { + $petition = $this->getPetition(); + + $PetitionAcceptances = TableRegistry::getTableLocator()->get('CoreEnroller.PetitionAcceptances'); + + $this->set('vv_pa', $PetitionAcceptances->find()->where(['petition_id' => $petition->id])->first()); + } +} diff --git a/app/plugins/CoreEnroller/src/CoreEnrollerPlugin.php b/app/plugins/CoreEnroller/src/CoreEnrollerPlugin.php new file mode 100644 index 000000000..405285cd9 --- /dev/null +++ b/app/plugins/CoreEnroller/src/CoreEnrollerPlugin.php @@ -0,0 +1,93 @@ +plugin( + 'CoreEnroller', + ['path' => '/core-enroller'], + 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/CoreEnroller/src/Model/Entity/AttributeCollector.php b/app/plugins/CoreEnroller/src/Model/Entity/AttributeCollector.php new file mode 100644 index 000000000..373b031a5 --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Entity/AttributeCollector.php @@ -0,0 +1,49 @@ + + */ + protected $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/CoreEnroller/src/Model/Entity/BasicAttributeCollector.php b/app/plugins/CoreEnroller/src/Model/Entity/BasicAttributeCollector.php new file mode 100644 index 000000000..01f468029 --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Entity/BasicAttributeCollector.php @@ -0,0 +1,49 @@ + + */ + protected $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/CoreEnroller/src/Model/Entity/EnrollmentAttribute.php b/app/plugins/CoreEnroller/src/Model/Entity/EnrollmentAttribute.php new file mode 100644 index 000000000..5e4976e9b --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Entity/EnrollmentAttribute.php @@ -0,0 +1,49 @@ + + */ + protected $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/CoreEnroller/src/Model/Entity/IdentifierCollector.php b/app/plugins/CoreEnroller/src/Model/Entity/IdentifierCollector.php new file mode 100644 index 000000000..85e00897e --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Entity/IdentifierCollector.php @@ -0,0 +1,49 @@ + + */ + protected $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/CoreEnroller/src/Model/Entity/InvitationAccepter.php b/app/plugins/CoreEnroller/src/Model/Entity/InvitationAccepter.php new file mode 100644 index 000000000..8017d29b1 --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Entity/InvitationAccepter.php @@ -0,0 +1,49 @@ + + */ + protected $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/CoreEnroller/src/Model/Entity/PetitionAcceptance.php b/app/plugins/CoreEnroller/src/Model/Entity/PetitionAcceptance.php new file mode 100644 index 000000000..cc3d84c55 --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Entity/PetitionAcceptance.php @@ -0,0 +1,49 @@ + + */ + protected $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/CoreEnroller/src/Model/Entity/PetitionAttribute.php b/app/plugins/CoreEnroller/src/Model/Entity/PetitionAttribute.php new file mode 100644 index 000000000..558ac97bc --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Entity/PetitionAttribute.php @@ -0,0 +1,49 @@ + + */ + protected $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/CoreEnroller/src/Model/Entity/PetitionBasicAttributeSet.php b/app/plugins/CoreEnroller/src/Model/Entity/PetitionBasicAttributeSet.php new file mode 100644 index 000000000..d506679eb --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Entity/PetitionBasicAttributeSet.php @@ -0,0 +1,49 @@ + + */ + protected $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/CoreEnroller/src/Model/Entity/PetitionIdentifier.php b/app/plugins/CoreEnroller/src/Model/Entity/PetitionIdentifier.php new file mode 100644 index 000000000..7045282fe --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Entity/PetitionIdentifier.php @@ -0,0 +1,49 @@ + + */ + protected $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} diff --git a/app/plugins/CoreEnroller/src/Model/README.md b/app/plugins/CoreEnroller/src/Model/README.md new file mode 100644 index 000000000..6851e4972 --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/README.md @@ -0,0 +1,11 @@ +# CoreEnroller 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-enroller +``` diff --git a/app/plugins/CoreEnroller/src/Model/Table/AttributeCollectorsTable.php b/app/plugins/CoreEnroller/src/Model/Table/AttributeCollectorsTable.php new file mode 100644 index 000000000..2c7701b4c --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Table/AttributeCollectorsTable.php @@ -0,0 +1,240 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('EnrollmentFlowSteps'); + + $this->hasMany('CoreEnroller.EnrollmentAttributes') + ->setDependent(true) + ->setCascadeCallbacks(true); + + $this->setDisplayField('id'); + + $this->setPrimaryLink('enrollment_flow_step_id'); + $this->setRequiresCO(true); + $this->setAllowLookupPrimaryLink(['dispatch', 'display']); + + // All the tabs share the same configuration in the ModelTable file + $this->setTabsConfig( + [ + // Ordered list of Tabs + 'tabs' => ['EnrollmentFlowSteps', 'CoreEnroller.AttributeCollectors', 'CoreEnroller.EnrollmentAttributes'], + // What actions will inlcude the subnavigation header + 'action' => [ + // If a model renders in a subnavigation mode in edit/view mode, it cannot + // render in index mode for the same use case/context + // XXX edit should go first. + 'EnrollmentFlowSteps' => ['edit', 'view'], + 'CoreEnroller.AttributeCollectors' => ['edit'], + 'CoreEnroller.EnrollmentAttributes' => ['index'], + ] + ] + ); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, // Delete the pluggable object instead + 'dispatch' => true, + 'display' => true, + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, // This is added by the parent model + 'index' => ['platformAdmin', 'coAdmin'] + ], + 'related' => [ + 'table' => [ + 'CoreEnroller.EnrollmentAttributes' + ] + ] + ]); + } + + /** + * Perform steps necessary to finalize the Petition. + * + * @since COmanage Registry v5.0.0 + * @param int $id Attribute Collector ID + * @param int $petitionId Petition ID + * @return bool true on success + */ + + public function finalize(int $id, int $petitionId) { +// XXX convert Petition Attributes to operational Attributes +// keep in mind this can be called multiple times if the plugin is +// instantiated more than once in the Enrollment Flow ($id will be different) +// any errors should be logged or otherwise managed, returning false +// or throwing an error will NOT prevent the petition from finalizing + +debug("in AttributeCollector finalize"); + + return true; + } + + /** + * Insert or update a set of Petition Attributes. + * + * @since COmanage Registry v5.0.0 + * @param int $id Attribute Collector ID + * @param int $petitionId Petition ID + * @param array $attributes Petition Attributes + * @return bool true on success + * @throws PersistenceFailedException + */ + + public function upsert(int $id, int $petitionId, array $attributes) { + $attributeCollector = $this->get($id); + + // Do we have existing attributes for this petition? Note this will pull + // _all_ attributes for the Petition, not just those associated with this + // particular Attribute Collector; however we'll only look at the attributes + // we need below. + $currentAttributes = $this->EnrollmentAttributes + ->PetitionAttributes->find('list', [ + 'keyField' => 'enrollment_attribute_id', + 'valueField' => 'id' + ]) + ->where(['petition_id' => $petitionId]) + ->toArray(); + + $petitionAttributes = []; + + foreach($attributes as $enrollmentAttributeLabel => $value) { + // Remove field- prefix from the form field name + $enrollmentAttributeId = (int)substr($enrollmentAttributeLabel, 6); + + $newAttribute = [ + 'petition_id' => $petitionId, + 'enrollment_attribute_id' => $enrollmentAttributeId, + 'value' => $value + ]; + + if(array_key_exists($enrollmentAttributeId, $currentAttributes)) { + // This is an update of an existing attribute + + $newAttribute['id'] = $currentAttributes[$enrollmentAttributeId]; + + $entity = $this->EnrollmentAttributes + ->PetitionAttributes->get($newAttribute['id']); + + // We don't bother with patch entity since the only thing we support + // changing is value + + $entity->value = $value; + + $this->EnrollmentAttributes->PetitionAttributes->saveOrFail($entity); +// XXX we could record petition history that this specific attribute was updated + } else { + // This is a new attribute + $petitionAttributes[] = $newAttribute; + } + } + + if(!empty($petitionAttributes)) { + $entities = $this->EnrollmentAttributes->PetitionAttributes->newEntities($petitionAttributes); + + $this->EnrollmentAttributes->PetitionAttributes->saveManyOrFail($entities); + } + + // Record Petition History + + $PetitionHistoryRecords = TableRegistry::getTableLocator()->get('PetitionHistoryRecords'); + + $PetitionHistoryRecords->record( + petitionId: $petitionId, + enrollmentFlowStepId: $attributeCollector->enrollment_flow_step_id, + action: PetitionActionEnum::AttributesUpdated, + comment: __d('core_enroller', 'result.attr.saved') +// We don't have $actorPersonId yet... +// ?int $actorPersonId=null + ); + + 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('enrollment_flow_step_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('enrollment_flow_step_id'); + + $this->registerStringValidation($validator, $schema, 'description', true); + + return $validator; + } +} diff --git a/app/plugins/CoreEnroller/src/Model/Table/BasicAttributeCollectorsTable.php b/app/plugins/CoreEnroller/src/Model/Table/BasicAttributeCollectorsTable.php new file mode 100644 index 000000000..1ec67cead --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Table/BasicAttributeCollectorsTable.php @@ -0,0 +1,321 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('EnrollmentFlowSteps'); + $this->belongsTo('EmailAddressTypes') + ->setClassName('Types') + ->setForeignKey('email_address_type_id') + ->setProperty('email_address_type'); + $this->belongsTo('NameTypes') + ->setClassName('Types') + ->setForeignKey('name_type_id') + ->setProperty('name_type'); + $this->belongsTo('AffiliationTypes') + ->setClassName('Types') + ->setForeignKey('affiliation_type_id') + ->setProperty('affiliation_type'); + $this->belongsTo('Cous'); + + $this->hasMany('CoreEnroller.PetitionBasicAttributeSets') + ->setDependent(true) + ->setCascadeCallbacks(true); + + $this->setDisplayField('id'); + + $this->setPrimaryLink('enrollment_flow_step_id'); + $this->setRequiresCO(true); + $this->setAllowLookupPrimaryLink(['dispatch', 'display']); + + $this->setAutoViewVars([ + 'affiliationTypes' => [ + 'type' => 'type', + 'attribute' => 'PersonRoles.affiliation_type' + ], + 'cous' => [ + 'type' => 'select', + 'model' => 'Cous' + ], + 'emailAddressTypes' => [ + 'type' => 'type', + 'attribute' => 'EmailAddresses.type' + ], + 'nameTypes' => [ + 'type' => 'type', + 'attribute' => 'Names.type' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, // Delete the pluggable object instead + 'dispatch' => true, + 'display' => true, + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, // This is added by the parent model + 'index' => ['platformAdmin', 'coAdmin'] + ], + 'related' => [ + 'table' => [ + 'CoreEnroller.EnrollmentAttributes' + ] + ] + ]); + } + + /** + * Perform steps necessary to finalize the Petition. + * + * @since COmanage Registry v5.1.0 + * @param int $id Attribute Collector ID + * @param Petition $petition Petition + * @return bool true on success + */ + + public function finalize(int $id, \App\Model\Entity\Petition $petition) { + $cfg = $this->get($id); + + // At this point there is a Person record allocated and stored in the Petition, + // but it doesn't have any attributes on it, including a Primary Name. + // We assume we're the only attribute collector, so we'll force a Primary Name + // based on the Basic Attribtues, and create a skeletal role. + + if(empty($petition->enrollee_person_id)) { + throw new \InvalidArgumentException(__d('error', 'Petitions.enrollee.notfound', [$petition->id])); + } + + $People = TableRegistry::getTableLocator()->get('People'); + + $person = $People->get($petition->enrollee_person_id); + + $attributes = $this->PetitionBasicAttributeSets + ->find() + ->where([ + 'petition_id' => $petition->id, + // Strictly speaking we only support one instance per Flow, + // but we'll filter on the $id anyway since we have it + 'basic_attribute_collector_id' => $id + ]) + ->firstOrFail(); + + // Since we're not modifying $person, it's a bit clearer if we save each entity + // individually than try to save related + + $Names = TableRegistry::getTableLocator()->get('Names'); + +// XXX enforce CoSettings required/permitted fields here? + $name = [ + 'person_id' => $person->id, + 'primary_name' => true, + 'type_id' => $cfg->name_type_id + ]; + + foreach(['honorific', 'given', 'middle', 'family', 'suffix'] as $n) { + if(!empty($attributes->$n)) { + $name[$n] = $attributes->$n; + } + } + + $Names->saveOrFail($Names->newEntity($name)); + + $EmailAddresses = TableRegistry::getTableLocator()->get('EmailAddresses'); + + $email = [ + 'person_id' => $person->id, + 'mail' => $attributes->mail, + 'type_id' => $cfg->email_address_type_id + ]; + + $EmailAddresses->saveOrFail($EmailAddresses->newEntity($email)); + + $PersonRoles = TableRegistry::getTableLocator()->get('PersonRoles'); + + $personRole = [ + 'person_id' => $person->id, + 'affiliation_type_id' => $cfg->affiliation_type_id, + 'cou_id' => $cfg->cou_id, + 'status' => StatusEnum::Active + ]; + + $PersonRoles->saveOrFail($PersonRoles->newEntity($personRole)); + + $PetitionHistoryRecords = TableRegistry::getTableLocator()->get('PetitionHistoryRecords'); + + $PetitionHistoryRecords->record( + petitionId: $petition->id, + enrollmentFlowStepId: $cfg->enrollment_flow_step_id, + action: PetitionActionEnum::Finalized, + comment: __d('core_enroller', 'result.basicattr.finalized') +// We don't have $actorPersonId yet... +// ?int $actorPersonId=null + ); + + return true; + } + + /** + * Insert or update a Basic Petition Attribute Set. + * + * @since COmanage Registry v5.1.0 + * @param int $id Basic Attribute Collector ID + * @param int $petitionId Petition ID + * @param array $attributes Petition Attributes + * @return bool true on success + * @throws PersistenceFailedException + */ + + public function upsert(int $id, int $petitionId, array $attributes) { + $basicAttributeCollector = $this->get($id); + + // Do we have existing attributes for this petition? Note this will pull + // _all_ attributes for the Petition, not just those associated with this + // particular Attribute Collector; however we'll only look at the attributes + // we need below. + $entity = $this->PetitionBasicAttributeSets + ->find() + ->where([ + 'petition_id' => $petitionId, + // Strictly speaking we only support one instance per Flow, + // but we'll filter on the $id anyway since we have it + 'basic_attribute_collector_id' => $id + ]) + ->first(); + + if(!$entity) { + // insert, not update + + $entity = $this->PetitionBasicAttributeSets->newEntity([ + 'basic_attribute_collector_id' => $id, + 'petition_id' => $petitionId + ]); + } + + foreach(['honorific', 'given', 'middle', 'family', 'suffix', 'mail'] as $f) { +// XXX we should probably check CoSettings for name settings + $entity->$f = $attributes[$f] ?? null; + } + + $this->PetitionBasicAttributeSets->saveOrFail($entity); + + // Record Petition History + + $PetitionHistoryRecords = TableRegistry::getTableLocator()->get('PetitionHistoryRecords'); + + $PetitionHistoryRecords->record( + petitionId: $petitionId, + enrollmentFlowStepId: $basicAttributeCollector->enrollment_flow_step_id, + action: PetitionActionEnum::AttributesUpdated, + comment: __d('core_enroller', 'result.attr.saved') +// We don't have $actorPersonId yet... +// ?int $actorPersonId=null + ); + + return true; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.1.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('enrollment_flow_step_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('enrollment_flow_step_id'); + + $validator->add('name_type_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('name_type_id'); + + $validator->add('email_address_type_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('email_address_type_id'); + + $validator->add('affiliation_type_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('affiliation_type_id'); + + $validator->add('cou_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('cou_id'); + + return $validator; + } +} diff --git a/app/plugins/CoreEnroller/src/Model/Table/EnrollmentAttributesTable.php b/app/plugins/CoreEnroller/src/Model/Table/EnrollmentAttributesTable.php new file mode 100644 index 000000000..3ca48ba25 --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Table/EnrollmentAttributesTable.php @@ -0,0 +1,451 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Orderable'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('CoreEnroller.AttributeCollectors'); + + $this->hasMany('CoreEnroller.PetitionAttributes') +// XXX do we really want to allow deletion once the definition is in use? + ->setDependent(true) + ->setCascadeCallbacks(true); + + $this->setDisplayField('label'); + + $this->setPrimaryLink('CoreEnroller.attribute_collector_id'); + $this->setRequiresCO(true); + $this->setRedirectGoal(action: 'add', goal: 'index'); + $this->setRedirectGoal(action: 'edit', goal: 'index'); + + $this->setAutoViewVars([ + 'addressRequiredFields' => [ + 'type' => 'enum', + 'class' => 'RequiredAddressFieldsEnum' + ], + 'addressGroupedFields' => [ + 'type' => 'enum', + 'class' => 'GroupedAddressFieldsEnum' + ], + 'addressTypes' => [ + 'type' => 'type', + 'attribute' => 'Addresses.type' + ], + 'attributes' => [ + 'type' => 'hash', + 'hash' => $this->supportedAttributes('list') + ], + 'attributeLanguages' => [ + 'type' => 'enum', + 'class' => 'LanguageEnum' + ], + // We set attributeMveaParents to the maximal set of options so that the form + // load on an edit will render correctly. Note this list must match the validation + // rule, below. + 'attributeMveaParents' => [ + 'type' => 'array', + 'array' => ['Person', 'PersonRole'] + ], + 'defaultValueAffiliationTypes' => [ + 'type' => 'type', + 'attribute' => 'PersonRoles.affiliation_type' + ], + 'defaultValueCous' => [ + 'type' => 'select', + 'model' => 'Cous' + ], + 'defaultValueGroups' => [ + 'type' => 'select', + 'model' => 'Groups', + // only writeable groups are selectable + 'where' => [ + 'group_type IN' => [ + GroupTypeEnum::Admins, + GroupTypeEnum::Owners, + GroupTypeEnum::Standard + ] + ] + ], + 'emailAddressTypes' => [ + 'type' => 'type', + 'attribute' => 'EmailAddresses.type' + ], + 'identifierTypes' => [ + 'type' => 'type', + 'attribute' => 'Identifiers.type' + ], + 'nameRequiredFields' => [ + 'type' => 'enum', + 'class' => 'RequiredNameFieldsEnum' + ], + 'nameTypes' => [ + 'type' => 'type', + 'attribute' => 'Names.type' + ], + 'pronounTypes' => [ + 'type' => 'type', + 'attribute' => 'Pronouns.type' + ], + 'statuses' => [ + 'type' => 'enum', + 'class' => 'SuspendableStatusEnum' + ], + 'telephoneNumberTypes' => [ + 'type' => 'type', + 'attribute' => 'TelephoneNumbers.type' + ], + 'urlTypes' => [ + 'type' => 'type', + 'attribute' => 'Urls.type' + ], + // Required for attribute collection + 'cosettings' => [ + 'type' => 'auxiliary', + 'model' => 'CoSettings' + ], + ]); + + $this->setLayout([ 'index' => 'iframe', + 'add' => 'iframe', + 'edit' => 'iframe', + 'view' => 'iframe', + ]); + + // All the tabs share the same configuration in the ModelTable file + $this->setTabsConfig( + [ + // Ordered list of Tabs + 'tabs' => ['EnrollmentFlowSteps', 'CoreEnroller.AttributeCollectors', 'CoreEnroller.EnrollmentAttributes'], + // What actions will inlcude the subnavigation header + 'action' => [ + // If a model renders in a subnavigation mode in edit/view mode, it cannot + // render in index mode for the same use case/context + // XXX edit should go first. + 'EnrollmentFlowSteps' => ['edit', 'view'], + 'CoreEnroller.AttributeCollectors' => ['edit'], + 'CoreEnroller.EnrollmentAttributes' => ['index'], + ], + 'skipTab' => ['CoreEnroller.AttributeCollectors'] + ] + ); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + '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'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Obtain the set of supported attributes. + * + * @since COmanage Registry v5.0.0 + * @param string $format How to return attributes ('full', 'list') + * @return array Supported attributes + */ + + public function supportedAttributes(string $format='full'): array { + $attrs = []; + + // Single valued Person attributes + $attrs['date_of_birth'] = [ + 'label' => __d('field', 'date_of_birth'), + 'model' => 'Person', + 'fieldType' => 'date' + ]; + + // Single valued Person Role attributes + + $attrs['affiliation_type_id'] = [ + 'label' => __d('field', 'affiliation'), + 'model' => 'PersonRole', + 'fieldType' => 'string' + ]; + + $attrs['cou_id'] = [ + 'label' => __d('controller', 'Cous', [1]), + 'model' => 'PersonRole', + 'fieldType' => 'integer' + ]; + + $attrs['department'] = [ + 'label' => __d('field', 'department'), + 'model' => 'PersonRole', + 'fieldType' => 'string' + ]; + + $attrs['manager_person_id'] = [ + 'label' => __d('field', 'manager'), + 'model' => 'PersonRole', + 'fieldType' => 'integer' + ]; + + $attrs['organization'] = [ + 'label' => __d('field', 'organization'), + 'model' => 'PersonRole', + 'fieldType' => 'string' + ]; + + $attrs['sponsor_person_id'] = [ + 'label' => __d('field', 'sponsor'), + 'model' => 'PersonRole', + 'fieldType' => 'integer' + ]; + + $attrs['title'] = [ + 'label' => __d('field', 'title'), + 'model' => 'PersonRole', + 'fieldType' => 'string' + ]; + + $attrs['valid_from'] = [ + 'label' => __d('field', 'valid_from'), + 'model' => 'PersonRole', + 'fieldType' => 'datetime' + ]; + + $attrs['valid_through'] = [ + 'label' => __d('field', 'valid_through'), + 'model' => 'PersonRole', + 'fieldType' => 'datetime' + ]; + + // MVEAs, which might attach to the Person or Person Role or both + + $attrs['address'] = [ + 'label' => __d('controller', 'Addresses', [1]), + 'mveaModel' => 'Addresses', + 'mveaParents' => ['Person', 'PersonRole'], + 'fieldType' => 'string', + 'autoViewVar' => 'addressTypes', + ]; + + $attrs['adHocAttribute'] = [ + 'label' => __d('controller', 'AdHocAttributes', [1]), + 'mveaModel' => 'AdHocAttributes', + 'mveaParents' => ['Person', 'PersonRole'], + 'fieldType' => 'string' + ]; + + $attrs['emailAddress'] = [ + 'label' => __d('controller', 'EmailAddresses', [1]), + 'mveaModel' => 'EmailAddresses', + 'mveaParents' => ['Person'], + 'fieldType' => 'string' + ]; + + $attrs['identifier'] = [ + 'label' => __d('controller', 'Identifiers', [1]), + 'mveaModel' => 'Identifiers', + 'mveaParents' => ['Person'], + 'fieldType' => 'string' + ]; + + $attrs['name'] = [ + 'label' => __d('controller', 'Names', [1]), + 'mveaModel' => 'Names', + 'mveaParents' => ['Person'], + 'fieldType' => 'string' + ]; + + $attrs['pronoun'] = [ + 'label' => __d('controller', 'Pronouns', [1]), + 'mveaModel' => 'Pronouns', + 'mveaParents' => ['Person'], + 'fieldType' => 'string' + ]; + + $attrs['telephoneNumber'] = [ + 'label' => __d('controller', 'TelephoneNumbers', [1]), + 'mveaModel' => 'TelephoneNumbers', + 'mveaParents' => ['Person', 'PersonRole'], + 'fieldType' => 'string' + ]; + + $attrs['url'] = [ + 'label' => __d('controller', 'Urls', [1]), + 'mveaModel' => 'Urls', + 'mveaParents' => ['Person'], + 'fieldType' => 'string' + ]; + + // Group memberships, as reflected by the Group ID for the membership to be created in + + $attrs['group_id'] = [ + // We name the attribute group_id because the value we need to track is the Group + // to attach the membership to, but the label says Group Member because that'll be + // more obvious + 'label' => __d('controller', 'GroupMembers', [1]), + 'model' => 'Group', + 'fieldType' => 'integer' + ]; + + // Attributes that are only stored in the Petition + + $attrs['petition_text'] = [ + 'label' => __d('core_enroller', 'field.EnrollmentAttributes.petition_text'), + 'model' => 'Petition', + 'fieldType' => 'string' + ]; + + $attrs['petition_textarea'] = [ + 'label' => __d('core_enroller', 'field.EnrollmentAttributes.petition_textarea'), + 'model' => 'Petition', + 'fieldType' => 'text' + ]; + + switch($format) { + case 'list': + // Return as a hash of tag => label values + $l = []; + foreach(array_keys($attrs) as $k) { + $l[$k] = $attrs[$k]['label']; + } + asort($l); + return $l; + } + + return $attrs; + } + + /** + * 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('attribute_collector_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('attribute_collector_id'); + + $this->registerStringValidation($validator, $schema, 'label', true); + + $this->registerStringValidation($validator, $schema, 'description', false); + + $validator->notEmptyString('attribute'); + + $validator->add('attribute_type', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('attribute_type'); + + $this->registerStringValidation($validator, $schema, 'attribute_language', false); + + $validator->add('attribute_mvea_parent', [ + // Note this list must match the autoViewVar, above + 'content' => ['rule' => ['inList', ['Person', 'PersonRole']]] + ]); + $validator->allowEmptyString('attribute_mvea_parent'); + + $this->registerStringValidation($validator, $schema, 'attribute_tag', false); + + $validator->add('status', [ + 'content' => ['rule' => ['inList', SuspendableStatusEnum::getConstValues()]] + ]); + $validator->notEmptyString('status'); + + $validator->add('required', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('required'); + + $validator->add('ordr', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('ordr'); + + $this->registerStringValidation($validator, $schema, 'default_value', false); + + $validator->allowEmptyString('default_value_datetime'); + + $this->registerStringValidation($validator, $schema, 'default_value_env_name', false); + + $validator->add('modifiable', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('modifiable'); + + $validator->add('hidden', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('hidden'); + + return $validator; + } +} diff --git a/app/plugins/CoreEnroller/src/Model/Table/IdentifierCollectorsTable.php b/app/plugins/CoreEnroller/src/Model/Table/IdentifierCollectorsTable.php new file mode 100644 index 000000000..895aeabff --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Table/IdentifierCollectorsTable.php @@ -0,0 +1,163 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('EnrollmentFlowSteps'); + $this->belongsTo('Types'); + + // We intentionally don't hasMany PetitionIdentifiers since there should only be one + // collector per Petition, and the net result of not having the direct foreign key + // is that if an admin instantiates the plugin multiple times the second instance + // will refuse to do anything. + + $this->setDisplayField('id'); + + $this->setPrimaryLink('enrollment_flow_step_id'); + $this->setRequiresCO(true); + $this->setAllowLookupPrimaryLink(['dispatch', 'display']); + + $this->setAutoViewVars([ + 'types' => [ + 'type' => 'type', + 'attribute' => 'Identifiers.type' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, // Delete the pluggable object instead + 'dispatch' => true, + 'display' => true, + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, // This is added by the parent model + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Perform steps necessary to finalize the Petition. + * + * @since COmanage Registry v5.1.0 + * @param int $id Invitation Accepter ID + * @param Petition $petition Petition + * @return bool true on success + */ + + public function finalize(int $id, \App\Model\Entity\Petition $petition) { + $cfg = $this->get($id); + + // Pull the Identifier that was recorded + + $PetitionIdentifiers = TableRegistry::getTableLocator()->get('CoreEnroller.PetitionIdentifiers'); + + $pidentifier = $PetitionIdentifiers->find()->where(['petition_id' => $petition->id])->first(); + + if(!empty($pidentifier->identifier)) { + // Copy the Identifier to the Person record in accordance with the configuration + + $Identifiers = TableRegistry::getTableLocator()->get('Identifiers'); + + $id = $Identifiers->newEntity([ + 'person_id' => $petition->enrollee_person_id, + 'identifier' => $pidentifier->identifier, + 'type_id' => $cfg->type_id, + 'status' => SuspendableStatusEnum::Active, + 'login' => true + ]); + + $Identifiers->saveOrFail($id); + } + + return true; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.1.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('enrollment_flow_step_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('enrollment_flow_step_id'); + + $validator->add('type_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('type_id'); + + return $validator; + } +} diff --git a/app/plugins/CoreEnroller/src/Model/Table/InvitationAcceptersTable.php b/app/plugins/CoreEnroller/src/Model/Table/InvitationAcceptersTable.php new file mode 100644 index 000000000..2fbdfe5fa --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Table/InvitationAcceptersTable.php @@ -0,0 +1,208 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('EnrollmentFlowSteps'); + + // We intentionally don't hasMany PetitionAcceptances since there should only be one + // acceptance per Petition, and the net result of not having the direct foreign key + // is that if an admin instantiates the plugin multiple times the second instance + // will refuse to do anything. + + $this->setDisplayField('id'); + + $this->setPrimaryLink('enrollment_flow_step_id'); + $this->setRequiresCO(true); + $this->setAllowLookupPrimaryLink(['dispatch', 'display']); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, // Delete the pluggable object instead + 'dispatch' => true, + 'display' => true, + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, // This is added by the parent model + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Perform steps necessary to finalize the Petition. + * + * @since COmanage Registry v5.1.0 + * @param int $id Invitation Accepter ID + * @param Petition $petition Petition + * @return bool true on success + */ + + public function finalize(int $id, \App\Model\Entity\Petition $petition) { + // $cfg = $this->get($id); + + // We don't have anything to do for finalization + + return true; + } + + /** + * Perform tasks prior to transitioning to this step. + * + * @since COmanage Registry v5.1.0 + * @param EnrollmentFlowStep $step Enrollment Flow Step + * @param Petition $petition Petition + * @return bool true on success + */ + + public function prepare( + \App\Model\Entity\EnrollmentFlowStep $step, + \App\Model\Entity\Petition $petition + ): bool { + // Allocate an Invitation. We create an empty petition_acceptance to use to measure + // invitation validity (based on the created timestamp). + + $PetitionAcceptances = TableRegistry::getTableLocator()->get('CoreEnroller.PetitionAcceptances'); + + $acceptance = $PetitionAcceptances->find() + ->where(['petition_id' => $petition->id]) + ->first(); + + if(!empty($acceptance)) { + // We'll allow a null (pending) invitation, but if there is already a response + // we throw an error. This probably isn't the best behavior, once we have some + // more requirements we should probably change this to do something else (eg: + // fast forward to the next step). + + if(!is_null($acceptance->accepted)) { + throw new \RuntimeException(__d('core_enroller', 'error.PetitionAcceptances.exists')); + } + + // We don't reset the Petition status, at least pending further requirements. + } else { + // Register a new, pending invitation + + $acceptance = $PetitionAcceptances->newEntity([ + 'petition_id' => $petition->id, + 'accepted' => null + ]); + + // If for some reason there is already an acceptance record + // for this petition we'll basically reset it + $PetitionAcceptances->saveOrFail($acceptance); + + // Set this petition to Pending Invitation + + $Petitions = TableRegistry::getTableLocator()->get('Petitions'); + + $petition->status = PetitionStatusEnum::PendingAcceptance; + + $Petitions->saveOrFail($petition); + + // Record history + $Petitions->PetitionHistoryRecords->record( + petitionId: $petition->id, + enrollmentFlowStepId: $step->id, + action: PetitionActionEnum::InvitationViewed, + comment: __d('result', 'Petitions.viewed.inv') + // actorPersonId + ); + } + + return true; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.1.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('enrollment_flow_step_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('enrollment_flow_step_id'); + + $validator->add('invitation_validity', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('invitation_validity'); + + $validator->add('welcome_message', [ + 'filter' => ['rule' => ['validateInput'], + 'provider' => 'table'] + ]); + $validator->allowEmptyString('welcome_message'); + + return $validator; + } +} diff --git a/app/plugins/CoreEnroller/src/Model/Table/PetitionAcceptancesTable.php b/app/plugins/CoreEnroller/src/Model/Table/PetitionAcceptancesTable.php new file mode 100644 index 000000000..d5eb08c65 --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Table/PetitionAcceptancesTable.php @@ -0,0 +1,177 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Artifact); + + // Define associations + $this->belongsTo('Petitions'); + + $this->setDisplayField('accepted'); + + $this->setPrimaryLink('petition_id'); + $this->setRequiresCO(true); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, + 'edit' => false, + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, + 'index' => false + ] + ]); + } + + /** + * Process an Invitation reply. + * + * @since COmanage Registry v5.1.0 + * @param int $petitionId Petition ID + * @param int $enrollmentFlowStepId Enrollment Flow Step ID + * @param bool $accepted true if the reply was accepted, false if declined + * @throws Exceptions + */ + + public function processReply(int $petitionId, int $enrollmentFlowStepId, bool $accepted) { + // There should already be an empty Acceptance indicating when the step was handed off + // for Invitation, and for which we need to refer to to determine the invitation validity. + // If it doesn't exist, that's an error. + + $acceptance = $this->find()->where(['petition_id' => $petitionId])->firstOrFail(); + + // If accepted is not null, we've already processed this invitation, so that's also an error. + + if(!is_null($acceptance->accepted)) { + throw new \RuntimeException(__d('core_enroller', 'error.PetitionAcceptances.processed')); + } + + // Next check the create time of the original record. For this, we need the configuration. + + $InvitationAccepters = TableRegistry::getTableLocator()->get('CoreEnroller.InvitationAccepters'); + + $ia = $InvitationAccepters->find() + ->where(['enrollment_flow_step_id' => $enrollmentFlowStepId]) + ->firstOrFail(); + + // A validity of 0 disables expiration + + if($ia->invitation_validity > 0) { + $expires = $acceptance->created->addSeconds($ia->invitation_validity); + + if($expires->isPast()) { + throw new \RuntimeException('error.PetitionAcceptances.expired'); + } + } + + // We're finally ready to process the reply + + $acceptance->accepted = $accepted; + + $this->saveOrFail($acceptance); + + // Set the Petition status appropriately + + $petition = $this->Petitions->get($petitionId); + + $petition->status = $accepted ? PetitionStatusEnum::Accepted : PetitionStatusEnum::Declined; + + $this->Petitions->saveOrFail($petition); + + // Record Petition History + + $this->Petitions->PetitionHistoryRecords->record( + petitionId: $petitionId, + enrollmentFlowStepId: $enrollmentFlowStepId, + action: PetitionActionEnum::StatusUpdated, + comment: __d('core_enroller', $accepted ? 'result.accept.accepted' : 'result.accept.declined') +// We don't have $actorPersonId yet... +// ?int $actorPersonId=null + ); + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.1.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('petition_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('petition_id'); + + $validator->add('accepted', [ + 'content' => ['rule' => ['boolean']] + ]); + $validator->allowEmptyString('accepted'); + + return $validator; + } +} diff --git a/app/plugins/CoreEnroller/src/Model/Table/PetitionAttributesTable.php b/app/plugins/CoreEnroller/src/Model/Table/PetitionAttributesTable.php new file mode 100644 index 000000000..161d873eb --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Table/PetitionAttributesTable.php @@ -0,0 +1,109 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Artifact); + + // Define associations + $this->belongsTo('CoreEnroller.EnrollmentAttributes'); + $this->belongsTo('Petition'); + + $this->setDisplayField('value'); + + $this->setPrimaryLink('petition_id'); + $this->setRequiresCO(true); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, + 'edit' => false, + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * 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('petition_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('petition_id'); + + $validator->add('enrollment_attribute_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('enrollment_attribute_id'); + + $this->registerStringValidation($validator, $schema, 'value', false); + + return $validator; + } +} diff --git a/app/plugins/CoreEnroller/src/Model/Table/PetitionBasicAttributeSetsTable.php b/app/plugins/CoreEnroller/src/Model/Table/PetitionBasicAttributeSetsTable.php new file mode 100644 index 000000000..a51e31976 --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Table/PetitionBasicAttributeSetsTable.php @@ -0,0 +1,125 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Artifact); + + // Define associations + $this->belongsTo('CoreEnroller.BasicAttributeCollectors'); + $this->belongsTo('Petition'); + + $this->setDisplayField('mail'); + + $this->setPrimaryLink('petition_id'); + $this->setRequiresCO(true); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, + 'edit' => false, + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.1.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('petition_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('petition_id'); + + $validator->add('basic_attribute_collector_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('basic_attribute_collector_id'); + + // For now we allow any attribute to be empty and rely on upsert and the UI to + // enforce requirements. + foreach(['honorific', 'given', 'middle', 'family', 'suffix'] as $f) { + $validator->add($f, [ + 'size' => ['rule' => ['validateMaxLength', ['column' => $schema->getColumn($f)]], + 'provider' => 'table'], + 'filter' => ['rule' => ['validateInput'], + 'provider' => 'table'] + ]); + $validator->allowEmptyString($f); + } + + $this->registerStringValidation($validator, $schema, 'mail', false); + $validator->add('mail', [ + 'content' => ['rule' => ['email'], + 'message' => __d('error', 'input.invalid.email')] + ]); + + return $validator; + } +} diff --git a/app/plugins/CoreEnroller/src/Model/Table/PetitionIdentifiersTable.php b/app/plugins/CoreEnroller/src/Model/Table/PetitionIdentifiersTable.php new file mode 100644 index 000000000..e9d529c2e --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Table/PetitionIdentifiersTable.php @@ -0,0 +1,139 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Artifact); + + // Define associations + $this->belongsTo('Petitions'); + + $this->setDisplayField('identifier'); + + $this->setPrimaryLink('petition_id'); + $this->setRequiresCO(true); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, + 'edit' => false, + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, + 'index' => false + ] + ]); + } + + /** + * Record an Identifier. + * + * @since COmanage Registry v5.1.0 + * @param int $petitionId Petition ID + * @param int $enrollmentFlowStepId Enrollment Flow Step ID + * @param string $identifier Login Identifier + */ + + public function record(int $petitionId, int $enrollmentFlowStepId, string $identifier) { + // Record the Identifier. We use upsert since at least initially we only support + // one Identifier per Petition. + + $pi = [ + 'petition_id' => $petitionId, + 'identifier' => $identifier + ]; + + $this->upsertOrFail($pi, ['petition_id' => $petitionId]); + + // Record PetitionHistory + + $this->Petitions->PetitionHistoryRecords->record( + petitionId: $petitionId, + enrollmentFlowStepId: $enrollmentFlowStepId, + action: PetitionActionEnum::AttributesUpdated, + comment: __d('core_enroller', 'result.IdentifierCollector.collected', [$identifier]) +// We don't have $actorPersonId yet... +// ?int $actorPersonId=null + ); + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.1.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('petition_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('petition_id'); + + $this->registerStringValidation($validator, $schema, 'identifier', true); + + return $validator; + } +} diff --git a/app/plugins/CoreEnroller/src/config/plugin.json b/app/plugins/CoreEnroller/src/config/plugin.json new file mode 100644 index 000000000..679d2cf60 --- /dev/null +++ b/app/plugins/CoreEnroller/src/config/plugin.json @@ -0,0 +1,159 @@ +{ + "types": { + "enroller": [ + "AttributeCollectors", + "BasicAttributeCollectors", + "EmailVerifiers", + "IdentifierCollectors", + "InvitationAccepters" + ] + }, + "schema": { + "tables": { + "attribute_collectors": { + "columns": { + "id": {}, + "enrollment_flow_step_id": {}, + "description": { "temporary": true, "type": "string", "size": 80 } + }, + "indexes": { + "attribute_collectors_i1": { "columns": [ "enrollment_flow_step_id" ] } + } + }, + "basic_attribute_collectors": { + "columns": { + "id": {}, + "enrollment_flow_step_id": {}, + "name_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "email_address_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "affiliation_type_id": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "cou_id": { "type": "integer", "foreignkey": { "table": "cous", "column": "id" } } + }, + "indexes": { + "basic_attribute_collectors_i1": { "columns": [ "enrollment_flow_step_id" ] }, + "basic_attribute_collectors_i2": { "needed": false, "columns": [ "name_type_id" ] }, + "basic_attribute_collectors_i3": { "needed": false, "columns": [ "email_address_type_id" ] }, + "basic_attribute_collectors_i4": { "needed": false, "columns": [ "affiliation_type_id" ] }, + "basic_attribute_collectors_i5": { "needed": false, "columns": [ "cou_id" ] } + } + }, + "petition_basic_attribute_sets": { + "columns": { + "id": {}, + "petition_id": {}, + "basic_attribute_collector_id": { "type": "integer", "foreignkey": { "table": "basic_attribute_collectors", "column": "id" } }, + "honorific": { "type": "string", "size": 32 }, + "given": { "type": "string", "size": 128 }, + "middle": { "type": "string", "size": 128 }, + "family": { "type": "string", "size": 128 }, + "suffix": { "type": "string", "size": 32 }, + "mail": { "type": "string", "size": 256 } + }, + "indexes": { + "petition_basic_attribute_sets_i1": { "columns": [ "petition_id" ] }, + "petition_basic_attribute_sets_i2": { "columns": [ "basic_attribute_collector_id" ] } + } + }, + "email_verifiers": { + "columns": { + "id": {}, + "enrollment_flow_step_id": {}, + "mode": { "type": "string", "size": 2 } + }, + "indexes": { + "email_verifiers_i1": { "columns": [ "enrollment_flow_step_id" ] } + } + }, + "enrollment_attributes": { + "columns": { + "id": {}, + "attribute_collector_id": { "type": "integer", "foreignkey": { "table": "attribute_collectors", "column": "id" } }, + "attribute": { "type": "string", "size": 80 }, + "attribute_type": { "type": "integer", "foreignkey": { "table": "types", "column": "id" } }, + "attribute_language": { "type": "string", "size": 16 }, + "attribute_mvea_parent": { "type": "string", "size": 32 }, + "attribute_tag": { "type": "string", "size": 128 }, + "status": {}, + "label": { "type": "string", "size": 80 }, + "description": {}, + "required": { "type": "boolean" }, + "ordr": {}, + "default_value": { "type": "string", "size": 160 }, + "default_value_datetime": { "type": "datetime" }, + "default_value_env_name": { "type": "string", "size": 80 }, + "modifiable": { "type": "boolean" }, + "hidden": { "type": "boolean" } + }, + "indexes": { + "enrollment_attributes_i1": { "columns": [ "attribute_collector_id" ] } + } + }, + "identifier_collectors": { + "columns": { + "id": {}, + "enrollment_flow_step_id": {}, + "type_id": { "notnull": false, "comment": "Skeletal row needs to be created without type" } + }, + "indexes": { + "identifier_collectors_i1": { "columns": [ "enrollment_flow_step_id" ] }, + "identifier_collectors_i2": { "needed": false, "columns": [ "type_id" ] } + } + }, + "invitation_accepters": { + "columns": { + "id": {}, + "enrollment_flow_step_id": {}, + "invitation_validity": { "type": "integer" }, + "welcome_message": { "type": "text" } + }, + "indexes": { + "invitation_accepters_i1": { "columns": [ "enrollment_flow_step_id" ] } + } + }, + "petition_acceptances": { + "columns": { + "id": {}, + "petition_id": {}, + "accepted": { "type": "boolean" } + }, + "indexes": { + "petition_acceptances_i1": { "columns": [ "petition_id" ] } + } + }, + "petition_attributes": { + "columns": { + "id": {}, + "petition_id": {}, + "enrollment_attribute_id": { "type": "integer", "foreignkey": { "table": "enrollment_attributes", "column": "id" } }, + "value": { "type": "string", "size": 160 } + }, + "indexes": { + "petition_attributes_i1": { "columns": [ "petition_id" ] }, + "petition_attributes_i2": { "needed": false, "columns": [ "enrollment_attribute_id" ] } + } + }, + "petition_identifiers": { + "columns": { + "id": {}, + "petition_id": {}, + "identifier": { "type": "string", "size": 512 } + }, + "indexes": { + "petition_identifiers_i1": { "columns": [ "petition_id" ] } + } + }, + "petition_verifications": { + "columns": { + "id": {}, + "petition_id": {}, + "mail": {}, + "verification_id": {} + }, + "indexes": { + "petition_verifications_i1": { "columns": [ "petition_id" ] }, + "petition_verifications_i2": { "needed": false, "columns": [ "verification_id" ] } + } + } + } + } +} \ No newline at end of file diff --git a/app/plugins/CoreEnroller/templates/AttributeCollectors/dispatch.inc b/app/plugins/CoreEnroller/templates/AttributeCollectors/dispatch.inc new file mode 100644 index 000000000..95288d700 --- /dev/null +++ b/app/plugins/CoreEnroller/templates/AttributeCollectors/dispatch.inc @@ -0,0 +1,64 @@ +Field->enableFormEditMode(); +// Populate the AutoViewVars. These are the same we do for the EnrollmentAttributes configuration view +$this->Petition->populateAutoViewVars(); +// We just populated the AutoViewVars. Add them to the current context +extract($this->viewVars); + +// Sort according to configuration +$vv_enrollment_attributes_sorted = $vv_enrollment_attributes->sortBy(function ($attribute) { + return $attribute->ordr; +}, SORT_ASC)->toList(); + +// Iterate over the attributes and render +foreach($vv_enrollment_attributes_sorted as $attr) { + // Do not render if this is not active + if ($attr->status !== StatusEnum::Active) { + continue; + } + + // Fieldset with legend for MVEAs + if(\in_array($attr->attribute, ['name', 'address', 'telephoneNumber'], true)) { + print $this->element('CoreEnroller.mveas/mvea-fieldset', compact('attr')); + continue; + } + + // Default + print $this->element('CoreEnroller.field', compact('attr')); +} \ No newline at end of file diff --git a/app/plugins/CoreEnroller/templates/AttributeCollectors/fields-nav.inc b/app/plugins/CoreEnroller/templates/AttributeCollectors/fields-nav.inc new file mode 100644 index 000000000..45b5bbb81 --- /dev/null +++ b/app/plugins/CoreEnroller/templates/AttributeCollectors/fields-nav.inc @@ -0,0 +1,39 @@ + 'edit_attributes', + 'order' => 'Default', + 'label' => __d('core_enroller', 'controller.EnrollmentAttributes', [99]), + 'link' => [ + 'plugin' => 'CoreEnroller', + 'controller' => 'enrollment_attributes', + 'action' => 'index', + 'attribute_collector_id' => $vv_obj->id + ], + 'class' => '' +]; diff --git a/app/templates/Urls/fields-nav.inc b/app/plugins/CoreEnroller/templates/AttributeCollectors/fields.inc similarity index 80% rename from app/templates/Urls/fields-nav.inc rename to app/plugins/CoreEnroller/templates/AttributeCollectors/fields.inc index 8e0c5364f..40a5b752c 100644 --- a/app/templates/Urls/fields-nav.inc +++ b/app/plugins/CoreEnroller/templates/AttributeCollectors/fields.inc @@ -1,6 +1,6 @@ Field->disableFormEditMode(); + +print __d('information', 'plugin.config.none'); -$subnav = [ - 'name' => 'person', - 'active' => 'person', - 'subActive' => 'names' -]; \ No newline at end of file diff --git a/app/plugins/CoreEnroller/templates/BasicAttributeCollectors/dispatch.inc b/app/plugins/CoreEnroller/templates/BasicAttributeCollectors/dispatch.inc new file mode 100644 index 000000000..a89f5620a --- /dev/null +++ b/app/plugins/CoreEnroller/templates/BasicAttributeCollectors/dispatch.inc @@ -0,0 +1,48 @@ +Field->enableFormEditMode(); + + foreach(['honorific', 'given', 'middle', 'family', 'suffix'] as $n) { + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => $n, + 'fieldLabel' => __d('field', $n) + ]]); + } + + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'mail', + 'fieldLabel' => __d('field', 'mail') + ]]); +} \ No newline at end of file diff --git a/app/plugins/CoreEnroller/templates/BasicAttributeCollectors/display.php b/app/plugins/CoreEnroller/templates/BasicAttributeCollectors/display.php new file mode 100644 index 000000000..4247e7400 --- /dev/null +++ b/app/plugins/CoreEnroller/templates/BasicAttributeCollectors/display.php @@ -0,0 +1,8 @@ + diff --git a/app/plugins/CoreEnroller/templates/BasicAttributeCollectors/fields.inc b/app/plugins/CoreEnroller/templates/BasicAttributeCollectors/fields.inc new file mode 100644 index 000000000..23c5a986c --- /dev/null +++ b/app/plugins/CoreEnroller/templates/BasicAttributeCollectors/fields.inc @@ -0,0 +1,45 @@ +element('form/listItem', [ + 'arguments' => ['fieldName' => 'name_type_id'] + ]); + + print $this->element('form/listItem', [ + 'arguments' => ['fieldName' => 'email_address_type_id'] + ]); + + print $this->element('form/listItem', [ + 'arguments' => ['fieldName' => 'affiliation_type_id'] + ]); + + print $this->element('form/listItem', [ + 'arguments' => ['fieldName' => 'cou_id'] + ]); +} diff --git a/app/plugins/CoreEnroller/templates/EnrollmentAttributes/columns.inc b/app/plugins/CoreEnroller/templates/EnrollmentAttributes/columns.inc new file mode 100644 index 000000000..8281f4cb9 --- /dev/null +++ b/app/plugins/CoreEnroller/templates/EnrollmentAttributes/columns.inc @@ -0,0 +1,123 @@ + [ + 'type' => 'link', + 'sortable' => true, +// 'class' => 'cm-modal-link nospin', // launch this in a modal + 'dataAttrs' => [ + ['data-cm-modal-title', __d('operation', 'EnrollmentAttributes', 1)] + ] + ], + 'attribute' => [ +// XXX we probably want to render something more like v4 does + 'type' => 'echo' + ], + 'ordr' => [ + 'type' => 'echo', + 'sortable' => true + ], + 'required' => [ + 'type' => 'boolean', + 'class' => 'YesBooleanEnum' + ] +]; + + +if(empty($attributes)) { + return; +} + +// Map attributes to icons +$attributeToIcons = + [ + 'adHocAttribute' => 'grass', + 'address' => 'home', + 'affiliation_type_id' => 'person_search', + 'cou_id' => 'supervised_user_circle', + 'date_of_birth' => 'event', + 'department' => 'domain', + 'emailAddress' => 'email', + 'group_id' => 'groups', + 'identifier' => 'badge', + 'manager_person_id' => 'supervisor_account', + 'name' => 'face', + 'organization' => 'corporate_fare', + 'pronoun' => 'transgender', + 'sponsor_person_id' => 'person', + 'telephoneNumber' => 'call', + 'petition_textarea' => 'text_fields', + 'petition_text' => 'text_fields', + 'title' => 'title', + 'url' => 'link', + 'valid_from' => 'calendar_month', + 'valid_through' => 'calendar_month' + ]; + +// Build the Add menu +$action_args = array(); +// This is an add action as a result we do not have an obect id +// and we will use the username instead. +$action_args['vv_attr_id'] = $vv_user['username']; +$action_args['vv_actions_type'] = 'mvea-add-menu'; +$action_args['vv_actions_title'] = __d('operation', 'add.attribute'); +$action_args['vv_actions_icon'] = 'add_circle'; +$action_args['vv_actions_class'] = 'mvea-add-menu'; +$actionOrderDefault = $this->Menu->getMenuOrder('Default'); +foreach ($attributes as $attr => $hr_name) { + $title = __d('operation', 'add.a-1', [ + __d('core_enroller', 'controller.EnrollmentAttributes', [1]), + $hr_name + ]); + $actionLabel = $hr_name; + // XXX This is already inside a modal. We just need the layout to be correct +// $actionClass = 'cm-modal-link nospin'; + $actionOrder = $actionOrderDefault++; + $actionIcon = $attributeToIcons[$attr] ?? $this->Menu->getMenuIcon('Default'); + $dataAttrs = ['data-cm-modal-title', __d('file-connector', 'EnrollmentAttributes', 1)]; + $actionUrl = [ + 'pluing' => $this->getPlugin(), + 'controller' => $vv_controller, + 'action' => 'add', + '?' => array_merge($linkFilter, ['attribute_type' => $attr]) + ]; + + $action_args['vv_actions'][] = [ + 'order' => $actionOrder, + 'icon' => $actionIcon, + 'iconClass' => '', + 'url' => $actionUrl, + 'class' => $actionClass ?? '', + 'label' => $actionLabel, + 'dataAttrs' => [] + ]; +} diff --git a/app/plugins/CoreEnroller/templates/EnrollmentAttributes/fields.inc b/app/plugins/CoreEnroller/templates/EnrollmentAttributes/fields.inc new file mode 100644 index 000000000..9b17608f7 --- /dev/null +++ b/app/plugins/CoreEnroller/templates/EnrollmentAttributes/fields.inc @@ -0,0 +1,251 @@ +request->getQuery('attribute_type'); +} else if($vv_action == 'edit' && !empty($vv_obj) +) { + $attribute_type = $vv_obj->attribute; +} + +if (empty($attribute_type)) { + return; +} + +/* + * Set the attribute hidden field + */ + +$hidden = [ + 'attribute' => $attribute_type +]; + +/* + * Include a "cancel" button next to the "save" button + */ + +$this->set('vv_include_cancel', true); + +/* + * Common Fields for all attributes + * - label + * - description + * - status + * - order + * - required + */ +foreach ( ['label', + 'description', + 'status', + 'ordr' + ] as $field) { + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => $field + ]]); +} + +print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'required', + 'fieldOptions' => [ + 'default' => true + ] + ]]); + + +/* + * + * Optional/Per Attribute supported fields + * + */ + + +/* + * MVEAS which are allowed to attach to a Person or PersonRole + */ +if ( + !empty($vv_supported_attributes[$attribute_type]['mveaParents']) + && count($vv_supported_attributes[$attribute_type]['mveaParents']) > 0 +) { + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'attribute_mvea_parent', + 'fieldOptions' => [ + 'empty' => false + ], + 'fieldType' => 'select', + 'fieldSelectOptions' => array_combine( + $vv_supported_attributes[$attribute_type]['mveaParents'], + $vv_supported_attributes[$attribute_type]['mveaParents'] + ) + ] + ]); + + + // This field is called attribute_type and not attribute_type_id because we want this + // to behave as a hidden value populated by the appropriate select, and we don't want + // Cake to implement foreign key automagic. + $mveaAutoPopulatedVariable = $attribute_type . 'Types'; + // Check if this mvea model supports types, if it does then render the attribute type + // dropdown list + if ($this->get($mveaAutoPopulatedVariable) !== null) { + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'attribute_type', + 'fieldLabel' => $attributes[$attribute_type] . ' Type', + 'fieldOptions' => [ + 'empty' => false + ], + 'fieldType' => 'select', + 'fieldSelectOptions' => $this->get($mveaAutoPopulatedVariable) + ] + ]); + } +} + +/* + * Default Values field + * Supported for attributes: group_id, cou_id, affiliation_type_id + */ +if(str_ends_with($attribute_type, '_id')) { + $suffix = substr($attribute_type, 0, -3); + $suffix = Inflector::pluralize(Inflector::camelize($suffix)) ; + $defaultValuesPopulated = 'defaultValue' . $suffix; + if ($this->get($defaultValuesPopulated) !== null) { + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'default_value', + 'fieldOptions' => [ + 'empty' => true + ], + 'fieldType' => 'select', + 'fieldSelectOptions' => $this->get($defaultValuesPopulated) + ] + ]); + } +} + +/* + * Attribute Tag field + * + * Supported for attributes: adHocAttribute + */ +if (\in_array($attribute_type, ['adHocAttribute'], true)) { + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'attribute_tag', + ]]); +} + +/* + * Attribute Language field + */ +if(\in_array($attribute_type, ['name', 'address', 'pronoun'], true)) { + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'attribute_language', + ]]); +} + + +/* + * Default Environmental Variable Name field + */ +$defaultValueEnvNameSupported = [ + 'address', 'adHocAttribute', 'emailAddress', 'identifier', 'name' ,'pronoun', 'telephoneNumber', 'url' +]; +$personRoleExclude = [ + 'affiliation_type_id', 'cou_id', 'valid_from', 'valid_through' +]; +if ( + \in_array($attribute_type, $defaultValueEnvNameSupported, true) + || ( + isset($vv_supported_attributes[$attribute_type]['model']) + && $vv_supported_attributes[$attribute_type]['model'] !== 'PersonRole' + && !\in_array($attribute_type, $personRoleExclude, true) + ) +) { + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'default_value_env_name', + ]]); +} + +/* + * The valid from attribute is a dateTime field + * + */ +if($attribute_type === 'valid_from') { + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'default_value_datetime', + 'fieldType' => 'datetime' + ]]); +} + +/* + * The valid through attribute is a textbox/number field + */ +if($attribute_type === 'valid_through') { + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'default_value', + 'fieldDescription' => __d('core_enroller', 'enumeration.DefaultValueValidityType.after'), + 'fieldOptions' => [ + 'type' => 'number' + ] + ]]); +} + +/* + * Fields modifiable, hidden + * Supported for Models: PersonRole, Person, Group + */ +if ( + isset($vv_supported_attributes[$attribute_type]['model']) + && \in_array($vv_supported_attributes[$attribute_type]['model'], ['PersonRole', 'Person', 'Group'], true) +) { + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'modifiable', + 'fieldOptions' => [ + 'default' => true + ] + ] + ]); + + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'hidden' + ]]); +} diff --git a/app/plugins/CoreEnroller/templates/IdentifierCollectors/display.php b/app/plugins/CoreEnroller/templates/IdentifierCollectors/display.php new file mode 100644 index 000000000..30359a605 --- /dev/null +++ b/app/plugins/CoreEnroller/templates/IdentifierCollectors/display.php @@ -0,0 +1,4 @@ +identifier)) { + print __d('core_enroller', 'result.IdentifierCollector.collected', [$vv_pi->identifier]); +} diff --git a/app/plugins/CoreEnroller/templates/IdentifierCollectors/fields.inc b/app/plugins/CoreEnroller/templates/IdentifierCollectors/fields.inc new file mode 100644 index 000000000..d2eaa73be --- /dev/null +++ b/app/plugins/CoreEnroller/templates/IdentifierCollectors/fields.inc @@ -0,0 +1,33 @@ +element('form/listItem', [ + 'arguments' => ['fieldName' => 'type_id'] + ]); +} diff --git a/app/plugins/CoreEnroller/templates/InvitationAccepters/dispatch.inc b/app/plugins/CoreEnroller/templates/InvitationAccepters/dispatch.inc new file mode 100644 index 000000000..379bf8b09 --- /dev/null +++ b/app/plugins/CoreEnroller/templates/InvitationAccepters/dispatch.inc @@ -0,0 +1,51 @@ +welcome_message . "\n"; + + // Make the Form fields editable + $this->Field->enableFormEditMode(); + + print $this->element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'accepted', + 'fieldLabel' => "Accept this invitation?", + 'fieldOptions' => [ + 'type' => 'radio', + 'options' => [ + ['value' => '1', 'text' => 'Accept'], + ['value' => '0', 'text' => 'Decline'], + ], + 'empty' => false, + 'required' => true + ] + ]]); +} \ No newline at end of file diff --git a/app/plugins/CoreEnroller/templates/InvitationAccepters/display.php b/app/plugins/CoreEnroller/templates/InvitationAccepters/display.php new file mode 100644 index 000000000..d1d911100 --- /dev/null +++ b/app/plugins/CoreEnroller/templates/InvitationAccepters/display.php @@ -0,0 +1,10 @@ +element('form/listItem', [ + 'arguments' => [ + 'fieldName' => 'invitation_validity', + 'fieldOptions' => [ + 'default' => 1440 + ] + ] + ]); + + print $this->element('form/listItem', [ + 'arguments' => ['fieldName' => 'welcome_message'] + ]); +} diff --git a/app/plugins/CoreEnroller/templates/element/field.php b/app/plugins/CoreEnroller/templates/element/field.php new file mode 100644 index 000000000..68e4be12b --- /dev/null +++ b/app/plugins/CoreEnroller/templates/element/field.php @@ -0,0 +1,118 @@ +Petition->getSupportedEnrollmentAttribute($attr->attribute); + +// Do we have a default value configured? +// Either a value or an Environmental Variable, +// Each default value is mutually exclusive to the rest. We do not have to worry about a conflict. +$options['default'] = match(true) { + isset($attr->default_value) => $attr->default_value, + isset($attr->default_value_env_name) + && getenv($attr->default_value_env_name) !== false => getenv($attr->default_value_env_name), + isset($attr->default_value_datetime) => $attr->default_value_datetime, + default => '' +}; + +// If we are re-rendering the Petition, override the default value with whatever +// was previously saved +if(!empty($vv_petition_attributes)) { + $curEntity = $vv_petition_attributes->firstMatch(['enrollment_attribute_id' => $attr->id]); + + if(!empty($curEntity->value)) { + $options['default'] = $curEntity->value; + } +} + +// Construct the field arguments +$formArguments = [ + // We prefix the attribute ID with a string because Cake seems to sometimes have + // problems with field names that are purely integers (even if cast to strings) + 'fieldName' => 'field-' . $attr->id, + 'fieldLabel' => $attr->label, // fieldLabel is only applicable to checkboxes + 'fieldType' => $supportedAttributes['fieldType'], + 'fieldDescription' => $attr->description, + 'fieldNameAlias' => $attr->attribute // the field name to its enrollment attribute field name +]; + + +/* + * Get the values for the attributes ending with _id + * Supported for attributes: group_id, cou_id, affiliation_type_id + */ +if(str_ends_with($attr->attribute, '_id')) { + $suffix = substr($attr->attribute, 0, -3); + $suffix = Inflector::pluralize(Inflector::camelize($suffix)) ; + $defaultValuesPopulated = 'defaultValue' . $suffix; + if ($this->get($defaultValuesPopulated) !== null) { + $formArguments['fieldType'] = 'select'; + $formArguments['fieldSelectOptions'] = $this->get($defaultValuesPopulated); + } +} + +// READ-ONLY +if (isset($attr->modifiable) && !$attr->modifiable) { + $options['readonly'] = true; +} + +// REQUIRED +if (isset($attr->required) && $attr->required) { + $options['required'] = true; +} + +$hidden = true; +// XXX We need to render a field that is required and expects a value from the environment but +// the env value is empty. +if ( + isset($attr->default_value_env_name, $attr->required) + && empty($options['default']) + && $attr->required +) { + $hidden = false; +} + +// Set the final fieldOptions +$formArguments['fieldOptions'] = $options; + +print match(true) { + // HIDDEN Field + // We print directly, we do not delegate to the element for further processing + // In case this is a hidden field, we need to get only the value + $attr->hidden && $hidden =>$this->Form->hidden($formArguments['fieldName'], ['value' => $options['default']]), + // Default use case + default => $this->element('form/listItem', ['arguments' => $formArguments]) +}; diff --git a/app/plugins/CoreEnroller/templates/element/mveas/fieldset-field.php b/app/plugins/CoreEnroller/templates/element/mveas/fieldset-field.php new file mode 100644 index 000000000..58c89298c --- /dev/null +++ b/app/plugins/CoreEnroller/templates/element/mveas/fieldset-field.php @@ -0,0 +1,64 @@ +Petition->getSupportedEnrollmentAttribute($attr->attribute); + +if(isset($supportedAttributes['mveaModel'])) { + $supportedAttributes = $this->Petition->getSupportedEnrollmentAttribute($attr->attribute); + $modelTable = $this->Petition->getTable($supportedAttributes['mveaModel']); + $isRequiredFromValidationRule = !$modelTable->getValidator()->field($field)->isEmptyAllowed(); +} + +// Construct the field arguments +$formArguments = [ + // We prefix the attribute ID with a string because Cake seems to sometimes have + // problems with field names that are purely integers (even if cast to strings) + 'fieldName' => "field-$field-$attr->id", + 'fieldLabel' => $attr->label, // fieldLabel is only applicable to checkboxes + 'fieldType' => $modelTable->getSchema()->getColumn($field)['type'], + 'fieldNameAlias' => $attr->attribute // the field name to its enrollment attribute field name +]; +?> + +
"> + + element('form/requiredSpan') : ''?> + Field->formField(...$formArguments) ?> +
+ + diff --git a/app/plugins/CoreEnroller/templates/element/mveas/fieldset-group.php b/app/plugins/CoreEnroller/templates/element/mveas/fieldset-group.php new file mode 100644 index 000000000..380e77e8b --- /dev/null +++ b/app/plugins/CoreEnroller/templates/element/mveas/fieldset-group.php @@ -0,0 +1,81 @@ +attribute . 'GroupedFields'; +$groupedFieldsArray = []; +if(!empty($$groupedFieldsVar)) { + $groupedFieldsArray = collection(array_keys($$groupedFieldsVar))->map(static fn($fields) => explode(',', $fields))->toArray(); +} + +$permitted_fields_list = []; +$permitted_fields_variable_name = 'permitted_fields_' . Inflector::underscore($attr->attribute); +if (!empty($cosettings[0][$permitted_fields_variable_name])) { + $permitted_fields_list = explode(',', $cosettings[0][$permitted_fields_variable_name]); +} +// Address has no permitted fields configuration at CO level. We will get them from +// the model configuration +$supportedAttributes = $this->Petition->getSupportedEnrollmentAttribute($attr->attribute); +$modelTable = $this->Petition->getTable($supportedAttributes['mveaModel']); +if(empty($permitted_fields_list) && !empty($modelTable?->getPermittedFields())) { + $permitted_fields_list = $modelTable->getPermittedFields(); +} + +$permitted_fields_list_flipped = array_flip($permitted_fields_list); +?> + + $fields): ?> +
+ element('CoreEnroller.mveas/fieldset-field', compact('field', 'attr')); + } + // Remove the field we rendered from the permitted list. + unset($permitted_fields_list_flipped[$field]); + } + ?> +
+ + +element('CoreEnroller.mveas/fieldset-field', compact('field', 'attr')); +} +?> + + diff --git a/app/plugins/CoreEnroller/templates/element/mveas/mvea-fieldset.php b/app/plugins/CoreEnroller/templates/element/mveas/mvea-fieldset.php new file mode 100644 index 000000000..9bda744ab --- /dev/null +++ b/app/plugins/CoreEnroller/templates/element/mveas/mvea-fieldset.php @@ -0,0 +1,58 @@ +attribute . 'Types'; +// Check if this mvea model supports types, if it does then render the attribute type +// dropdown list +$fieldLabel = 'Not found'; +if ($this->get($mveaAutoPopulatedVariable) !== null) { + $fieldLabel = $this->get($mveaAutoPopulatedVariable)[$attr->attribute_type] + . ' ' + . Inflector::humanize(Inflector::underscore($attr->attribute)); +} + +?> + +
  • +
    + + + +
    + element('CoreEnroller.mveas/fieldset-group', compact( 'attr')) ?> +
    +
    +
  • diff --git a/app/plugins/CoreEnroller/tests/bootstrap.php b/app/plugins/CoreEnroller/tests/bootstrap.php new file mode 100644 index 000000000..b33afdd31 --- /dev/null +++ b/app/plugins/CoreEnroller/tests/bootstrap.php @@ -0,0 +1,55 @@ +loadSqlFiles('tests/schema.sql', 'test'); diff --git a/app/plugins/CoreEnroller/tests/schema.sql b/app/plugins/CoreEnroller/tests/schema.sql new file mode 100644 index 000000000..0fe014a26 --- /dev/null +++ b/app/plugins/CoreEnroller/tests/schema.sql @@ -0,0 +1 @@ +-- Test database schema for CoreEnroller diff --git a/app/plugins/CoreEnroller/webroot/.gitkeep b/app/plugins/CoreEnroller/webroot/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/app/resources/locales/en_US/controller.po b/app/resources/locales/en_US/controller.po index 714484a6e..b4fab7b4a 100644 --- a/app/resources/locales/en_US/controller.po +++ b/app/resources/locales/en_US/controller.po @@ -51,6 +51,12 @@ msgstr "{0,plural,=1{Dashboard} other{Dashboards}}" msgid "EmailAddresses" msgstr "{0,plural,=1{Email Address} other{Email Addresses}}" +msgid "EnrollmentFlowSteps" +msgstr "{0,plural,=1{Enrollment Flow Step} other{Enrollment Flow Steps}}" + +msgid "EnrollmentFlows" +msgstr "{0,plural,=1{Enrollment Flow} other{Enrollment Flows}}" + msgid "ExternalIdentities" msgstr "{0,plural,=1{External Identity} other{External Identities}}" @@ -96,6 +102,9 @@ msgstr "{0,plural,=1{Job} other{Jobs}}" msgid "MessageTemplates" msgstr "{0,plural,=1{Message Template} other{Message Templates}}" +msgid "MostlyStaticPages" +msgstr "{0,plural,=1{Mostly Static Page} other{Mostly Static Pages}}" + msgid "Names" msgstr "{0,plural,=1{Name} other{Names}}" @@ -108,6 +117,12 @@ msgstr "{0,plural,=1{Person} other{People}}" msgid "PersonRoles" msgstr "{0,plural,=1{Person Role} other{Person Roles}}" +msgid "PetitionHistoryRecords" +msgstr "{0,plural,=1{PetitionHistoryRecord} other{PetitionHistoryRecords}}" + +msgid "Petitions" +msgstr "{0,plural,=1{Petition} other{Petitions}}" + msgid "Pipelines" msgstr "{0,plural,=1{Pipeline} other{Pipelines}}" diff --git a/app/resources/locales/en_US/enumeration.po b/app/resources/locales/en_US/enumeration.po index 2d04220f4..c6f01644c 100644 --- a/app/resources/locales/en_US/enumeration.po +++ b/app/resources/locales/en_US/enumeration.po @@ -99,6 +99,39 @@ msgstr "Staff" msgid "EduPersonAffiliationEnum.student" msgstr "Student" +msgid "EnrollmentActorEnum.A" +msgstr "Approver" + +msgid "EnrollmentActorEnum.E" +msgstr "Enrollee" + +msgid "EnrollmentActorEnum.P" +msgstr "Petitioner" + +msgid "EnrollmentAuthzEnum.A" +msgstr "CO or COU Admin" + +msgid "EnrollmentAuthzEnum.AU" +msgstr "Any Authenticated User" + +msgid "EnrollmentAuthzEnum.CA" +msgstr "CO Administrator" + +msgid "EnrollmentAuthzEnum.CP" +msgstr "Person" + +msgid "EnrollmentAuthzEnum.GM" +msgstr "Group Member" + +msgid "EnrollmentAuthzEnum.N" +msgstr "None" + +msgid "EnrollmentAuthzEnum.UA" +msgstr "COU Administrator" + +msgid "EnrollmentAuthzEnum.UP" +msgstr "COU Person" + msgid "ExternalIdentityStatusEnum.A" msgstr "Active" @@ -348,6 +381,15 @@ msgstr "Resolved" msgid "NotificationStatusEnum.X" msgstr "Canceled" +msgid "PageContextEnum.EH" +msgstr "Enrollment Handoff" + +msgid "PageContextEnum.ER" +msgstr "Error Landing" + +msgid "PageContextEnum.G" +msgstr "General" + msgid "PermittedNameFieldsEnum.given,family" msgstr "Given, Family" @@ -396,6 +438,55 @@ msgstr "Country Code, Area Code, Number" msgid "PermittedTelephoneNumberFieldsEnum.country_code,area_code,number,extension" msgstr "Country Code, Area Code, Number, Extension" +msgid "PetitionActionEnum.AU" +msgstr "Attributes Updated" + +msgid "PetitionActionEnum.F" +msgstr "Finalized" + +msgid "PetitionActionEnum.IV" +msgstr "Invitation Viewed" + +msgid "PetitionActionEnum.SU" +msgstr "Status Updated" + +msgid "PetitionStatusEnum.A" +msgstr "Active" + +msgid "PetitionStatusEnum.Y" +msgstr "Approved" + +# This was "Confirmed" in v4 +msgid "PetitionStatusEnum.C" +msgstr "Accepted" + +msgid "PetitionStatusEnum.CR" +msgstr "Created" + +msgid "PetitionStatusEnum.D2" +msgstr "Duplicate" + +msgid "PetitionStatusEnum.F" +msgstr "Finalized" + +msgid "PetitionStatusEnum.N" +msgstr "Denied" + +msgid "PetitionStatusEnum.PA" +msgstr "Pending Approval" + +msgid "PetitionStatusEnum.PC" +msgstr "Pending Acceptance" + +msgid "PetitionStatusEnum.PV" +msgstr "Pending Vetting" + +msgid "PetitionStatusEnum.X" +msgstr "Declined" + +msgid "PetitionStatusEnum.XX" +msgstr "Failed" + msgid "ProvisionerModeEnum.A" msgstr "Immediate" @@ -441,8 +532,14 @@ msgstr "Street" msgid "RequiredAddressFieldsEnum.street,locality,state,postal_code" msgstr "Street, City, State, Postal Code" -msgid "RequiredAddressFieldsEnum.street,locality,state,postal_code,country" -msgstr "Street, City, State, Postal Code, Country" +msgid "GroupedAddressFieldsEnum.street,room" +msgstr "Street, Room" + +msgid "GroupedAddressFieldsEnum.city,locality" +msgstr "City, Locality" + +msgid "GroupedAddressFieldsEnum.state,postal_code,country" +msgstr "State, Postal Code, Country" msgid "RequiredNameFieldsEnum.given" msgstr "Given" diff --git a/app/resources/locales/en_US/error.po b/app/resources/locales/en_US/error.po index b86ddf222..3586723f3 100644 --- a/app/resources/locales/en_US/error.po +++ b/app/resources/locales/en_US/error.po @@ -66,6 +66,9 @@ msgstr "Cannot change co_id of an existing object" msgid "coid.mismatch" msgstr "Requested CO does not match CO of {0} {1}" +msgid "Cos.active" +msgstr "Requested CO {0} is not active" + msgid "cou.parent" msgstr "COU Parent ID not valid" @@ -124,6 +127,12 @@ msgstr "Email Address is already verified" msgid "EmailAddresses.mail.verify.force.person" msgstr "Email Addresses not associated with People cannot be force verified" +msgid "EnrollmentFlowSteps.message_template" +msgstr "Enrollment Flow Step {0} is transitioning Actor Types but does not have a Mesasge Template configured" + +msgid "EnrollmentFlowSteps.none" +msgstr "This Enrollment Flow has no Active steps and so cannot be run" + msgid "GroupNestings.active" msgstr "Group {0} is not active and so cannot be nested" @@ -229,6 +238,15 @@ msgstr "Job {0} is not in {1} status and cannot be set to {2} (Job is {3})" msgid "Jobs.status.invalid.cancel" msgstr "Job {0} is not in a cancelable status (Job is {1})" +msgid "MostlyStaticPages.default.delete" +msgstr "This page cannot be deleted" + +msgid "MostlyStaticPages.default.modify" +msgstr "This page cannot be renamed, suspended, or given a different context" + +msgid "MostlyStaticPages.slug.invalid" +msgstr "Slug contains invalid characters" + msgid "Names.minimum" msgstr "At least one name is required" @@ -262,6 +280,9 @@ msgstr "Notification status {0} is not a valid resolution" msgid "notprov" msgstr "{0} not provided" +msgid "ordr.unique" +msgstr "Each {0} must have a unique order" + msgid "pagenum.exceeded" msgstr "Page number may not be larger than {0}" @@ -274,6 +295,15 @@ msgstr "Permission Denied" msgid "PersonRoles.valid_from.after" msgstr "Valid From date must be earlier than Valid Through date" +msgid "Petitions.completed" +msgstr "Petition {0} has been completed and cannot be changed" + +msgid "Petitions.enrollee.notfound" +msgstr "Enrollee Person not found in Petition {0}" + +msgid "Petitions.enrollee_email" +msgstr "An Email Address for the Enrollee is required by this Enrollment Flow" + msgid "Pipelines.plugin.notimpl" msgstr "Pipeline plugin does not implement {0}" diff --git a/app/resources/locales/en_US/field.po b/app/resources/locales/en_US/field.po index d2ceb620f..f26b666db 100644 --- a/app/resources/locales/en_US/field.po +++ b/app/resources/locales/en_US/field.po @@ -41,6 +41,9 @@ msgstr "Actor" msgid "api_key" msgstr "API Key" +msgid "authz_type" +msgstr " Authorization Type" + msgid "affiliation" msgstr "Affiliation" @@ -110,6 +113,12 @@ msgstr "Ends at:" msgid "ends_at.tz" msgstr "Ends at ({0})" +msgid "enrollee_email" +msgstr "Enrollee Email" + +msgid "enrollee_identifier" +msgstr "Enrollee Identifier" + msgid "extension" msgstr "Extension" @@ -176,6 +185,12 @@ msgstr "Manager Identifier" msgid "middle" msgstr "Middle" +msgid "modifiable" +msgstr "Modifiable" + +msgid "modified" +msgstr "Modified" + msgid "name" msgstr "Name" @@ -237,6 +252,9 @@ msgstr "Clear global search" msgid "search.placeholder" msgstr "Search..." +msgid "sor_label" +msgstr "System of Record Label" + msgid "source" msgstr "Source" @@ -396,6 +414,33 @@ 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 "EnrollmentFlowSteps.actor_type" +msgstr "Actor Type" + +msgid "EnrollmentFlowSteps.message_template_id.desc" +msgstr "If a handoff is required to start this step, this Message Template will be used to notify the next Actor" + +msgid "EnrollmentFlowSteps.redirect_on_handoff" +msgstr "Redirect on Handoff" + +msgid "EnrollmentFlowSteps.redirect_on_handoff.desc" +msgstr "If a handoff is required to start this step, the original Actor will be redirected here instead of the default landing page" + +msgid "EnrollmentFlows.authz_cou_id" +msgstr "Authorized COU" + +msgid "EnrollmentFlows.authz_group_id" +msgstr "Authorized Group" + +msgid "EnrollmentFlows.authz_type" +msgstr "Petitioner Authorization" + +msgid "EnrollmentFlows.collect_enrollee_email" +msgstr "Collect Enrollee Email" + +msgid "EnrollmentFlows.redirect_on_finalize" +msgstr "Redirect on Finalize" + msgid "ExternalIdentitySources.hash_source_record" msgstr "Hash Source Records" @@ -567,6 +612,50 @@ msgstr "Message Subject" msgid "MessageTemplates.subject.desc" msgstr "Subject line for message to be sent. See XXX LINK supported substitutions." +msgid "MostlyStaticPages.body" +msgstr "Body" + +msgid "MostlyStaticPages.name" +msgstr "Slug" + +msgid "MostlyStaticPages.name.desc" +msgstr "The URL fragment for this Page, which must be unique and use only lowercase alphanumeric characters and dashes (-)" + +msgid "MostlyStaticPages.pageUrl" +msgstr "Page Display URL" + +msgid "MostlyStaticPages.pageUrl.desc" +msgstr "The full (public) URL to access the rendered page (this is a read only field)" + +# These are strings for the default Pages that are created for each CO. +# It's not clear they belong here, but we don't have a better place for them right now. +msgid "MostlyStaticPages.default.dh.title" +msgstr "Enrollment Flow Handoff" + +msgid "MostlyStaticPages.default.dh.description" +msgstr "Default Enrollment Flow Handoff Landing Page" + +msgid "MostlyStaticPages.default.dh.body" +msgstr "Thank you for completing this Enrollment Flow Step. No further action is required from you at this time. The next person to act on this Petition has been notified." + +msgid "MostlyStaticPages.default.el.title" +msgstr "An Error Occurred" + +msgid "MostlyStaticPages.default.el.description" +msgstr "Default Error Landing Page" + +msgid "MostlyStaticPages.default.el.body" +msgstr "An unexpected error occurred. Please contact your administrator for further assistance." + +msgid "MostlyStaticPages.default.pc.title" +msgstr "Enrollment Complete" + +msgid "MostlyStaticPages.default.pc.description" +msgstr "Default Petition Finalization Landing Page" + +msgid "MostlyStaticPages.default.pc.body" +msgstr "This Petition is complete and has been finalized. Please contact your administrator for further instructions." + msgid "Notifications.actor_person_id" msgstr "Actor" @@ -597,6 +686,15 @@ msgstr "Resolution Time" msgid "Notifications.subject_person_id" msgstr "Subject" +msgid "Petitions.enrollee.new" +msgstr "New Enrollee" + +msgid "Petitions.enrollee_person_id" +msgstr "Enrollee" + +msgid "Petitions.petitioner_person_id" +msgstr "Petitioner" + msgid "Pipelines.match_email_address_type_id" msgstr "Email Address Type" diff --git a/app/resources/locales/en_US/information.po b/app/resources/locales/en_US/information.po index 3d35cff34..87ca65138 100644 --- a/app/resources/locales/en_US/information.po +++ b/app/resources/locales/en_US/information.po @@ -54,6 +54,9 @@ msgstr "not set" msgid "pagination.format" msgstr "Page {{page}} of {{pages}}, Viewing {{start}}-{{end}} of {{count}}" +msgid "enrollment.steps" +msgstr "Enrollment Steps" + msgid "ExternalIdentities.source" msgstr "This External Identity was created from {0}." @@ -99,15 +102,30 @@ msgstr "No value" msgid "global.visit.link" msgstr "Visit link" +msgid "HistoryRecords.xref" +msgstr "Additional History Records may be available via Petitions and Provisioning Status" + msgid "pagination.format" msgstr "Page {{page}} of {{pages}}, Viewing {{start}}-{{end}} of {{count}}" +msgid "petition.history" +msgstr "Petition History" + +msgid "petition.information" +msgstr "Petition Information" + +msgid "Petitions.pending" +msgstr "This Petition has now been assigned to someone else. There is no further action for you at this time." + msgid "plugin.active" msgstr "Active" msgid "plugin.active.only" msgstr "Active, Cannot Be Disabled" +msgid "plugin.config.none" +msgstr "This plugin requires no configuration." + msgid "plugin.inactive" msgstr "Inactive" diff --git a/app/resources/locales/en_US/menu.po b/app/resources/locales/en_US/menu.po index 72e4011aa..9020e1cd3 100644 --- a/app/resources/locales/en_US/menu.po +++ b/app/resources/locales/en_US/menu.po @@ -112,7 +112,7 @@ msgid "co.people.enrollments.pending" msgstr "Pending Enrollments" msgid "co.people.enrollments.pending.desc" -msgstr "See and manage in-progress enrollments (CO Petitions)" +msgstr "See and manage pending enrollments" msgid "co.people.external.source.records" msgstr "External Source Records" @@ -192,6 +192,9 @@ msgstr "medium" msgid "menu.density.large" msgstr "large" +msgid "menu.home" +msgstr "Home" + msgid "menu.introduction" msgstr "Please select an action from the menu." diff --git a/app/resources/locales/en_US/operation.po b/app/resources/locales/en_US/operation.po index 3828656ba..f28b3fd72 100644 --- a/app/resources/locales/en_US/operation.po +++ b/app/resources/locales/en_US/operation.po @@ -24,6 +24,9 @@ # Operations (Commands) +msgid "accept" +msgstr "Accept" + msgid "acknowledge" msgstr "Acknowledge" @@ -36,6 +39,12 @@ msgstr "Add" msgid "add.a" msgstr "Add a New {0}" +msgid "add.a-1" +msgstr "Add a New {0} `{1}`" + +msgid "add.attribute" +msgstr "Add attribute" + msgid "add.member" msgstr "Add member: " @@ -102,6 +111,9 @@ msgstr "Configure {0}" msgid "configure.plugin" msgstr "Configure Plugin" +msgid "continue" +msgstr "Continue" + msgid "copy" msgstr "Copy" @@ -114,6 +126,9 @@ msgstr "{0} Configuration" msgid "deactivate" msgstr "Deactivate" +msgid "decline" +msgstr "Decline" + msgid "delete" msgstr "Delete" @@ -129,6 +144,15 @@ msgstr "Edit" msgid "edit.a" msgstr "Edit {0}" +msgid "edit.a-1" +msgstr "Edit {0} `{1}`" + +msgid "edit.PersonRoles.a" +msgstr "Edit Role {0}" + +msgid "edit.ExternalIdentityRoles.a" +msgstr "Edit Role {0}" + msgid "EmailAddresses.verify.force" msgstr "Force Verify" @@ -189,6 +213,9 @@ msgstr "Display records" msgid "page.goto" msgstr "Go to page" +msgid "Petitions.rerun" +msgstr "Rerun" + msgid "pick" msgstr "Pick" @@ -225,6 +252,9 @@ msgstr "Remove" msgid "resend" msgstr "Resend" +msgid "resume" +msgstr "Resume" + msgid "save" msgstr "Save" @@ -240,6 +270,9 @@ msgstr "Please select..." msgid "skip_to_content" msgstr "Skip to main content" +msgid "EnrollmentFlows.start" +msgstr "Start" + msgid "Cos.switch" msgstr "Switch To This CO" @@ -252,3 +285,12 @@ msgstr "View" msgid "view.a" msgstr "View {0}" +msgid "view.PersonRoles.a" +msgstr "View Role {0}" + +msgid "view.Petitions.a" +msgstr "View Petition {0}" + +msgid "view.ExternalIdentityRoles.a" +msgstr "View Role {0}" + diff --git a/app/resources/locales/en_US/result.po b/app/resources/locales/en_US/result.po index c0aa9e347..6f940ea69 100644 --- a/app/resources/locales/en_US/result.po +++ b/app/resources/locales/en_US/result.po @@ -139,6 +139,9 @@ msgstr "Notification {0} delivered to {1}" msgid "Notifications.resent" msgstr "Notification resent" +msgid "People.added.petition" +msgstr "Created new Person via Enrollment Flow {0} ({1}), Petition {2}" + msgid "People.added.pipeline" msgstr "Created new Person via Pipeline {0} ({1}) using Source {2} ({3}) Key {4}" @@ -148,6 +151,12 @@ msgstr "Person status recalculated from {0} to {1}" msgid "PersonRoles.status.recalculated" msgstr "Person Role status recalculated from {0} to {1}" +msgid "Petitions.finalized" +msgstr "Petition Finalized" + +msgid "Petitions.viewed.inv" +msgstr "Invitation Viewed" + msgid "Pipelines.complete" msgstr "Pipeline {0} complete for EIS {1} source key {2}" @@ -166,6 +175,9 @@ msgstr "Reprovisioning for {0} queued for {1} ({2})" msgid "removed" msgstr "removed" +msgid "result" +msgstr "Result" + msgid "saved" msgstr "Saved" diff --git a/app/src/Controller/AppController.php b/app/src/Controller/AppController.php index 35b51d485..7d3e06ff2 100644 --- a/app/src/Controller/AppController.php +++ b/app/src/Controller/AppController.php @@ -520,6 +520,29 @@ protected function populateAvailableCos() { $this->set('vv_available_cos', $availableCos); } + + /** + * Find a parameter that may be submitted via a request URL (for GETs) + * or form data (for POSTs). + * + * @since COmanage Registry v5.0.0 + * @param string $name Parameter name + * @return string Parameter value if found, or null + */ + + protected function requestParam(string $name): ?string { + if($this->request->is('get')) { + if(!empty($this->request->getQuery($name))) { + return $this->request->getQuery($name); + } + } elseif($this->request->is(['post', 'put'])) { + if(!empty($this->request->getData($name))) { + return $this->request->getData($name); + } + } + + return null; + } /** * Determine the (requested) current CO and make it available to the @@ -570,15 +593,23 @@ protected function setCO() { // trigger setting of the viewVar for breadcrumbs and anything else. $link = $this->getPrimaryLink(true); - // getPrimaryLink has already done our work - if($link->attr == 'co_id') { - $coid = $link->value; - } else { - if(!empty($link->co_id)) { - $coid = $link->co_id; + if(!empty($link->attr)) { + // getPrimaryLink has already done our work + if($link->attr == 'co_id') { + $coid = $link->value; + } else { + if(!empty($link->co_id)) { + $coid = $link->co_id; + } } } } + + if(!$coid + && $this->$modelsName->allowUnkeyedCO($this->request->getParam('action')) + && !empty($this->request->getQuery('co_id'))) { + $coid = $this->request->getQuery('co_id'); + } if(!$coid && !$this->$modelsName->allowEmptyCO() diff --git a/app/src/Controller/Component/BreadcrumbComponent.php b/app/src/Controller/Component/BreadcrumbComponent.php index 071733ee8..f6e54df4f 100644 --- a/app/src/Controller/Component/BreadcrumbComponent.php +++ b/app/src/Controller/Component/BreadcrumbComponent.php @@ -191,7 +191,7 @@ public function injectPrimaryLink(object $link, bool $index=true, string $linkLa // In the case we are dealing with non-standard actions we need to fallback to a standard one // in order to get access to the contain array. We will use the permissions to decide which // action to fall back to - if(!in_array($requestAction, [ + if(!\in_array($requestAction, [ 'index', 'view', 'delete', 'add', 'edit' ])) { $permissionsArray = $this->getController()->RegistryAuth->calculatePermissionsForView($requestAction); @@ -276,7 +276,7 @@ public function injectTitleLink( $entity, string $action='edit', ?string $label=null - ) { + ): void { $displayField = $table->getDisplayField(); $this->injectTitleLinks[] = [ @@ -298,7 +298,8 @@ public function injectTitleLink( * @param array $skipPaths Array of regular expressions describing paths to be skipped */ - public function skipAll(array $skipPaths) { + public function skipAll(array $skipPaths): void + { $this->skipAllPaths = $skipPaths; } @@ -311,7 +312,8 @@ public function skipAll(array $skipPaths) { * @param array $skipPaths Array of regular expressions describing paths */ - public function skipConfig(array $skipPaths) { + public function skipConfig(array $skipPaths): void + { $this->skipConfigPaths = $skipPaths; } @@ -323,7 +325,8 @@ public function skipConfig(array $skipPaths) { * @param array $skipPaths Array of regular expressions describing paths */ - public function skipParents(array $skipPaths) { + public function skipParents(array $skipPaths): void + { $this->skipParentPaths = $skipPaths; } } \ No newline at end of file diff --git a/app/src/Controller/Component/RegistryAuthComponent.php b/app/src/Controller/Component/RegistryAuthComponent.php index 36aa7b4be..e0854862e 100644 --- a/app/src/Controller/Component/RegistryAuthComponent.php +++ b/app/src/Controller/Component/RegistryAuthComponent.php @@ -140,6 +140,7 @@ public function beforeFilter(EventInterface $event) { if(method_exists($controller, 'willHandleAuth')) { // The Controller might handle its own authn/z + // We'll just let any exception bubble up $mode = $controller->willHandleAuth($event); switch($mode) { @@ -158,8 +159,14 @@ public function beforeFilter(EventInterface $event) { break; case 'yes': // The controller will handle both authn and authz, simply return + // (The expectation is that the controller already performed the appropriate + // checks before returning 'yes', on failure 'notauth' should be returned.) return true; break; + case 'notauth': + // The controller has rejected this request as unauthenticated or unauthorized + throw new ForbiddenException(__d('error', 'perm')); + break; default: throw new \InvalidArgumentException("Unknown willHandleAuth return value $mode"); break; diff --git a/app/src/Controller/DashboardsController.php b/app/src/Controller/DashboardsController.php index c06e4ee40..f027da29a 100644 --- a/app/src/Controller/DashboardsController.php +++ b/app/src/Controller/DashboardsController.php @@ -92,21 +92,36 @@ public function configuration() { 'controller' => 'cous', 'action' => 'index' ], + __d('controller', 'EnrollmentFlows', [99]) => [ + 'icon' => 'subscriptions', + 'iconClass' => 'material-symbols-outlined', + 'controller' => 'enrollment_flows', + 'action' => 'index' + ], __d('controller', 'ExternalIdentitySources', [99]) => [ 'icon' => 'cloud_download', + 'iconClass' => 'material-symbols-outlined', 'controller' => 'external_identity_sources', 'action' => 'index' ], __d('controller', 'IdentifierAssignments', [99]) => [ 'icon' => 'badge', + 'iconClass' => 'material-symbols-outlined', 'controller' => 'identifier_assignments', 'action' => 'index' ], __d('controller', 'MessageTemplates', [99]) => [ 'icon' => 'email', + 'iconClass' => 'material-symbols-outlined', 'controller' => 'message_templates', 'action' => 'index' ], + __d('controller', 'MostlyStaticPages', [99]) => [ + 'icon' => 'article', + 'iconClass' => 'material-symbols-outlined', + 'controller' => 'mostly_static_pages', + 'action' => 'index' + ], __d('controller', 'Pipelines', [99]) => [ 'icon' => 'cable', 'controller' => 'pipelines', @@ -114,6 +129,7 @@ public function configuration() { ], __d('controller', 'ProvisioningTargets', [99]) => [ 'icon' => 'cloud_upload', + 'iconClass' => 'material-symbols-outlined', 'controller' => 'provisioning_targets', 'action' => 'index' ], @@ -170,6 +186,7 @@ public function configuration() { $registryMenuItems = [ __d('controller', 'Groups', [99]) => [ 'icon' => 'people', + 'iconClass' => 'material-symbols-outlined', 'controller' => 'groups', 'action' => 'index' ], @@ -180,6 +197,7 @@ public function configuration() { ], __d('controller', 'Servers', [99]) => [ 'icon' => 'computer', + 'iconClass' => 'material-symbols-outlined', 'controller' => 'servers', 'action' => 'index' ] @@ -191,14 +209,22 @@ public function configuration() { $artifactMenuItems = [ __d('controller', 'ExtIdentitySourceRecords', [99]) => [ - 'icon' => 'assignment', + 'icon' => 'badge', + 'iconClass' => 'material-symbols-outlined', 'controller' => 'ext_identity_source_records', 'action' => 'index' ], __d('controller', 'Jobs', [99]) => [ - 'icon' => 'assignment', + 'icon' => 'work_history', + 'iconClass' => 'material-symbols-outlined', 'controller' => 'jobs', 'action' => 'index' + ], + __d('controller', 'Petitions', [99]) => [ + 'icon' => 'pending_actions', + 'iconClass' => 'material-symbols-outlined', + 'controller' => 'petitions', + 'action' => 'index' ] ]; diff --git a/app/src/Controller/EnrollmentFlowStepsController.php b/app/src/Controller/EnrollmentFlowStepsController.php new file mode 100644 index 000000000..866c9a599 --- /dev/null +++ b/app/src/Controller/EnrollmentFlowStepsController.php @@ -0,0 +1,66 @@ + [ + 'EnrollmentFlowSteps.ordr' => 'asc' + ] + ]; + + /** + * Callback run prior to the request render. + * + * @param EventInterface $event Cake Event + * + * @return Response|void + * @since COmanage Registry v5.0.0 + */ + + public function beforeRender(EventInterface $event) { + // Pull the Person name for breadcrumb rendering + + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->EnrollmentFlowSteps->EnrollmentFlows->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->EnrollmentFlowSteps->EnrollmentFlows->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->EnrollmentFlowSteps->EnrollmentFlows->getPrimaryKey()); + } + + return parent::beforeRender($event); + } +} \ No newline at end of file diff --git a/app/src/Controller/EnrollmentFlowsController.php b/app/src/Controller/EnrollmentFlowsController.php new file mode 100644 index 000000000..79f043fbe --- /dev/null +++ b/app/src/Controller/EnrollmentFlowsController.php @@ -0,0 +1,183 @@ + [ + 'EnrollmentFlows.name' => 'asc' + ] + ]; + + /** + * Calculate authorization for the current request. + * + * @since COmanage Registry v5.0.0 + * @return bool True if the current request is permitted, false otherwise + */ + + public function calculatePermission(): bool { + $request = $this->getRequest(); + $action = $request->getParam('action'); + + $authorized = false; + + // We should only get called for 'start', based on willHandleAuth(), below. + if($action == 'start') { + $actorInfo = $this->getCurrentActor(); + + // We need to pull the config to get the Petitioner Authorization mode + $params = $this->request->getParam('pass'); + + if(empty($params[0])) { + throw new \InvalidArgumentException(__d('error', 'notprov', 'enrollment_flow_id')); + } + + $flow = $this->EnrollmentFlows->get($params[0]); + + switch($flow->authz_type) { + case EnrollmentAuthzEnum::AuthUser: + $authorized = !empty($actorInfo['identifier']); + break; + case EnrollmentAuthzEnum::CoAdmin: + $authorized = $this->RegistryAuth->isCoAdmin($flow->co_id); + break; + case EnrollmentAuthzEnum::CoOrCouAdmin: +// XXX + break; + case EnrollmentAuthzEnum::CouAdmin: +// XXX + break; + case EnrollmentAuthzEnum::CouPerson: +// XXX + break; + case EnrollmentAuthzEnum::GroupMember: +// XXX + break; + case EnrollmentAuthzEnum::Person: +// XXX + break; + case EnrollmentAuthzEnum::None: +// XXX willHandleAuth needs to check for this mode and then return 'open' if set + $authorized = true; + break; + } + } + + return $authorized; + } + + /** + * Start an Enrollment flow. + * + * @since COmanage Registry v5.0.0 + * @param string $id Enrollment Flow ID + */ + + public function start(string $id) { + $flow = $this->EnrollmentFlows->get((int)$id); + +// XXX Is this an AR? + // By default, the Petitioner is the Enrollee if the Flow Authorization is not some + // sort of Admin. (This can be changed by a Plugin later, if appropriate.) + + $isEnrollee = in_array($flow->authz_type, [ + EnrollmentAuthzEnum::AuthUser, + EnrollmentAuthzEnum::CouPerson, + EnrollmentAuthzEnum::GroupMember, + EnrollmentAuthzEnum::Person, + EnrollmentAuthzEnum::None + ]); + + $actor = $this->getCurrentActor(); + + if($this->request->is(['post', 'put'])) { + // We should now have an enrollee email, so we can create the Pettion. + // Saving the entity should syntactically validate the email address. + + $petition = $this->EnrollmentFlows->Petitions->start( + enrollmentFlowId: (int)$id, + petitionerIdentifier: $actor['identifier'], + petitionerPersonId: $actor['person_id'], + isEnrollee: $isEnrollee, + enrolleeEmail: $this->request->getData('enrollee_email') + ); + + // No form to render, simply redirect to the next (ie: first) step + return $this->transitionToStep(petitionId: $petition->id, start: true); + } else { + if(isset($flow->collect_enrollee_email) && $flow->collect_enrollee_email) { + // We need to render a form, so we'll delay creating the petition + // until we come back from the form. Since there's no petition there's + // no meaningful information to pass through and back. + + // Get the title + // XXX We should have a "Title" for end-users that is different from the Enrollment Flow "Name" + // for start and dispatch. + $this->set('vv_title', $flow->name); + + } else { + // No form, so just allocate a new Petition and set appropriate metadata + + $petition = $this->EnrollmentFlows->Petitions->start( + enrollmentFlowId: (int)$id, + petitionerIdentifier: $actor['identifier'], + petitionerPersonId: $actor['person_id'], + isEnrollee: $isEnrollee + ); + + // No form to render, simply redirect to the next (ie: first) step + return $this->transitionToStep(petitionId: $petition->id, start: true); + } + } + } + + /** + * Indicate whether this Controller will handle some or all authnz. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Cake event, ie: from beforeFilter + * @return string "no", "open", "authz", or "yes" + */ + + public function willHandleAuth(\Cake\Event\EventInterface $event): string { + $request = $this->getRequest(); + $action = $request->getParam('action'); + + // We only need to take over authz for start + return ($action == 'start') ? 'authz' : 'no'; + } +} \ No newline at end of file diff --git a/app/src/Controller/ExternalIdentitiesController.php b/app/src/Controller/ExternalIdentitiesController.php index 441f526e3..e1ab7db68 100644 --- a/app/src/Controller/ExternalIdentitiesController.php +++ b/app/src/Controller/ExternalIdentitiesController.php @@ -29,9 +29,8 @@ namespace App\Controller; -// XXX not doing anything with Log yet -use Cake\Log\Log; -use Cake\ORM\TableRegistry; +use Cake\Event\EventInterface; +use Cake\Http\Response; // Use extend MVEAController for breadcrumb rendering. ExternalIdentities is // sort of an MVEA, so maybe it makes sense to treat it as such. @@ -45,4 +44,27 @@ class ExternalIdentitiesController extends MVEAController { 'Names.family' ] ]; + + /** + * Callback run prior to the request render. + * + * @param EventInterface $event Cake Event + * + * @return Response|void + * @since COmanage Registry v5.0.0 + */ + + public function beforeRender(EventInterface $event) { + // Pull the Person name for breadcrumb rendering + + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->ExternalIdentities->People->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->ExternalIdentities->People->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->ExternalIdentities->People->getPrimaryKey()); + } + + return parent::beforeRender($event); + } } \ No newline at end of file diff --git a/app/src/Controller/GroupMembersController.php b/app/src/Controller/GroupMembersController.php index b258d32db..6d7306a9a 100644 --- a/app/src/Controller/GroupMembersController.php +++ b/app/src/Controller/GroupMembersController.php @@ -58,6 +58,7 @@ public function beforeRender(EventInterface $event) { if(!empty($link->value)) { $this->set('vv_bc_parent_obj', $this->GroupMembers->Groups->get($link->value)); $this->set('vv_bc_parent_displayfield', $this->GroupMembers->Groups->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->GroupMembers->Groups->getPrimaryKey()); } return parent::beforeRender($event); diff --git a/app/src/Controller/GroupNestingsController.php b/app/src/Controller/GroupNestingsController.php index a42f86c2f..20b6d76bd 100644 --- a/app/src/Controller/GroupNestingsController.php +++ b/app/src/Controller/GroupNestingsController.php @@ -54,6 +54,7 @@ public function beforeRender(\Cake\Event\EventInterface $event) { if(!empty($link->value)) { $this->set('vv_bc_parent_obj', $this->GroupNestings->Groups->get($link->value)); $this->set('vv_bc_parent_displayfield', $this->GroupNestings->Groups->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->GroupNestings->Groups->getPrimaryKey()); } // We need to calculate the available set of groups for nesting. We do this diff --git a/app/src/Controller/JobHistoryRecordsController.php b/app/src/Controller/JobHistoryRecordsController.php index 66948a95d..95ed62b55 100644 --- a/app/src/Controller/JobHistoryRecordsController.php +++ b/app/src/Controller/JobHistoryRecordsController.php @@ -55,6 +55,7 @@ public function beforeRender(\Cake\Event\EventInterface $event) { if(!empty($link->value)) { $this->set('vv_bc_parent_obj', $this->JobHistoryRecords->Jobs->get($link->value)); $this->set('vv_bc_parent_displayfield', $this->JobHistoryRecords->Jobs->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->JobHistoryRecords->Jobs->getPrimaryKey()); } return parent::beforeRender($event); diff --git a/app/src/Controller/MostlyStaticPagesController.php b/app/src/Controller/MostlyStaticPagesController.php new file mode 100644 index 000000000..ad1ee8171 --- /dev/null +++ b/app/src/Controller/MostlyStaticPagesController.php @@ -0,0 +1,79 @@ + [ + 'MostlyStaticPages.title' => 'asc' + ] + ]; + + /** + * Callback run prior to the request render. + * + * @since COmanage Registry v5.1.0 + * @param EventInterface $event Cake Event + * @return \Cake\Http\Response HTTP Response + */ + + public function beforeRender(\Cake\Event\EventInterface $event) { + $this->set('vv_base_url', \Cake\Routing\Router::url( + url: "/" . $this->getCOID(), + full: true + )); + + return parent::beforeRender($event); + } + + /** + * Indicate whether this Controller will handle some or all authnz. + * + * @since COmanage Registry v5.1.0 + * @param EventInterface $event Cake event, ie: from beforeFilter + * @return string "no", "open", "authz", or "yes" + */ + + public function willHandleAuth(\Cake\Event\EventInterface $event): string { + $request = $this->getRequest(); + $action = $request->getParam('action'); + + // We only take over authz for display + + if(in_array($action, ['display'])) { + return 'open'; + } + + return 'no'; + } +} \ No newline at end of file diff --git a/app/src/Controller/PagesController.php b/app/src/Controller/PagesController.php index 709348c60..669a4100d 100644 --- a/app/src/Controller/PagesController.php +++ b/app/src/Controller/PagesController.php @@ -20,7 +20,9 @@ use Cake\Http\Exception\ForbiddenException; use Cake\Http\Exception\NotFoundException; use Cake\Http\Response; +use Cake\ORM\TableRegistry; use Cake\View\Exception\MissingTemplateException; +use \App\Lib\Enum\SuspendableStatusEnum; /** * Static content controller @@ -88,6 +90,51 @@ public function display(...$path): ?Response return $this->render(); } + /** + * Render a Mostly Static Page. + * + * @since COmanage Registry v5.1.0 + * @param string $coid CO ID + * @param string $name MSP Name (slug) + */ + + public function show(string $coid, string $name) { + // We use PagesController rather than MostlyStaticPagesController to avoid complexities + // with PrimaryLink lookups. We use show() rather than render() because the latter is + // used by Controller, and rather than display() so we don't confuse things by + // redefining it. We render here rather than redirecting into the MSPController to + // reduce URL bar thrashing. + + $MSPTable = TableRegistry::getTableLocator()->get("MostlyStaticPages"); + + $msp = $MSPTable->find() + ->where([ + 'co_id' => (int)$coid, + 'name' => $name, + 'status' => SuspendableStatusEnum::Active + ]) + ->first(); + + if(empty($msp)) { + if($name == 'error-landing') { + // error-landing should always exist, if not throw an error + + throw new NotFoundException(); + } else { + $this->Flash->error(__d('error', 'notfound', $name)); + + return $this->redirect("/$coid/error-landing"); + } + } + + $this->set('vv_bc_skip', true); // this doesn't do anything? + + $this->set('vv_title', $msp->title); + $this->set('vv_body', $msp->body); + + return $this->render('/MostlyStaticPages/display'); + } + /** * Indicate whether this Controller will handle some or all authnz. * @@ -97,6 +144,17 @@ public function display(...$path): ?Response */ public function willHandleAuth(\Cake\Event\EventInterface $event): string { - return "open"; + $request = $this->getRequest(); + $action = $request->getParam('action'); + + // We only take over authz for display and show + // (These are the only two actions we currently support, but better to require + // an explicit action to add to this list) + + if(in_array($action, ['display', 'show'])) { + return 'open'; + } + + return 'no'; } } diff --git a/app/src/Controller/PetitionsController.php b/app/src/Controller/PetitionsController.php new file mode 100644 index 000000000..974d63bcf --- /dev/null +++ b/app/src/Controller/PetitionsController.php @@ -0,0 +1,357 @@ + [ + 'Petitions.modified' => 'desc' + ] + ]; + + // Cached copy of the next step information + private $nextStep = null; + + /** + * Callback run prior to the request render. + * + * @param EventInterface $event Cake Event + * + * @return Response|void + * @since COmanage Registry v5.1.0 + */ + + public function beforeRender(EventInterface $event) { + $link = $this->getPrimaryLink(true); + + if(!empty($link->value)) { + $this->set('vv_bc_parent_obj', $this->Petitions->EnrollmentFlows->get($link->value)); + $this->set('vv_bc_parent_displayfield', $this->Petitions->EnrollmentFlows->getDisplayField()); + $this->set('vv_bc_parent_primarykey', $this->Petitions->EnrollmentFlows->getPrimaryKey()); + } + + return parent::beforeRender($event); + } + + /** + * Calculate authorization for the current request. + * + * @since COmanage Registry v5.1.0 + * @return bool True if the current request is permitted, false otherwise + */ + + public function calculatePermission(): bool { + $request = $this->getRequest(); + $action = $request->getParam('action'); + + $authorized = false; + + // We're currently only used for finalize + + if($action == 'finalize') { + // If we're using token auth, we checked the token in willHandleAuth(), + // so all we really need to do here is compare the actor roles (including + // for actors authenticated via the web server) against the role for the + // last step. + + // willHandleAuth() already checked that we have a valid Petition ID, and + // also set $this->nextStep + $currentActor = $this->getCurrentActor((int)$this->request->getParam('pass.0')); + + $authorized = in_array($this->nextStep['lastStep']->actor_type, $currentActor['roles']); + } + + return $authorized; + } + + /** + * Continue a Petition (re-enter an Enrollment Flow). + * + * @since COmanage Registry v5.1.0 + * @param string $id Petition ID + */ + + public function continue(string $id) { + return $this->transitionToStep((int)$id); + } + + /** + * Finalize a Petition. + * + * @since COmanage Registry v5.1.0 + * @param string $id Petition ID + */ + + public function finalize(string $id) { + try { + $this->Petitions->finalize((int)$id); + + $this->Flash->success(__d('result', 'Petitions.finalized')); + + // We only use the Redirect on Finalize URL (if specified) on success, + // since otherwise the Flash error won't render + + $petition = $this->Petitions->get((int)$id, ['contain' => ['EnrollmentFlows']]); + + if(!empty($petition->enrollment_flow->redirect_on_finalize)) { + return $this->redirect($petition->enrollment_flow->redirect_on_finalize); + } + } + catch(\Exception $e) { + $this->Flash->error($e->getMessage()); + } + + // Redirect to the default Petition Complete landing page. + + $coId = $this->getCOID(); + + return $this->redirect("/$coId/petition-complete"); + } + + /** + * Redirect into a plugin to render the result of an Enrollment Flow Step. + * + * @since COmanage Registry v5.1.0 + * @param string $id Petition ID + */ + + public function result(string $id) { + try { + $stepId = $this->getRequest()->getQuery('enrollment_flow_step_id'); + + if(!$stepId) { + throw new \InvalidArgumentException(__d('error', 'notprov', 'enrollment_flow_step_id')); + } + + // Start by pulling the petition + + $petition = $this->Petitions->get((int)$id); + + // And the Step Result and Configuration + + $stepResult = $this->Petitions + ->PetitionStepResults + ->find() + ->where([ + 'PetitionStepResults.enrollment_flow_step_id' => $stepId, + 'PetitionStepResults.petition_id' => $id + ]) + ->contain(['EnrollmentFlowSteps' => $this->Petitions->PetitionStepResults->EnrollmentFlowSteps->getPluginRelations()]) + ->firstOrFail(); + + // Redirect to /registry-pe/plugin/controller/display/x?petition_id=y + + $pluginEntity = Inflector::singularize(Inflector::underscore(StringUtilities::pluginModel($stepResult->enrollment_flow_step->plugin))); + + return $this->redirect([ + 'plugin' => StringUtilities::pluginPlugin($stepResult->enrollment_flow_step->plugin), + 'controller' => StringUtilities::pluginModel($stepResult->enrollment_flow_step->plugin), + 'action' => 'display', + $stepResult->enrollment_flow_step->$pluginEntity->id, + '?' => [ + 'petition_id' => $petition->id + ] + ]); + } + catch(\Exception $e) { + $this->Flash->error($e->getMessage()); + return $this->generateRedirect(null); + } + } + + /** + * Resume an Enrollment Flow. + * + * @since COmanage Registry v5.1.0 + * @param string $id Petition ID + */ + + public function resume(string $id) { + try { + // First retrieve the petition + $petition = $this->Petitions->get((int)$id); + + if($petition->isComplete()) { + // A number of checks should prevent us from having to test for this, + // but just in case... + throw new \InvalidArgumentException(__d('error', 'Petitions.completed', [$id])); + } + + $this->set('vv_petition', $petition); + + // We pull the Petition steps separately (instead of via contains) because + // we want to get all Enrollment Steps to render them + $steps = $this->Petitions->EnrollmentFlows->EnrollmentFlowSteps->find() + ->where(['EnrollmentFlowSteps.enrollment_flow_id' => $petition->enrollment_flow_id, + 'EnrollmentFlowSteps.status' => SuspendableStatusEnum::Active]) + ->contain(array_merge( + ['PetitionStepResults' => ['conditions' => ['PetitionStepResults.petition_id' => $petition->id]]], + $this->Petitions->EnrollmentFlows->EnrollmentFlowSteps->getPluginRelations() + )) + ->order(['EnrollmentFlowSteps.ordr']) + ->all(); + + $this->set('vv_steps', $steps); + + $urls = []; + $nextStepId = null; + + if(!empty($steps)) { + // We need to create dispatch URLs for each step _except_ anything after the + // current one. (ie: the first one with no result is OK, but not after.) + + foreach($steps as $step) { + $pluginModel = StringUtilities::pluginModel($step->plugin); + $pluginName = Inflector::singularize(Inflector::underscore($pluginModel)); + + $urls[ $step->id ] = [ + 'plugin' => StringUtilities::pluginPlugin($step->plugin), + 'controller' => StringUtilities::pluginModel($step->plugin), + 'action' => 'dispatch', + $step->$pluginName->id, + '?' => [ + 'petition_id' => $petition->id + ] + ]; + + // We might need to insert the token... + + if($petition->useToken($step->actor_type)) { + $urls[ $step->id ]['?']['token'] = $petition->token; + } + + if(!$nextStepId && empty($step->petition_step_results)) { + // There is no result for this step, and we haven't found a step + // without a result yet, so this is the next step + + $nextStepId = $step->id; + } + } + } + + $this->set('vv_dispatch_urls', $urls); + $this->set('vv_next_step_id', $nextStepId); + } + catch(\Exception $e) { + $this->Flash->error($e->getMessage()); + return $this->generateRedirect(null); + } + } + + /** + * Indicate whether this Controller will handle some or all authnz. + * + * @since COmanage Registry v5.1.0 + * @param EventInterface $event Cake event, ie: from beforeFilter + * @return string "no", "notauth", "open", "authz", or "yes" + */ + + public function willHandleAuth(\Cake\Event\EventInterface $event): string { + $request = $this->getRequest(); + $action = $request->getParam('action'); + + // We take over authz for continue (which is really just a glorified redirect, + // but which will send handoff emails under certain circumstances); and for + // finalize but only if the request will be authenticated via Petition Token. + + $petitionId = (int)$this->request->getParam('pass.0'); + + if(!in_array($action, ['continue', 'finalize'])) { + return 'no'; + } + + if(empty($petitionId)) { + $this->llog('error', "No Petition ID specified for finalize"); + return 'notauth'; + } + + if($action == 'continue') { + // For continue, we mostly just check that if the user type is anonymous + // that a token was provided and validates. + $actorInfo = $this->getCurrentActor($petitionId); + + if($actorInfo['type'] == 'anonymous') { + if(!$actorInfo['token_ok']) { + $this->llog('trace', "Token validation failed for Petition " . $petitionId); + return 'notauth'; + } + } + + // We'll allow any authenticated user through since continue is basically + // a redirect + return 'yes'; + } elseif($action == 'finalize') { + // For finalize, the relevant Step is the last one. We'll use calculateNextStep() + // to get the last Step, which will also check if the petition is already completed. + $this->nextStep = $this->Petitions->EnrollmentFlows->calculateNextStep($petitionId); + + if(!$this->nextStep['finalize']) { + // Petition is not ready for finalization + $this->llog('trace', "Petition " . $petitionId . " is not ready for finalization"); + return 'notauth'; + } + + if($this->nextStep['petition']->useToken($this->nextStep['lastStep']->actor_type)) { + // A token is required + + $tokenRoles = $this->validateToken($this->nextStep['petition']); + + if(!$tokenRoles) { + // Token validation failed + $this->llog('trace', "Token validation failed for Petition " . $petitionId); + return 'notauth'; + } + + // If we have a valid token, we need to call calculatePermission now + // since RegistryAuthComponent won't (when we return 'yes'). + + return $this->calculatePermission() ? 'yes' : 'notauth'; + } + + // Token not in use, we'll just handle authz + + return 'authz'; + } + + return 'no'; + } +} \ No newline at end of file diff --git a/app/src/Controller/StandardController.php b/app/src/Controller/StandardController.php index c6f2c3ef1..02340d666 100644 --- a/app/src/Controller/StandardController.php +++ b/app/src/Controller/StandardController.php @@ -420,9 +420,17 @@ public function edit(string $id) { // Calculate and set title, supertitle and subtitle [$title, $supertitle, $subtitle] = StringUtilities::entityAndActionToTitle($obj, $modelsName, 'edit'); - $this->set('vv_title', $title); - $this->set('vv_supertitle', $supertitle); - $this->set('vv_subtitle', $subtitle); + // We might have calculated the following values earlier. For example, MVEAController runs before the StandarController + // and makes similar calculations. We will keep the ones calculated before we get here + if ($this->viewBuilder()->getVar('vv_title') === null) { + $this->set('vv_title', $title); + } + if ($this->viewBuilder()->getVar('vv_supertitle') === null) { + $this->set('vv_supertitle', $supertitle); + } + if ($this->viewBuilder()->getVar('vv_subtitle') === null) { + $this->set('vv_subtitle', $subtitle); + } // Let the view render $this->render('/Standard/add-edit-view'); @@ -608,150 +616,13 @@ protected function populateAutoViewVars(object $obj=null) { $modelsName = $this->name; // $table = the actual table object $table = $this->$modelsName; - - // Populate certain view vars (eg: selects) automatically. - - // AutoViewVarsTrait - if(method_exists($table, "getAutoViewVars") - && $table->getAutoViewVars()) { - foreach($table->getAutoViewVars() as $vvar => $avv) { - switch($avv['type']) { - case 'array': - // Use the provided array of values. By default, we use the values - // for the keys as well, to generate HTML along the lines of - // . (See also 'hash'.) - $this->set($vvar, array_combine($avv['array'], $avv['array'])); - break; - case 'enum': - // We just want the localized text strings for the defined constants. - $class = '\\App\\Lib\\Enum\\'.$avv['class']; - // We support plugin notation for plugin defined enumerations. - if(strstr($avv['class'], ".")) { - $bits = explode('.', $avv['class'], 2); - $class = '\\'.$bits[0].'\\Lib\\Enum\\'.$bits[1]; - } - $this->set($vvar, $class::getLocalizedConsts()); - break; - case 'hash': - // Like 'array' but we assume we are passed key/value pairs - $this->set($vvar, $avv['hash']); - break; - // "auxiliary" and "select" do basically the same thing, but the former - // returns the full object and the latter just returns a hash suitable - // for a select. "type" is a shorthand for "select" for type_id. - case 'type': - // Inject configuration. Since we're only ever looking at the types - // table, inject the current CO along with the requested attribute - $avv['model'] = 'Types'; - 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? - case 'select': - $avvmodel = $avv['model']; - $this->$avvmodel = TableRegistry::getTableLocator()->get($avvmodel); - // XXX We should probably move to a more generic approach. - // Models can have various types of parent keys (and sometimes multiple concurrently), - // so it’s better to use PrimaryLinkTrait to handle this. - // if(method_exists($this->$avvmodel, "calculateCoForRecord")) { - // $avv['where']['co_id'] = $this->$avvmodel->calculateCoForRecord($obj) - // } - if($this->$avvmodel->getSchema()->hasColumn('co_id')) { - $avv['where']['co_id'] = $this->getCOID(); - } - $query = $this->$avvmodel->find($avv['type'] == 'auxiliary' ? 'all' : 'list'); - - if(!empty($avv['find'])) { - if($avv['find'] == 'filterPrimaryLink') { - // We're filtering the requested model, not our current model. - // See if the requested key is available, and if so run the find. - - $linkFilter = $table->getPrimaryLink(); - - if($linkFilter) { - // Try to find the $linkFilter value - $v = null; - - // We might have been passed an object with the current value - if($obj && !empty($obj->$linkFilter)) { - $v = $obj->$linkFilter; - } elseif(!empty($this->request->getQuery($linkFilter))) { - $v = $this->request->getQuery($linkFilter); - } -// XXX also need to check getData()? -// XXX shouldn't this use $this->getPrimaryLink() instead? Or maybe move $this->primaryLink -// to PrimaryLinkTrait and call it there? - - if($v) { - $avv['where'][$table->getAlias().'.'.$linkFilter] = $v; - //$query = $query->where([$table->getAlias().'.'.$linkFilter => $v]); - } - } - } else { - // Use the specified finder, if configured - $query = $query->find($avv['find']); - } - } elseif($table->getSchema()->hasColumn('co_id')) { - // XXX is this the best logic? maybe some relation to filterPrimaryLink? - // By default, filter everything on CO ID - $avv['where']['co_id'] = $this->getCOID(); - //$query = $query->where([$table->getAlias().'.co_id' => $this->getCOID()]); - } - - // Where Rule. The rule will be transfered as is - if(!empty($avv['where'])) { - // Filter on the specified clause (of the form [column=>value]) - $query = $query->where($avv['where']); - } - - // Where rule that will be evaluated. We use the custom whereEvan key to - // distinguish from the plain where. Also it might contain more than one conditions - if(!empty($avv['whereEval'])) { - foreach ($avv['whereEval'] as $whereClauseColumn => $chainedMethodDescription) { - $calculatedValue = FunctionUtilities::dynamicChainedFunction( - $this, - $chainedMethodDescription - ); - $query = $query->where([$whereClauseColumn => $calculatedValue]); - } - } - - // Sort the list by display field - if(!empty($avv['model']) && method_exists($this->$avvmodel, "getDisplayField")) { - $query->order([$this->$avvmodel->getDisplayField() => 'ASC']); - } elseif(method_exists($table, "getDisplayField")) { - $query->order([$table->getDisplayField() => 'ASC']); - } - - $this->set($vvar, $query->toArray()); - break; - case 'parent': - $modelsName = $this->name; - // $table = the actual table object - $table = $this->$modelsName; - $this->set($vvar, $table->getParents($this->getCOID())); - break; - case 'plugin': - $PluginTable = $this->getTableLocator()->get('Plugins'); - $this->set($vvar, $PluginTable->getActivePluginModels($avv['pluginType'])); - break; - default: -// XXX I18n? and in match? - throw new \LogicException(__d('error', 'auto.viewvar.type.unknown', [$avv['type']])); - } + // AutoViewVarsTrait + if(method_exists($table, 'getAutoViewVars') && $table->getAutoViewVars()) { + foreach ($table->calculateAutoViewVars($this->getCOID(), $obj) as $vvar => $value) { + $this->set($vvar, $value); } - } + } } /** diff --git a/app/src/Controller/StandardEnrollerController.php b/app/src/Controller/StandardEnrollerController.php new file mode 100644 index 000000000..e320eb39f --- /dev/null +++ b/app/src/Controller/StandardEnrollerController.php @@ -0,0 +1,270 @@ +get('Petitions'); + // Make the Petition available to the view + $this->set( + 'vv_petition', + $this->petition?->id ? + $Petition->findById($this->petition->id) + // We need to include the Enrollment Flow of the Petition. + // The least, we can get if the co id which cannot be calculated + // for unauthenticated use cases. + ->contain(['EnrollmentFlows']) + ->firstOrFail() : null + ); + + return parent::beforeRender($event); + } + + /** + * Calculate authorization for the current request. + * + * @since COmanage Registry v5.0.0 + * @return bool True if the current request is permitted, false otherwise + */ + + public function calculatePermission(): bool { + $request = $this->getRequest(); + $action = $request->getParam('action'); + + // We currently support $actions of 'dispatch' and 'display' + + $petitionId = $this->requestParam('petition_id'); + + if(!$petitionId) { + $this->llog('error', "petition_id not found in request"); + return false; + } + + $actorInfo = $this->getCurrentActor((int)$petitionId); + $this->petition = $actorInfo['petition']; + + // We only accept anonymous requests for 'dispatch', and only if the token matches. + // We'll further check authorization below. + if($actorInfo['type'] == 'anonymous') { + if($action != 'dispatch') { + $this->llog('trace', "Rejecting anonymous access to unsupported enroller action for petition " . $petitionId); + return false; + } + + // We do the token check here rather than in willHandleAuth() because + // we don't know in willHandleAuth which auth metchanism is in use yet. + + if(!isset($actorInfo['token_ok']) || !$actorInfo['token_ok']) { + $this->llog('trace', "Rejecting incorrect token for access to petition " . $petitionId); + return false; + } + } + + if($action == 'dispatch') { + // We already validated the petition state in willHandleAuth + + $modelsName = $this->name; + $modelId = $this->request->getParam('pass.0'); // XXX check if empty + + if(!$modelId) { + $this->llog('error', "Model ID missing from request"); + return false; + } + + $stepConfig = $this->$modelsName->get($modelId, ['contain' => ['EnrollmentFlowSteps' => ['EnrollmentFlows']]]); + $this->set('vv_step_config', $stepConfig); + $this->set('vv_title', $stepConfig['enrollment_flow_step']['enrollment_flow']['name']); + + // Check that the current actor has the role required for this step. + // Note that role validation has already been performed for anonymous access + // via tokens (via getcurrentActor) so we don't have to recheck that here. + + if(in_array($stepConfig->enrollment_flow_step->actor_type, + $actorInfo['roles'])) { + $this->llog('trace', "Authorizing access to petition " . $petitionId . " step " . $stepConfig->enrollment_flow_step_id); + return true; + } + } elseif($action == 'display') { +// XXX need to replace this with better logic + return true; + } + + $this->llog('trace', "Rejecting unauthorized access to petition " . $petitionId . " step " . $stepConfig->enrollment_flow_step_id); + return false; + } + + /** + * Record a result for the Enrollment Step and redirect to the next Step. + * + * @since COmanage Registry v5.0.0 + * @param int $enrollmentFlowStepId Enrollment Flow Step Id + * @param int $petitionId Petition ID + * @param string $status PetitionStatusEnum + * @param string $comment Comment + * @return \Cake\Http\Response Redirect to next step + */ + + protected function finishStep( + int $enrollmentFlowStepId, + int $petitionId, + // string $status, + string $comment + ): \Cake\Http\Response { + $PetitionStepResults = TableRegistry::getTableLocator()->get('PetitionStepResults'); + + $PetitionStepResults->record( + enrollmentFlowStepId: $enrollmentFlowStepId, + petitionId: $petitionId, + // status: $status, + comment: $comment + ); + + $EnrollmentFlows = TableRegistry::getTableLocator()->get('EnrollmentFlows'); + + return $this->transitionToStep(petitionId: $petitionId); + } + + /** + * Obtain the Petition artifact associated with this request. + * + * @since COmanage Registry v5.0.0 + * @return Petition Petition artifact + */ + + public function getPetition(): ?\App\Model\Entity\Petition { + return $this->petition; + } + + /** + * Indicate whether this Controller will handle some or all authnz. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Cake event, ie: from beforeFilter + * @return string "no", "open", "authz", or "yes" + */ + + public function willHandleAuth(\Cake\Event\EventInterface $event): string { + $request = $this->getRequest(); + $action = $request->getParam('action'); + + if($action == 'dispatch') { + $petitionId = (int)$this->requestParam('petition_id'); + + if(empty($petitionId)) { + $this->llog('error', "No Petition ID specified for dispatch"); + return 'notauth'; + } + + // Determine if we're going to use a token to authenticate the current request. + // For this, we need the current step's authorization. + + // $this->name = Models (ie: from ModelsTable) + $modelsName = $this->name; + $modelId = $this->request->getParam('pass.0'); + + if(empty($modelId)) { + $this->llog('error', "No step ID specified for dispatch"); + return 'noauth'; + } + + $stepConfig = $this->$modelsName->get($modelId, ['contain' => 'EnrollmentFlowSteps']); + + // Determine if the requested step is past the current/next step. + // We don't allow steps that haven't run yet to be run out of order. + + $EnrollmentFlows = TableRegistry::getTableLocator()->get('EnrollmentFlows'); + + // "next" means "uncompleted step with the lowest ordr value". + // calculateNextStep() will also throw an error if the Petition is complete. + $nextStep = $EnrollmentFlows->calculateNextStep($petitionId); + + if(!empty($nextStep['step']->id)) { + if($stepConfig->enrollment_flow_step->ordr > $nextStep['step']->ordr) { + $this->llog('trace', "Requested step " . $stepConfig->enrollment_flow_step->enrollment_flow_id . " for petition " . $petitionId . " has not yet been reached"); + return 'notauth'; + } + } + + $petition = $nextStep['petition']; + + if($petition->enrollment_flow_id + != $stepConfig->enrollment_flow_step->enrollment_flow_id) { + // Mismatch between Petition Enrollment Flow and requested Step's Enrollment Flow + $this->llog('trace', "Requested step " . $stepConfig->enrollment_flow_step->enrollment_flow_id . " and requested petition " . $petitionId . " are not associated with the same Enrollment Flow"); + return 'notauth'; + } + + if($petition->useToken($stepConfig->enrollment_flow_step->actor_type)) { + // A token is required + + $tokenRoles = $this->validateToken($petition); + + if(!$tokenRoles) { + // Token validation failed + $this->llog('trace', "Token validation failed for Petition " . $petitionId); + return 'notauth'; + } + + // If we have a valid token, we need to call calculatePermission now + // since RegistryAuthComponent won't (when we return 'yes'). + + return $this->calculatePermission() ? 'yes' : 'notauth'; + } + + // Token not in use, we'll just handle authz + + return 'authz'; + } elseif($action == 'display') { + return 'authz'; + } + + return 'no'; + } +} \ No newline at end of file diff --git a/app/src/Controller/StandardPluginController.php b/app/src/Controller/StandardPluginController.php index 3b132fd14..53f96cc35 100644 --- a/app/src/Controller/StandardPluginController.php +++ b/app/src/Controller/StandardPluginController.php @@ -96,6 +96,7 @@ public function beforeRender(\Cake\Event\EventInterface $event) { $this->set('vv_bc_parent_obj', $parentObj); $this->set('vv_bc_parent_displayfield', $parentDisplayField); + $this->set('vv_bc_parent_primarykey', $parentTable->getPrimaryKey()); // Override the title set in StandardController. Since that was set in edit() // which is called before the rendering hooks, this title will take precedence. diff --git a/app/src/Lib/Enum/ActionEnum.php b/app/src/Lib/Enum/ActionEnum.php index d13f76c21..c7752ef70 100644 --- a/app/src/Lib/Enum/ActionEnum.php +++ b/app/src/Lib/Enum/ActionEnum.php @@ -51,6 +51,7 @@ class ActionEnum extends StandardEnum { const NotificationCanceled = 'NOTX'; const NotificationDelivered = 'NOTD'; const NotificationResolved = 'NOTR'; + const PersonAddedPetition = 'ACPP'; const PersonAddedPipeline = 'ACPL'; const PersonMatchedPipeline = 'MCPL'; const PersonPipelineComplete = 'CCPL'; diff --git a/app/templates/Names/fields-nav.inc b/app/src/Lib/Enum/EnrollmentActorEnum.php similarity index 80% rename from app/templates/Names/fields-nav.inc rename to app/src/Lib/Enum/EnrollmentActorEnum.php index 955afd929..74665821a 100644 --- a/app/templates/Names/fields-nav.inc +++ b/app/src/Lib/Enum/EnrollmentActorEnum.php @@ -1,6 +1,6 @@ 'person', - 'active' => 'person', - 'subActive' => 'names' -]; \ No newline at end of file +namespace App\Lib\Enum; + +class EnrollmentActorEnum extends StandardEnum { + const Approver = 'A'; + const Enrollee = 'E'; + const Petitioner = 'P'; +} \ No newline at end of file diff --git a/app/src/Lib/Enum/EnrollmentAuthzEnum.php b/app/src/Lib/Enum/EnrollmentAuthzEnum.php new file mode 100644 index 000000000..f44e025a9 --- /dev/null +++ b/app/src/Lib/Enum/EnrollmentAuthzEnum.php @@ -0,0 +1,43 @@ + 'person', - 'active' => 'person', - 'subActive' => 'pronouns' -]; \ No newline at end of file +namespace App\Lib\Enum; + +class PageContextEnum extends StandardEnum { + const EnrollmentHandoff = 'EH'; + const ErrorLanding = 'ER'; + const General = 'G'; +} \ No newline at end of file diff --git a/app/templates/AdHocAttributes/fields-nav.inc b/app/src/Lib/Enum/PetitionActionEnum.php similarity index 78% rename from app/templates/AdHocAttributes/fields-nav.inc rename to app/src/Lib/Enum/PetitionActionEnum.php index 9b42482f4..89a59122d 100644 --- a/app/templates/AdHocAttributes/fields-nav.inc +++ b/app/src/Lib/Enum/PetitionActionEnum.php @@ -1,6 +1,6 @@ 'person', - 'active' => 'person', - 'subActive' => 'ad_hoc_attributes' -]; \ No newline at end of file +namespace App\Lib\Enum; + +class PetitionActionEnum extends StandardEnum { + const AttributesUpdated = 'AU'; + const Finalized = 'F'; + const InvitationViewed = 'IV'; + const StatusUpdated = 'SU'; +} \ No newline at end of file diff --git a/app/src/Lib/Enum/PetitionStatusEnum.php b/app/src/Lib/Enum/PetitionStatusEnum.php new file mode 100644 index 000000000..6d7e08662 --- /dev/null +++ b/app/src/Lib/Enum/PetitionStatusEnum.php @@ -0,0 +1,46 @@ +autoViewVars = $vars; } + + /** + * Calculate the AutoView Vars + * + * @param int $coId + * @param Object|null $obj Current object (eg: from edit), if set + * + * @return \Generator + * @since COmanage Registry v5.0.0 + */ + public function calculateAutoViewVars(int $coId, Object $obj = null): \Generator + { + // $table = the actual table object + $table = $this; + + foreach($table->getAutoViewVars() as $vvar => $avv) { + $generatedValue = null; + + switch($avv['type']) { + case 'array': + // Use the provided array of values. By default, we use the values + // for the keys as well, to generate HTML along the lines of + // . (See also 'hash'.) + $generatedValue = array_combine($avv['array'], $avv['array']); + break; + case 'enum': + // We just want the localized text strings for the defined constants. + $class = '\\App\\Lib\\Enum\\'.$avv['class']; + // We support plugin notation for plugin defined enumerations. + if(strstr($avv['class'], ".")) { + $bits = explode('.', $avv['class'], 2); + $class = '\\'.$bits[0].'\\Lib\\Enum\\'.$bits[1]; + } + + $generatedValue = $class::getLocalizedConsts(); + break; + case 'hash': + // Like 'array' but we assume we are passed key/value pairs + $generatedValue = $avv['hash']; + break; + // "auxiliary" and "select" do basically the same thing, but the former + // returns the full object and the latter just returns a hash suitable + // for a select. "type" is a shorthand for "select" for type_id. + case 'type': + // Inject configuration. Since we're only ever looking at the types + // table, inject the current CO along with the requested attribute + $avv['model'] = 'Types'; + 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? + case 'select': + $avvmodel = $avv['model']; + $this->$avvmodel = TableRegistry::getTableLocator()->get($avvmodel); + // XXX We should probably move to a more generic approach. + // Models can have various types of parent keys (and sometimes multiple concurrently), + // so it’s better to use PrimaryLinkTrait to handle this. + // if(method_exists($this->$avvmodel, "calculateCoForRecord")) { + // $avv['where']['co_id'] = $this->$avvmodel->calculateCoForRecord($obj) + // } + if($this->$avvmodel->getSchema()->hasColumn('co_id')) { + $avv['where']['co_id'] = $coId; + } + + $query = $this->$avvmodel->find($avv['type'] == 'auxiliary' ? 'all' : 'list'); + + if(!empty($avv['find'])) { + if($avv['find'] == 'filterPrimaryLink') { + // We're filtering the requested model, not our current model. + // See if the requested key is available, and if so run the find. + + $linkFilter = $table->getPrimaryLink(); + + if($linkFilter) { + // Try to find the $linkFilter value + $v = null; + + // We might have been passed an object with the current value + if($obj && !empty($obj->$linkFilter)) { + $v = $obj->$linkFilter; + } elseif(!empty($this->request->getQuery($linkFilter))) { + $v = $this->request->getQuery($linkFilter); + } +// XXX also need to check getData()? +// XXX shouldn't this use $this->getPrimaryLink() instead? Or maybe move $this->primaryLink +// to PrimaryLinkTrait and call it there? + + if($v) { + $avv['where'][$table->getAlias().'.'.$linkFilter] = $v; + //$query = $query->where([$table->getAlias().'.'.$linkFilter => $v]); + } + } + } else { + // Use the specified finder, if configured + $query = $query->find($avv['find']); + } + } elseif($table->getSchema()->hasColumn('co_id')) { + // XXX is this the best logic? maybe some relation to filterPrimaryLink? + // By default, filter everything on CO ID + $avv['where']['co_id'] = $coId; + //$query = $query->where([$table->getAlias().'.co_id' => $coId]); + } + + // Where Rule. The rule will be transfered as is + if(!empty($avv['where'])) { + // Filter on the specified clause (of the form [column=>value]) + $query = $query->where($avv['where']); + } + + // Where rule that will be evaluated. We use the custom whereEvan key to + // distinguish from the plain where. Also it might contain more than one conditions + if(!empty($avv['whereEval'])) { + foreach ($avv['whereEval'] as $whereClauseColumn => $chainedMethodDescription) { + $calculatedValue = FunctionUtilities::dynamicChainedFunction( + $this, + $chainedMethodDescription + ); + $query = $query->where([$whereClauseColumn => $calculatedValue]); + } + } + + // Sort the list by display field + if(!empty($avv['model']) && method_exists($this->$avvmodel, "getDisplayField")) { + $query->order([$this->$avvmodel->getDisplayField() => 'ASC']); + } elseif(method_exists($table, "getDisplayField")) { + $query->order([$table->getDisplayField() => 'ASC']); + } + + $generatedValue = $query->toArray(); + break; + case 'parent': + $generatedValue = $table->getParents($coId); + break; + case 'plugin': + $PluginTable = TableRegistry::getTableLocator()->get('Plugins'); + $generatedValue = $PluginTable->getActivePluginModels($avv['pluginType']); + break; + default: +// XXX I18n? and in match? + throw new \LogicException(__d('error', 'auto.viewvar.type.unknown', [$avv['type']])); + } + + yield $vvar => $generatedValue; + } + } } diff --git a/app/src/Lib/Traits/EnrollmentControllerTrait.php b/app/src/Lib/Traits/EnrollmentControllerTrait.php new file mode 100644 index 000000000..27aae0f1e --- /dev/null +++ b/app/src/Lib/Traits/EnrollmentControllerTrait.php @@ -0,0 +1,340 @@ +cache['actor'])) { + return $this->cache['actor']; + } + + $ret = [ + 'type' => 'anonymous', + 'person_id' => null, + 'identifier' => $this->RegistryAuth->getAuthenticatedUser(), + 'token_ok' => false, + 'roles' => [], + 'petition' => null + ]; + + if(!empty($ret['identifier'])) { + // Can we map this identifier to a Person ID? + + $Identifiers = TableRegistry::getTableLocator()->get('Identifiers'); + + try { + $ret['person_id'] = $Identifiers->lookupPersonByLogin($this->getCOID(), $ret['identifier']); + } catch(RecordNotFoundException $e) { + $ret['person_id'] = null; + } + + if(!empty($ret['person_id'])) { + $ret['type'] = 'person'; + } else { + $ret['type'] = 'identifier'; + } + } + + if($petitionId) { + // Pull the Petition to figure out what roles the person has + + $Petitions = TableRegistry::getTableLocator()->get('Petitions'); + + $petition = $Petitions->get($petitionId); + + if($ret['type'] == 'person' && !empty($ret['person_id'])) { + // A person can be both Petitioner and Enrollee, so check both + + if($ret['person_id'] === $petition->petitioner_person_id) { + $ret['roles'][] = EnrollmentActorEnum::Petitioner; + } + + if($ret['person_id'] === $petition->enrollee_person_id) { + $ret['roles'][] = EnrollmentActorEnum::Enrollee; + } + } elseif($ret['type'] == 'identifier' && !empty($ret['identifier'])) { + if($ret['identifier'] === $petition->petitioner_identifier) { + $ret['roles'][] = EnrollmentActorEnum::Petitioner; + } + + if($ret['identifier'] === $petition->enrollee_identifier) { + $ret['roles'][] = EnrollmentActorEnum::Enrollee; + } + + if(empty($petition->petitioner_identifier) + && empty($petition->enrollee_identifier)) { + // We have an identifier at run time but none in the petition. + // If we can validate a token we can store the identifier and + // use it instead. (eg: An Enrollee receives an initial handoff + // email/invitation.) + + // Note in general we should only accept an Enrollee identifier + // this way. Petitioner identifiers should be collected at Petition + // start, and Approvers shouldn't use tokens. + + $tokenRoles = $this->validateToken($petition); + + if(!empty($tokenRoles) && in_array(EnrollmentActorEnum::Enrollee, $tokenRoles)) { + $this->llog('trace', "Transitioning Enrollee to authenticated identifier " + . $ret['identifier'] . " for Petition " . $petition->id); + + // Update the Petition to store the identifier and remove the token + $petition->enrollee_identifier = $ret['identifier']; + $petition->token = null; + +// XXX Also add petition history? + $Petitions->saveOrFail($petition); + + $ret['roles'][] = EnrollmentActorEnum::Enrollee; + } + } + } elseif($ret['type'] == 'anonymous') { + $ret['roles'] = $this->validateToken($petition); + + if($ret['roles'] !== false) { + $ret['token_ok'] = true; + + // Tell the form generator (dispatch.php) to use the token + $this->set('vv_token_ok', true); + } + } + + // XXX need to add checks for Approver somehow; a Petitioner can also be an Approver + // though probably not commonly + + // Since we have the Petition entity, make it easier for other functions + // to access it + $ret['petition'] = $petition; + } + + if($petitionId) { + // If we have a Petition ID we cache the info. We don't cache without one + // since that is how the EnrollmentFlowsControlller::start() calls us, and + // no role data is available at that point. + + $this->cache['actor'] = $ret; + } + + $this->llog('trace', (!empty($ret['petition']->id) ? "Petition " . $ret['petition']->id : "New Petition") + . " current actor: type=" . $ret['type'] + . ", personid=" . $ret['person_id'] + . ", identifier=" . $ret['identifier'] + . ", token=" . $ret['token_ok']); + + return $ret; + } + + /** + * Transition to an Enrollment Flow Step. Typically this will be the next step, + * but this also permits re-entering a flow. + * + * @since COmanage Registry v5.1.0 + * @param int $petitionId Petition ID + * @param bool $start True if transitioning from start + * @throws Cake\Network\Exception\SocketException On SMTP error + * @throws RuntimeException If no SMTP server configured + */ + + protected function transitionToStep(int $petitionId, bool $start=false) { + $EnrollmentFlows = TableRegistry::getTableLocator()->get('EnrollmentFlows'); + + $stepInfo = $EnrollmentFlows->calculateNextStep($petitionId); + $petition = $stepInfo['petition']; + + $coId = $EnrollmentFlows->findCoForRecord($petition->enrollment_flow_id); + +/* no need to to this, we don't cache on start() + if($start) { + // $actorInfo was cached before the Petition was created, so force it to reload + unset($this->cache['actor']); + }*/ + + $actorInfo = $this->getCurrentActor($petitionId); + + // Before we process the handoff, give the plugin an opportunity to run any + // preparatory steps. We don't specifically support errors here, ie: if a plugin + // throws an Exception we let it bubble up because it's not really clear what we + // should do if a plugin fails. + + // (If this is the last step, 'step' will be null, and there's no prepare() to call.) + + if(!empty($stepInfo['step'])) { + $EnrollmentFlows->EnrollmentFlowSteps->prepare($stepInfo['step'], $petition); + } + + // We need to compare the current actor type with the actor type configured for the + // next step. If they are the same, we can simply redirect. If they are different, + // we need to hand off via a notification, and then redirect the current actor to + // a generic landing page. + + // Note we perform basically the same logic for finalize as regular steps, + // except we need to explicitly set $nextActorType to the current actor type. + // In particular, if there is a token we need to insert it for finalize as well. + + $nextActorType = null; + + if($stepInfo['finalize']) { + // Authorization for finalize is the same as the last step configured to run. + $nextActorType = $stepInfo['lastStep']->actor_type; + } else { + $nextActorType = $stepInfo['step']->actor_type; + } + + if(in_array($nextActorType, $actorInfo['roles'])) { + // The current actor is eligible to perform the next step, so simply redirect. + // Note we will need to re-insert the token if currently in use. + + if($petition->useToken($nextActorType)) { + $stepInfo['url']['?']['token'] = $this->requestParam('token'); + } + + return $this->redirect($stepInfo['url']); + } else { + // We need to hand off. We do this by creating a Notification for the recipient + // (or recipient group) and then redirect to a landing page. + + // The target URL can be used as is if we have a person_id or identifier + // for the appropriate role. If not, we need to append the petition token. + // Note that we only permit a single non-authenticated email address since + // we don't support different anonymous petitioners and enrollees. + + if($petition->useToken($nextActorType)) { + // We only have an enrollee_email field to use since either the petitioner _is_ + // the enrollee (in which case that address is sufficient, eg: self signup), + // or they are not the same person, in which case the petition _must_ be + // authenticated (eg: an admin). + + $token = $EnrollmentFlows->Petitions->getToken($petitionId); + + // For simplicity, we just inject the continue URL into the message. + $entryUrl = [ + 'controller' => 'petitions', + 'action' => 'continue', + $petition->id, + '?' => [ + 'token' => $token //$this->requestParam('token') + ] + ]; + + // Message Templates handle substitutions, so if none is configured it's an error + if(empty($stepInfo['step']->message_template_id)) { + throw new \RuntimeException(__d('error', 'EnrollmentFlowSteps.message_template', [ $stepInfo['step']->id ])); + } + + $MessageTemplates = TableRegistry::getTableLocator()->get('MessageTemplates'); + + // Perform substitutions + + $msg = $MessageTemplates->generateMessage( + id: $stepInfo['step']->message_template_id, + entryUrl: $entryUrl, + ); + + // Send the message. sendEmailToAddress will throw an Exception if SMTP failed, + // but if there is no SMTP server configured we'll just get false back. + + if(!DeliveryUtilities::sendEmailToAddress( + coId: $coId, + recipient: $petition->enrollee_email, + subject: $msg['subject'], + body_text: $msg['body_text'], + body_html: $msg['body_html'] + )) { + throw new \RuntimeException("Message delivery failed"); // XXX I18n. can we get an exception from sendEmailToAddress instead? + } + } else { + // XXX Register a notification or send an email or whatever + // (once notification infrastructure is available) + +debug("Handing off to actor type " . $nextActorType . " would send a notitication to visit " + . \Cake\Routing\Router::url(url: $stepInfo['url'], full: true)); + } + + // Redirect to a landing page indicating that no further action is required at this time + if(!empty($stepInfo['step']->redirect_on_handoff)) { + // Use the step specific handoff URL + return $this->redirect($stepInfo['step']->redirect_on_handoff); + } else { + // Redirect to the default Enrollment Handoff URL for this CO + return $this->redirect("/$coId/default-handoff"); + } + } + } + + /** + * Validate a token associated with the requested petition. + * + * @since COmanage Registry v5.1.0 + * @param Petition $petition Petition entity + * @return array|bool Roles associated with the token, or false on token error + */ + + protected function validateToken(Petition $petition): array|bool { + $reqToken = $this->requestParam('token'); + + if(!empty($petition->token) && ($reqToken == $petition->token)) { + // Token match. The roles are whichever of petitioner and enrollee + // _don't_ have a Petition value. + + $roles = []; + + if(empty($petition->petitioner_identifier) + && empty($petition->petitioner_person_id)) { + $roles[] = EnrollmentActorEnum::Petitioner; + } + + if(empty($petition->enrollee_identifier) + && empty($petition->enrollee_person_id)) { + $roles[] = EnrollmentActorEnum::Enrollee; + } + + return $roles; + } + + return false; + } +} \ No newline at end of file diff --git a/app/src/Lib/Traits/LayoutTrait.php b/app/src/Lib/Traits/LayoutTrait.php new file mode 100644 index 000000000..b178b5076 --- /dev/null +++ b/app/src/Lib/Traits/LayoutTrait.php @@ -0,0 +1,74 @@ +layout)) { + return $this->layout[$action]; + } + + return match($action) { + 'add', + 'view', + 'edit' => 'iframe', + default => 'default' + }; + } + + /** + * Set the layout variable + * + * @param array $layout + * + * @return void + * @since COmanage Registry v5.0.0 + */ + public function setLayout(array $layout): void + { + $this->layout = $layout; + } +} \ No newline at end of file diff --git a/app/src/Lib/Traits/SearchFilterTrait.php b/app/src/Lib/Traits/SearchFilterTrait.php index eeaec4665..42917277e 100644 --- a/app/src/Lib/Traits/SearchFilterTrait.php +++ b/app/src/Lib/Traits/SearchFilterTrait.php @@ -207,9 +207,10 @@ public function expressionsConstructor(Query $query, QueryExpression $exp, strin // Use the `lower` function to apply uniformity for the search 'string' => $exp->like($query->func()->lower([$attributeWithModelPrefix => 'identifier']), strtolower('%' . $search . '%')), - 'integer', + 'select', // AutoviewVar type + 'parent', // AutoviewVar type 'boolean', - 'parent' => $exp->add([$attributeWithModelPrefix => $search]), + 'integer' => $exp->add([$attributeWithModelPrefix => $search]), 'date' => $exp->add([$attributeWithModelPrefix => FrozenTime::parseDate($search, 'y-M-d')]), 'timestamp' => $this->constructDateComparisonClause($exp, $attributeWithModelPrefix, $search), default => $exp->eq($query->func()->lower([$attributeWithModelPrefix => 'identifier']), @@ -238,6 +239,12 @@ public function getSearchableAttributes(string $controller, string $vv_tz=null): $modelname = Inflector::classify(Inflector::underscore($controller)); $filterConfig = $this->getFilterConfig(); + // We get the filter keys and we will force include the fields that we + // have excluded in the filterMetadataFields() method. This way we have a + // method to exclude a field globally but then force its usage when needed through + // configuration + $filterKeys = array_keys($filterConfig); + // Gather up related models defined in the $filterConfig // XXX For now, we'll list these first - but we should probably provide a better way to order these. foreach ($filterConfig as $field => $f) { @@ -274,9 +281,19 @@ public function getSearchableAttributes(string $controller, string $vv_tz=null): ]; } - foreach ($this->filterMetadataFields() as $column => $type) { + // Include meta fields that are defined in the configuration + // FORCE USAGE + $filterMetadatFielsList = $this->filterMetadataFields(); + foreach ($filterKeys as $key) { + if (isset($filterMetadatFielsList['meta'][$key])) { + $filterMetadatFielsList[$key] = $filterMetadatFielsList['meta'][$key]; + } + + } + + foreach ($filterMetadatFielsList as $column => $type) { // If the column is an array, then we are accessing the Metadata fields. Skip - if(is_array($type)) { + if(\is_array($type)) { continue; } diff --git a/app/src/Lib/Traits/TabTrait.php b/app/src/Lib/Traits/TabTrait.php new file mode 100644 index 000000000..66f029f25 --- /dev/null +++ b/app/src/Lib/Traits/TabTrait.php @@ -0,0 +1,71 @@ +tabsConfig[$action])) { + return $this->tabsConfig[$action]; + } + + return $this->tabsConfig; + } + + /** + * @param array $tabsConfig + * + * @return void + * @since COmanage Registry v5.0.0 + */ + public function setTabsConfig(array $tabsConfig): void + { + $this->tabsConfig = $tabsConfig; + } + +} \ No newline at end of file diff --git a/app/src/Lib/Traits/TableMetaTrait.php b/app/src/Lib/Traits/TableMetaTrait.php index 2a41883d0..39d4fd88a 100644 --- a/app/src/Lib/Traits/TableMetaTrait.php +++ b/app/src/Lib/Traits/TableMetaTrait.php @@ -84,7 +84,13 @@ protected function filterMetadataFields() { 'source_pronoun_id', 'source_telephone_number_id', 'source_url_id', - 'owners_group_id' + 'owners_group_id', + 'enrollee_person_id', + 'petitioner_person_id', + 'authz_group_id', + 'authz_cou_id', + 'redirect_on_finalize', + 'collect_enrollee_email' ]; $newa = array(); diff --git a/app/src/Lib/Traits/UpsertTrait.php b/app/src/Lib/Traits/UpsertTrait.php new file mode 100644 index 000000000..449c4c772 --- /dev/null +++ b/app/src/Lib/Traits/UpsertTrait.php @@ -0,0 +1,87 @@ +find() + ->where($whereClause) + ->epilog('FOR UPDATE') + ->first(); + + if($entity) { + // This is an update + + $entity = $this->patchEntity($entity, $data); + } else { + // This is an insert + + $entity = $this->newEntity($data); + } + + return $this->save($entity); + } + + /** + * Perform an upsert, or throw an exception on failure. + * + * @since COmanage Registry v5.1.0 + * @param array $data Data to persist + * @param array $whereClause Conditions to search for current entity + * @return Cake\Datasource\EntityInterface|false Persisted entity, or false on failure + * @throws Cake\ORM\Exception\PersistenceFailedException + */ + + public function upsertOrFail( + array $data, + array $whereClause + ): \Cake\Datasource\EntityInterface { + $entity = $this->upsert($data, $whereClause); + + if($entity === false) { + throw new Cake\ORM\Exception\PersistenceFailedException($entity, ['upsert']); + } + + return $entity; + } +} diff --git a/app/src/Lib/Util/CountryCodeUtilities.php b/app/src/Lib/Util/CountryCodeUtilities.php new file mode 100644 index 000000000..aea299ad9 --- /dev/null +++ b/app/src/Lib/Util/CountryCodeUtilities.php @@ -0,0 +1,263 @@ + 'UK (+44)', + 1 => 'USA (+1)', + 213 => 'Algeria (+213)', + 376 => 'Andorra (+376)', + 244 => 'Angola (+244)', + 1264 => 'Anguilla (+1264)', + 1268 => 'Antigua & Barbuda (+1268)', + 54 => 'Argentina (+54)', + 374 => 'Armenia (+374)', + 297 => 'Aruba (+297)', + 61 => 'Australia (+61)', + 43 => 'Austria (+43)', + 994 => 'Azerbaijan (+994)', + 1242 => 'Bahamas (+1242)', + 973 => 'Bahrain (+973)', + 880 => 'Bangladesh (+880)', + 1246 => 'Barbados (+1246)', + 375 => 'Belarus (+375)', + 32 => 'Belgium (+32)', + 501 => 'Belize (+501)', + 229 => 'Benin (+229)', + 1441 => 'Bermuda (+1441)', + 975 => 'Bhutan (+975)', + 591 => 'Bolivia (+591)', + 387 => 'Bosnia Herzegovina (+387)', + 267 => 'Botswana (+267)', + 55 => 'Brazil (+55)', + 673 => 'Brunei (+673)', + 359 => 'Bulgaria (+359)', + 226 => 'Burkina Faso (+226)', + 257 => 'Burundi (+257)', + 855 => 'Cambodia (+855)', + 237 => 'Cameroon (+237)', + 1 => 'Canada (+1)', + 238 => 'Cape Verde Islands (+238)', + 1345 => 'Cayman Islands (+1345)', + 236 => 'Central African Republic (+236)', + 56 => 'Chile (+56)', + 86 => 'China (+86)', + 57 => 'Colombia (+57)', + 269 => 'Comoros (+269)', + 242 => 'Congo (+242)', + 682 => 'Cook Islands (+682)', + 506 => 'Costa Rica (+506)', + 385 => 'Croatia (+385)', + 53 => 'Cuba (+53)', + 90392 => 'Cyprus North (+90392)', + 357 => 'Cyprus South (+357)', + 42 => 'Czech Republic (+42)', + 45 => 'Denmark (+45)', + 253 => 'Djibouti (+253)', + 1809 => 'Dominica (+1809)', + 1809 => 'Dominican Republic (+1809)', + 593 => 'Ecuador (+593)', + 20 => 'Egypt (+20)', + 503 => 'El Salvador (+503)', + 240 => 'Equatorial Guinea (+240)', + 291 => 'Eritrea (+291)', + 372 => 'Estonia (+372)', + 251 => 'Ethiopia (+251)', + 500 => 'Falkland Islands (+500)', + 298 => 'Faroe Islands (+298)', + 679 => 'Fiji (+679)', + 358 => 'Finland (+358)', + 33 => 'France (+33)', + 594 => 'French Guiana (+594)', + 689 => 'French Polynesia (+689)', + 241 => 'Gabon (+241)', + 220 => 'Gambia (+220)', + 7880 => 'Georgia (+7880)', + 49 => 'Germany (+49)', + 233 => 'Ghana (+233)', + 350 => 'Gibraltar (+350)', + 30 => 'Greece (+30)', + 299 => 'Greenland (+299)', + 1473 => 'Grenada (+1473)', + 590 => 'Guadeloupe (+590)', + 671 => 'Guam (+671)', + 502 => 'Guatemala (+502)', + 224 => 'Guinea (+224)', + 245 => 'Guinea - Bissau (+245)', + 592 => 'Guyana (+592)', + 509 => 'Haiti (+509)', + 504 => 'Honduras (+504)', + 852 => 'Hong Kong (+852)', + 36 => 'Hungary (+36)', + 354 => 'Iceland (+354)', + 91 => 'India (+91)', + 62 => 'Indonesia (+62)', + 98 => 'Iran (+98)', + 964 => 'Iraq (+964)', + 353 => 'Ireland (+353)', + 972 => 'Israel (+972)', + 39 => 'Italy (+39)', + 1876 => 'Jamaica (+1876)', + 81 => 'Japan (+81)', + 962 => 'Jordan (+962)', + 7 => 'Kazakhstan (+7)', + 254 => 'Kenya (+254)', + 686 => 'Kiribati (+686)', + 850 => 'Korea North (+850)', + 82 => 'Korea South (+82)', + 965 => 'Kuwait (+965)', + 996 => 'Kyrgyzstan (+996)', + 856 => 'Laos (+856)', + 371 => 'Latvia (+371)', + 961 => 'Lebanon (+961)', + 266 => 'Lesotho (+266)', + 231 => 'Liberia (+231)', + 218 => 'Libya (+218)', + 417 => 'Liechtenstein (+417)', + 370 => 'Lithuania (+370)', + 352 => 'Luxembourg (+352)', + 853 => 'Macao (+853)', + 389 => 'Macedonia (+389)', + 261 => 'Madagascar (+261)', + 265 => 'Malawi (+265)', + 60 => 'Malaysia (+60)', + 960 => 'Maldives (+960)', + 223 => 'Mali (+223)', + 356 => 'Malta (+356)', + 692 => 'Marshall Islands (+692)', + 596 => 'Martinique (+596)', + 222 => 'Mauritania (+222)', + 269 => 'Mayotte (+269)', + 52 => 'Mexico (+52)', + 691 => 'Micronesia (+691)', + 373 => 'Moldova (+373)', + 377 => 'Monaco (+377)', + 976 => 'Mongolia (+976)', + 1664 => 'Montserrat (+1664)', + 212 => 'Morocco (+212)', + 258 => 'Mozambique (+258)', + 95 => 'Myanmar (+95)', + 264 => 'Namibia (+264)', + 674 => 'Nauru (+674)', + 977 => 'Nepal (+977)', + 31 => 'Netherlands (+31)', + 687 => 'New Caledonia (+687)', + 64 => 'New Zealand (+64)', + 505 => 'Nicaragua (+505)', + 227 => 'Niger (+227)', + 234 => 'Nigeria (+234)', + 683 => 'Niue (+683)', + 672 => 'Norfolk Islands (+672)', + 670 => 'Northern Marianas (+670)', + 47 => 'Norway (+47)', + 968 => 'Oman (+968)', + 680 => 'Palau (+680)', + 507 => 'Panama (+507)', + 675 => 'Papua New Guinea (+675)', + 595 => 'Paraguay (+595)', + 51 => 'Peru (+51)', + 63 => 'Philippines (+63)', + 48 => 'Poland (+48)', + 351 => 'Portugal (+351)', + 1787 => 'Puerto Rico (+1787)', + 974 => 'Qatar (+974)', + 262 => 'Reunion (+262)', + 40 => 'Romania (+40)', + 7 => 'Russia (+7)', + 250 => 'Rwanda (+250)', + 378 => 'San Marino (+378)', + 239 => 'Sao Tome & Principe (+239)', + 966 => 'Saudi Arabia (+966)', + 221 => 'Senegal (+221)', + 381 => 'Serbia (+381)', + 248 => 'Seychelles (+248)', + 232 => 'Sierra Leone (+232)', + 65 => 'Singapore (+65)', + 421 => 'Slovak Republic (+421)', + 386 => 'Slovenia (+386)', + 677 => 'Solomon Islands (+677)', + 252 => 'Somalia (+252)', + 27 => 'South Africa (+27)', + 34 => 'Spain (+34)', + 94 => 'Sri Lanka (+94)', + 290 => 'St. Helena (+290)', + 1869 => 'St. Kitts (+1869)', + 1758 => 'St. Lucia (+1758)', + 249 => 'Sudan (+249)', + 597 => 'Suriname (+597)', + 268 => 'Swaziland (+268)', + 46 => 'Sweden (+46)', + 41 => 'Switzerland (+41)', + 963 => 'Syria (+963)', + 886 => 'Taiwan (+886)', + 7 => 'Tajikstan (+7)', + 66 => 'Thailand (+66)', + 228 => 'Togo (+228)', + 676 => 'Tonga (+676)', + 1868 => 'Trinidad & Tobago (+1868)', + 216 => 'Tunisia (+216)', + 90 => 'Turkey (+90)', + 7 => 'Turkmenistan (+7)', + 993 => 'Turkmenistan (+993)', + 1649 => 'Turks & Caicos Islands (+1649)', + 688 => 'Tuvalu (+688)', + 256 => 'Uganda (+256)', + 380 => 'Ukraine (+380)', + 971 => 'United Arab Emirates (+971)', + 598 => 'Uruguay (+598)', + 7 => 'Uzbekistan (+7)', + 678 => 'Vanuatu (+678)', + 379 => 'Vatican City (+379)', + 58 => 'Venezuela (+58)', + 84 => 'Vietnam (+84)', + 84 => 'Virgin Islands - British (+1284)', + 84 => 'Virgin Islands - US (+1340)', + 681 => 'Wallis & Futuna (+681)', + 969 => 'Yemen (North)(+969)', + 967 => 'Yemen (South)(+967)', + 260 => 'Zambia (+260)', + 263 => 'Zimbabwe (+263)', + ]; + + return $ccodes[$code] ?? $ccodes; + } +} \ No newline at end of file diff --git a/app/src/Lib/Util/DeliveryUtilities.php b/app/src/Lib/Util/DeliveryUtilities.php index eb6068806..f5a15a830 100644 --- a/app/src/Lib/Util/DeliveryUtilities.php +++ b/app/src/Lib/Util/DeliveryUtilities.php @@ -51,7 +51,8 @@ class DeliveryUtilities { * @param string $cc Addresses to cc * @param string $bcc Addresses to bcc * @param string $replyTo Reply-To address to use, instead of the default - * @return bool Returns true if mail was sent, false otherwise (including if no SMTP server was sent) + * @return bool Returns true if mail was sent, false if no SMTP server was set + * @throws Cake\Network\Exception\SocketException */ public static function sendEmailToAddress( @@ -127,9 +128,18 @@ public static function sendEmailToAddress( 'tls' => $smtp->use_tls ]); - $result = $transport->send($message); + try { + $result = $transport->send($message); + } + catch(Cake\Network\Exception\SocketException $e) { + self::slog('error', $e->getMessage()); + + throw $e; + } self::slog('debug', "Mail for $to sent successfully"); + + return true; } /** diff --git a/app/src/Lib/Util/StringUtilities.php b/app/src/Lib/Util/StringUtilities.php index 97437c183..d25ba6336 100644 --- a/app/src/Lib/Util/StringUtilities.php +++ b/app/src/Lib/Util/StringUtilities.php @@ -162,6 +162,7 @@ public static function entityAndActionToTitle($entity, $linkTable = TableRegistry::getTableLocator()->get($modelPath); $msgId = "{$action}.a"; + $msgIdOverride = "{$action}.{$modelsName}.a"; if(Inflector::singularize(self::entityToClassName($entity)) !== Inflector::singularize($modelsName)) { $linkTable = TableRegistry::getTableLocator()->get(self::entityToClassName($entity)); @@ -188,7 +189,10 @@ public static function entityAndActionToTitle($entity, && method_exists($linkTable, 'generateDisplayField')) { // We don't use a trait for this since each table will implement different logic - $title = __d($domain, $msgId, $linkTable->generateDisplayField($entity)); + $title = __d($domain, $msgIdOverride, $linkTable->generateDisplayField($entity)); + if ($msgIdOverride === $title) { + $title = __d($domain, $msgId, $linkTable->generateDisplayField($entity)); + } $supertitle = $linkTable->generateDisplayField($entity); // Pass the display field also into subtitle for dealing with External IDs $subtitle = $linkTable->generateDisplayField($entity); @@ -197,7 +201,10 @@ public static function entityAndActionToTitle($entity, $field = $linkTable->getDisplayField(); if(!empty($entity->$field)) { - $title = __d($domain, $msgId, $entity->$field); + $title = __d($domain, $msgIdOverride, $entity->$field); + if($msgIdOverride === $title) { + $title = __d($domain, $msgId, $entity->$field); + } } else { $title = __d($domain, $msgId, __d('controller', $modelsName, [1])); } @@ -220,12 +227,13 @@ public static function foreignKeyToClassName(string $s): string { /** * Localize a controller name, accounting for plugins. - * - * @since COmanage Registry v5.0.0 - * @param string $controllerName Name of controller to localize - * @param string $pluginName Plugin name, if appropriate - * @param bool $plural Whether to use plural localization + * + * @param string $controllerName Name of controller to localize + * @param string|null $pluginName Plugin name, if appropriate + * @param bool $plural Whether to use plural localization + * * @return string Localized text string + * @since COmanage Registry v5.0.0 */ public static function localizeController(string $controllerName, ?string $pluginName, bool $plural=false): string { diff --git a/app/src/Lib/Util/TableUtilities.php b/app/src/Lib/Util/TableUtilities.php index b0753967a..c97680cc6 100644 --- a/app/src/Lib/Util/TableUtilities.php +++ b/app/src/Lib/Util/TableUtilities.php @@ -31,6 +31,8 @@ use Cake\ORM\Table; use Cake\ORM\TableRegistry; +use Cake\Datasource\ConnectionManager; +use Cake\Utility\Inflector; class TableUtilities { /** @@ -58,4 +60,108 @@ public static function getTableFromRegistry(string $alias, array $options): Tabl return $Locator->get($alias, $options); } } + + /** + * We calculate the model name from the primary link, the primary link value is the id + * of the record. We use these to traverse backwards to all the records associations + * Then we return a list where the keys are the model names and the values are the ids + * + * @param string $primaryLinkKey + * @param int $primaryLinkValue + * @param array $results + * + * @return void + * @since COmanage Registry v5.0.0 + */ + public static function treeTraversalFromPrimaryLink( + string $primaryLinkKey, + int $primaryLinkValue, + array &$results, + string $primaryLinkClassName = null + ): void + { + $db = ConnectionManager::get('default'); + // Create a schema collection. + $collection = $db->getSchemaCollection(); + $listOfTables = $collection->listTables(); + + $primaryLinkModelName = StringUtilities::foreignKeyToClassName(($primaryLinkKey)); + // Check if the table exists. + // We can not handle + + // We need to save the id by its alias not the containing class + $results[$primaryLinkModelName] = $primaryLinkValue; + + if ($primaryLinkClassName !== null) { + $primaryLinkModelName = $primaryLinkClassName; + $results[$primaryLinkModelName] = $primaryLinkValue; + } + + // Get a table reference + $ModelTable = TableRegistry::getTableLocator()->get($primaryLinkModelName); + // Get the Record from the database + $resp = $ModelTable->find() + ->where(['id' => $primaryLinkValue]) + ->first() + ->toArray(); + + // Find all the foreign keys and fetch the rest of the tree + foreach($resp as $col => $val) { + if ( + $val !== null + && $col !== $primaryLinkKey + && str_ends_with($col, '_id') + ) { + $fkModel = StringUtilities::foreignKeyToClassName(($col)); + $fk_table = Inflector::underscore($fkModel); + if (\in_array($fk_table, $listOfTables, true)) { + self::treeTraversalFromPrimaryLink($col, $val, $results); + } + } + } + } + + /** + * With a model name and the id know we return a list where the + * keys are the model names and the values are the ids + * + * @param string $modelName + * @param int $id + * @param array $results + * + * @return void + * @since COmanage Registry v5.0.0 + */ + public static function treeTraversalFromId(string $modelName, int $id, array &$results): void + { + $db = ConnectionManager::get('default'); + // Create a schema collection. + $collection = $db->getSchemaCollection(); + $listOfTables = $collection->listTables(); + + $results[$modelName] = $id; + // Get a table reference + $ModelTable = TableRegistry::getTableLocator()->get($modelName); + // Get the Record from the database + $resp = $ModelTable->find() + ->where(['id' => $id]) + ->first()?->toArray(); + + if ($resp !== null) { + // Find all the foreign keys and fetch the rest of the tree + foreach($resp as $col => $val) { + if ( + $val !== null + && $col !== $modelName + && str_ends_with($col, '_id') + ) { + $fkModel = StringUtilities::foreignKeyToClassName(($col)); + $fk_table = Inflector::underscore($fkModel); + if (\in_array($fk_table, $listOfTables, true)) { + self::treeTraversalFromPrimaryLink($col, $val, $results); + } + } + } + } + } } \ No newline at end of file diff --git a/app/src/Model/Behavior/OrderableBehavior.php b/app/src/Model/Behavior/OrderableBehavior.php index 917bec5bb..a33fbe74a 100644 --- a/app/src/Model/Behavior/OrderableBehavior.php +++ b/app/src/Model/Behavior/OrderableBehavior.php @@ -51,7 +51,27 @@ public function beforeMarshal(Event $event, ArrayObject $data, ArrayObject $opti $Table = $event->getSubject(); - $query = $Table->find(); + // We constrain our search by primary key, so ordr will be consecutive within the + // same configuration (eg provisioning_targets within a CO). Note tables can have + // multiple primary links (though this is more for MVEAs than configuration objects) + // though only one should be populated. + + $primaryLink = null; + + $primaryLinks = $Table->getPrimaryLinks(); + + foreach($primaryLinks as $p) { + if(!empty($data[$p])) { + $primaryLink = $p; + break; + } + } + + if(!$primaryLink) { + throw new \RuntimeException("No primary link found in OrderableBehavior::beforeMarshal for " . $Table->getTable()); + } + + $query = $Table->find()->where([$primaryLink => $data[$p]]); $query->select(['maxorder' => $query->func()->max('ordr', ['ordr'])]); $row = $query->first(); diff --git a/app/templates/element/tabs/tabTitleWithCount.php b/app/src/Model/Entity/EnrollmentFlow.php similarity index 78% rename from app/templates/element/tabs/tabTitleWithCount.php rename to app/src/Model/Entity/EnrollmentFlow.php index 07845fa6c..1e427a8bf 100644 --- a/app/templates/element/tabs/tabTitleWithCount.php +++ b/app/src/Model/Entity/EnrollmentFlow.php @@ -1,6 +1,6 @@ Common->getModelTotalCount($model, $where); -} +declare(strict_types = 1); + +namespace App\Model\Entity; -?> +use Cake\ORM\Entity; - - - - \ No newline at end of file +class EnrollmentFlow extends Entity { + use \App\Lib\Traits\ReadOnlyEntityTrait; + + protected $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} \ No newline at end of file diff --git a/app/templates/Addresses/fields-nav.inc b/app/src/Model/Entity/EnrollmentFlowStep.php similarity index 78% rename from app/templates/Addresses/fields-nav.inc rename to app/src/Model/Entity/EnrollmentFlowStep.php index 86686fdf9..7090eb7e4 100644 --- a/app/templates/Addresses/fields-nav.inc +++ b/app/src/Model/Entity/EnrollmentFlowStep.php @@ -1,6 +1,6 @@ 'person', - 'active' => 'person', - 'subActive' => 'addresses' -]; \ No newline at end of file +namespace App\Model\Entity; + +use Cake\ORM\Entity; + +class EnrollmentFlowStep extends Entity { + use \App\Lib\Traits\ReadOnlyEntityTrait; + + protected $_accessible = [ + '*' => true, + 'id' => false, + 'slug' => false, + ]; +} \ No newline at end of file diff --git a/app/src/Model/Entity/MostlyStaticPage.php b/app/src/Model/Entity/MostlyStaticPage.php new file mode 100644 index 000000000..ee604e7a9 --- /dev/null +++ b/app/src/Model/Entity/MostlyStaticPage.php @@ -0,0 +1,56 @@ + true, + 'id' => false, + 'slug' => false, + ]; + + /** + * Determine if this entity is a default Page (shipped out of the box and relied on by other + * parts of the Application). + * + * @since COmanage Registry v5.1.0 + * @return bool true if this entity is a defalut Page, false otherwise + */ + + public function isDefaultPage(): bool { + // We use the original value because if we're in the middle of a save we'll have + // the proposed new value even though we haven't persisted it yet + return in_array($this->getOriginal('name'), ['default-handoff', 'error-landing', 'petition-complete']); + } +} \ No newline at end of file diff --git a/app/src/Model/Entity/Petition.php b/app/src/Model/Entity/Petition.php new file mode 100644 index 000000000..43fe9c0c5 --- /dev/null +++ b/app/src/Model/Entity/Petition.php @@ -0,0 +1,115 @@ + true, + 'id' => false, + 'slug' => false, + ]; + + /** + * Determine if this Petition is complete, ie in a state where no further changes + * are permitted. + * + * @since COmanage Registry v5.0.0 + * @return bool True if this Petition is complete, false otherwise + */ + + public function isComplete(): bool { + return in_array($this->status, [ + PetitionStatusEnum::Declined, + PetitionStatusEnum::Denied, + PetitionStatusEnum::Duplicate, + PetitionStatusEnum::Failed, + PetitionStatusEnum::Finalized + ]); + } + + /** + * Determine if this Petition can be resumed, ie: is not complete. + * + * @since COmanage Registry v5.0.0 + * @return bool True if this Petition can be resumed, false otherwise + */ + + public function isResumable(): bool { + return !$this->isComplete(); + } + + /** + * Determine if this entity is Read Only. + * + * @since COmanage Registry v5.0.0 + * @param Entity $entity Cake Entity + * @return bool true if the entity is read only, false otherwise + */ + + public function isReadOnly(): bool { + // Completed petitions are read only, along with the usual stuff + + return $this->isComplete() || $this->traitIsReadOnly(); + } + + /** + * Determine whether or not a token should be used to authenticate this Petition + * for the requested Actor Role. Note token validation is NOT handled by this call. + * + * @since COmanage Registry v5.0.0 + * @param EnrollmentActorEnum $actorRole Requested Actor Role + * @return bool true if a token should be used, false otherwise + */ + + public function useToken(string $actorRole): bool { + // A token should be used when the current role does not have an associated + // authenticated identifier or person ID + if(($actorRole == EnrollmentActorEnum::Petitioner + && empty($this->petitioner_identifier) + && empty($this->petitioner_person_id)) + || + ($actorRole == EnrollmentActorEnum::Enrollee + && empty($this->enrollee_identifier) + && empty($this->enrollee_person_id))) { + // Note presence of a token is not an indicator as to whether a token should be used + return true; + } + + return false; + } +} \ No newline at end of file diff --git a/app/src/Model/Entity/PetitionHistoryRecord.php b/app/src/Model/Entity/PetitionHistoryRecord.php new file mode 100644 index 000000000..eaa5a5f42 --- /dev/null +++ b/app/src/Model/Entity/PetitionHistoryRecord.php @@ -0,0 +1,54 @@ + true, + 'id' => false, + 'slug' => false, + ]; + + /** + * Determine if this entity is Read Only. + * + * @since COmanage Registry v5.0.0 + * @param Entity $entity Cake Entity + * @return boolean true if the entity is read only, false otherwise + */ + + public function isReadOnly(): bool { + // Petition history records can't be altered once created + + return true; + } +} \ No newline at end of file diff --git a/app/src/Model/Entity/PetitionStepResult.php b/app/src/Model/Entity/PetitionStepResult.php new file mode 100644 index 000000000..9b64c15ca --- /dev/null +++ b/app/src/Model/Entity/PetitionStepResult.php @@ -0,0 +1,54 @@ + true, + 'id' => false, + 'slug' => false, + ]; + + /** + * Determine if this entity is Read Only. + * + * @since COmanage Registry v5.0.0 + * @param Entity $entity Cake Entity + * @return boolean true if the entity is read only, false otherwise + */ + + public function isReadOnly(): bool { + // Petition Step results can't be altered once created + + return true; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/AdHocAttributesTable.php b/app/src/Model/Table/AdHocAttributesTable.php index e1092e6b9..a8f2edf2e 100644 --- a/app/src/Model/Table/AdHocAttributesTable.php +++ b/app/src/Model/Table/AdHocAttributesTable.php @@ -37,6 +37,7 @@ class AdHocAttributesTable extends Table { use \App\Lib\Traits\ChangelogBehaviorTrait; use \App\Lib\Traits\CoLinkTrait; use \App\Lib\Traits\HistoryTrait; + use \App\Lib\Traits\LayoutTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\ProvisionableTrait; @@ -44,18 +45,6 @@ class AdHocAttributesTable extends Table { use \App\Lib\Traits\SearchFilterTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\ValidationTrait; - - /** - * Provide the default layout - * - * @since COmanage Registry v5.0.0 - * @return string Type of redirect - */ - public function getLayout(string $action = ''): string { - return match($action) { - default => 'iframe' - }; - } /** * Perform Cake Model initialization. diff --git a/app/src/Model/Table/AddressesTable.php b/app/src/Model/Table/AddressesTable.php index f1fc87173..3a0d80187 100644 --- a/app/src/Model/Table/AddressesTable.php +++ b/app/src/Model/Table/AddressesTable.php @@ -39,14 +39,15 @@ class AddressesTable extends Table { use \App\Lib\Traits\ChangelogBehaviorTrait; use \App\Lib\Traits\CoLinkTrait; use \App\Lib\Traits\HistoryTrait; + use \App\Lib\Traits\LayoutTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\ProvisionableTrait; use \App\Lib\Traits\QueryModificationTrait; + use \App\Lib\Traits\SearchFilterTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\TypeTrait; use \App\Lib\Traits\ValidationTrait; - use \App\Lib\Traits\SearchFilterTrait; // Default "out of the box" types for this model. Entries here should be // given a default localization in app/resources/locales/*/defaultType.po @@ -58,18 +59,9 @@ class AddressesTable extends Table { 'postal' ] ]; - - /** - * Provide the default layout - * - * @since COmanage Registry v5.0.0 - * @return string Type of redirect - */ - public function getLayout(string $action = ''): string { - return match($action) { - default => 'iframe' - }; - } + + // Default permitted Fields. Used for the Attribute Collection + private $permittedFields = ['locality', 'state', 'postal_code', 'country', 'street', 'room']; /** * Perform Cake Model initialization. @@ -273,4 +265,14 @@ public function validationDefault(Validator $validator): Validator { return $validator; } + + /** + * Get the hardcoded list of the Default Permitted Fields + * + * @since COmanage Registry v5.0.0 + * @return array List of permitted fields + */ + public function getPermittedFields(): array { + return $this->permittedFields; + } } \ No newline at end of file diff --git a/app/src/Model/Table/CosTable.php b/app/src/Model/Table/CosTable.php index 061e964b8..8aec0857b 100644 --- a/app/src/Model/Table/CosTable.php +++ b/app/src/Model/Table/CosTable.php @@ -86,6 +86,9 @@ public function initialize(array $config): void { $this->hasMany('Jobs') ->setDependent(true) ->setCascadeCallbacks(true); + $this->hasMany('MostlyStaticPages') + ->setDependent(true) + ->setCascadeCallbacks(true); $this->hasMany('People') ->setDependent(true) ->setCascadeCallbacks(true); @@ -440,6 +443,9 @@ public function setup(int $id): bool { // AR-CO-6 Create the default groups $this->Groups->addDefaults($id); + // AR-MostlyStaticPages-3 Set up the default landing pages + $this->MostlyStaticPages->addDefaults($id); + // Set up the default settings $this->CoSettings->addDefaults($id); diff --git a/app/src/Model/Table/CousTable.php b/app/src/Model/Table/CousTable.php index ee4d59b21..0afbe7534 100644 --- a/app/src/Model/Table/CousTable.php +++ b/app/src/Model/Table/CousTable.php @@ -76,6 +76,7 @@ public function initialize(array $config): void { ->setProperty('parent'); // AR-COU-6 If a COU is deleted, the special groups associated with the COU will also be deleted. + $this->hasMany('EnrollmentFlows'); $this->hasMany('Groups') ->setDependent(true) ->setCascadeCallbacks(true); diff --git a/app/src/Model/Table/EmailAddressesTable.php b/app/src/Model/Table/EmailAddressesTable.php index baa6c77af..62b41c3db 100644 --- a/app/src/Model/Table/EmailAddressesTable.php +++ b/app/src/Model/Table/EmailAddressesTable.php @@ -44,6 +44,7 @@ class EmailAddressesTable extends Table { use \App\Lib\Traits\CoLinkTrait; use \App\Lib\Traits\HistoryTrait; use \App\Lib\Traits\LabeledLogTrait; + use \App\Lib\Traits\LayoutTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\ProvisionableTrait; @@ -66,19 +67,7 @@ class EmailAddressesTable extends Table { 'recovery' ] ]; - - /** - * Provide the default layout - * - * @since COmanage Registry v5.0.0 - * @return string Type of redirect - */ - public function getLayout(string $action = ''): string { - return match($action) { - default => 'iframe' - }; - } - + /** * Perform Cake Model initialization. * diff --git a/app/src/Model/Table/EnrollmentFlowStepsTable.php b/app/src/Model/Table/EnrollmentFlowStepsTable.php new file mode 100644 index 000000000..99f359388 --- /dev/null +++ b/app/src/Model/Table/EnrollmentFlowStepsTable.php @@ -0,0 +1,253 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Orderable'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('EnrollmentFlows'); + $this->belongsTo('MessageTemplates'); + $this->hasMany('PetitionStepResults'); + + $this->setPluginRelations(); + + $this->setDisplayField('description'); + + $this->setPrimaryLink('enrollment_flow_id'); + $this->setRequiresCO(true); + $this->setRedirectGoal('self'); + + $this->setAutoViewVars([ + 'actorTypes' => [ + 'type' => 'enum', + 'class' => 'EnrollmentActorEnum' + ], + 'messageTemplates' => [ + 'type' => 'select', + 'model' => 'MessageTemplates', + 'where' => ['context' => \App\Lib\Enum\MessageTemplateContextEnum::EnrollmentHandoff] + ], + 'plugins' => [ + 'type' => 'plugin', + 'pluginType' => 'enroller' + ], + 'statuses' => [ + 'type' => 'enum', + 'class' => 'SuspendableStatusEnum' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'configure' => ['platformAdmin', 'coAdmin'], + 'delete' => ['platformAdmin', 'coAdmin'], + // We handle dispatch authorization in the Controller + 'edit' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ], + 'related' => [ + 'table' => [ + 'EnrollmentFlowSteps' + ] + ] + ]); + + $this->setTabsConfig( + [ + 'index' => [ + // Ordered list of Tabs + 'tabs' => ['EnrollmentFlows', 'EnrollmentFlowSteps', 'Petitions'], + // What actions will inlcude the subnavigation header + 'action' => [ + // If a model renders in a subnavigation mode in edit/view mode, it cannot + // render in index mode for the same use case/context + // XXX edit should go first. + 'EnrollmentFlows' => ['edit', 'view'], + 'EnrollmentFlowSteps' => ['index'], + 'Petitions' => ['index'], + ], + // What model will have a counter-badge after the tab title + 'counter' => ['EnrollmentFlowSteps', 'Petitions'] + ], + 'edit' => [ + // Ordered list of Tabs + 'tabs' => ['EnrollmentFlowSteps', 'EnrollmentFlowSteps.Plugin', 'EnrollmentFlowSteps.Hierarchy'], + // What actions will inlcude the subnavigation header + 'action' => [ + // If a model renders in a subnavigation mode in edit/view mode, it cannot + // render in index mode for the same use case/context + // XXX edit should go first. + 'EnrollmentFlowSteps' => ['edit', 'view'], + 'EnrollmentFlowSteps.Plugin' => ['edit'], + // This means that we are looking at the plugins associated model + // EnrollmentFlowSteps -> plugin -> @plugin + // XXX There might be plugins that have no hasMany associations. We will check + // for these use cases inside the element. + 'EnrollmentFlowSteps.Hierarchy' => ['index'] + ], + ] + ] + ); + } + + /** + * Define business rules. + * + * @since COmanage Registry v5.1.0 + * @param RulesChecker $rules RulesChecker object + * @return RulesChecker + */ + + public function buildRules(RulesChecker $rules): RulesChecker { + // AR-EnrollmentFlowStep-1 Two Enrollment Flow Steps in the same Enrollment Flow + // cannot have the same order value. This is because we need to deterministically + // determine the next step (in order to make sure we don't accidentally skip one) + // and the last step run (in particular for determining the correct authorization + // for finalize). + $rules->add($rules->isUnique(['ordr', 'enrollment_flow_id'], __d('error', 'ordr.unique', [__d('controller', 'EnrollmentFlowSteps', [1])]))); + + return $rules; + } + + /** + * Prepare for handoff to an Enrollment Flow Step. + * + * @since COmanage Registry v5.1.0 + * @param EnrollmentFlowStep $step Enrollment Flow Step + * @param Petition $petition Petition to prepare for + */ + + public function prepare( + \App\Model\Entity\EnrollmentFlowStep $step, + \App\Model\Entity\Petition $petition + ) { + // We give the plugin for this Step an opportunity to do something before + // the handoff to the Step takes place. This is intended, for example, to + // allow the Petition to be set to a Pending status prior to the next Actor + // taking an action. + + // (We accept a Step entity rather than an ID because in the context in which + // we're called we already have the Step, so no need to make another DB call.) + + $Plugin = TableRegistry::getTableLocator()->get($step->plugin); + + if(method_exists($Plugin, "prepare")) { + $Plugin->prepare($step, $petition); + } + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.1.0 + * @param Validator $validator Validator + * @return Validator Validator + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('enrollment_flow_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('enrollment_flow_id'); + + $this->registerStringValidation($validator, $schema, 'description', true); + + $validator->add('status', [ + 'content' => ['rule' => ['inList', SuspendableStatusEnum::getConstValues()]] + ]); + $validator->notEmptyString('status'); + + $this->registerStringValidation($validator, $schema, 'plugin', true); + + $validator->add('ordr', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('ordr'); + + $validator->add('actor_type', [ + 'content' => ['rule' => ['inList', EnrollmentActorEnum::getConstValues()]] + ]); + $validator->notEmptyString('actor_type'); + + $validator->add('message_template_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('message_template_id'); + + $validator->add('redirect_on_handoff', [ + 'content' => ['rule' => 'url'] + ]); + $validator->allowEmptyString('redirect_on_handoff'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/EnrollmentFlowsTable.php b/app/src/Model/Table/EnrollmentFlowsTable.php new file mode 100644 index 000000000..f8527065a --- /dev/null +++ b/app/src/Model/Table/EnrollmentFlowsTable.php @@ -0,0 +1,299 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('Cos'); + $this->belongsTo('Cous') + ->setForeignKey('authz_cou_id') + // Property is set so ruleValidateCO can find it. We don't use the + // _id suffix to match Cake's default pattern. + ->setProperty('authz_cou'); + $this->belongsTo('Groups') + ->setForeignKey('authz_group_id') + // Property is set so ruleValidateCO can find it. We don't use the + // _id suffix to match Cake's default pattern. + ->setProperty('authz_group'); + + $this->hasMany('Petitions'); + $this->hasMany('EnrollmentFlowSteps') + ->setDependent(true) + ->setCascadeCallbacks(true); + + $this->setDisplayField('name'); + + $this->setPrimaryLink('co_id'); + $this->setRequiresCO(true); + $this->setAllowLookupPrimaryLink(['start']); + $this->setRedirectGoal('self'); + + $this->setAutoViewVars([ + 'authzTypes' => [ + 'type' => 'enum', + 'class' => 'EnrollmentAuthzEnum' + ], + 'statuses' => [ + 'type' => 'enum', + 'class' => 'TemplateableStatusEnum' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => ['platformAdmin', 'coAdmin'], + 'edit' => ['platformAdmin', 'coAdmin'], + // We handle start authorization in the Controller + 'start' => true, + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => ['platformAdmin', 'coAdmin'], + 'index' => ['platformAdmin', 'coAdmin'] + ], + 'related' => [ + 'table' => [ + 'EnrollmentFlowSteps' + ] + ] + ]); + + $this->setTabsConfig( + [ + // Ordered list of Tabs + 'tabs' => ['EnrollmentFlows', 'EnrollmentFlowSteps', 'Petitions'], + // What actions will inlcude the subnavigation header + 'action' => [ + // If a model renders in a subnavigation mode in edit/view mode, it cannot + // render in index mode for the same use case/context + // XXX edit should go first. + 'EnrollmentFlows' => ['edit', 'view'], + 'EnrollmentFlowSteps' => ['index'], + 'Petitions' => ['index'], + ], + // What model will have a counter-badge after the tab title + 'counter' => ['EnrollmentFlowSteps', 'Petitions'] + ] + ); + } + + /** + * Calculate the next Step for an Enrollment Flow. + * + * @since COmanage Registry v5.0.0 + * @param int $petitionId Petition ID + * @return array url: URL to redirect to + * step: EnrollmentFlowStep + * finalize: True if there are no further steps + * lastStep: If finalize is true, the last EnrollmentFlowStep + * petition: The Petition entity + * @throws InvalidArgumentException + */ + + public function calculateNextStep(int $petitionId): array { + // Start by retrieving the Petition + + $petition = $this->Petitions->get($petitionId); + + // Completed Petitions have no next step + if($petition->isComplete()) { + throw new \InvalidArgumentException(__d('error', 'Petitions.completed', [$petitionId])); + } + + // Pull the set of Petition Step Results for this Petition. + + $results = $this->Petitions->PetitionStepResults->find('list', [ + 'keyField' => 'enrollment_flow_step_id', + 'valueField' => 'status' + ]) + ->where(['petition_id' => $petition->id]) + ->order(['enrollment_flow_step_id' => 'ASC']) + ->toArray(); + + // Pull the Enrollment Flow Steps for this Enrollment Flow, in order, + // and ignoring suspended Steps. + + $steps = $this->EnrollmentFlowSteps->find() + ->where([ + 'enrollment_flow_id' => $petition->enrollment_flow_id, + 'status' => SuspendableStatusEnum::Active + ]) + ->order(['EnrollmentFlowSteps.ordr' => 'ASC']) + ->contain($this->EnrollmentFlowSteps->getPluginRelations()) + ->all(); + + // Look for the first Enrollment Flow Step without a corresponding Result, + // this is our next Step. + + if(empty($steps)) { + // AR-EnrollmentFlow-1 An Enrollment Flow must have at least one Enrollment Flow Step + // defined in order to be run. This is because the authorization for finalize is + // calculated as the last configured step, so if there is no such step we cannpt + // calculate permission correctly. Also, a Flow with no Steps has no purpose. + + throw new \InvalidArgumentException(__d('error', 'EnrollmentFlowSteps.none')); + } + + foreach($steps as $step) { + if(!array_key_exists($step->id, $results)) { + // We do not have an array for this step, so it is the next step + + // We need the plugin name to find its instantiation ID + $pluginModel = StringUtilities::pluginModel($step->plugin); + $pluginName = Inflector::singularize(Inflector::underscore($pluginModel)); + + return [ + 'url' => [ + 'plugin' => StringUtilities::pluginPlugin($step->plugin), + 'controller' => StringUtilities::pluginModel($step->plugin), + 'action' => 'dispatch', + $step->$pluginName->id, + '?' => [ + 'petition_id' => $petition->id + ] + ], + 'step' => $step, + 'finalize' => false, + 'lastStep' => null, + 'petition' => $petition + ]; + } + } + + // If we didn't find a Step, it's time to Finalize the Petition. + + return [ + 'url' => [ + 'plugin' => null, + 'controller' => 'Petitions', + 'action' => 'finalize', + $petition->id + ], + 'step' => null, + 'finalize' => true, + 'lastStep' => $steps->last(), + 'petition' => $petition + ]; + } + + /** + * 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, 'name', true); + + $validator->add('status', [ + 'content' => ['rule' => ['inList', TemplateableStatusEnum::getConstValues()]] + ]); + $validator->notEmptyString('status'); + + $this->registerStringValidation($validator, $schema, 'sor_label', false); + + $validator->add('authz_type', [ + 'content' => ['rule' => ['inList', EnrollmentAuthzEnum::getConstValues()]] + ]); + $validator->notEmptyString('authz_type'); + +// XXX this becomes required when authz_type=CouAdmin || CouPerson + $validator->add('authz_cou_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('authz_cou_id'); + +// XXX this becomes required when authz_type=GroupMember + $validator->add('authz_group_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('authz_group_id'); + + $validator->add('collect_enrollee_email', [ + 'content' => ['rule' => 'boolean'] + ]); + $validator->allowEmptyString('collect_enrollee_email'); + + $validator->add('redirect_on_finalize', [ + 'content' => ['rule' => 'url'] + ]); + $validator->allowEmptyString('redirect_on_finalize'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/ExtIdentitySourceRecordsTable.php b/app/src/Model/Table/ExtIdentitySourceRecordsTable.php index c8356f2f4..e16c54f74 100644 --- a/app/src/Model/Table/ExtIdentitySourceRecordsTable.php +++ b/app/src/Model/Table/ExtIdentitySourceRecordsTable.php @@ -43,24 +43,14 @@ class ExtIdentitySourceRecordsTable extends Table { use \App\Lib\Traits\ChangelogBehaviorTrait; use \App\Lib\Traits\CoLinkTrait; use \App\Lib\Traits\LabeledLogTrait; + use \App\Lib\Traits\LayoutTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\QueryModificationTrait; use \App\Lib\Traits\SearchFilterTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\ValidationTrait; - - /** - * Provide the default layout - * - * @since COmanage Registry v5.0.0 - * @return string Type of redirect - */ - public function getLayout(string $action = ''): string { - return match($action) { - default => 'iframe' - }; - } + use \App\Lib\Traits\TabTrait; /** * Perform Cake Model initialization. @@ -113,6 +103,38 @@ public function initialize(array $config): void { ] ]);*/ + $this->setTabsConfig( + [ + // Ordered list of Tabs + 'tabs' => ['People', 'PersonRoles', 'ExternalIdentities'], + // What actions will include the subnavigation header + 'action' => [ + // If a model renders in a subnavigation mode in edit/view mode, it cannot + // render in index mode for the same use case/context + // XXX edit should go first. + 'People' => ['edit', 'view'], + 'PersonRoles' => ['index'], + 'ExternalIdentities' => ['index'], + ], + // What model will have a counter-badge after the tab title + 'counter' => ['PersonRoles', 'ExternalIdentities'], + 'nested' => [ + // Ordered list of Tabs + 'tabs' => ['ExternalIdentities', 'ExternalIdentityRoles'], + // What actions will include the subnavigation header + 'action' => [ + // If a model renders in a subnavigation mode in edit/view mode, it cannot + // render in index mode for the same use case/context + // XXX edit should go first. + 'ExternalIdentities' => ['edit', 'view'], + 'ExternalIdentityRoles' => ['index'], + ], + // What model will have a counter-badge after the tab title + 'counter' => ['ExternalIdentityRoles'], + ] + ] + ); + $this->setPermissions([ // Actions that operate over an entity (ie: require an $id) 'entity' => [ diff --git a/app/src/Model/Table/ExternalIdentitiesTable.php b/app/src/Model/Table/ExternalIdentitiesTable.php index 9f945a039..dca4900e6 100644 --- a/app/src/Model/Table/ExternalIdentitiesTable.php +++ b/app/src/Model/Table/ExternalIdentitiesTable.php @@ -47,7 +47,8 @@ class ExternalIdentitiesTable extends Table { use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\ValidationTrait; use \App\Lib\Traits\SearchFilterTrait; - + use \App\Lib\Traits\TabTrait; + /** * Perform Cake Model initialization. * @@ -147,7 +148,40 @@ public function initialize(array $config): void { 'class' => 'StatusEnum' ] ]); - + + // All the tabs share the same configuration in the ModelTable file + $this->setTabsConfig( + [ + // Ordered list of Tabs + 'tabs' => ['People', 'PersonRoles', 'ExternalIdentities'], + // What actions will include the subnavigation header + 'action' => [ + // If a model renders in a subnavigation mode in edit/view mode, it cannot + // render in index mode for the same use case/context + // XXX edit should go first. + 'People' => ['edit', 'view'], + 'PersonRoles' => ['index'], + 'ExternalIdentities' => ['index'], + ], + // What model will have a counter-badge after the tab title + 'counter' => ['PersonRoles', 'ExternalIdentities'], + 'nested' => [ + // Ordered list of Tabs + 'tabs' => ['ExternalIdentities', 'ExternalIdentityRoles'], + // What actions will include the subnavigation header + 'action' => [ + // If a model renders in a subnavigation mode in edit/view mode, it cannot + // render in index mode for the same use case/context + // XXX edit should go first. + 'ExternalIdentities' => ['edit', 'view'], + 'ExternalIdentityRoles' => ['index'] + ], + // What model will have a counter-badge after the tab title + 'counter' => ['ExternalIdentityRoles'], + ] + ] + ); + $this->setPermissions([ // Actions that operate over an entity (ie: require an $id) // See also CFM-126 diff --git a/app/src/Model/Table/ExternalIdentityRolesTable.php b/app/src/Model/Table/ExternalIdentityRolesTable.php index 35a267ba1..2d780f8ce 100644 --- a/app/src/Model/Table/ExternalIdentityRolesTable.php +++ b/app/src/Model/Table/ExternalIdentityRolesTable.php @@ -46,10 +46,11 @@ class ExternalIdentityRolesTable extends Table { use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\QueryModificationTrait; + use \App\Lib\Traits\SearchFilterTrait; + use \App\Lib\Traits\TabTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\ValidationTrait; - use \App\Lib\Traits\SearchFilterTrait; - + /** * Perform Cake Model initialization. * @@ -90,7 +91,7 @@ public function initialize(array $config): void { ->setDependent(true) ->setCascadeCallbacks(true); - $this->setDisplayField('id'); + $this->setDisplayField('title'); $this->setPrimaryLink('external_identity_id'); $this->setRequiresCO(true); @@ -118,7 +119,39 @@ public function initialize(array $config): void { 'attribute' => 'PersonRoles.affiliation_type' ] ]); - + + $this->setTabsConfig( + [ + // Ordered list of Tabs + 'tabs' => ['People', 'PersonRoles', 'ExternalIdentities'], + // What actions will include the subnavigation header + 'action' => [ + // If a model renders in a subnavigation mode in edit/view mode, it cannot + // render in index mode for the same use case/context + // XXX edit should go first. + 'People' => ['edit', 'view'], + 'PersonRoles' => ['index'], + 'ExternalIdentities' => ['index'], + ], + // What model will have a counter-badge after the tab title + 'counter' => ['PersonRoles', 'ExternalIdentities'], + 'nested' => [ + // Ordered list of Tabs + 'tabs' => ['ExternalIdentities', 'ExternalIdentityRoles'], + // What actions will include the subnavigation header + 'action' => [ + // If a model renders in a subnavigation mode in edit/view mode, it cannot + // render in index mode for the same use case/context + // XXX edit should go first. + 'ExternalIdentities' => ['edit', 'view'], + 'ExternalIdentityRoles' => ['edit', 'view', 'index'], + ], + // What model will have a counter-badge after the tab title + 'counter' => ['ExternalIdentityRoles'], + ] + ] + ); + $this->setPermissions([ // Actions that operate over an entity (ie: require an $id) // See also CFM-126 diff --git a/app/src/Model/Table/ExternalIdentitySourcesTable.php b/app/src/Model/Table/ExternalIdentitySourcesTable.php index f50542d44..45312a0c4 100644 --- a/app/src/Model/Table/ExternalIdentitySourcesTable.php +++ b/app/src/Model/Table/ExternalIdentitySourcesTable.php @@ -48,6 +48,7 @@ class ExternalIdentitySourcesTable extends Table { use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\ValidationTrait; + use \App\Lib\Traits\TabTrait; // Cache of the EIS configuration, keyed on id protected $eisCache = null; @@ -99,7 +100,24 @@ public function initialize(array $config): void { 'class' => 'SyncModeEnum' ] ]); - + + // All the tabs share the same configuration in the ModelTable file + $this->setTabsConfig( + [ + // Ordered-list of Tabs + 'tabs' => ['ExternalIdentitySources', 'ExternalIdentitySources.Plugin', 'ExternalIdentitySources@action.search'], + // What actions will include the subnavigation header + 'action' => [ + // If a model renders in a subnavigation mode in edit/view mode, it cannot + // render in index mode for the same use case/context + // XXX edit should go first. + 'ExternalIdentitySources' => ['edit', 'view', 'search'], + 'ExternalIdentitySources.Plugin' => ['edit'], + 'ExternalIdentitySources@action.search' => [], + ], + ] + ); + $this->setPermissions([ // Actions that operate over an entity (ie: require an $id) 'entity' => [ diff --git a/app/src/Model/Table/GroupMembersTable.php b/app/src/Model/Table/GroupMembersTable.php index 3aaca7a71..5b8b61865 100644 --- a/app/src/Model/Table/GroupMembersTable.php +++ b/app/src/Model/Table/GroupMembersTable.php @@ -46,27 +46,16 @@ class GroupMembersTable extends Table { use \App\Lib\Traits\CoLinkTrait; use \App\Lib\Traits\HistoryTrait; use \App\Lib\Traits\LabeledLogTrait; + use \App\Lib\Traits\LayoutTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\ProvisionableTrait; use \App\Lib\Traits\QueryModificationTrait; + use \App\Lib\Traits\SearchFilterTrait; + use \App\Lib\Traits\TabTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\ValidationTrait; - use \App\Lib\Traits\SearchFilterTrait; - /** - * Provide the default layout - * - * @since COmanage Registry v5.0.0 - * @return string Type of redirect - */ - public function getLayout(string $action = ''): string { - return match($action) { - 'add','edit','view' => 'iframe', - default => 'default' - }; - } - /** * Perform Cake Model initialization. * @@ -143,6 +132,18 @@ public function initialize(array $config): void { 'order' => 1 ], ]); + + $this->setTabsConfig( + [ + 'tabs' => ['Groups', 'GroupMembers', 'GroupNestings'], + 'action' => [ + 'Groups' => ['edit', 'view'], + 'GroupMembers' => ['index'], + 'GroupNestings' => ['index'], + ], + 'counter' => ['GroupMembers'] + ] + ); } /** diff --git a/app/src/Model/Table/GroupNestingsTable.php b/app/src/Model/Table/GroupNestingsTable.php index 0fdda7cb2..6eb28c8c5 100644 --- a/app/src/Model/Table/GroupNestingsTable.php +++ b/app/src/Model/Table/GroupNestingsTable.php @@ -43,9 +43,10 @@ class GroupNestingsTable extends Table { use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\QueryModificationTrait; + use \App\Lib\Traits\TabTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\ValidationTrait; - + /** * Perform Cake Model initialization. * @@ -96,6 +97,18 @@ public function initialize(array $config): void { ] ]); + $this->setTabsConfig( + [ + 'tabs' => ['Groups', 'GroupMembers', 'GroupNestings'], + 'action' => [ + 'Groups' => ['edit', 'view'], + 'GroupMembers' => ['index'], + 'GroupNestings' => ['index'], + ], + 'counter' => ['GroupMembers'] + ] + ); + // XXX Keeping for functionality reference // $this->setAutoViewVars([ // 'groupMembers' => [ diff --git a/app/src/Model/Table/GroupsTable.php b/app/src/Model/Table/GroupsTable.php index f42470ee1..4df6db0cd 100644 --- a/app/src/Model/Table/GroupsTable.php +++ b/app/src/Model/Table/GroupsTable.php @@ -52,10 +52,11 @@ class GroupsTable extends Table { use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\ProvisionableTrait; use \App\Lib\Traits\QueryModificationTrait; + use \App\Lib\Traits\SearchFilterTrait; + use \App\Lib\Traits\TabTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\ValidationTrait; - use \App\Lib\Traits\SearchFilterTrait; - + /** * Perform Cake Model initialization. * @@ -182,7 +183,25 @@ public function initialize(array $config): void { ] ] ]); - + + $this->setTabsConfig( + [ + // Ordered list of Tabs + 'tabs' => ['Groups', 'GroupMembers', 'GroupNestings'], + // What actions will inlcude the subnavigation header + 'action' => [ + // If a model renders in a subnavigation mode in edit/view mode, it cannot + // render in index mode for the same use case/context + // XXX edit should go first. + 'Groups' => ['edit', 'view'], + 'GroupMembers' => ['index'], + 'GroupNestings' => ['index'], + ], + // What model will have a counter-badge after the tab title + 'counter' => ['GroupMembers'] + ] + ); + $this->setPermissions([ // XXX update for couAdmins, etc // Actions that operate over an entity (ie: require an $id) diff --git a/app/src/Model/Table/IdentifiersTable.php b/app/src/Model/Table/IdentifiersTable.php index 8d2f45978..cbc80efb0 100644 --- a/app/src/Model/Table/IdentifiersTable.php +++ b/app/src/Model/Table/IdentifiersTable.php @@ -40,14 +40,15 @@ class IdentifiersTable extends Table { use \App\Lib\Traits\ChangelogBehaviorTrait; use \App\Lib\Traits\CoLinkTrait; use \App\Lib\Traits\HistoryTrait; + use \App\Lib\Traits\LayoutTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\ProvisionableTrait; use \App\Lib\Traits\QueryModificationTrait; + use \App\Lib\Traits\SearchFilterTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\TypeTrait; use \App\Lib\Traits\ValidationTrait; - use \App\Lib\Traits\SearchFilterTrait; // Default "out of the box" types for this model. Entries here should be // given a default localization in app/resources/locales/*/defaultType.po @@ -73,19 +74,7 @@ class IdentifiersTable extends Table { 'uid' ] ]; - - /** - * Provide the default layout - * - * @since COmanage Registry v5.0.0 - * @return string Type of redirect - */ - public function getLayout(string $action = ''): string { - return match($action) { - default => 'iframe' - }; - } - + /** * Perform Cake Model initialization. * @@ -242,7 +231,7 @@ public function lookupPersonByLogin(int $coId, string $identifier): int { return $id->person_id; } - + /** * Perform a keyword search. * diff --git a/app/src/Model/Table/MessageTemplatesTable.php b/app/src/Model/Table/MessageTemplatesTable.php index d6aa26421..94ad0d413 100644 --- a/app/src/Model/Table/MessageTemplatesTable.php +++ b/app/src/Model/Table/MessageTemplatesTable.php @@ -43,7 +43,6 @@ class MessageTemplatesTable extends Table { use \App\Lib\Traits\ChangelogBehaviorTrait; use \App\Lib\Traits\CoLinkTrait; use \App\Lib\Traits\PermissionsTrait; - use \App\Lib\Traits\PluggableModelTrait; use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\ValidationTrait; @@ -65,6 +64,7 @@ public function initialize(array $config): void { // Define associations $this->belongsTo('Cos'); + $this->hasMany('EnrollmentFlowSteps'); $this->hasMany('Notifications'); $this->setDisplayField('description'); @@ -108,8 +108,9 @@ public function initialize(array $config): void { * * @since COmanage Registry v5.0.0 * @param int $id Message Template ID - * @param Person $subjectPerson Subject Person, including Primary Name + * @param array $entryUrl Entry URL for responding to a handoff or notification * @param Notification $notification Notification + * @param Person $subjectPerson Subject Person, including Primary Name * @return array 'subject': Message subject * 'body_text': Plaintext message * 'body_html': HTML message @@ -117,13 +118,15 @@ public function initialize(array $config): void { public function generateMessage( int $id, - \App\Model\Entity\Person $subjectPerson=null, - \App\Model\Entity\Notification $notification=null + array $entryUrl=[], + \App\Model\Entity\Notification $notification=null, + \App\Model\Entity\Person $subjectPerson=null ): array { + // We return "" instead of null by default for compatibility with DeliveryUtilities $ret = [ - 'subject' => null, - 'body_text' => null, - 'body_html' => null + 'subject' => "", + 'body_text' => "", + 'body_html' => "" ]; // First retrieve the requested template @@ -133,18 +136,29 @@ public function generateMessage( // entities were provided. $substitutions = []; - - if($subjectPerson && !empty($subjectPerson->primary_name)) { - $substitutions['SUBJECT_NAME'] = $subjectPerson->primary_name->full_name; + + // Lookup the CO Name + $co = $this->Cos->get($template->co_id); + + $substitutions['CO_NAME'] = $co->name; + + if(!empty($entryUrl)) { + $substitutions['ENTRY_URL'] = \Cake\Routing\Router::url( + array_merge($entryUrl, ['_full' => true]) + ); } if($notification) { $substitutions['NOTIFICATION_COMMENT'] = $notification->comment; $substitutions['NOTIFICATION_SOURCE'] = $notification->source; } + + if($subjectPerson && !empty($subjectPerson->primary_name)) { + $substitutions['SUBJECT_NAME'] = $subjectPerson->primary_name->full_name; + } // Finally run the substitutions through each of the supported parts - + foreach(array_keys($ret) as $part) { if(!empty($template->$part)) { // Process the (@SUBSTITUTIONS) for this part diff --git a/app/src/Model/Table/MostlyStaticPagesTable.php b/app/src/Model/Table/MostlyStaticPagesTable.php new file mode 100644 index 000000000..36704cabe --- /dev/null +++ b/app/src/Model/Table/MostlyStaticPagesTable.php @@ -0,0 +1,337 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('Cos'); + + $this->setDisplayField('name'); + + $this->setPrimaryLink('co_id'); + $this->setRequiresCO(true); + + $this->setAutoViewVars([ + 'contexts' => [ + 'type' => 'enum', + 'class' => 'PageContextEnum' + ], + 'statuses' => [ + 'type' => 'enum', + 'class' => 'SuspendableStatusEnum' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + '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'], + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Add the default Mostly Static Pages. + * + * @since COmanage Registry v5.1.0 + * @param int $coId CO ID + * @return bool true on success + * @throws PersistenceFailedException + */ + + public function addDefaults(int $coId) { + // Any pages added here should also be added to MostlyStaticPage.php::isDefaultPage + $records = [ + [ + 'co_id' => $coId, + 'name' => 'default-handoff', + 'title' => __d('field', 'MostlyStaticPages.default.dh.title'), + 'description' => __d('field', 'MostlyStaticPages.default.dh.description'), + 'status' => SuspendableStatusEnum::Active, + 'context' => PageContextEnum::EnrollmentHandoff, + 'body' => __d('field', 'MostlyStaticPages.default.dh.body') + ], + [ + 'co_id' => $coId, + 'name' => 'error-landing', + 'title' => __d('field', 'MostlyStaticPages.default.el.title'), + 'description' => __d('field', 'MostlyStaticPages.default.el.description'), + 'status' => SuspendableStatusEnum::Active, + 'context' => PageContextEnum::ErrorLanding, + 'body' => __d('field', 'MostlyStaticPages.default.el.body') + ], + [ + 'co_id' => $coId, + 'name' => 'petition-complete', + 'title' => __d('field', 'MostlyStaticPages.default.pc.title'), + 'description' => __d('field', 'MostlyStaticPages.default.pc.description'), + 'status' => SuspendableStatusEnum::Active, + 'context' => PageContextEnum::EnrollmentHandoff, + 'body' => __d('field', 'MostlyStaticPages.default.pc.body') + ] + ]; + + // Convert the arrays to entities + $entities = $this->newEntities($records); + + // throws PersistenceFailedException on failure + $this->saveManyOrFail($entities); + + return true; + } + + /** + * Define business rules. + * + * @since COmanage Registry v5.1.0 + * @param RulesChecker $rules RulesChecker object + * @return RulesChecker + */ + + public function buildRules(RulesChecker $rules): RulesChecker { +// XXX document these in the wiki + // AR-MostlyStaticPage-1 Two Mostly Static Pages within the same CO cannot share the same name + $rules->add($rules->isUnique(['name', 'co_id'], __d('error', 'exists', [__d('field', 'MostlyStaticPages.name')]))); + + // AR-MostlyStaticPage-3 Default Pages can not be deleted, or have their names, status, or + // context changed + $rules->addUpdate([$this, 'ruleModifiedDefaultPage'], + 'modifiedDefaultPage', + ['errorField' => 'name']); + + $rules->addDelete([$this, 'ruleIsDefaultPage'], + 'isDefaultPage', + ['errorField' => 'name']); + + return $rules; + } + + /** + * Generate a message based on a Message Template, using the provided entities to + * perform variable substitution. + * + * @since COmanage Registry v5.0.0 + * @param int $id Message Template ID + * @param array $entryUrl Entry URL for responding to a handoff or notification + * @param Notification $notification Notification + * @param Person $subjectPerson Subject Person, including Primary Name + * @return array 'subject': Message subject + * 'body_text': Plaintext message + * 'body_html': HTML message + * + + public function generateMessage( + int $id, + array $entryUrl=[], + \App\Model\Entity\Notification $notification=null, + \App\Model\Entity\Person $subjectPerson=null + ): array { + // We return "" instead of null by default for compatibility with DeliveryUtilities + $ret = [ + 'subject' => "", + 'body_text' => "", + 'body_html' => "" + ]; + + // First retrieve the requested template + $template = $this->get($id); + + // Next build an array of supported substitutions for which appropriate + // entities were provided. + + $substitutions = []; + + // Lookup the CO Name + $co = $this->Cos->get($template->co_id); + + $substitutions['CO_NAME'] = $co->name; + + if(!empty($entryUrl)) { +// debug($entryUrl); + $substitutions['ENTRY_URL'] = \Cake\Routing\Router::url( + array_merge($entryUrl, ['_full' => true]) + ); + } + + if($notification) { + $substitutions['NOTIFICATION_COMMENT'] = $notification->comment; + $substitutions['NOTIFICATION_SOURCE'] = $notification->source; + } + + if($subjectPerson && !empty($subjectPerson->primary_name)) { + $substitutions['SUBJECT_NAME'] = $subjectPerson->primary_name->full_name; + } + + // Finally run the substitutions through each of the supported parts + +// debug($substitutions); + foreach(array_keys($ret) as $part) { + if(!empty($template->$part)) { + // Process the (@SUBSTITUTIONS) for this part + $searchKeys = []; + $replaceVals = []; + + foreach(array_keys($substitutions) as $k) { + $searchKeys[] = "(@" . $k . ")"; + $replaceVals[] = $substitutions[$k] ?? "(?)"; + } + + $ret[$part] = str_replace($searchKeys, $replaceVals, $template->$part); + } + } + + return $ret; + }*/ + + /** + * Application Rule to determine if the current entity is a default Page. + * + * @param Entity $entity Entity to be validated + * @param array $options Application rule options + * + * @return string|bool true if the Rule check passes, false otherwise + * @since COmanage Registry v5.1.0 + */ + + public function ruleIsDefaultPage($entity, array $options): string|bool { + if($entity->isDefaultPage()) { + return __d('error', 'MostlyStaticPages.default.delete'); + } + + return true; + } + + /** + * Application Rule to determine if a default Page has been modified. + * + * @param Entity $entity Entity to be validated + * @param array $options Application rule options + * + * @return string|bool true if the Rule check passes, false otherwise + * @since COmanage Registry v5.1.0 + */ + + public function ruleModifiedDefaultPage($entity, array $options): string|bool { + if($entity->isDefaultPage() + && ($entity->isDirty('name') || $entity->isDirty('status') || $entity->isDirty('context'))) { + return __d('error', 'MostlyStaticPages.default.modify'); + } + + return true; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.1.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, 'name', true); + + // AR-MostlyStaticPage-2 A Mostly Static Page name may consist only of lowercase alphanumeric + // characters and dashes + $validator->add('name', [ + 'filter' => [ + 'rule' => ['custom', '/^[a-z0-9-]+$/'], + 'message' => __d('error', 'MostlyStaticPages.slug.invalid') + ] + ]); + + $this->registerStringValidation($validator, $schema, 'title', true); + + $this->registerStringValidation($validator, $schema, 'description', false); + + $validator->add('status', [ + 'content' => ['rule' => ['inList', SuspendableStatusEnum::getConstValues()]] + ]); + $validator->notEmptyString('status'); + + $validator->add('context', [ + 'content' => ['rule' => ['inList', PageContextEnum::getConstValues()]] + ]); + $validator->notEmptyString('context'); + + $validator->add('body', [ + 'filter' => ['rule' => ['validateInput'], + 'provider' => 'table'] + ]); + $validator->allowEmptyString('body'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/NamesTable.php b/app/src/Model/Table/NamesTable.php index 6785ab3dc..df19131cf 100644 --- a/app/src/Model/Table/NamesTable.php +++ b/app/src/Model/Table/NamesTable.php @@ -44,6 +44,7 @@ class NamesTable extends Table { use \App\Lib\Traits\CoLinkTrait; use \App\Lib\Traits\HistoryTrait; use \App\Lib\Traits\LabeledLogTrait; + use \App\Lib\Traits\LayoutTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\ProvisionableTrait; @@ -53,7 +54,6 @@ class NamesTable extends Table { use \App\Lib\Traits\TypeTrait; use \App\Lib\Traits\ValidationTrait; - // Default "out of the box" types for this model. Entries here should be // given a default localization in app/resources/locales/*/defaultType.po protected $defaultTypes = [ @@ -65,18 +65,6 @@ class NamesTable extends Table { 'preferred' ] ]; - - /** - * Provide the default layout - * - * @since COmanage Registry v5.0.0 - * @return string Type of redirect - */ - public function getLayout(string $action = ''): string { - return match($action) { - default => 'iframe' - }; - } /** * Perform Cake Model initialization. diff --git a/app/src/Model/Table/PeopleTable.php b/app/src/Model/Table/PeopleTable.php index aee5d57e3..e829ecd0b 100644 --- a/app/src/Model/Table/PeopleTable.php +++ b/app/src/Model/Table/PeopleTable.php @@ -50,9 +50,10 @@ class PeopleTable extends Table { use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\ProvisionableTrait; use \App\Lib\Traits\QueryModificationTrait; + use \App\Lib\Traits\SearchFilterTrait; + use \App\Lib\Traits\TabTrait; use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\ValidationTrait; - use \App\Lib\Traits\SearchFilterTrait; /** * Perform Cake Model initialization. @@ -128,6 +129,10 @@ public function initialize(array $config): void { $this->hasMany('PersonRoles') ->setDependent(true) ->setCascadeCallbacks(true); + $this->hasMany('Petitions') + ->setDependent(true) + ->setCascadeCallbacks(true) + ->setForeignKey('enrollee_person_id'); $this->hasMany('Pronouns') ->setDependent(true) ->setCascadeCallbacks(true); @@ -221,7 +226,25 @@ public function initialize(array $config): void { 'order' => 99 ] ]); - + + $this->setTabsConfig( + [ + // Ordered list of Tabs + 'tabs' => ['People', 'PersonRoles', 'ExternalIdentities'], + // What actions will inlcude the subnavigation header + 'action' => [ + // If a model renders in a subnavigation mode in edit/view mode, it cannot + // render in index mode for the same use case/context + // XXX edit should go first. + 'People' => ['edit', 'view'], + 'PersonRoles' => ['index'], + 'ExternalIdentities' => ['index'], + ], + // What model will have a counter-badge after the tab title + 'counter' => ['PersonRoles', 'ExternalIdentities'] + ] + ); + $this->setPermissions([ // Actions that operate over an entity (ie: require an $id) // See also CFM-126 diff --git a/app/src/Model/Table/PersonRolesTable.php b/app/src/Model/Table/PersonRolesTable.php index ac797fee5..324b81f0f 100644 --- a/app/src/Model/Table/PersonRolesTable.php +++ b/app/src/Model/Table/PersonRolesTable.php @@ -53,7 +53,8 @@ class PersonRolesTable extends Table { use \App\Lib\Traits\TypeTrait; use \App\Lib\Traits\ValidationTrait; use \App\Lib\Traits\SearchFilterTrait; - + use \App\Lib\Traits\TabTrait; + // Default "out of the box" types for this model. Entries here should be // given a default localization in app/resources/locales/*/defaultType.po protected $defaultTypes = [ @@ -163,7 +164,25 @@ public function initialize(array $config): void { 'model' => 'Cous' ] ]); - + + $this->setTabsConfig( + [ + // Ordered list of Tabs + 'tabs' => ['People', 'PersonRoles', 'ExternalIdentities'], + // What actions will inlcude the subnavigation header + 'action' => [ + // If a model renders in a subnavigation mode in edit/view mode, it cannot + // render in index mode for the same use case/context + // XXX edit should go first. + 'People' => ['edit', 'view'], + 'PersonRoles' => ['edit', 'view', 'index'], + 'ExternalIdentities' => ['index'], + ], + // What model will have a counter-badge after the tab title + 'counter' => ['PersonRoles', 'ExternalIdentities'] + ] + ); + $this->setPermissions([ // Actions that operate over an entity (ie: require an $id) // See also CFM-126 diff --git a/app/src/Model/Table/PetitionHistoryRecordsTable.php b/app/src/Model/Table/PetitionHistoryRecordsTable.php new file mode 100644 index 000000000..64791c497 --- /dev/null +++ b/app/src/Model/Table/PetitionHistoryRecordsTable.php @@ -0,0 +1,203 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Artifact); + + // Define associations + $this->belongsTo('Petitions'); + $this->belongsTo('EnrollmentFlowSteps'); + $this->belongsTo('ActorPeople') + ->setClassName('People') + ->setForeignKey('actor_person_id') + // Property is set so ruleValidateCO can find it. We don't use the + // _id suffix to match Cake's default pattern. + ->setProperty('actor_person'); + + $this->setDisplayField('comment'); + + $this->setPrimaryLink(['petition_id']); + $this->setRequiresCO(true); + + $this->setViewContains([ + // contain results in a join when the relation is belongsTo (or hasOne), + // and joining the same table twice makes the database unhappy, so we + // force ActorPeople to use multiple queries. + 'ActorPeople' => ['Names' => ['queryBuilder' => function ($q) { + return $q->where(['primary_name' => true]); + }]] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, + 'edit' => false, + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Perform actions while marshaling data, before validation. + * + * @since COmanage Registry v5.0.0 + * @param EventInterface $event Event + * @param ArrayObject $data Object data, in array format + * @param ArrayObject $options Entity save options + */ + + public function beforeMarshal(EventInterface $event, \ArrayObject $data, \ArrayObject $options) + { + if(!empty($data['comment'])) { + // Truncate the comment to fit the column width + $column = $this->getSchema()->getColumn('comment'); + + $data['comment'] = substr($data['comment'], 0, $column['length']); + } + } + + /** + * Table specific logic to generate a display field. + * + * @since COmanage Registry v5.0.0 + * @param JobHistoryRecord $entity Entity to generate display field for + * @return string Display field + */ + + public function generateDisplayField(\App\Model\Entity\JobHistoryRecord $entity): string { + // Comments may be too long to render, so we just use the model name + // (which will get appended with the record ID) + + return __d('controller', 'PetitionHistoryRecords', [1]); + } + + /** + * Record a Petition History Record. + * + * @since COmanage Registry v5.0.0 + * @param int $petitionId Petition ID + * @param string $enrollmentFlowStepId Enrollment Flow Step ID, or null for start or finalize + * @param string $action PetitionActionEnum + * @param string $comment Comment + * @param int $actorPersonId Actor Person ID + * @return int Petition History Record ID + */ + + public function record( + int $petitionId, + ?int $enrollmentFlowStepId=null, + string $action, + string $comment, + ?int $actorPersonId=null + ): int { + $obj = $this->newEntity([ + 'petition_id' => $petitionId, + 'enrollment_flow_step_id' => $enrollmentFlowStepId, + 'action' => $action, + 'comment' => $comment, + 'actor_person_id' => $actorPersonId + ]); + + $this->saveOrFail($obj); + + return $obj->id; + } + + /** + * Set validation rules. + * + * @since COmanage Registry v5.0.0 + * @param Validator $validator Validator + * @return Validator Validator + * @throws InvalidArgumentException + * @throws RecordNotFoundException + */ + + public function validationDefault(Validator $validator): Validator { + $schema = $this->getSchema(); + + $validator->add('petition_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('petition_id'); + + $validator->add('enrollment_flow_step_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + // There is no enrollment_flow_step_id for start or finalize + $validator->allowEmptyString('enrollment_flow_step_id'); + + $this->registerStringValidation($validator, $schema, 'action', true); + + $this->registerStringValidation($validator, $schema, 'comment', true); + + $validator->add('actor_person_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('actor_person_id'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/PetitionStepResultsTable.php b/app/src/Model/Table/PetitionStepResultsTable.php new file mode 100644 index 000000000..a714d2612 --- /dev/null +++ b/app/src/Model/Table/PetitionStepResultsTable.php @@ -0,0 +1,135 @@ +addBehavior('Changelog'); + $this->addBehavior('Timestamp'); + $this->addBehavior('Timezone'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Artifact); + + // Define associations + $this->belongsTo('EnrollmentFlowSteps'); + $this->belongsTo('Petitions'); + + $this->setDisplayField('comment'); + + $this->setPrimaryLink('petition_id'); + $this->setRequiresCO(true); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + 'delete' => false, + 'edit' => false, + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * Record a Petition Step Result. + * + * @since COmanage Registry v5.0.0 + * @param int $enrollmentFlowStepId Enrollment Flow Step ID + * @param int $petitionId Petition ID + * @param string $status Status + * @param string $comment Comment + * @return int Petition Step Result ID + */ + + public function record( + int $enrollmentFlowStepId, + int $petitionId, + string $comment + ): int { + $obj = $this->newEntity([ + 'enrollment_flow_step_id' => $enrollmentFlowStepId, + 'petition_id' => $petitionId, + 'comment' => $comment + ]); + + $this->saveOrFail($obj); + + return $obj->id; + } + + /** + * 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('petition_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('petition_id'); + + $validator->add('enrollment_flow_step_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('enrollment_flow_step_id'); + + $this->registerStringValidation($validator, $schema, 'comment', true); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/PetitionsTable.php b/app/src/Model/Table/PetitionsTable.php new file mode 100644 index 000000000..9c6352c42 --- /dev/null +++ b/app/src/Model/Table/PetitionsTable.php @@ -0,0 +1,440 @@ +addBehavior('Changelog'); + $this->addBehavior('Timestamp'); + $this->addBehavior('Timezone'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Artifact); + + // Define associations + $this->belongsTo('Cous'); + $this->belongsTo('EnrollmentFlows'); + $this->belongsTo('EnrolleePeople') + ->setClassName('People') + ->setForeignKey('enrollee_person_id') + // Property is set so ruleValidateCO can find it. We don't use the + // _id suffix to match Cake's default pattern. + ->setProperty('enrollee_person'); + $this->belongsTo('PetitionerPeople') + ->setClassName('People') + ->setForeignKey('petitioner_person_id') + ->setProperty('petitioner_person'); + + $this->hasMany('PetitionHistoryRecords') + ->setDependent(true) + ->setCascadeCallbacks(true); + $this->hasMany('PetitionStepResults') + ->setDependent(true) + ->setCascadeCallbacks(true); + + $this->setDisplayField('id'); + + $this->setPrimaryLink('enrollment_flow_id'); + $this->setRequiresCO(true); + $this->setAllowLookupPrimaryLink(['continue', 'finalize', 'pending', 'result', 'resume']); + + // These are required for the link to work from the Artifacts page + $this->setAllowUnkeyedPrimaryCO(['index']); + $this->setAllowEmptyPrimaryLink(['index']); + + $this->setIndexContains([ + 'Cous', + 'EnrolleePeople' => ['PrimaryName' => ['foreignKey' => 'person_id']], + 'EnrollmentFlows', + 'PetitionerPeople' => ['PrimaryName' => ['foreignKey' => 'person_id']] + ]); + $this->setViewContains([ + 'EnrollmentFlows' => ['EnrollmentFlowSteps' => ['sort' => ['ordr' => 'ASC']]], + 'EnrolleePeople' => ['PrimaryName' => ['foreignKey' => 'person_id']], + 'PetitionerPeople' => ['PrimaryName' => ['foreignKey' => 'person_id']], + 'PetitionStepResults', + 'PetitionHistoryRecords' + ]); + + $this->setAutoViewVars([ + 'statuses' => [ + 'type' => 'enum', + 'class' => 'PetitionStatusEnum' + ], + 'couIds' => [ + 'type' => 'select', + 'model' => 'Cous' + ] + ]); + + $this->setFilterConfig( + [ + 'cou_id' => [ + // We want to keep the default column configuration and add extra functionality. + // Here the extra functionality is additional to select options since the cou_id + // is of type select + // XXX If the extras key is present, no other provided key will be evaluated. The rest + // of the configuration will be expected from the TableMetaTrait::filterMetadataFields() + 'extras' => [ + 'options' => [ + 'isnotnull' => __d('operation','any'), + 'isnull' => __d('operation','none'), + __d('information','table.list', 'COUs') => '@DATA@', + ] + ] + ] + ] + ); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + // We handle continue authorization in the Controller + 'continue' => true, + 'delete' => false, + 'edit' => false, + // We handle finalize authorization in the Controller + 'finalize' => true, + // result just issues a redirect, so we're generous with permissions + 'result' => ['platformAdmin', 'coAdmin'], + // resume renders a landing page, the admin can copy a URL and resend it + // to the appropriate actor if the actor is not also an admin + 'resume' => ['platformAdmin', 'coAdmin'], + 'view' => ['platformAdmin', 'coAdmin'] + ], + // Actions that are permitted on readonly entities (besides view) + 'readOnly' => ['result'], + // Actions that operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + + $this->setTabsConfig( + [ + // Ordered list of Tabs + 'tabs' => ['EnrollmentFlows', 'EnrollmentFlowSteps', 'Petitions'], + // What actions will include the subnavigation header + 'action' => [ + // If a model renders in a subnavigation mode in edit/view mode, it cannot + // render in index mode for the same use case/context + // XXX edit should go first. + 'EnrollmentFlows' => ['edit', 'view'], + 'EnrollmentFlowSteps' => ['index'], + 'Petitions' => ['index'], + ], + // What model will have a counter-badge after the tab title + 'counter' => ['EnrollmentFlowSteps', 'Petitions'] + ] + ); + } + + /** + * Define business rules. + * + * @since COmanage Registry v5.0.0 + * @param RulesChecker $rules RulesChecker object + * @return RulesChecker + */ + + public function buildRules(RulesChecker $rules): RulesChecker { + // An Enrollee email address may be required by the Enrollment Flow configuration + $rules->add([$this, 'ruleEnrolleeEmail'], + 'enrolleeEmail', + ['errorField' => 'enrollee_email']); + + return $rules; + } + + /** + * Finalize a Petition. + * + * @since COmanage Registry v5.0.0 + * @param int $id Petition ID + */ + + public function finalize(int $id) { + $petition = $this->get($id, ['contain' => [ + 'EnrollmentFlows' => [ + 'EnrollmentFlowSteps' => array_merge( + $this->EnrollmentFlows->EnrollmentFlowSteps->getPluginRelations(), + ['sort' => ['EnrollmentFlowSteps.ordr' => 'ASC']] + ) + ]]]); + + if($petition->isComplete()) { + throw new \InvalidArgumentException(__d('error', 'Petitions.completed', [$id])); + } + + // If there is no Person attached to this Petition, allocate a new Person now + // (with no attributes). + + if(empty($petition->enrollee_person_id)) { + $People = TableRegistry::getTableLocator()->get('People'); + + $person = $People->newEntity([ + 'co_id' => $petition->enrollment_flow->co_id, + 'status' => StatusEnum::Active + ]); + + $People->saveOrFail($person); + + $petition->enrollee_person_id = $person->id; + + // Save here in case any plugin tries to reload petition info + $this->saveOrFail($petition); + + $People->recordHistory( + entity: $person, + action: ActionEnum::PersonAddedPetition, + comment: __d('result', + 'People.added.petition', [ + $petition->enrollment_flow->description, + $petition->enrollment_flow->id, + $petition->id]) + ); + + $this->llog('trace', 'Created new Person ' . $person->id . ' for Petition ' . $petition->id); + } + + if(!empty($petition->enrollment_flow->enrollment_flow_steps)) { + foreach($petition->enrollment_flow->enrollment_flow_steps as $step) { + if($step->status == SuspendableStatusEnum::Suspended) { + // Skip suspended steps + continue; + } + + $Plugin = TableRegistry::getTableLocator()->get($step->plugin); + + // Plugins cannot interrupt finalization by returning false or + // throwing errors, but if we catch an Exception we'll at least log it + try { + if(method_exists($Plugin, "finalize")) { + // We have "CoreEnroller.AttributeCollectors" but we want "attribute_collector" + $pmodel = Inflector::underscore(Inflector::singularize(StringUtilities::pluginModel($step->plugin))); + + $Plugin->finalize($step->$pmodel->id, $petition); + } + } + catch(\Exception $e) { + $this->llog('error', "Plugin " . $step->plugin . " error during finalization of petition " . $petition->id . ": " . $e->getMessage()); + } + } + } + + // Finally, update the Petition status and create a History Record. + $petition->status = PetitionStatusEnum::Finalized; + + $this->saveOrFail($petition); + + $this->PetitionHistoryRecords->record( + petitionId: $petition->id, + enrollmentFlowStepId: null, + action: PetitionActionEnum::Finalized, + comment: __d('result', 'Petitions.finalized') + // actorPersonId + ); + } + + /** + * Obtain a Petition's token. + * + * @since COmanage Registry v5.0.0 + * @param int $id Petition ID + * @return string Petition token + */ + + public function getToken(int $id): string { + // We use this function rather than have the invoking code access the + // entity directly so we can allocate the token and persist it if there + // isn't yet one. + + $petition = $this->get($id); + + if(empty($petition->token)) { + // No token, so allocate one + $petition->token = RandomString::generateToken(); + + $this->save($petition); + } + + return $petition->token; + } + + /** + * Application Rule to determine if an Enrollee Email is required. + * + * @since COmanage Registyr 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 ruleEnrolleeEmail($entity, $options) { + // Whether or not an Enrollee Email address is required depends on the + // Enrollment Flow configuration, so it's a bit cleaner to do this as an + // application rule rather than a validation rule. Note this is _not_ an + // official Registry Application Rule. + + if(!empty($entity->enrollment_flow_id)) { + $EnrollmentFlows = TableRegistry::getTableLocator()->get('EnrollmentFlows'); + + $flow = $EnrollmentFlows->get($entity->enrollment_flow_id); + + if(isset($flow->collect_enrollee_email) && $flow->collect_enrollee_email) { + // Enrollee Email is required + + if(empty($entity->enrollee_email)) { + return __d('error', 'Petitions.enrollee_email'); + } + } + } + + return true; + } + + /** + * Start a new Petition. + * + * @since Registry v5.0.0 + * @param int $enrollmentFlowId Enrollment Flow ID + * @param string $petitionerIdentifier Authenticated Petitioner Identifier (NOT Person ID) + * @param int $petitionerPersonId Petitioner Person ID, if known + * @param bool $isEnrollee If true, the Petitioner is also the Enrollee + * @return Petition Newly created Petition + */ + + public function start( + int $enrollmentFlowId, + string $petitionerIdentifier=null, + int $petitionerPersonId=null, + bool $isEnrollee=false, + string $enrolleeEmail=null + ): \App\Model\Entity\Petition { + $petition = $this->newEntity([ + 'enrollment_flow_id' => $enrollmentFlowId, + 'status' => PetitionStatusEnum::Created, + 'petitioner_identifier' => $petitionerIdentifier, + 'petitioner_person_id' => $petitionerPersonId, + 'enrollee_email' => $enrolleeEmail, + 'enrollee_identifier' => $isEnrollee ? $petitionerIdentifier : null, + 'enrollee_person_id' => $isEnrollee ? $petitionerPersonId : null + ]); + + $this->saveOrFail($petition); + + // We don't create Petition History on start since it's sort of implied + // by the Petition having been created + + return $petition; + } + + /** + * 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('enrollment_flow_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->notEmptyString('enrollment_flow_id'); + + $validator->add('status', [ + 'content' => ['rule' => ['inList', PetitionStatusEnum::getConstValues()]] + ]); + $validator->notEmptyString('status'); + + $validator->add('cou_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('cou_id'); + + $this->registerStringValidation($validator, $schema, 'enrollee_identifier', false); + + $validator->add('enrollee_email', [ + 'content' => ['rule' => ['email'], + 'message' => __d('error', 'input.invalid.email')] + ]); + // See ruleEnrolleeEmail for additional logic + $validator->allowEmptyString('enrollee_email'); + + $validator->add('enrollee_person_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('enrollee_person_id'); + + $this->registerStringValidation($validator, $schema, 'petitioner_identifier', false); + + $validator->add('petitioner_person_id', [ + 'content' => ['rule' => 'isInteger'] + ]); + $validator->allowEmptyString('petitioner_person_id'); + + return $validator; + } +} \ No newline at end of file diff --git a/app/src/Model/Table/PronounsTable.php b/app/src/Model/Table/PronounsTable.php index 82bb98c6a..399a921de 100644 --- a/app/src/Model/Table/PronounsTable.php +++ b/app/src/Model/Table/PronounsTable.php @@ -38,6 +38,7 @@ class PronounsTable extends Table { use \App\Lib\Traits\ChangelogBehaviorTrait; use \App\Lib\Traits\CoLinkTrait; use \App\Lib\Traits\HistoryTrait; + use \App\Lib\Traits\LayoutTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\ProvisionableTrait; @@ -54,19 +55,7 @@ class PronounsTable extends Table { 'default' ] ]; - - /** - * Provide the default layout - * - * @since COmanage Registry v5.0.0 - * @return string Type of redirect - */ - public function getLayout(string $action = ''): string { - return match($action) { - default => 'iframe' - }; - } - + /** * Perform Cake Model initialization. * diff --git a/app/src/Model/Table/TelephoneNumbersTable.php b/app/src/Model/Table/TelephoneNumbersTable.php index 6f40d7ae6..dd2776ee2 100644 --- a/app/src/Model/Table/TelephoneNumbersTable.php +++ b/app/src/Model/Table/TelephoneNumbersTable.php @@ -38,6 +38,7 @@ class TelephoneNumbersTable extends Table { use \App\Lib\Traits\ChangelogBehaviorTrait; use \App\Lib\Traits\CoLinkTrait; use \App\Lib\Traits\HistoryTrait; + use \App\Lib\Traits\LayoutTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\ProvisionableTrait; @@ -46,7 +47,7 @@ class TelephoneNumbersTable extends Table { use \App\Lib\Traits\TableMetaTrait; use \App\Lib\Traits\TypeTrait; use \App\Lib\Traits\ValidationTrait; - + // Default "out of the box" types for this model. Entries here should be // given a default localization in app/resources/locales/*/defaultType.po protected $defaultTypes = [ @@ -58,19 +59,7 @@ class TelephoneNumbersTable extends Table { 'office' ] ]; - - /** - * Provide the default layout - * - * @since COmanage Registry v5.0.0 - * @return string Type of redirect - */ - public function getLayout(string $action = ''): string { - return match($action) { - default => 'iframe' - }; - } - + /** * Perform Cake Model initialization. * diff --git a/app/src/Model/Table/UrlsTable.php b/app/src/Model/Table/UrlsTable.php index eb6e53707..307ccf854 100644 --- a/app/src/Model/Table/UrlsTable.php +++ b/app/src/Model/Table/UrlsTable.php @@ -37,6 +37,7 @@ class UrlsTable extends Table { use \App\Lib\Traits\ChangelogBehaviorTrait; use \App\Lib\Traits\CoLinkTrait; use \App\Lib\Traits\HistoryTrait; + use \App\Lib\Traits\LayoutTrait; use \App\Lib\Traits\PermissionsTrait; use \App\Lib\Traits\PrimaryLinkTrait; use \App\Lib\Traits\ProvisionableTrait; @@ -54,19 +55,7 @@ class UrlsTable extends Table { 'personal' ] ]; - - /** - * Provide the default layout - * - * @since COmanage Registry v5.0.0 - * @return string Type of redirect - */ - public function getLayout(string $action = ''): string { - return match($action) { - default => 'iframe' - }; - } - + /** * Perform Cake Model initialization. * diff --git a/app/src/View/Helper/FieldHelper.php b/app/src/View/Helper/FieldHelper.php index ca2fa9431..8fcf3ad67 100644 --- a/app/src/View/Helper/FieldHelper.php +++ b/app/src/View/Helper/FieldHelper.php @@ -38,6 +38,13 @@ class FieldHelper extends Helper { public $helpers = ['Form', 'Html']; + /** + * List of predefined editable form actions + */ + public const EDITABLE_ACTIONS = [ + 'add', 'edit', // CRUD actions + ]; + // Is this read-only or read-write? protected bool $editable = true; @@ -74,7 +81,8 @@ public function initialize(array $config): void $this->reqFields = $this->getView()->get('vv_required_fields'); $this->modelName = $this->getView()->getName(); $this->action = $this->getView()->get('vv_action'); - $this->editable = \in_array($this->action, ['add', 'edit']); + $vv_is_editable = filter_var($this->getView()->get('vv_is_editable'), FILTER_VALIDATE_BOOLEAN); + $this->editable = \in_array($this->action, self::EDITABLE_ACTIONS, true) || $vv_is_editable; $this->pluginName = $this->getView()->getPlugin(); $this->entity = $this->getView()->get('vv_obj'); $this->fieldTypes = $this->getView()->get('vv_field_types'); @@ -178,8 +186,13 @@ public function calculateLiClasses(): string $fieldName = $this->getView()->get('fieldName'); $vv_field_arguments = $this->getView()->get('vv_field_arguments'); + // Get the fieldtype directly from the configuration or calculate it + // The latter will always work for simple model forms. The first one is used + // for more complex use cases + $fieldType = $vv_field_arguments['fieldType'] ?? $this->getFieldType($fieldName); + // Class calculation by field Type - $classes = match ($this->getFieldType($fieldName)) { + $classes = match ($fieldType) { 'date', 'datetime', 'timestamp' => 'fields-datepicker ', @@ -199,6 +212,10 @@ public function calculateLiClasses(): string $classes .= 'fields-people-autocomplete '; } + // Each field should have a class like `fields-` + $field = $vv_field_arguments['fieldNameAlias'] ?? $fieldName ?? 'unknown'; + $classes .= " fields-$field"; + return $classes; } @@ -208,15 +225,15 @@ public function calculateLiClasses(): string * * @param string $fieldName Form field * @param string $dateType Standard, DateOnly, FromTime, ThroughTime - * @param string|null $label + * @param array|null $fieldArgs * * @return string HTML element * @since COmanage Registry v5.0.0 */ public function dateField(string $fieldName, - string $dateType=DateTypeEnum::Standard, - string $label=null): string + string $dateType = DateTypeEnum::Standard, + array $fieldArgs = null): string { // Initialize $dateFormat = $dateType === DateTypeEnum::DateOnly ? 'yyyy-MM-dd' : 'yyyy-MM-dd HH:mm:ss'; @@ -227,6 +244,10 @@ public function dateField(string $fieldName, ? FrozenTime::parse($queryParams[$fieldName]) : $this->getEntity()?->$fieldName; + // Petition Attribute Collection use case + if($date_object === null && !empty($fieldArgs['default'])) { + $date_object = $fieldArgs['default']; + } // Create the options array for the (text input) form control $coptions = []; @@ -234,8 +255,12 @@ public function dateField(string $fieldName, // that will interact with the field value. Allowing direct access to the input field is for // accessibility purposes. - // ACTION VIEW - if($this->action == 'view') { + // ACTION VIEW or Readonly Field + // The latter applies for the attribute collection view + if($this->action == 'view' + || + (isset($fieldArgs['readonly']) && $fieldArgs['readonly']) + ) { // return the date as plaintext $element = $this->getView()->element('form/notSetDiv', [], [ 'cache' => '_html_elements', @@ -251,7 +276,8 @@ public function dateField(string $fieldName, // Special-case the very common "valid_from" and "valid_through" fields, so we won't need // to specify their types in fields.inc. - $pickerType = match ($fieldName) { + $pickerTypeName = $fieldArgs['fieldNameAlias'] ?? $fieldName; + $pickerType = match ($pickerTypeName) { 'valid_from' => DateTypeEnum::FromTime, 'valid_through' => DateTypeEnum::ThroughTime, default => $dateType @@ -260,9 +286,6 @@ public function dateField(string $fieldName, // Append the timezone to the label $coptions['class'] = 'form-control datepicker'; $coptions['placeholder'] = $dateFormat; - if(!empty($label)) { - $coptions['label'] = $label; - } $coptions['pattern'] = $datePattern; $coptions['title'] = __d('field', $dateTitle); @@ -292,18 +315,28 @@ public function dateField(string $fieldName, 'pickerFloor' => $pickerFloor, ]; + $fieldLabel = ''; + if(!empty($fieldArgs['label'])) { + $fieldLabel = $this->Form->label($fieldName, $fieldArgs['label']); + } // Create a text field to hold our value and call the datePicker - return $this->Form->text($fieldName, $coptions) . $this->getView()->element('datePicker', $date_picker_args); + return $fieldLabel // label + . $this->Form->text($fieldName, $coptions) // hidden input + . $this->getView()->element('datePicker', $date_picker_args); // datepicker field } /** * Create the actual Form element * - * @param string $fieldName Form field - * @param array|null $fieldOptions The second parameter of the Form->control helper. List of element options - * @param string|null $fieldLabel Custom label thext - * @param string $fieldPrefix If the field has a specil prefix provide the value - * @param string|null $fieldType Field type to override the one calculated from the schema + * @param string $fieldName Form field + * @param array|null $fieldOptions The second parameter of the Form->control helper. List of element options + * @param string|null $fieldLabel Custom label text. Applicable to checkboxes ONLY + * @param string $fieldPrefix If the field has a specil prefix provide the value + * @param string|null $fieldType Field type to override the one calculated from the schema + * @param array|null $fieldSelectOptions Options array to override the one calculated options from the AutoPopulate property + * fieldType has to be 'select' + * @param string|null $fieldNameAlias Used for the Petition Attribute Collection form. The form uses generic field name. + * The variable is used to map the generic field name to the actual enrollment attribute name * * @return string HTML element * @since COmanage Registry v5.0.0 @@ -312,7 +345,9 @@ public function formField(string $fieldName, array $fieldOptions = null, string $fieldLabel = null, string $fieldPrefix = '', - string $fieldType = null): string + string $fieldType = null, + array $fieldSelectOptions = null, + string $fieldNameAlias = null): string { $fieldArgs = $fieldOptions ?? []; $fieldArgs['label'] = $fieldOptions['label'] ?? false; @@ -339,8 +374,8 @@ public function formField(string $fieldName, // Check if the empty option comes with a value if($fieldArgs['empty'] - && !empty($fieldOptions['empty']) - && \is_string($fieldOptions['empty'])) { + && isset($fieldOptions['empty']) + && \is_bool($fieldOptions['empty'])) { $fieldArgs['empty'] = $fieldOptions['empty']; } @@ -354,7 +389,7 @@ public function formField(string $fieldName, $this->getView()->set($optionName, $optionValues); } - // Is this a multiple select + // Is this multiple select? $fieldArgs['multiple'] = !empty($fieldOptions['multiple']); // Manipulate the vv_object for the hasPrefix use case @@ -362,6 +397,11 @@ public function formField(string $fieldName, // Get the field type from the map of fields (e.g. 'boolean', 'string', 'timestamp') $fieldType = $fieldType ?? $this->getFieldType($fieldName); + // $fieldType=select requires the $fieldSelectOptions. If the options are empty, we will + // force the usage of the default option + if(empty($fieldSelectOptions) && $fieldType === 'select') { + $fieldType = ''; + } // Generate the form control or pass along the markup generated in a wrapper function return match($fieldType) { // A boolean field is a checkbox. Set the label and class to improve rendering @@ -372,9 +412,11 @@ public function formField(string $fieldName, 'label' => $fieldLabel, 'class' => 'form-check-input', ]), - 'date' => $this->dateField($fieldName, DateTypeEnum::DateOnly), + 'select' => $this->Form->select($fieldName, $fieldSelectOptions, $fieldArgs), + 'text' => $this->Form->textarea($fieldName, $fieldArgs), + 'date' => $this->dateField(fieldName: $fieldName, dateType: DateTypeEnum::DateOnly, fieldArgs: $fieldArgs), 'datetime', - 'timestamp' => $this->dateField($fieldName), + 'timestamp' => $this->dateField(fieldName: $fieldName, fieldArgs: $fieldArgs), default => $this->Form->control($fieldName, $fieldArgs) }; } @@ -456,6 +498,28 @@ public function isEditable(): bool return $this->editable; } + /** + * Enable Form Edit mode. This will allow fields to be editable + * and the submit button will be rendered + * + * @return void + */ + public function enableFormEditMode(): void + { + $this->editable = true; + } + + /** + * Disable Form's edit mode. Fields will be become readonly/disabled + * and the submit button will be removed from the DOM + * + * @return void + */ + public function disableFormEditMode(): void + { + $this->editable = false; + } + /** * @param string $field * diff --git a/app/src/View/Helper/PetitionHelper.php b/app/src/View/Helper/PetitionHelper.php new file mode 100644 index 000000000..2de9a6364 --- /dev/null +++ b/app/src/View/Helper/PetitionHelper.php @@ -0,0 +1,98 @@ +entity = $this->getView()->get('vv_obj'); + $this->petition = $this->getView()->get('vv_petition'); + $this->enrollmentAttributesTable = new EnrollmentAttributesTable(); + } + + /** + * Get the Enrollment Attribute hardcoded configuration + * + * @param string $attribute + * + * @return array + * @since COmanage Registry v5.0.0 + */ + public function getSupportedEnrollmentAttribute(string $attribute): array + { + return $this->enrollmentAttributesTable->supportedAttributes()[$attribute]; + } + + /** + * Calculate and populate the Enrollment Attributes auto view vars + * + * @since COmanage Registry v5.0.0 + */ + public function populateAutoViewVars(): void + { + // XXX Find the co id + foreach ( + $this->enrollmentAttributesTable->calculateAutoViewVars($this->petition?->enrollment_flow?->co_id,$this->entity) as $vvar => $value + ) { + $this->getView()->set($vvar, $value); + } + } + + /** + * Get the table validation rules + * + * @param string $tableName + * + * @return Table + * @since COmanage Registry v5.0.0 + */ + public function getTable(string $tableName): Table + { + return TableRegistry::getTableLocator()->get($tableName); + } +} \ No newline at end of file diff --git a/app/src/View/Helper/TabHelper.php b/app/src/View/Helper/TabHelper.php new file mode 100644 index 000000000..9bc78da51 --- /dev/null +++ b/app/src/View/Helper/TabHelper.php @@ -0,0 +1,533 @@ +getView()->getRequest()->getParam('controller'); + $modelName = $tab; + $controller = $modelName; + $plugin = null; + + // Action calculation + $action = $this->getTabAction($tab, $isNested); + // id or query parameter calculation + $linkFilter = $this->getLinkFilter($tab, $curId, $action, $isNested); + + // Controller + Plugin calculation + if(str_ends_with($tab, '.Plugin')) { + // This is always the second tab of the plugin and it is configuration + $controller = $curController; + $action = 'configure'; + } else if (str_ends_with($tab, '.Hierarchy')) { + $modelName = $this->retrievePluginName($tab, (int)$curId); + [$plugin, ] = explode('.', $modelName); + foreach ($this->getHasManyAssociationModels($modelName) as $association) { + [$plugin, $controller] = explode('.', $association); + break; + } + } else if (str_contains($tab, '@action')) { + // We have a plugin path + [$controller,] = explode('@', $modelName); + [, $action] = explode('.', $modelName); + } else if (str_contains($tab, '.')) { + // We have a plugin path + [$plugin, $controller] = explode('.', $modelName); + } + + $url = [ + 'plugin' => $plugin, + 'controller' => $controller, + 'action' => $action + ]; + + if ($action === 'index') { + $url['?'] = $linkFilter; + } else { + $url[] = $curId; + } + + return $url; + } + + /** + * Calculate the link Class + * + * @param string $tab + * @param bool $isNested + * + * @return string + * @since COmanage Registry v5.0.0 + */ + public function getLinkClass(string $tab, bool $isNested = false): string + { + $vv_sub_nav_attributes = $this->getView()->get('vv_sub_nav_attributes'); + + $curController = $this->getView()->getRequest()->getParam('controller'); + $curAction = $this->getView()->getRequest()->getParam('action'); + $plugin = $this->getView()->getPlugin(); + $fullModelName = $curController; + if(isset($plugin)) { + $fullModelName = "{$plugin}.{$curController}"; + } + + // The list of Nested models is in order. Which means that the first tab is always the parent and the one + // that will be set as active + $parentModelForNested = $vv_sub_nav_attributes['nested']['tabs'][0] ?? 0; + + // Calculate Tab Style Class(es) + return match(true) { + // Matches the tab to the current controller. It addresses the simple subnavigation + // The fullModelName can either be a simple Model or a Plugin with the path. + $tab === $fullModelName && \in_array($curAction, ['index', 'edit', 'view']), + // Always mark active the parent Tab + !$isNested && $parentModelForNested !== null && $tab === $parentModelForNested, + // Matches the action tab links, e.g. FileSource/search + $tab === "{$curController}@action.{$curAction}" => 'nav-link active', + default => 'nav-link' + }; + } + + /** + * Check the belongsTo tree hierarchy + * + * @param string $tab + * @param string $modelFullName + * @param int $depth + * + * @return bool + * @since COmanage Registry v5.0.0 + */ + public function tabBelongsToModelPath(string $tab, string $modelFullName, int &$depth = 0): bool + { + $model = TableRegistry::getTableLocator()->get($modelFullName); + // We'll start by getting the set of models directly associated with the CO model. + $associations = $model->associations(); + + $depth++; + foreach($associations->getByType(['belongsTo', 'belongsToMany']) as $ta) { + if($ta->getClassName() === $tab) { + return true; + } + return $this->tabBelongsToModelPath($tab, $ta->getClassName(), $depth); + } + + return false; + } + + /** + * Calculate the ID for the tab link + * + * @param string|null $tabName + * @param bool $isNested + * + * @return int + * @since COmanage Registry v5.0.0 + */ + public function getCurrentId(string $tabName = null, bool $isNested = false): int + { + $vv_obj = $this->getView()->get('vv_obj'); + $vv_primary_link = $this->getView()->get('vv_primary_link'); + $vv_bc_title_links = $this->getView()->get('vv_bc_title_links'); + $request = $this->getView()->getRequest(); + $curController = $request->getParam('controller'); + $vv_sub_nav_attributes = $this->getView()->get('vv_sub_nav_attributes'); + $tab_actions = !$isNested ? $vv_sub_nav_attributes['action'] : $vv_sub_nav_attributes['nested']['action']; + $tabs = !$isNested ? $vv_sub_nav_attributes['tabs'] : $vv_sub_nav_attributes['nested']['tabs']; + + $tid = $request->getQuery($vv_primary_link) + ?? $vv_obj->id + ?? end($vv_bc_title_links[0]['target']); + + // Get the ids of all the associated Model records + $results = []; + if ($request->getQuery($vv_primary_link) !== null) { + TableUtilities::treeTraversalFromPrimaryLink($vv_primary_link, (int)$tid, $results, ); + } else { + TableUtilities::treeTraversalFromId($curController, (int)$tid, $results); + } + + $tabAction = $this->getTabAction($tabName, $isNested); + + if( + !$isNested + && ($tabAction === 'index' || $curController !== $tabName) + && \in_array($tabName, $tabs, true) + ) { + return (int)$results[ $tabs[0] ]; + } else if ( + !$isNested + && ($tabAction !== 'index' || $curController === $tabName) + && \in_array($tabName, $tabs, true) + ) { + return (int)$tid; + } else if ( + $isNested + && $curController !== $tabName + && !str_contains($tabName, '.') + && \in_array($tabAction, ['view', 'edit'], true) + && \in_array($tabAction, $tab_actions[$tabName], true) + ) { + return (int)$results[ $tabName ]; + } + + return (int)$tid; + } + + /** + * Check the belongsTo tree hierarchy + * + * @param string $modelName + * + * @return \Generator + * @since COmanage Registry v5.0.0 + */ + public function getHasManyAssociationModels(string $modelName): \Generator + { + $model = TableRegistry::getTableLocator()->get($modelName); + // We'll start by getting the set of models directly associated with the CO model. + $associations = $model->associations()->getByType(['hasMany', 'hasOne']); + + if(empty($associations)) { + // Yield null if empty + yield; + } + + foreach($associations as $ta) { + $this->setAssociation($ta->getClassName()); + yield $ta->getClassName(); + } + } + + /** + * Construct the link filter + * + * @param string $tab + * @param int|string $curId + * @param string $tabAction + * @param bool $isNested + * + * @return int[]|string[] + * @since COmanage Registry v5.0.0 + */ + public function getLinkFilter( + string $tab, + int|string $curId, + string $tabAction, + bool $isNested = false + ): array { + $vv_sub_nav_attributes = $this->getView()->get('vv_sub_nav_attributes'); + $subnav_tabs = $vv_sub_nav_attributes['tabs']; + $subnav_allowed_actions = $vv_sub_nav_attributes['action']; + if ($isNested) { + $subnav_tabs = $vv_sub_nav_attributes['nested']['tabs']; + $subnav_allowed_actions = $vv_sub_nav_attributes['nested']['action']; + } + $fullModelsName = $tab; + $modelName = $tab; + $curController = $this->getView()->getRequest()->getParam('controller'); + + // We have two use cases. The first one is for the Core models and the second one is for the + // plugins. In case we have a plugin we need to retrieve the name from the database + if(str_contains($tab, '.Plugin')) { + $modelName = $this->retrievePluginName($tab, (int)$curId); + $this->setPluginName($modelName); + $fullModelsName = $modelName; + } else if (str_contains($tab, '.Hierarchy')) { + $modelName = $this->retrievePluginName($tab, (int)$curId); + [$plugin, ] = explode('.', $modelName); + foreach ($this->getHasManyAssociationModels($modelName) as $association) { + $fullModelsName = $association; + break; + } + } else if(str_contains($tab, '@action')) { + [$modelName, ] = explode('@', $tab); + $fullModelsName = $modelName; + } else if(str_contains($tab, '.')) { + [$plugin, $modelName] = explode('.', $tab); + } + + $modelsTable = TableRegistry::getTableLocator()->get($fullModelsName); + $primary_link_list = $modelsTable->getPrimaryLinks(); + $primary_link = null; + if(count($primary_link_list) > 1) { + $primary_link = collection($primary_link_list) + ->filter(function($link) use ($subnav_tabs) { + $linkToClass = StringUtilities::foreignKeyToClassName($link); + return \in_array($linkToClass, $subnav_tabs, true); + })->first(); + } else if (\is_array($primary_link_list) && !empty($primary_link_list)) { + $primary_link = $primary_link_list[0]; + } + + $foreignKey = StringUtilities::classNameToForeignKey($modelName); + + return match(true) { + // The current controller and the tab controller match + // If the action is edit or view then we return the id since we actually have no filter + $fullModelsName === $curController + && $tabAction !== 'index' => ['id' => $curId], + // If the current controller and the tab constructed controller do not match + // but we have an edit or view action then we have no link filter and no id. We return empty + $fullModelsName !== $curController + && \in_array($tabAction, ['edit', 'view'], true) + && isset($subnav_allowed_actions[$fullModelsName]) => [], + // If the action is index then filter using the primary link + \in_array($tabAction, ['index'], true) + && $primary_link !== 'co_id' => [$primary_link => $curId], + // - If the primary link is the co_id, it means this is a root element. As a result, we will construct + // the link filter key from the controller itself. + $primary_link === 'co_id' => [$foreignKey => $curId], + // We fallback to the primary link directly. + default => [$primary_link => $curId] + }; + } + + /** + * @return string|null + * @since COmanage Registry v5.0.0 + */ + public function getPluginName(): ?string + { + return $this->pluginName; + } + + /** + * Calculate the Tab link action + * - First level Tab: + * The first level usually allows edit/view action for the first tab link and index for the rest. + * There are exceptions like: + * * External Identity Sources: This has a plugin structure. The first and second tab + * refer to the same action: + * - Plugin instantiation edit view + * - Plugin configuration view + * + * - Second level Tab: + * The second level follows the same logic. The first Tab Link is a view/edit + * while the rest are always an index Link + * + * @param string $tab + * @param bool $isNested + * + * @return string|null + * @since COmanage Registry v5.0.0 + */ + public function getTabAction(string $tab, bool $isNested = false): ?string + { + $vv_sub_nav_attributes = $this->getView()->get('vv_sub_nav_attributes'); + $subnav_tabs = $vv_sub_nav_attributes['tabs']; + $subnav_allowed_actions = $vv_sub_nav_attributes['action']; + if ($isNested) { + $subnav_tabs = $vv_sub_nav_attributes['nested']['tabs']; + $subnav_allowed_actions = $vv_sub_nav_attributes['nested']['action']; + } + $vv_action = $this->getView()->get('vv_action'); + $curController = $this->getView()->getRequest()->getParam('controller'); + + $modelName = $tab; + $controller = $modelName; + + $customActionRegex = '/^.*?(@action.)(\w+)/m'; + $customAction = preg_match_all($customActionRegex, $tab, $matches, PREG_SET_ORDER, 0); + return match(true) { + // We get the action from the configuration + filter_var($customAction, FILTER_VALIDATE_BOOLEAN) => $matches[0][2], + // First level ONLY: return the index action (applies to all tabs except the first one) + $subnav_tabs[0] !== $tab + && \in_array('index', $subnav_allowed_actions[$tab], true) => 'index', + // Second level ONLY: return the action of the url. We could just say edit + // but we might have a use case with a different action? + $controller === $curController + && \in_array($vv_action, $subnav_allowed_actions[$tab], true) + && $isNested => $vv_action, + // Return the first action we allow in the configuration + default => $subnav_allowed_actions[$tab][0] + }; + } + + /** + * @param string|null $pluginName + * + * @return void + * @since COmanage Registry v5.0.0 + */ + public function setPluginName(?string $pluginName): void + { + $this->pluginName = $pluginName; + } + + /** + * Get the plugin name from the database + * + * @param string $tab + * @param int $curId + * + * @return string + * @since COmanage Registry v5.0.0 + */ + public function retrievePluginName(string $tab, int $curId): string + { + // Get the name of the Core Model + [$coreModel, $dummy] = explode('.', $tab); + $ModelTable = TableRegistry::getTableLocator()->get($coreModel); + $response = $ModelTable + ->find() + ->select(['plugin']) + ->where(['id' => $curId]) + ->first(); + + return $response?->plugin; + } + + /** + * Get reference to Model Table + * + * @param string $modelsName + * + * @return Table + * @since COmanage Registry v5.0.0 + */ + public function getModelTableReference(string $modelsName): Table + { + return TableRegistry::getTableLocator()->get($modelsName); + } + + /** + * Select count(*) + * + * @param string $modelName Model name in `group_members` format + * @param array $whereClause where clause array + * + * @return int + * @since COmanage Registry v5.0.0 + */ + public function getModelTotalCount(string $modelName, array $whereClause): int + { + $modelsName = Inflector::camelize($modelName); + $ModelTable = TableRegistry::getTableLocator()->get($modelsName); + $count = $ModelTable->find() + ->where($whereClause) + ->count(); + + return $count; + } + + /** + * Get Person Status by ID + * + * @param int $personId + * + * @return string + * @since COmanage Registry v5.0.0 + */ + public function getPersonStatus(int $personId): string + { + $peopleTable = TableRegistry::getTableLocator()->get('people'); + $response = $peopleTable + ->find() + ->select(['status']) + ->where(['id' => $personId]) + ->first(); + + return $response?->status; + } + + /** + * Get Person Full Name by + * + * @param int $personId + * + * @return string + * @since COmanage Registry v5.0.0 + */ + public function getPersonPrimaryName(int $personId): string + { + $namesTable = TableRegistry::getTableLocator()->get('names'); + $response = $namesTable + ->find() + ->select(['given', 'family', 'middle', 'honorific', 'suffix']) + ->where(['person_id' => $personId]) + ->where(['primary_name' => true]) + ->first(); + + return $response?->full_name; + } + + /** + * @return string|null + * @since COmanage Registry v5.0.0 + */ + public function getAssociation(): ?string + { + return $this->association; + } + + /** + * @param string|null $association + * + * @return void + * @since COmanage Registry v5.0.0 + */ + public function setAssociation(?string $association): void + { + $this->association = $association; + } +} \ No newline at end of file diff --git a/app/templates/Dashboards/configuration.php b/app/templates/Dashboards/configuration.php index 94c51b1bb..1016a428e 100644 --- a/app/templates/Dashboards/configuration.php +++ b/app/templates/Dashboards/configuration.php @@ -43,7 +43,8 @@ $cfg): ?>
  • ' . $cfg['icon'] . '' + $iconClass = !empty($cfg['iconClass']) ? $cfg['iconClass'] : 'material-symbols'; + $linkContent = '' . '' . $label . ''; print $this->Html->link( $linkContent, @@ -63,7 +64,8 @@ $cfg): ?>
  • ' . $cfg['icon'] . '' + $iconClass = !empty($cfg['iconClass']) ? $cfg['iconClass'] : 'material-symbols'; + $linkContent = '' . '' . $label . ''; print $this->Html->link( $linkContent, @@ -83,7 +85,8 @@ $cfg): ?>
  • ' . $cfg['icon'] . '' + $iconClass = !empty($cfg['iconClass']) ? $cfg['iconClass'] : 'material-symbols'; + $linkContent = '' . '' . $label . ''; print $this->Html->link( $linkContent, @@ -105,8 +108,9 @@