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..6092c69a1 100644
--- a/app/src/Model/Table/IdentifiersTable.php
+++ b/app/src/Model/Table/IdentifiersTable.php
@@ -44,10 +44,10 @@ 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;
// 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 +243,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 "- " . $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 "
\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')
+ ];
+}
+?>
+
+
+
+
= $vv_primary_link_obj->name; // this is the Enrollment Flow name ?>
+
+
+
+ = $this->element('menuAction', $action_args) ?>
+
+
+
+
+
+
+ = $this->Flash->render() ?>
+
+
+
+ = $this->Alert->alert($b, 'warning') ?>
+
+
+
+
+
+ = $this->Alert->alert($b, 'warning') ?>
+
+
+
+
+
+
+
+
+ = __d('field', 'ordr') ?> |
+ = __d('controller', 'EnrollmentFlowSteps', [1]) ?> |
+ = __d('result', 'result') ?> |
+ = __d('field', 'EnrollmentFlows.authz_type') ?> |
+ = __d('field', 'action') ?> |
+
+
+
+ = $step->ordr ?? "" ?> |
+ = $step->description ?> |
+
+ = $step->petition_step_results[0]->comment ?? "" ?> |
+ = __d('enumeration', 'EnrollmentActorEnum.'.$step->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)
+];
+?>
+
+
+
+
= $vv_title; ?>
+
+
+ = $this->element('menuAction', $action_args) ?>
+
+
+
+
+
+ = $this->Flash->render() ?>
+
+
+
+ = $this->Alert->alert($b, 'warning') ?>
+
+
+
+
+
+ = $this->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 @@
*/ ?>
-
-
+
+
-
+
+
+
+ Html->link(
+ __d('controller', 'Properties', [99]),
+ [
+ 'controller' => 'enrollment-flows',
+ 'action' => $curAction == 'view' ? 'view' : 'edit',
+ $curId
+ ],
+ ['class' => $linkClass]
+ );
+ ?>
+
+
+ Html->link(
+ __d('controller', 'EnrollmentFlowSteps', [99]),
+ [
+ 'controller' => 'enrollment_flow_steps',
+ 'action' => 'index',
+ '?' => $linkFilter
+ ],
+ ['class' => $linkClass]
+ );
+ ?>
+
+
+
+
diff --git a/app/templates/layout/default.php b/app/templates/layout/default.php
index fe70da0bf..2f0ce58c5 100644
--- a/app/templates/layout/default.php
+++ b/app/templates/layout/default.php
@@ -90,6 +90,7 @@
$bodyClasses = $controller_stripped . ' ' .$action_stripped;
$isCoSelectView = $controller_stripped == 'cos' && $action_stripped == 'select';
$isDashboard = $controller_stripped == 'dashboards' && $action_stripped == 'dashboard';
+ $isActivePetition = $vv_action == 'dispatch' || $vv_action == 'resume';
$generateHomeLink = (!$isCoSelectView && !empty($vv_cur_co));
// add further body classes as needed
@@ -166,17 +167,19 @@
-
-
-
-
menu
- = $this->element('searchGlobal') ?>
+
+
+
+
+
menu
+ = $this->element('searchGlobal') ?>
+
+
+
-
-
-
+
-
+
= $this->element('menuMain') ?>
-
- = $this->element('breadcrumbs') ?>
-
+
+
+ = $this->element('breadcrumbs') ?>
+
+
diff --git a/app/webroot/css/co-base.css b/app/webroot/css/co-base.css
index f66b87746..3901b572a 100644
--- a/app/webroot/css/co-base.css
+++ b/app/webroot/css/co-base.css
@@ -633,8 +633,9 @@ ul.form-list li.alert-banner .co-alert {
#main-menu .menu-panel-sidepanel h3 {
margin-bottom: 0.5em;
}
-#main-menu .menu-panel-sidepanel ul li a {
- display: inline;
+#main-menu a.menu-panel-secondary-link {
+ display: flex !important;
+ align-items: center;
padding: 0;
}
#main-menu .menu-panel-common-items {
@@ -1786,6 +1787,15 @@ ul.form-list .cm-time-picker-vals li {
.eis-meta.eis-item {
width: 50%;
}
+/* PETITIONS */
+#petition-resume td {
+ vertical-align: middle;
+}
+.btn.ef-continue-step,
+.btn.ef-rerun-step {
+ padding-right: 1em;
+ white-space: nowrap;
+}
/* GENERAL */
.hidden,
.invisible,
@@ -2059,6 +2069,23 @@ button, .btn, .btn:hover {
color: var(--cmg-color-txt-inverse);
border-color: var(--cmg-color-btn-bg-001);
}
+.btn-secondary,
+.btn-secondary:focus,
+.btn-secondary:active {
+ background-color: var(--cmg-color-btn-bg-004);
+ color: var(--cmg-color-txt-inverse);
+}
+.btn-tertiary,
+.btn-tertiary:focus,
+.btn-tertiary:active {
+ background-color: var(--cmg-color-btn-bg-005);
+ color: var(--cmg-color-txt-inverse);
+}
+.btn-default,
+.btn-default:focus,
+.btn-default:active {
+ background-color: var(--cmg-color-bg-005);
+}
.btn-primary,
.btn-primary:active,
.btn-secondary,
@@ -2066,7 +2093,20 @@ button, .btn, .btn:hover {
box-shadow: 0 2px 2px 0 rgba(0,0,0,.14),0 3px 1px -2px rgba(0,0,0,.2),0 1px 5px 0 rgba(0,0,0,.12);
}
.btn-primary:hover,
-.btn-secondary:hover {
+.btn-secondary:hover,
+.btn-secondary,
+.btn-tertiary,
+.btn-default,
+.btn-primary:active,
+.btn-secondary:active,
+.btn-tertiary:active,
+.btn-default:active {
+ box-shadow: 0 2px 2px 0 rgba(0,0,0,.14),0 3px 1px -2px rgba(0,0,0,.2),0 1px 5px 0 rgba(0,0,0,.12);
+}
+.btn-primary:hover,
+.btn-secondary:hover,
+.btn-tertiary:hover,
+.btn-default:hover {
background-color: var(--cmg-color-btn-bg-002);
color: var(--cmg-color-txt-inverse) !important;
border-color: var(--cmg-color-btn-bg-002);
@@ -2079,11 +2119,6 @@ button, .btn, .btn:hover {
border-color: var(--cmg-color-btn-bg-001);
opacity: 0.4;
}
-.btn-default,
-.btn-default:active {
- background-color: var(--cmg-color-bg-005);
- box-shadow: 0 2px 2px 0 rgba(0,0,0,.14),0 3px 1px -2px rgba(0,0,0,.2),0 1px 5px 0 rgba(0,0,0,.12);
-}
html.dark-mode .btn-default,
html.dark-mode .btn-default:active {
background-color: var(--cmg-color-bg-005);
diff --git a/app/webroot/css/co-color.css b/app/webroot/css/co-color.css
index a3bbf3ceb..c9b6524dd 100644
--- a/app/webroot/css/co-color.css
+++ b/app/webroot/css/co-color.css
@@ -51,7 +51,8 @@
--cmg-color-btn-bg-001: #115791; /* primary button background color (blue), submit buttons, .btn-primary, pagination */
--cmg-color-btn-bg-002: #000; /* primary button hover color (black) */
--cmg-color-btn-bg-003: #444; /* common background color (gray) */
- --cmg-color-btn-004: #c33; /* button (red) */
+ --cmg-color-btn-bg-004: #c33; /* button (red) */
+ --cmg-color-btn-bg-005: #17730d; /* button (green) */
/* General Background and Border Colors (Grayscale) */
--cmg-color-bg-001: #fafafa; /* background color */
@@ -128,7 +129,8 @@ html.dark-mode {
--cmg-color-btn-bg-001: #0d4573; /* primary button background color (blue), submit buttons, .btn-primary, pagination */
--cmg-color-btn-bg-002: #333; /* primary button hover color (black) */
--cmg-color-btn-bg-003: #eee; /* common background color (gray) */
- --cmg-color-btn-004: #c33; /* button (red) */
+ --cmg-color-btn-bg-004: #c33; /* button (red) */
+ --cmg-color-btn-bg-005: #17730d; /* button (green) */
/* General Background and Border Colors (Grayscale) */
--cmg-color-bg-001: #121212; /* background color */