From a063f809f66d8020c6d628df2fcae9c47ebe6d92 Mon Sep 17 00:00:00 2001 From: Benn Oshrin Date: Mon, 26 Feb 2024 19:56:09 -0500 Subject: [PATCH 1/2] Initial implementation of Enrollment Flow infrastructure and Attribute Collector (CFM-31) Miscellaneous Enrollment fixes (CFM-31) Refactor EnrollmentFlow* files:setPermissions Add top-level subnavigation to Enrollment Flow and Enrollment Flow Steps (CFM-31) (#166) Add main-menu navigation for Petitions and Enrollments (CFM-31) Add titles to Petition steps and buttons to the steps index (CFM-31) (#173) Simplify end-user Petition views (CFM-31) (#175) Subnavigation fix for Enrollment Flow steps (CFM-31) (#177) --- app/composer.json | 2 + app/config/schema/schema.json | 93 ++- app/plugins/CoreEnroller/composer.json | 24 + app/plugins/CoreEnroller/phpunit.xml.dist | 30 + .../resources/locales/en_US/core_enroller.po | 111 +++ .../src/Controller/AppController.php | 10 + .../AttributeCollectorsController.php | 113 ++++ .../EnrollmentAttributesController.php | 71 ++ .../CoreEnroller/src/CoreEnrollerPlugin.php | 93 +++ .../src/Model/Entity/AttributeCollector.php | 49 ++ .../src/Model/Entity/EnrollmentAttribute.php | 49 ++ .../src/Model/Entity/PetitionAttribute.php | 49 ++ app/plugins/CoreEnroller/src/Model/README.md | 11 + .../Model/Table/AttributeCollectorsTable.php | 219 ++++++ .../Model/Table/EnrollmentAttributesTable.php | 396 +++++++++++ .../Model/Table/PetitionAttributesTable.php | 109 +++ .../CoreEnroller/src/config/plugin.json | 58 ++ .../AttributeCollectors/dispatch.inc | 50 ++ .../AttributeCollectors/fields-nav.inc | 39 ++ .../templates/AttributeCollectors/fields.inc | 31 + .../EnrollmentAttributes/columns.inc | 45 ++ .../templates/EnrollmentAttributes/fields.inc | 632 ++++++++++++++++++ app/plugins/CoreEnroller/tests/bootstrap.php | 55 ++ app/plugins/CoreEnroller/tests/schema.sql | 1 + app/plugins/CoreEnroller/webroot/.gitkeep | 0 app/resources/locales/en_US/controller.po | 12 + app/resources/locales/en_US/enumeration.po | 69 ++ app/resources/locales/en_US/error.po | 16 +- app/resources/locales/en_US/field.po | 36 + app/resources/locales/en_US/information.po | 3 + app/resources/locales/en_US/menu.po | 5 +- app/resources/locales/en_US/operation.po | 12 + app/resources/locales/en_US/result.po | 9 + app/src/Controller/AppController.php | 50 +- .../Component/RegistryAuthComponent.php | 7 + app/src/Controller/DashboardsController.php | 14 +- .../EnrollmentFlowStepsController.php | 41 ++ .../Controller/EnrollmentFlowsController.php | 177 +++++ app/src/Controller/PetitionsController.php | 320 +++++++++ .../Controller/StandardEnrollerController.php | 258 +++++++ app/src/Lib/Enum/ActionEnum.php | 1 + app/src/Lib/Enum/EnrollmentActorEnum.php | 36 + app/src/Lib/Enum/EnrollmentAuthzEnum.php | 43 ++ app/src/Lib/Enum/PetitionActionEnum.php | 35 + app/src/Lib/Enum/PetitionStatusEnum.php | 45 ++ app/src/Lib/Random/RandomString.php | 27 +- .../Lib/Traits/EnrollmentControllerTrait.php | 251 +++++++ app/src/Model/Behavior/OrderableBehavior.php | 22 +- app/src/Model/Entity/EnrollmentFlow.php | 42 ++ app/src/Model/Entity/EnrollmentFlowStep.php | 42 ++ app/src/Model/Entity/Petition.php | 115 ++++ .../Model/Entity/PetitionHistoryRecord.php | 54 ++ app/src/Model/Entity/PetitionStepResult.php | 54 ++ app/src/Model/Table/CousTable.php | 1 + .../Model/Table/EnrollmentFlowStepsTable.php | 171 +++++ app/src/Model/Table/EnrollmentFlowsTable.php | 279 ++++++++ app/src/Model/Table/IdentifiersTable.php | 28 + app/src/Model/Table/PeopleTable.php | 4 + .../Table/PetitionHistoryRecordsTable.php | 203 ++++++ .../Model/Table/PetitionStepResultsTable.php | 135 ++++ app/src/Model/Table/PetitionsTable.php | 392 +++++++++++ app/templates/EnrollmentFlowSteps/columns.inc | 64 ++ .../EnrollmentFlowSteps/fields-nav.inc | 31 + app/templates/EnrollmentFlowSteps/fields.inc | 39 ++ app/templates/EnrollmentFlows/columns.inc | 52 ++ app/templates/EnrollmentFlows/fields-nav.inc | 54 ++ app/templates/EnrollmentFlows/fields.inc | 75 +++ app/templates/EnrollmentFlows/start.php | 53 ++ app/templates/HistoryRecords/columns.inc | 5 + app/templates/Petitions/columns.inc | 94 +++ app/templates/Petitions/fields.inc | 87 +++ app/templates/Petitions/resume.php | 128 ++++ app/templates/Standard/add-edit-view.php | 4 - app/templates/Standard/dispatch.php | 108 +++ app/templates/Standard/index.php | 22 +- app/templates/element/menuAction.php | 2 + app/templates/element/menuPanel.php | 44 +- app/templates/element/subnavigation.php | 52 +- app/templates/layout/default.php | 31 +- app/webroot/css/co-base.css | 51 +- app/webroot/css/co-color.css | 6 +- 81 files changed, 6278 insertions(+), 73 deletions(-) create mode 100644 app/plugins/CoreEnroller/composer.json create mode 100644 app/plugins/CoreEnroller/phpunit.xml.dist create mode 100644 app/plugins/CoreEnroller/resources/locales/en_US/core_enroller.po create mode 100644 app/plugins/CoreEnroller/src/Controller/AppController.php create mode 100644 app/plugins/CoreEnroller/src/Controller/AttributeCollectorsController.php create mode 100644 app/plugins/CoreEnroller/src/Controller/EnrollmentAttributesController.php create mode 100644 app/plugins/CoreEnroller/src/CoreEnrollerPlugin.php create mode 100644 app/plugins/CoreEnroller/src/Model/Entity/AttributeCollector.php create mode 100644 app/plugins/CoreEnroller/src/Model/Entity/EnrollmentAttribute.php create mode 100644 app/plugins/CoreEnroller/src/Model/Entity/PetitionAttribute.php create mode 100644 app/plugins/CoreEnroller/src/Model/README.md create mode 100644 app/plugins/CoreEnroller/src/Model/Table/AttributeCollectorsTable.php create mode 100644 app/plugins/CoreEnroller/src/Model/Table/EnrollmentAttributesTable.php create mode 100644 app/plugins/CoreEnroller/src/Model/Table/PetitionAttributesTable.php create mode 100644 app/plugins/CoreEnroller/src/config/plugin.json create mode 100644 app/plugins/CoreEnroller/templates/AttributeCollectors/dispatch.inc create mode 100644 app/plugins/CoreEnroller/templates/AttributeCollectors/fields-nav.inc create mode 100644 app/plugins/CoreEnroller/templates/AttributeCollectors/fields.inc create mode 100644 app/plugins/CoreEnroller/templates/EnrollmentAttributes/columns.inc create mode 100644 app/plugins/CoreEnroller/templates/EnrollmentAttributes/fields.inc create mode 100644 app/plugins/CoreEnroller/tests/bootstrap.php create mode 100644 app/plugins/CoreEnroller/tests/schema.sql create mode 100644 app/plugins/CoreEnroller/webroot/.gitkeep create mode 100644 app/src/Controller/EnrollmentFlowStepsController.php create mode 100644 app/src/Controller/EnrollmentFlowsController.php create mode 100644 app/src/Controller/PetitionsController.php create mode 100644 app/src/Controller/StandardEnrollerController.php create mode 100644 app/src/Lib/Enum/EnrollmentActorEnum.php create mode 100644 app/src/Lib/Enum/EnrollmentAuthzEnum.php create mode 100644 app/src/Lib/Enum/PetitionActionEnum.php create mode 100644 app/src/Lib/Enum/PetitionStatusEnum.php create mode 100644 app/src/Lib/Traits/EnrollmentControllerTrait.php create mode 100644 app/src/Model/Entity/EnrollmentFlow.php create mode 100644 app/src/Model/Entity/EnrollmentFlowStep.php create mode 100644 app/src/Model/Entity/Petition.php create mode 100644 app/src/Model/Entity/PetitionHistoryRecord.php create mode 100644 app/src/Model/Entity/PetitionStepResult.php create mode 100644 app/src/Model/Table/EnrollmentFlowStepsTable.php create mode 100644 app/src/Model/Table/EnrollmentFlowsTable.php create mode 100644 app/src/Model/Table/PetitionHistoryRecordsTable.php create mode 100644 app/src/Model/Table/PetitionStepResultsTable.php create mode 100644 app/src/Model/Table/PetitionsTable.php create mode 100644 app/templates/EnrollmentFlowSteps/columns.inc create mode 100644 app/templates/EnrollmentFlowSteps/fields-nav.inc create mode 100644 app/templates/EnrollmentFlowSteps/fields.inc create mode 100644 app/templates/EnrollmentFlows/columns.inc create mode 100644 app/templates/EnrollmentFlows/fields-nav.inc create mode 100644 app/templates/EnrollmentFlows/fields.inc create mode 100644 app/templates/EnrollmentFlows/start.php create mode 100644 app/templates/Petitions/columns.inc create mode 100644 app/templates/Petitions/fields.inc create mode 100644 app/templates/Petitions/resume.php create mode 100644 app/templates/Standard/dispatch.php 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/schema/schema.json b/app/config/schema/schema.json index 190826b33..b7effc6ce 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" } }, @@ -27,11 +29,13 @@ "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 +521,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 +598,91 @@ } }, + "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 } + }, + "indexes": { + "enrollment_flow_steps_i1": { "columns": [ "enrollment_flow_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..b7df19fd3 --- /dev/null +++ b/app/plugins/CoreEnroller/resources/locales/en_US/core_enroller.po @@ -0,0 +1,111 @@ +# 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.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 "enumeration.DefaultValueValidityType.on" +msgstr "On This Date" + +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 Value" + +msgid "field.EnrollmentAttributes.default_value_cou_id" +msgstr "Default Value" + +msgid "field.EnrollmentAttributes.default_value_datetime" +msgstr "Default Value" + +msgid "field.EnrollmentAttributes.default_value_env_name" +msgstr "Environmental Variable for Default Value" + +msgid "field.EnrollmentAttributes.default_value_group_id" +msgstr "Default Value" + +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 "result.attr.saved" +msgstr "Petition Attributes recorded" \ 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' + ] + ]; + + /** + * 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/EnrollmentAttributesController.php b/app/plugins/CoreEnroller/src/Controller/EnrollmentAttributesController.php new file mode 100644 index 000000000..84e1b1c16 --- /dev/null +++ b/app/plugins/CoreEnroller/src/Controller/EnrollmentAttributesController.php @@ -0,0 +1,71 @@ + [ + '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()); + + $ret = parent::beforeRender($event); + + // Override the auto-generated title + switch($this->request->getParam('action')) { + case 'add': + $this->set('vv_title', __d('operation', 'add.a', [__d('core_enroller', 'controller.EnrollmentAttributes', [1])])); + break; + case 'edit': + $this->set('vv_title', __d('operation', 'edit.a', [__d('core_enroller', 'controller.EnrollmentAttributes', [1])])); + break; + case 'index': + $this->set('vv_title', __d('core_enroller', 'controller.EnrollmentAttributes', [99])); + break; + } + + return $ret; + } +} 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/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/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/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..f8efcf3d1 --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Table/AttributeCollectorsTable.php @@ -0,0 +1,219 @@ +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']); + + $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' => [ + '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/EnrollmentAttributesTable.php b/app/plugins/CoreEnroller/src/Model/Table/EnrollmentAttributesTable.php new file mode 100644 index 000000000..dea7a12fb --- /dev/null +++ b/app/plugins/CoreEnroller/src/Model/Table/EnrollmentAttributesTable.php @@ -0,0 +1,396 @@ +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: 'self'); + + $this->setAutoViewVars([ + 'addressRequiredFields' => [ + 'type' => 'enum', + 'class' => 'RequiredAddressFieldsEnum' + ], + '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' + ] + ]); + + $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' + ]; + + // Single valued Person Role attributes + + $attrs['affiliation_type_id'] = [ + 'label' => __d('field', 'affiliation'), + 'model' => 'PersonRole' + ]; + + $attrs['cou_id'] = [ + 'label' => __d('controller', 'Cous', [1]), + 'model' => 'PersonRole' + ]; + + $attrs['department'] = [ + 'label' => __d('field', 'department'), + 'model' => 'PersonRole' + ]; + + $attrs['manager_person_id'] = [ + 'label' => __d('field', 'manager'), + 'model' => 'PersonRole' + ]; + + $attrs['organization'] = [ + 'label' => __d('field', 'organization'), + 'model' => 'PersonRole' + ]; + + $attrs['sponsor_person_id'] = [ + 'label' => __d('field', 'sponsor'), + 'model' => 'PersonRole' + ]; + + $attrs['title'] = [ + 'label' => __d('field', 'title'), + 'model' => 'PersonRole' + ]; + + $attrs['valid_from'] = [ + 'label' => __d('field', 'valid_from'), + 'model' => 'PersonRole' + ]; + + $attrs['valid_through'] = [ + 'label' => __d('field', 'valid_through'), + 'model' => 'PersonRole' + ]; + + // MVEAs, which might attach to the Person or Person Role or both + + $attrs['address'] = [ + 'label' => __d('controller', 'Addresses', [1]), + 'mveaModel' => 'Addresses', + 'mveaParents' => ['Person', 'PersonRole'] + ]; + + $attrs['adHocAttribute'] = [ + 'label' => __d('controller', 'AdHocAttributes', [1]), + 'mveaModel' => 'AdHocAttributes', + 'mveaParents' => ['Person', 'PersonRole'] + ]; + + $attrs['emailAddress'] = [ + 'label' => __d('controller', 'EmailAddresses', [1]), + 'mveaModel' => 'EmailAddresses', + 'mveaParents' => ['Person'] + ]; + + $attrs['identifier'] = [ + 'label' => __d('controller', 'Identifiers', [1]), + 'mveaModel' => 'Identifiers', + 'mveaParents' => ['Person'] + ]; + + $attrs['name'] = [ + 'label' => __d('controller', 'Names', [1]), + 'mveaModel' => 'Namen', + 'mveaParents' => ['Person'] + ]; + + $attrs['pronoun'] = [ + 'label' => __d('controller', 'Pronouns', [1]), + 'mveaModel' => 'Pronouns', + 'mveaParents' => ['Person'] + ]; + + $attrs['telephoneNumber'] = [ + 'label' => __d('controller', 'TelephoneNumbers', [1]), + 'mveaModel' => 'TelephoneNumbers', + 'mveaParents' => ['Person', 'PersonRole'] + ]; + + $attrs['url'] = [ + 'label' => __d('controller', 'Urls', [1]), + 'mveaModel' => 'Urls', + 'mveaParents' => ['Person'] + ]; + + // 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' + ]; + + // Attributes that are only stored in the Petition + + $attrs['petition_text'] = [ + 'label' => __d('core_enroller', 'field.EnrollmentAttributes.petition_text'), + 'model' => 'Petition' + ]; + + $attrs['petition_textarea'] = [ + 'label' => __d('core_enroller', 'field.EnrollmentAttributes.petition_textarea'), + 'model' => 'Petition' + ]; + + 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_required_fields', false); + + $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/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/config/plugin.json b/app/plugins/CoreEnroller/src/config/plugin.json new file mode 100644 index 000000000..9a13fabbe --- /dev/null +++ b/app/plugins/CoreEnroller/src/config/plugin.json @@ -0,0 +1,58 @@ +{ + "types": { + "enroller": [ + "AttributeCollectors" + ] + }, + "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" ] } + } + }, + "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_required_fields": { "type": "string", "size": 160 }, + "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" ] } + } + }, + "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" ] } + } + } + } + } +} \ 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..d0a6475ec --- /dev/null +++ b/app/plugins/CoreEnroller/templates/AttributeCollectors/dispatch.inc @@ -0,0 +1,50 @@ +firstMatch(['enrollment_attribute_id' => $attr->id]); + + if(!empty($curEntity->value)) { + $options['default'] = $curEntity->value; + } + } + + print $this->Field->control( + // 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, + labelText: $attr->label, + controlType: "string", + options: $options + ); + } +} \ 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/plugins/CoreEnroller/templates/AttributeCollectors/fields.inc b/app/plugins/CoreEnroller/templates/AttributeCollectors/fields.inc new file mode 100644 index 000000000..eb158621e --- /dev/null +++ b/app/plugins/CoreEnroller/templates/AttributeCollectors/fields.inc @@ -0,0 +1,31 @@ + [ + 'type' => 'link', + 'sortable' => true + ], + 'attribute' => [ +// XXX we probably want to render something more like v4 does + 'type' => 'echo' + ], + 'ordr' => [ + 'type' => 'echo', + 'sortable' => true + ], + 'required' => [ + 'type' => 'boolean', + 'class' => 'YesBooleanEnum' + ] +]; \ No newline at end of file diff --git a/app/plugins/CoreEnroller/templates/EnrollmentAttributes/fields.inc b/app/plugins/CoreEnroller/templates/EnrollmentAttributes/fields.inc new file mode 100644 index 000000000..dd4d817d1 --- /dev/null +++ b/app/plugins/CoreEnroller/templates/EnrollmentAttributes/fields.inc @@ -0,0 +1,632 @@ + + + +Field->control('label'); + + print $this->Field->control('description'); + + print $this->Field->control( + fieldName: 'attribute', + options: [ + 'onChange' => 'updateGadgets()' + ] + ); + + print $this->Field->control( + fieldName: 'attribute_mvea_parent', + controlType: 'select', + /* options: [ + // We default to an empty list then dynamically populate it + 'options' => ['Person', 'PersonRole'] + ]*/ + ); + + // 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. + print $this->Field->control( + fieldName: 'attribute_type', + controlType: 'text' + ); + + // These are the actual selects that will render for the appropriate Attribute + print $this->Field->control( + fieldName: 'address_type_id', + // Because these fields don't exist in the database, we need to explicitly set the controlType + controlType: 'select', + options: [ + 'onChange' => "document.getElementById('attribute-type').value = document.getElementById('address-type-id').value" + ] + ); + + print $this->Field->control( + fieldName: 'email_address_type_id', + controlType: 'select', + options: [ + 'onChange' => "document.getElementById('attribute-type').value = document.getElementById('email-address-type-id').value" + ] + ); + + print $this->Field->control( + fieldName: 'identifier_type_id', + controlType: 'select', + options: [ + 'onChange' => "document.getElementById('attribute-type').value = document.getElementById('identifier-type-id').value" + ] + ); + + print $this->Field->control( + fieldName: 'name_type_id', + controlType: 'select', + options: [ + 'onChange' => "document.getElementById('attribute-type').value = document.getElementById('name-type-id').value" + ] + ); + + print $this->Field->control( + fieldName: 'pronoun_type_id', + controlType: 'select', + options: [ + 'onChange' => "document.getElementById('attribute-type').value = document.getElementById('pronoun-type-id').value" + ] + ); + + print $this->Field->control( + fieldName: 'telephone_number_type_id', + controlType: 'select', + options: [ + 'onChange' => "document.getElementById('attribute-type').value = document.getElementById('telephone-number-type-id').value" + ] + ); + + print $this->Field->control( + fieldName: 'url_type_id', + controlType: 'select', + options: [ + 'onChange' => "document.getElementById('attribute-type').value = document.getElementById('url-type-id').value" + ] + ); + + print $this->Field->control('attribute_language'); + + print $this->Field->control('attribute_tag'); + + print $this->Field->control('status'); + + print $this->Field->control('ordr'); + + print $this->Field->control( + fieldName: 'default_value_validity_type', + controlType: 'select', + options: [ + 'options' => [ + 'on' => __d('core_enroller', 'enumeration.DefaultValueValidityType.on'), + 'after' => __d('core_enroller', 'enumeration.DefaultValueValidityType.after') + ], +// 'empty' => false, + 'onChange' => 'updateValidityGadgets()' + ] + ); + + // The default value is always stored in default_value, however for select based fields + // (such as cou_id or affiliation) the value is copied into default_value and the + // field specific attribute is used for display purposes only + print $this->Field->control('default_value'); + + print $this->Field->control( + fieldName: 'default_value_affiliation_type_id', + controlType: 'select', + options: [ + 'onChange' => "document.getElementById('default-value').value = document.getElementById('default-value-affiliation-type-id').value" + ] + ); + + print $this->Field->control( + fieldName: 'default_value_cou_id', + controlType: 'select', + options: [ + 'onChange' => "document.getElementById('default-value').value = document.getElementById('default-value-cou-id').value" + ] + ); + + print $this->Field->dateControl( + fieldName: 'default_value_datetime', + options: [ + ] + ); + + print $this->Field->control('default_value_env_name'); + + print $this->Field->control( + fieldName: 'default_value_group_id', + controlType: 'select', + options: [ + 'onChange' => "document.getElementById('default-value').value = document.getElementById('default-value-group-id').value" + ] + ); + + print $this->Field->control('required', ['default' => true]); + + // Like attribute_type, this is a "behind the scenes" field that is not directly edited + print $this->Field->control('attribute_required_fields'); + + print $this->Field->control( + fieldName: 'address_required_fields', + controlType: 'select', + options: [ + 'onChange' => "document.getElementById('attribute-required-fields').value = document.getElementById('address-required-fields').value" + ] + ); + + print $this->Field->control( + fieldName: 'name_required_fields', + controlType: 'select', + options: [ + 'onChange' => "document.getElementById('attribute-required-fields').value = document.getElementById('name-required-fields').value" + ] + ); + + print $this->Field->control('modifiable', ['default' => true]); + + print $this->Field->control('hidden'); +} 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..fcdddd7d2 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}}" @@ -108,6 +114,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..c0a316c1a 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" @@ -396,6 +429,42 @@ 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 "PetitionStatusEnum.A" +msgstr "Active" + +msgid "PetitionStatusEnum.C" +msgstr "Confirmed" + +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 Confirmation" + +msgid "PetitionStatusEnum.PV" +msgstr "Pending Vetting" + +msgid "PetitionStatusEnum.X" +msgstr "Declined" + +msgid "PetitionStatusEnum.XX" +msgstr "Failed" + msgid "ProvisionerModeEnum.A" msgstr "Immediate" diff --git a/app/resources/locales/en_US/error.po b/app/resources/locales/en_US/error.po index fe8b193b9..cd6848a70 100644 --- a/app/resources/locales/en_US/error.po +++ b/app/resources/locales/en_US/error.po @@ -63,6 +63,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" @@ -106,8 +109,8 @@ msgstr "Cannot read file {0}" msgid "flash" msgstr "{0}: {1}" -msgid "Cos.active" -msgstr "Requested CO {0} is not active" +msgid "EnrollmentFlowSteps.none" +msgstr "This Enrollment Flow has no Active steps and so cannot be run" msgid "EmailAddresses.mail.delivery" msgstr "No verified Email Address is available for Person {0}" @@ -259,6 +262,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 +280,12 @@ msgstr "Valid From date must be earlier than Valid Through date" msgid "Pipelines.plugin.notimpl" msgstr "Pipeline plugin does not implement {0}" +msgid "Petitions.completed" +msgstr "Petition {0} has been completed and cannot be changed" + +msgid "Petitions.enrollee_email" +msgstr "An Email Address for the Enrollee is required by this Enrollment Flow" + msgid "Plugins.inactive" msgstr "The plugin {0} is not active" diff --git a/app/resources/locales/en_US/field.po b/app/resources/locales/en_US/field.po index 2a00ffeca..50dca826e 100644 --- a/app/resources/locales/en_US/field.po +++ b/app/resources/locales/en_US/field.po @@ -173,6 +173,9 @@ msgstr "Manager Identifier" msgid "middle" msgstr "Middle" +msgid "modifiable" +msgstr "Modifiable" + msgid "name" msgstr "Name" @@ -234,6 +237,9 @@ msgstr "Clear global search" msgid "search.placeholder" msgstr "Search..." +msgid "sor_label" +msgstr "System of Record Label" + msgid "source" msgstr "Source" @@ -399,6 +405,27 @@ msgstr "Hash Source Records" msgid "ExternalIdentitySources.sor_label" msgstr "System of Record Label" +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.enrollee_email" +msgstr "Enrollee Email" + +msgid "EnrollmentFlows.redirect_on_finalize" +msgstr "Redirect on Finalize" + +msgid "EnrollmentFlowSteps.actor_type" +msgstr "Actor Type" + msgid "ExternalIdentitySources.source_record.empty" msgstr "The source record is empty. This suggests the record is no longer available from the datasource." @@ -594,6 +621,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 85a1b293f..10c5eba8f 100644 --- a/app/resources/locales/en_US/information.po +++ b/app/resources/locales/en_US/information.po @@ -96,6 +96,9 @@ 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}}" 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 13e737791..6091d7c50 100644 --- a/app/resources/locales/en_US/operation.po +++ b/app/resources/locales/en_US/operation.po @@ -99,6 +99,9 @@ msgstr "Configure {0}" msgid "configure.plugin" msgstr "Configure Plugin" +msgid "continue" +msgstr "Continue" + msgid "dashboard.configuration" msgstr "{0} Configuration" @@ -183,6 +186,9 @@ msgstr "Go to page" msgid "pick" msgstr "Pick" +msgid "Petitions.rerun" +msgstr "Rerun" + msgid "previous" msgstr "Previous" @@ -216,6 +222,9 @@ msgstr "Remove" msgid "resend" msgstr "Resend" +msgid "resume" +msgstr "Resume" + msgid "save" msgstr "Save" @@ -231,6 +240,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" diff --git a/app/resources/locales/en_US/result.po b/app/resources/locales/en_US/result.po index c0aa9e347..734ccd7ba 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,9 @@ 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 "Pipelines.complete" msgstr "Pipeline {0} complete for EIS {1} source key {2}" @@ -166,6 +172,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 01fd2ab98..fc11bcee4 100644 --- a/app/src/Controller/AppController.php +++ b/app/src/Controller/AppController.php @@ -256,7 +256,7 @@ public function getPrimaryLink(bool $lookup=false) { // If this action allows unkeyed, asserted primary link IDs, check the query // string (eg: 'add' or 'index' allow matchgrid_id to be passed in) if($this->$modelsName->allowUnkeyedPrimaryLink($this->request->getParam('action')) - && $this->request->getQuery()) { + && $this->request->getQuery($potentialPrimaryLink)) { $this->cur_pl->value = $this->request->getQuery($potentialPrimaryLink); } elseif($this->$modelsName->allowLookupPrimaryLink($this->request->getParam('action'))) { // Try to map the requested object ID @@ -345,8 +345,9 @@ public function getPrimaryLink(bool $lookup=false) { break; } } - - if(empty($this->cur_pl->value) && !$this->$modelsName->allowEmptyPrimaryLink()) { + + if(empty($this->cur_pl->value) + && !$this->$modelsName->allowEmptyPrimaryLink($this->request->getParam('action'))) { throw new \RuntimeException(__d('error', 'primary_link')); } } @@ -465,6 +466,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 @@ -515,15 +539,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/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..778680f61 100644 --- a/app/src/Controller/DashboardsController.php +++ b/app/src/Controller/DashboardsController.php @@ -92,6 +92,11 @@ public function configuration() { 'controller' => 'cous', 'action' => 'index' ], + __d('controller', 'EnrollmentFlows', [99]) => [ + 'icon' => 'subscriptions', + 'controller' => 'enrollment_flows', + 'action' => 'index' + ], __d('controller', 'ExternalIdentitySources', [99]) => [ 'icon' => 'cloud_download', 'controller' => 'external_identity_sources', @@ -191,14 +196,19 @@ public function configuration() { $artifactMenuItems = [ __d('controller', 'ExtIdentitySourceRecords', [99]) => [ - 'icon' => 'assignment', + 'icon' => 'badge', 'controller' => 'ext_identity_source_records', 'action' => 'index' ], __d('controller', 'Jobs', [99]) => [ - 'icon' => 'assignment', + 'icon' => 'work_history', 'controller' => 'jobs', 'action' => 'index' + ], + __d('controller', 'Petitions', [99]) => [ + 'icon' => 'pending_actions', + 'controller' => 'petitions', + 'action' => 'index' ] ]; diff --git a/app/src/Controller/EnrollmentFlowStepsController.php b/app/src/Controller/EnrollmentFlowStepsController.php new file mode 100644 index 000000000..8d1796340 --- /dev/null +++ b/app/src/Controller/EnrollmentFlowStepsController.php @@ -0,0 +1,41 @@ + [ + 'EnrollmentFlowSteps.ordr' => 'asc' + ] + ]; +} \ 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..940a69fef --- /dev/null +++ b/app/src/Controller/EnrollmentFlowsController.php @@ -0,0 +1,177 @@ + [ + '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. + } 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/PetitionsController.php b/app/src/Controller/PetitionsController.php new file mode 100644 index 000000000..4ab8a0d1a --- /dev/null +++ b/app/src/Controller/PetitionsController.php @@ -0,0 +1,320 @@ + [ + 'Petitions.modified' => 'desc' + ] + ]; + + // Cached copy of the next step information + private $nextStep = null; + + /** + * 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'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.0.0 + * @param string $id Petition ID + */ + + public function continue(string $id) { + return $this->transitionToStep(int($id)); + } + + /** + * Finalize a Petition. + * + * @since COmanage Registry v5.0.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()); + } + + // For now redrect to the main dashboard on error or if there is no finalize + // redirect specified. XXX When we implement error landing pages, update this. + + return $this->redirect([ + 'plugin' => null, + 'controller' => 'Dashboards', + 'action' => 'dashboard', + '?' => [ + 'co_id' => $this->getCOID() + ] + ]); + } + + /** + * Redirect into a plugin to render the result of an Enrollment Flow Step. + * + * @since COmanage Registry v5.0.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. More specifically. + * + * @since COmanage Registry v5.0.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.0.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 only take over authz for finalize, and only if the request will be + // authenticated via Petition Token. + + if($action == 'finalize') { + $petitionId = (int)$this->request->getParam('pass.0'); + + if(empty($petitionId)) { + $this->llog('error', "No Petition ID specified for finalize"); + return 'notauth'; + } + + // 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/StandardEnrollerController.php b/app/src/Controller/StandardEnrollerController.php new file mode 100644 index 000000000..d075de02f --- /dev/null +++ b/app/src/Controller/StandardEnrollerController.php @@ -0,0 +1,258 @@ +set('vv_petition', $this->petition); + + 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']); + + // 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/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/src/Lib/Enum/EnrollmentActorEnum.php b/app/src/Lib/Enum/EnrollmentActorEnum.php new file mode 100644 index 000000000..74665821a --- /dev/null +++ b/app/src/Lib/Enum/EnrollmentActorEnum.php @@ -0,0 +1,36 @@ +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'); + + $ret['person_id'] = $Identifiers->lookupPersonForLogin($ret['identifier'], $this->getCOID()); + + 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'] === $petitioner->petitioner_identifier) { + $ret['roles'][] = EnrollmentActorEnum::Petitioner; + } + + if($ret['identifier'] === $petitioner->enrollee_identifier) { + $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.0.0 + * @param int $petitionId Petition ID + * @param bool $start True if transitioning from start + */ + + protected function transitionToStep(int $petitionId, bool $start=false) { + $EnrollmentFlows = TableRegistry::getTableLocator()->get('EnrollmentFlows'); + + $stepInfo = $EnrollmentFlows->calculateNextStep($petitionId); + $petition = $stepInfo['petition']; +/* 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); + + // 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); + + // Just inject the token into the URL we already have + $stepInfo['url']['?']['token'] = $token; + + // XXX send an email + } 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)); + } + + return $this->redirect($stepInfo['url']); + } + + /** + * Validate a token associated with the requested petition. + * + * @since COmanage Registry v5.0.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/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/src/Model/Entity/EnrollmentFlow.php b/app/src/Model/Entity/EnrollmentFlow.php new file mode 100644 index 000000000..1e427a8bf --- /dev/null +++ b/app/src/Model/Entity/EnrollmentFlow.php @@ -0,0 +1,42 @@ + true, + 'id' => false, + 'slug' => false, + ]; +} \ No newline at end of file diff --git a/app/src/Model/Entity/EnrollmentFlowStep.php b/app/src/Model/Entity/EnrollmentFlowStep.php new file mode 100644 index 000000000..7090eb7e4 --- /dev/null +++ b/app/src/Model/Entity/EnrollmentFlowStep.php @@ -0,0 +1,42 @@ + true, + 'id' => false, + 'slug' => false, + ]; +} \ 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/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/EnrollmentFlowStepsTable.php b/app/src/Model/Table/EnrollmentFlowStepsTable.php new file mode 100644 index 000000000..8071e772b --- /dev/null +++ b/app/src/Model/Table/EnrollmentFlowStepsTable.php @@ -0,0 +1,171 @@ +addBehavior('Changelog'); + $this->addBehavior('Log'); + $this->addBehavior('Orderable'); + $this->addBehavior('Timestamp'); + + $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration); + + // Define associations + $this->belongsTo('EnrollmentFlows'); + $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' + ], + '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' + ] + ] + ]); + } + + /** + * Define business rules. + * + * @since COmanage Registry v5.0.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; + } + + /** + * 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'); + + $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'); + + 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..1ef0e2a64 --- /dev/null +++ b/app/src/Model/Table/EnrollmentFlowsTable.php @@ -0,0 +1,279 @@ +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' + ] + ] + ]); + } + + /** + * 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/IdentifiersTable.php b/app/src/Model/Table/IdentifiersTable.php index 8d2f45978..404916d42 100644 --- a/app/src/Model/Table/IdentifiersTable.php +++ b/app/src/Model/Table/IdentifiersTable.php @@ -44,10 +44,12 @@ class IdentifiersTable 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\TableMetaTrait; use \App\Lib\Traits\TypeTrait; use \App\Lib\Traits\ValidationTrait; use \App\Lib\Traits\SearchFilterTrait; + use \App\Lib\Traits\QueryModificationTrait; // Default "out of the box" types for this model. Entries here should be // given a default localization in app/resources/locales/*/defaultType.po @@ -243,6 +245,32 @@ public function lookupPersonByLogin(int $coId, string $identifier): int { return $id->person_id; } + /** + * Lookup a Person ID from a login identifier. Only active Identifiers can + * be used for lookups. + * + * @since COmanage Registry v5.0.0 + * @param string $identifier Identifier + * @param int $coId CO ID + * @return int Person ID or null + */ + + public function lookupPersonForLogin(string $identifier, int $coId): ?int { + $id = $this->find() + ->where([ + 'Identifiers.identifier' => $identifier, + 'Identifiers.status' => SuspendableStatusEnum::Active, + 'Identifiers.login' => true, + 'Identifiers.person_id IS NOT NULL' + ]) + ->matching('People', function ($q) use ($coId) { + return $q->where(['People.co_id' => $coId]); + }) + ->firstOrFail(); + + return $id->person_id ?? null; + } + /** * Perform a keyword search. * diff --git a/app/src/Model/Table/PeopleTable.php b/app/src/Model/Table/PeopleTable.php index 21c449f69..0cd260b73 100644 --- a/app/src/Model/Table/PeopleTable.php +++ b/app/src/Model/Table/PeopleTable.php @@ -128,6 +128,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); 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..becb95aea --- /dev/null +++ b/app/src/Model/Table/PetitionsTable.php @@ -0,0 +1,392 @@ +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(['finalize', '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' + ]); + + $this->setAutoViewVars([ + 'statuses' => [ + 'type' => 'enum', + 'class' => 'PetitionStatusEnum' + ] + ]); + + $this->setPermissions([ + // Actions that operate over an entity (ie: require an $id) + 'entity' => [ + '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 operate over a table (ie: do not require an $id) + 'table' => [ + 'add' => false, + 'index' => ['platformAdmin', 'coAdmin'] + ] + ]); + } + + /** + * 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->id); + } + } + 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/templates/EnrollmentFlowSteps/columns.inc b/app/templates/EnrollmentFlowSteps/columns.inc new file mode 100644 index 000000000..3f2035cdb --- /dev/null +++ b/app/templates/EnrollmentFlowSteps/columns.inc @@ -0,0 +1,64 @@ + [ + 'type' => 'echo', + 'sortable' => true + ], + 'description' => [ + 'type' => 'link', + 'sortable' => true + ], + 'actor_type' => [ + 'type' => 'enum', + 'class' => 'EnrollmentActorEnum' + ], + 'plugin' => [ + 'type' => 'echo', + 'sortable' => true + ], + 'status' => [ + 'type' => 'enum', + 'class' => 'SuspendableStatusEnum', + 'sortable' => true + ] +]; + +// $rowActions appear as row-level menu items in the index view gear icon +$rowActions = [ + [ + 'action' => 'configure', + 'label' => __d('operation', 'configure.plugin'), + 'icon' => 'electrical_services' + ] +]; + +$subnav = [ + 'name' => 'enrollment_flow', + 'active' => 'steps' +]; \ No newline at end of file diff --git a/app/templates/EnrollmentFlowSteps/fields-nav.inc b/app/templates/EnrollmentFlowSteps/fields-nav.inc new file mode 100644 index 000000000..7eebea083 --- /dev/null +++ b/app/templates/EnrollmentFlowSteps/fields-nav.inc @@ -0,0 +1,31 @@ + 'enrollment_flow', + 'active' => 'steps' +]; \ No newline at end of file diff --git a/app/templates/EnrollmentFlowSteps/fields.inc b/app/templates/EnrollmentFlowSteps/fields.inc new file mode 100644 index 000000000..4eb841b48 --- /dev/null +++ b/app/templates/EnrollmentFlowSteps/fields.inc @@ -0,0 +1,39 @@ +Field->control('description'); + + print $this->Field->control('status'); + + print $this->Field->control('plugin'); + + print $this->Field->control('ordr'); + + print $this->Field->control('actor_type'); +} diff --git a/app/templates/EnrollmentFlows/columns.inc b/app/templates/EnrollmentFlows/columns.inc new file mode 100644 index 000000000..7f1604916 --- /dev/null +++ b/app/templates/EnrollmentFlows/columns.inc @@ -0,0 +1,52 @@ + [ + 'type' => 'link', + 'sortable' => true + ], + 'status' => [ + 'type' => 'enum', + 'class' => 'TemplateableStatusEnum', + 'sortable' => true + ], + 'authz_type' => [ + 'type' => 'enum', + 'class' => 'EnrollmentAuthzEnum', + 'sortable' => true + ] +]; + +// $rowActions appear as row-level menu items in the index view gear icon +$rowActions = [ + [ + 'action' => 'start', + 'label' => __d('operation', 'EnrollmentFlows.start'), + 'icon' => 'start' + ] +]; \ No newline at end of file diff --git a/app/templates/EnrollmentFlows/fields-nav.inc b/app/templates/EnrollmentFlows/fields-nav.inc new file mode 100644 index 000000000..614ac726f --- /dev/null +++ b/app/templates/EnrollmentFlows/fields-nav.inc @@ -0,0 +1,54 @@ + 'format_list_numbered', + 'order' => 'Default', + 'label' => __d('controller', 'EnrollmentFlowSteps', [99]), + 'link' => [ + 'controller' => 'enrollment_flow_steps', + 'action' => 'index', + 'enrollment_flow_id' => $vv_obj->id + ], + 'class' => '' +]; + +$topLinks[] = [ + 'icon' => 'start', + 'order' => 'Default', + 'label' => __d('operation', 'EnrollmentFlows.start'), + 'link' => [ + 'action' => 'start', + $vv_obj->id + ], + 'class' => '' +]; + +$subnav = [ + 'name' => 'enrollment_flow', + 'active' => 'properties' +]; \ No newline at end of file diff --git a/app/templates/EnrollmentFlows/fields.inc b/app/templates/EnrollmentFlows/fields.inc new file mode 100644 index 000000000..f7513ede2 --- /dev/null +++ b/app/templates/EnrollmentFlows/fields.inc @@ -0,0 +1,75 @@ + + +Field->control('name'); + + print $this->Field->control('status'); + +// print $this->Field->control('sor_label'); + + print $this->Field->control( + fieldName: 'authz_type', + options: [ + 'onChange' => 'updateGadgets()' + ] + ); + + print $this->Field->control('authz_cou_id'); + + print $this->Field->control('authz_group_id'); + + print $this->Field->control('collect_enrollee_email'); + + print $this->Field->control('redirect_on_finalize'); +} diff --git a/app/templates/EnrollmentFlows/start.php b/app/templates/EnrollmentFlows/start.php new file mode 100644 index 000000000..732f70f32 --- /dev/null +++ b/app/templates/EnrollmentFlows/start.php @@ -0,0 +1,53 @@ +Form->create(); + +print $this->Field->startControlSet( + modelName: $this->name, + action: $vv_action, + editable: true, + reqFields: [] +); + +print $this->Field->control( + fieldName: 'enrollee_email', + controlType: 'string', + options: ['required' => true] +); + +print $this->Field->submit(__d('operation', 'save')); + +print $this->Form->end(); + +print $this->Field->endControlSet(); diff --git a/app/templates/HistoryRecords/columns.inc b/app/templates/HistoryRecords/columns.inc index b2ec8a4de..252bb9af5 100644 --- a/app/templates/HistoryRecords/columns.inc +++ b/app/templates/HistoryRecords/columns.inc @@ -25,6 +25,11 @@ * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ +$indexBanners = [ + // CFM-76 info message pending eventual "virtual" view consolidating all History Record types + __d('information', 'HistoryRecords.xref') +]; + $indexColumns = [ 'id' => [ 'type' => 'link' diff --git a/app/templates/Petitions/columns.inc b/app/templates/Petitions/columns.inc new file mode 100644 index 000000000..1cfed9ffc --- /dev/null +++ b/app/templates/Petitions/columns.inc @@ -0,0 +1,94 @@ + [ + 'type' => 'link' + ], + 'enrollee_person_id' => [ + 'type' => 'relatedLink', + 'action' => 'edit', + 'label' => __d('field', 'Petitions.enrollee_person_id'), + 'model' => 'enrollee_person', + 'submodel' => 'primary_name', + 'field' => 'full_name', + 'default' => __d('field', 'Petitions.enrollee.new') + ], + 'status' => [ + 'type' => 'enum', + 'class' => 'PetitionStatusEnum', + 'sortable' => true + ], + 'enrollment_flow_id' => [ + 'type' => 'relatedLink', + 'model' => 'enrollment_flow', + 'field' => 'name' + ], + 'cou_id' => [ + 'type' => 'relatedLink', + 'model' => 'cou', + 'field' => 'name' + ], +/* XXX this apparently isn't in the data model yet? + 'petitioner_person_id' => [ + 'type' => 'relatedLink', + 'action' => 'edit', +// 'label' => __d('field', 'actor'), + 'model' => 'petitioner_person', + 'submodel' => 'primary_name', + 'field' => 'full_name' + ],*/ +// sponsor +// approver - there can be multiple approvers going forward, maybe omit this column + 'created' => [ + 'type' => 'datetime' + ], + 'modified' => [ + 'type' => 'datetime' + ] +]; + +// $rowActions appear as row-level menu items in the index view gear icon +$rowActions = [ + [ + 'controller' => 'petitions', + 'action' => 'view', + 'class' => '', + 'icon' => 'visibility', + 'label' => __d('operation', 'view') + ], + [ + 'controller' => 'petitions', + 'action' => 'resume', + 'class' => '', +// XXX this icon is supposed to be "resume" but that doesn't appear to render + 'icon' => 'start', + 'label' => __d('operation', 'resume'), + 'if' => 'isResumable' + ] +]; \ No newline at end of file diff --git a/app/templates/Petitions/fields.inc b/app/templates/Petitions/fields.inc new file mode 100644 index 000000000..b89841d3f --- /dev/null +++ b/app/templates/Petitions/fields.inc @@ -0,0 +1,87 @@ +Field->control('status'); + + print $this->Field->statusControl( + fieldName: 'enrollee_person_id', + status: !empty($vv_obj->enrollee_person->primary_name) + ? $vv_obj->enrollee_person->primary_name->full_name + : __d('field', 'Petitions.enrollee.new'), + link: (!empty($vv_obj->enrollee_person->id) + ? ['url' => [ + 'controller' => 'people', + 'action' => 'edit', + $vv_obj->enrollee_person_id + ]] + : []) + ); + + print $this->Field->statusControl( + fieldName: 'petitioner_person_id', + status: !empty($vv_obj->petitioner_person->primary_name) + ? $vv_obj->petitioner_person->primary_name->full_name + : "", + link: (!empty($vv_obj->petitioner_person->id) + ? ['url' => [ + 'controller' => 'people', + 'action' => 'edit', + $vv_obj->enrollee_person_id + ]] + : []) + ); + +// XXX petitioner_identifier if set? + + print "
    \n"; + +// XXX merge this with resume? + foreach($vv_obj->enrollment_flow->enrollment_flow_steps as $step) { + $stepId = $step->id; + + print "
  1. " . $step->description; + + $result = \Cake\Utility\Hash::extract($vv_obj->petition_step_results, "{n}[enrollment_flow_step_id=$stepId]"); + + if(!empty($result)) { + print ", Result: " . $result[0]->comment + . $this->Html->link(' (View)', [ + 'controller' => 'petitions', + 'action' => 'result', + $vv_obj->id, + '?' => ['enrollment_flow_step_id' => $step->id] + ]); + } + + print "
  2. \n"; + } + + print "
\n"; +} diff --git a/app/templates/Petitions/resume.php b/app/templates/Petitions/resume.php new file mode 100644 index 000000000..2a8b6ca6e --- /dev/null +++ b/app/templates/Petitions/resume.php @@ -0,0 +1,128 @@ + $this->Menu->getMenuOrder('Default'), + 'icon' => 'pending_actions', + 'url' => [ + 'plugin' => null, + 'controller' => 'Petitions', + 'action' => 'index', + '?' => ['co_id' => $vv_cur_co->id] + ], + 'label' => __d('controller', 'Petitions', 99) + ]; + } + $action_args['vv_actions'][] = [ + 'order' => $this->Menu->getMenuOrder('Default'), + 'icon' => 'home', + 'url' => [ + 'plugin' => null, + 'controller' => 'Dashboards', + 'action' => 'dashboard', + '?' => ['co_id' => $vv_cur_co->id] + ], + 'label' => __d('menu', 'menu.home') + ]; +} +?> + +
+
+

name; // this is the Enrollment Flow name ?>

+
+ + + +
+ + +
+ Flash->render() ?> + + + + Alert->alert($b, 'warning') ?> + + + + + + Alert->alert($b, 'warning') ?> + + +
+ + +
+ + + + + + + + + + + + + + + + + + +
ordr ?? "" ?>description ?>petition_step_results[0]->comment ?? "" ?>actor_type) ?>id ])) { + print $this->Html->link( + ($step->id == $vv_next_step_id) ? + __d('operation', 'continue') . ' arrow_forward' : + __d('operation', 'Petitions.rerun') . ' restart_alt', + $vv_dispatch_urls[ $step->id ], + [ + 'class' => 'btn btn-sm ' . (($step->id == $vv_next_step_id) ? 'btn-tertiary ef-continue-step' : 'btn-default ef-rerun-step'), + 'escape' => false + ] + ); + + if($step->id == $vv_next_step_id) { + $seenNextStep = true; + } + } + ?>
+
diff --git a/app/templates/Standard/add-edit-view.php b/app/templates/Standard/add-edit-view.php index bce04253c..2192937a6 100644 --- a/app/templates/Standard/add-edit-view.php +++ b/app/templates/Standard/add-edit-view.php @@ -49,10 +49,6 @@ } } -if(file_exists($templatePath . DS . "fields-links.inc")) { - include($templatePath . DS . "fields-links.inc"); -} - // $linkFilter is used for models that belong to a specific parent model (eg: co_id) $linkFilter = []; diff --git a/app/templates/Standard/dispatch.php b/app/templates/Standard/dispatch.php new file mode 100644 index 000000000..924fb12a7 --- /dev/null +++ b/app/templates/Standard/dispatch.php @@ -0,0 +1,108 @@ +name = Models +$modelsName = $this->name; +// $tablename = models +$tableName = \Cake\Utility\Inflector::tableize(\Cake\Utility\Inflector::singularize($this->name)); + +// $vv_template_path will be set for plugins +$templatePath = $vv_template_path ?? ROOT . DS . "templates" . DS . $modelsName; + +$action_args['vv_actions'][] = [ + 'order' => $this->Menu->getMenuOrder('Default'), + 'icon' => 'list', + 'url' => [ + 'plugin' => null, + 'controller' => 'petitions', + 'action' => 'resume', + $vv_petition->id + ], + 'label' => __d('controller', 'EnrollmentFlowSteps', 99) +]; +?> + +
+
+

+
+ +
+ + +
+ Flash->render() ?> + + + + Alert->alert($b, 'warning') ?> + + + + + + Alert->alert($b, 'warning') ?> + + +
+ +Form->create(); + +print $this->Field->startControlSet( + modelName: $this->name, + action: $vv_action, + editable: true, + reqFields: [], + pluginName: $this->getPlugin() +); + +// Inject the Petition ID into the form, though it will most likely +// still be available in the URL. +print $this->Form->hidden('petition_id', ['value' => $vv_petition->id]); + +// Inject the token, if indicated. +if(isset($vv_token_ok) && $vv_token_ok && !empty($vv_petition->token)) { + print $this->Form->hidden('token', ['value' => $vv_petition->token]); +} + +if(file_exists($templatePath . DS . "dispatch.inc")) { + include($templatePath . DS . "dispatch.inc"); +} + +print $this->Field->submit(__d('operation', 'continue')); + +print $this->Form->end(); + +print $this->Field->endControlSet(); diff --git a/app/templates/Standard/index.php b/app/templates/Standard/index.php index 1e12d4e9d..b253337ae 100644 --- a/app/templates/Standard/index.php +++ b/app/templates/Standard/index.php @@ -263,17 +263,18 @@ // Action list for command menu dropdown / button listing $action_args = array(); $action_args['vv_attr_id'] = $entity->id; + $action_args['vv_actions'] = []; // Insert actions as per the .inc file // TODO: create an element or move this to MenuHelper so it can be used by topLinks as well as actions $actionOrderDefault = $this->Menu->getMenuOrder('Default'); foreach ($rowActions as $a) { $ok = false; - if (!empty($a['controller'])) { - $tableName = Inflector::camelize($a['controller']); + if (!empty($a['controller']) && $a['controller'] != $tableName) { + $relTableName = Inflector::camelize($a['controller']); - if (isset($vv_permission_set[$entity->id][$tableName][$a['action']])) { - $ok = $vv_permission_set[$entity->id][$tableName][$a['action']]; + if (isset($vv_permission_set[$entity->id][$relTableName][$a['action']])) { + $ok = $vv_permission_set[$entity->id][$relTableName][$a['action']]; } } else { $ok = $vv_permission_set[$entity->id][$a['action']]; @@ -294,7 +295,7 @@ $actionUrl = ['action' => $a['action'], $entity->id]; $actionLabel = !empty($a['label']) ? $a['label'] : __d('operation', $a['action']); - if (!empty($a['controller'])) { + if (!empty($a['controller']) && $a['controller'] != $tableName) { // We're linking into a related controller $actionLabel = !empty($a['label']) ? $a['label'] : __d('controller', Inflector::camelize(Inflector::pluralize($a['controller'])), [99]); $actionUrl = [ @@ -385,10 +386,11 @@ } // Output the row actions if present - if($isFirstLink && !empty($rowActions)) { + if($isFirstLink && !empty($action_args['vv_actions'])) { + // todo check if needed print '
'; print '
'; - print $this->element('menuAction', $action_args); + print $this->element('menuAction', $action_args); print '
'; } @@ -475,6 +477,12 @@ // By default our label is the column value, but it might be overridden $label = $entity->$col . $suffix; + // If there is no calculated default value but a default is configured, + // use that instead + if(empty($label) && !empty($cfg['default'])) { + $label = $cfg['default']; + } + if(!empty($cfg['model']) && !empty($cfg['field'])) { $m = $cfg['model']; $f = $cfg['field']; diff --git a/app/templates/element/menuAction.php b/app/templates/element/menuAction.php index 1832e89d1..5d05d5e49 100644 --- a/app/templates/element/menuAction.php +++ b/app/templates/element/menuAction.php @@ -25,6 +25,8 @@ * @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) */ +// If you're debugging a null $vv_actions, there's probably a permissions problem +// you need to trace $actionsCount = count($vv_actions) + (int)!empty($vv_people_picker); $actionsCountClass = $actionsCount > 0 ? ' actions-count-' . $actionsCount : ''; $actionsExpandedClass = ($actionsCount > 0 && $actionsCount < 4) ? ' actions-expanded' : ''; diff --git a/app/templates/element/menuPanel.php b/app/templates/element/menuPanel.php index 5ee0ca11a..e61ab68a2 100644 --- a/app/templates/element/menuPanel.php +++ b/app/templates/element/menuPanel.php @@ -50,13 +50,11 @@
- Url->build( ['plugin' => null, - 'controller' => 'people', + 'controller' => 'petitions', 'action' => 'index', '?' => [ 'co_id' => $vv_cur_co->id @@ -66,12 +64,11 @@ - */ ?> diff --git a/app/templates/element/subnavigation.php b/app/templates/element/subnavigation.php index 22f3461c2..bb9c56206 100644 --- a/app/templates/element/subnavigation.php +++ b/app/templates/element/subnavigation.php @@ -57,18 +57,24 @@ $navController = $tabsController; } elseif(!empty($vv_person_id)) { $curId = $vv_person_id; - } elseif($active == 'plugin' && !empty($vv_primary_link_obj) && !empty($vv_primary_link_model)) { + } elseif( + ($active == 'plugin' || (!empty($vv_primary_link) && $vv_primary_link == 'enrollment_flow_id')) + && !empty($vv_primary_link_obj) + && !empty($vv_primary_link_model) + ) { $curId = $vv_primary_link_obj->id; $navController = $vv_primary_link_model; } elseif(!empty($vv_primary_link_id)) { $curId = $vv_primary_link_id; - } - + } + // For top-level nav while in edit pages. if ($name == 'person') { $linkFilter = ['person_id' => $curId]; } elseif ($name == 'group') { $linkFilter = ['group_id' => $curId]; + } elseif ($name == 'enrollment_flow') { + $linkFilter = ['enrollment_flow_id' => $curId]; } } elseif(!empty($vv_bc_title_links)) { // All else fails? Use the breadcrumb which has figured this out. @@ -100,6 +106,8 @@ $supertitle = $vv_obj->$vv_display_field; } elseif(!empty($vv_bc_parent_obj)) { $supertitle = $vv_bc_parent_obj->$vv_bc_parent_displayfield; +} elseif(!empty($vv_primary_link_obj)) { + $supertitle = $vv_primary_link_obj->name; } elseif(!empty($vv_bc_title_links)) { // All else fails? Use the breadcrumb which has figured this out. // XXX We might just be able to do this and skip all the above after breadcrumbs have been refactored @@ -282,8 +290,8 @@ */ ?> - - + + + + + +