diff --git a/app/config/routes.php b/app/config/routes.php
index 89664c075..2bf695e7b 100644
--- a/app/config/routes.php
+++ b/app/config/routes.php
@@ -168,6 +168,20 @@ function (RouteBuilder $builder) {
* ...and connect the rest of 'Pages' controller's URLs.
*/
$builder->connect('/pages/*', ['controller' => 'Pages', 'action' => 'display']);
+
+ /**
+ * Registry allows URLs of the form /coid/name to render as a Mostly Static Page.
+ *
+ * Note this will effectively route any URL of the form /registry/x, where x consists of
+ * digits, to the Pages controller. We need to filter on digits, or we'll end up taking
+ * over all controllers as well. (The implication is we can't have a controller whose
+ * name consists entirely of digits, but we probably shouldn't...)
+ */
+ $builder->connect(
+ '/{coid}/{name}',
+ ['controller' => 'Pages', 'action' => 'show' ],
+ ['coid' => '\d+', 'pass' => ['coid', 'name']]
+ );
/*
* Connect catchall routes for all controllers.
diff --git a/app/config/schema/schema.json b/app/config/schema/schema.json
index de84a8e43..21e03464a 100644
--- a/app/config/schema/schema.json
+++ b/app/config/schema/schema.json
@@ -25,6 +25,7 @@
"id": { "type": "integer", "autoincrement": true, "primarykey": true },
"identifier_assignment_id": { "type": "integer", "foreignkey": { "table": "identifier_assignments", "column": "id" }, "notnull": true },
"language": { "type": "string", "size": 16 },
+ "message_template_id": { "type": "integer", "foreignkey": { "table": "message_templates", "column": "id" } },
"name": { "type": "string", "size": 128, "notnull": true },
"ordr": { "type": "integer" },
"person_id": { "type": "integer", "foreignkey": { "table": "people", "column": "id" } },
@@ -598,6 +599,22 @@
}
},
+ "mostly_static_pages": {
+ "columns": {
+ "id": {},
+ "co_id": {},
+ "name": {},
+ "title": { "type": "string", "size": 256 },
+ "description": {},
+ "status": {},
+ "context": {},
+ "body": { "type": "text" }
+ },
+ "indexes": {
+ "mostly_static_pages_i1": { "columns": [ "co_id" ] }
+ }
+ },
+
"enrollment_flows": {
"columns": {
"id": {},
@@ -626,10 +643,13 @@
"status": {},
"plugin": {},
"ordr": {},
- "actor_type": { "type": "string", "size": 2 }
+ "actor_type": { "type": "string", "size": 2 },
+ "message_template_id": {},
+ "redirect_on_handoff": { "type": "string", "size": 256 }
},
"indexes": {
- "enrollment_flow_steps_i1": { "columns": [ "enrollment_flow_id" ]}
+ "enrollment_flow_steps_i1": { "columns": [ "enrollment_flow_id" ]},
+ "enrollment_flow_steps_i2": { "needed": false, "columns": [ "message_template_id" ]}
}
},
diff --git a/app/plugins/CoreEnroller/resources/locales/en_US/core_enroller.po b/app/plugins/CoreEnroller/resources/locales/en_US/core_enroller.po
index 7341b0a35..0976cc229 100644
--- a/app/plugins/CoreEnroller/resources/locales/en_US/core_enroller.po
+++ b/app/plugins/CoreEnroller/resources/locales/en_US/core_enroller.po
@@ -35,6 +35,15 @@ msgstr "{0,plural,=1{Enrollment Attribute} other{Enrollment Attributes}}"
msgid "enumeration.DefaultValueValidityType.after"
msgstr "Days After Finalization"
+msgid "error.PetitionAcceptances.exists"
+msgstr "An Invitation for this Petition already exists"
+
+msgid "error.PetitionAcceptances.expired"
+msgstr "This Invitation has expired"
+
+msgid "error.PetitionAcceptances.processed"
+msgstr "This Invitation has already been processed"
+
msgid "field.BasicAttributeCollectors.affiliation_type_id"
msgstr "Affiliation Type"
@@ -131,8 +140,32 @@ msgstr "Telephone Number Type"
msgid "field.EnrollmentAttributes.url_type_id"
msgstr "URL Type"
+msgid "field.InvitationAccepters.invitation_validity"
+msgstr "Invitation Validity"
+
+msgid "field.InvitationAccepters.invitation_validity.desc"
+msgstr "Time in minutes before the invitation expires, default is 1440 (1 day)"
+
+msgid "field.InvitationAccepters.welcome_message"
+msgstr "Welcome Message"
+
+msgid "result.accept.accepted"
+msgstr "Invitation Accepted"
+
+msgid "result.accept.declined"
+msgstr "Invitation Declined"
+
msgid "result.attr.saved"
msgstr "Petition Attributes recorded"
msgid "result.basicattr.finalized"
msgstr "Name, Email Address, and Person Role created during finalization"
+
+msgid "result.InvitationAccepters.accepted"
+msgstr "Invitation Accepted at {0}"
+
+msgid "result.InvitationAccepters.declined"
+msgstr "Invitation Declined at {0}"
+
+msgid "result.InvitationAccepters.none"
+msgstr "No response to invitation yet"
\ No newline at end of file
diff --git a/app/plugins/CoreEnroller/src/Controller/BasicAttributeCollectorsController.php b/app/plugins/CoreEnroller/src/Controller/BasicAttributeCollectorsController.php
index 56c9af772..bf3f701f8 100644
--- a/app/plugins/CoreEnroller/src/Controller/BasicAttributeCollectorsController.php
+++ b/app/plugins/CoreEnroller/src/Controller/BasicAttributeCollectorsController.php
@@ -98,10 +98,10 @@ public function dispatch(string $id) {
public function display(string $id) {
$petition = $this->getPetition();
- $this->set('vv_basic_petition_attribute_set', $this->BasicAttributeCollectors
- ->BasicPetitionAttributeSets
+ $this->set('vv_petition_basic_attribute_set', $this->BasicAttributeCollectors
+ ->PetitionBasicAttributeSets
->find()
- ->where(['BasicPetitionAttributeSets.petition_id' => $petition->id])
+ ->where(['PetitionBasicAttributeSets.petition_id' => $petition->id])
->firstOrFail());
}
}
diff --git a/app/plugins/CoreEnroller/src/Controller/InvitationAcceptersController.php b/app/plugins/CoreEnroller/src/Controller/InvitationAcceptersController.php
new file mode 100644
index 000000000..3b377d355
--- /dev/null
+++ b/app/plugins/CoreEnroller/src/Controller/InvitationAcceptersController.php
@@ -0,0 +1,130 @@
+ [
+ 'InvitationAccepters.id' => 'asc'
+ ]
+ ];
+
+ /**
+ * Dispatch an Enrollment Flow Step.
+ *
+ * @since COmanage Registry v5.1.0
+ * @param string $id Invitation Accepter ID
+ */
+
+ public function dispatch(string $id) {
+ $petition = $this->getPetition();
+
+ $cfg = $this->InvitationAccepters->get($id);
+
+ $this->set('vv_config', $cfg);
+
+ $PetitionAcceptances = TableRegistry::getTableLocator()->get('CoreEnroller.PetitionAcceptances');
+
+ $pa = $PetitionAcceptances->find()->where(['petition_id' => $petition->id])->first();
+
+ $this->set('vv_pa', $pa);
+
+ if($this->request->is(['post', 'put'])) {
+ $data = $this->request->getData();
+
+ $accepted = (bool)$data['accepted'];
+
+ try {
+ $PetitionAcceptances->processReply(
+ $petition->id,
+ $cfg->enrollment_flow_step_id,
+ $accepted
+ );
+
+ if($accepted) {
+ // On acceptance, indicate the step is completed and generate a redirect
+ // to the next step
+
+ $link = $this->getPrimaryLink(true);
+
+ return $this->finishStep(
+ enrollmentFlowStepId: $link->value,
+ petitionId: $petition->id,
+ comment: __d('core_enroller', ($accepted ? 'result.accept.accepted' : 'result.accept.declined'))
+ );
+ } else {
+ // On decline, set a flash message and redirect to the petition complete landing
+ $this->Flash->success(__d('core_enroller', 'result.accept.declined'));
+
+ $coId = $this->getCOID();
+
+ return $this->redirect("/$coId/petition-complete");
+ }
+ }
+ catch(\Exception $e) {
+ $this->Flash->error($e->getMessage());
+ }
+ } else {
+ // Record that the invitation was viewed
+ $PetitionHistoryRecords = TableRegistry::getTableLocator()->get('PetitionHistoryRecords');
+
+ $PetitionHistoryRecords->record(
+ petitionId: $petition->id,
+ enrollmentFlowStepId: $cfg->enrollment_flow_step_id,
+ action: PetitionActionEnum::InvitationViewed,
+ comment: __d('result', 'Petitions.viewed.inv')
+ // actorPersonId
+ );
+ }
+
+ // Fall through and let the form render*/
+
+ $this->render('/Standard/dispatch');
+ }
+
+ /**
+ * Display information about this Step.
+ *
+ * @since COmanage Registry v5.1.0
+ * @param string $id Invitation Accepters ID
+ */
+
+ public function display(string $id) {
+ $petition = $this->getPetition();
+
+ $PetitionAcceptances = TableRegistry::getTableLocator()->get('CoreEnroller.PetitionAcceptances');
+
+ $this->set('vv_pa', $PetitionAcceptances->find()->where(['petition_id' => $petition->id])->first());
+ }
+}
diff --git a/app/plugins/CoreEnroller/src/Model/Entity/BasicPetitionAttributeSet.php b/app/plugins/CoreEnroller/src/Model/Entity/InvitationAccepter.php
similarity index 93%
rename from app/plugins/CoreEnroller/src/Model/Entity/BasicPetitionAttributeSet.php
rename to app/plugins/CoreEnroller/src/Model/Entity/InvitationAccepter.php
index b810bd47d..8017d29b1 100644
--- a/app/plugins/CoreEnroller/src/Model/Entity/BasicPetitionAttributeSet.php
+++ b/app/plugins/CoreEnroller/src/Model/Entity/InvitationAccepter.php
@@ -1,6 +1,6 @@
+ */
+ protected $_accessible = [
+ '*' => true,
+ 'id' => false,
+ 'slug' => false,
+ ];
+}
diff --git a/app/plugins/CoreEnroller/src/Model/Entity/PetitionBasicAttributeSet.php b/app/plugins/CoreEnroller/src/Model/Entity/PetitionBasicAttributeSet.php
new file mode 100644
index 000000000..d506679eb
--- /dev/null
+++ b/app/plugins/CoreEnroller/src/Model/Entity/PetitionBasicAttributeSet.php
@@ -0,0 +1,49 @@
+
+ */
+ protected $_accessible = [
+ '*' => true,
+ 'id' => false,
+ 'slug' => false,
+ ];
+}
diff --git a/app/plugins/CoreEnroller/src/Model/Table/BasicAttributeCollectorsTable.php b/app/plugins/CoreEnroller/src/Model/Table/BasicAttributeCollectorsTable.php
index bc550daba..5ed3d6aa0 100644
--- a/app/plugins/CoreEnroller/src/Model/Table/BasicAttributeCollectorsTable.php
+++ b/app/plugins/CoreEnroller/src/Model/Table/BasicAttributeCollectorsTable.php
@@ -78,7 +78,7 @@ public function initialize(array $config): void {
->setProperty('affiliation_type');
$this->belongsTo('Cous');
- $this->hasMany('CoreEnroller.BasicPetitionAttributeSets')
+ $this->hasMany('CoreEnroller.PetitionBasicAttributeSets')
->setDependent(true)
->setCascadeCallbacks(true);
@@ -151,10 +151,10 @@ public function finalize(int $id, \App\Model\Entity\Petition $petition) {
}
$People = TableRegistry::getTableLocator()->get('People');
-
+
$person = $People->get($petition->enrollee_person_id);
- $attributes = $this->BasicPetitionAttributeSets
+ $attributes = $this->PetitionBasicAttributeSets
->find()
->where([
'petition_id' => $petition->id,
@@ -237,7 +237,7 @@ public function upsert(int $id, int $petitionId, array $attributes) {
// _all_ attributes for the Petition, not just those associated with this
// particular Attribute Collector; however we'll only look at the attributes
// we need below.
- $entity = $this->BasicPetitionAttributeSets
+ $entity = $this->PetitionBasicAttributeSets
->find()
->where([
'petition_id' => $petitionId,
@@ -250,7 +250,7 @@ public function upsert(int $id, int $petitionId, array $attributes) {
if(!$entity) {
// insert, not update
- $entity = $this->BasicPetitionAttributeSets->newEntity([
+ $entity = $this->PetitionBasicAttributeSets->newEntity([
'basic_attribute_collector_id' => $id,
'petition_id' => $petitionId
]);
@@ -261,7 +261,7 @@ public function upsert(int $id, int $petitionId, array $attributes) {
$entity->$f = $attributes[$f] ?? null;
}
- $this->BasicPetitionAttributeSets->saveOrFail($entity);
+ $this->PetitionBasicAttributeSets->saveOrFail($entity);
// Record Petition History
diff --git a/app/plugins/CoreEnroller/src/Model/Table/InvitationAcceptersTable.php b/app/plugins/CoreEnroller/src/Model/Table/InvitationAcceptersTable.php
new file mode 100644
index 000000000..dfc844519
--- /dev/null
+++ b/app/plugins/CoreEnroller/src/Model/Table/InvitationAcceptersTable.php
@@ -0,0 +1,208 @@
+addBehavior('Changelog');
+ $this->addBehavior('Log');
+ $this->addBehavior('Timestamp');
+
+ $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration);
+
+ // Define associations
+ $this->belongsTo('EnrollmentFlowSteps');
+
+ // We intentionally don't hasMany PetitionAcceptances since there should only be one
+ // acceptance per Petition, and the net result of not having the direct foreign key
+ // is that if an admin instantiates the plugin multiple times the second instance
+ // will refuse to do anything.
+
+ $this->setDisplayField('id');
+
+ $this->setPrimaryLink('enrollment_flow_step_id');
+ $this->setRequiresCO(true);
+ $this->setAllowLookupPrimaryLink(['dispatch', 'display']);
+
+ $this->setPermissions([
+ // Actions that operate over an entity (ie: require an $id)
+ 'entity' => [
+ 'delete' => false, // Delete the pluggable object instead
+ 'dispatch' => true,
+ 'display' => true,
+ 'edit' => ['platformAdmin', 'coAdmin'],
+ 'view' => ['platformAdmin', 'coAdmin']
+ ],
+ // Actions that operate over a table (ie: do not require an $id)
+ 'table' => [
+ 'add' => false, // This is added by the parent model
+ 'index' => ['platformAdmin', 'coAdmin']
+ ]
+ ]);
+ }
+
+ /**
+ * Perform steps necessary to finalize the Petition.
+ *
+ * @since COmanage Registry v5.1.0
+ * @param int $id Invitation Accepter ID
+ * @param Petition $petition Petition
+ * @return bool true on success
+ */
+
+ public function finalize(int $id, \App\Model\Entity\Petition $petition) {
+ // $cfg = $this->get($id);
+
+ // We don't have anything to do for finalization
+
+ return true;
+ }
+
+ /**
+ * Perform tasks prior to transitioning to this step.
+ *
+ * @since COmanage Registry v5.1.0
+ * @param EnrollmentFlowStep $step Enrollment Flow Step
+ * @param Petition $petition Petition
+ * @return bool true on success
+ */
+
+ public function prepare(
+ \App\Model\Entity\EnrollmentFlowStep $step,
+ \App\Model\Entity\Petition $petition
+ ): bool {
+ // Allocate an Invitation. We create an empty petition_acceptance to use to measure
+ // invitation validity (based on the created timestamp).
+
+ $PetitionAcceptances = TableRegistry::getTableLocator()->get('CoreEnroller.PetitionAcceptances');
+
+ $acceptance = $PetitionAcceptances->find()
+ ->where(['petition_id' => $petition->id])
+ ->first();
+
+ if(!empty($acceptance)) {
+ // We'll allow a null (pending) invitation, but if there is already a response
+ // we throw an error. This probably isn't the best behavior, once we have some
+ // more requirements we should probably change this to do something else (eg:
+ // fast forward to the next step).
+
+ if(!is_null($acceptance)) {
+ throw new \RuntimeException(__d('core_enroller', 'error.PetitionAcceptances.exists'));
+ }
+
+ // We don't reset the Petition status, at least pending further requirements.
+ } else {
+ // Register a new, pending invitation
+
+ $acceptance = [
+ 'petition_id' => $petition->id,
+ 'accepted' => null
+ ];
+
+ // If for some reason there is already an acceptance record
+ // for this petition we'll basically reset it
+ $PetitionAcceptances->saveOrFail($acceptance);
+
+ // Set this petition to Pending Invitation
+
+ $Petitions = TableRegistry::getTableLocator()->get('Petitions');
+
+ $petition->status = PetitionStatusEnum::PendingAcceptance;
+
+ $Petitions->saveOrFail($petition);
+
+ // Record history
+ $Petitions->PetitionHistoryRecords->record(
+ petitionId: $petition->id,
+ enrollmentFlowStepId: $step->id,
+ action: PetitionActionEnum::InvitationViewed,
+ comment: __d('result', 'Petitions.viewed.inv')
+ // actorPersonId
+ );
+ }
+
+ return true;
+ }
+
+ /**
+ * Set validation rules.
+ *
+ * @since COmanage Registry v5.1.0
+ * @param Validator $validator Validator
+ * @return Validator Validator
+ */
+
+ public function validationDefault(Validator $validator): Validator {
+ $schema = $this->getSchema();
+
+ $validator->add('enrollment_flow_step_id', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->notEmptyString('enrollment_flow_step_id');
+
+ $validator->add('invitation_validity', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->notEmptyString('invitation_validity');
+
+ $validator->add('welcome_message', [
+ 'filter' => ['rule' => ['validateInput'],
+ 'provider' => 'table']
+ ]);
+ $validator->allowEmptyString('welcome_message');
+
+ return $validator;
+ }
+}
diff --git a/app/plugins/CoreEnroller/src/Model/Table/PetitionAcceptancesTable.php b/app/plugins/CoreEnroller/src/Model/Table/PetitionAcceptancesTable.php
new file mode 100644
index 000000000..76a716cfd
--- /dev/null
+++ b/app/plugins/CoreEnroller/src/Model/Table/PetitionAcceptancesTable.php
@@ -0,0 +1,177 @@
+addBehavior('Changelog');
+ $this->addBehavior('Log');
+ $this->addBehavior('Timestamp');
+
+ $this->setTableType(\App\Lib\Enum\TableTypeEnum::Artifact);
+
+ // Define associations
+ $this->belongsTo('Petitions');
+
+ $this->setDisplayField('accepted');
+
+ $this->setPrimaryLink('petition_id');
+ $this->setRequiresCO(true);
+
+ $this->setPermissions([
+ // Actions that operate over an entity (ie: require an $id)
+ 'entity' => [
+ 'delete' => false,
+ 'edit' => false,
+ 'view' => ['platformAdmin', 'coAdmin']
+ ],
+ // Actions that operate over a table (ie: do not require an $id)
+ 'table' => [
+ 'add' => false,
+ 'index' => false //['platformAdmin', 'coAdmin']
+ ]
+ ]);
+ }
+
+ /**
+ * Process an Invitation reply.
+ *
+ * @since COmanage Registry v5.1.0
+ * @param int $petitionId Petition ID
+ * @param int $enrollmentFlowStepId Enrollment Flow Step ID
+ * @param bool $accepted true if the reply was accepted, false if declined
+ * @throws Exceptions
+ */
+
+ public function processReply(int $petitionId, int $enrollmentFlowStepId, bool $accepted) {
+ // There should already be an empty Acceptance indicating when the step was handed off
+ // for Invitation, and for which we need to refer to to determine the invitation validity.
+ // If it doesn't exist, that's an error.
+
+ $acceptance = $this->find()->where(['petition_id' => $petitionId])->firstOrFail();
+
+ // If accepted is not null, we've already processed this invitation, so that's also an error.
+
+ if(!is_null($acceptance->accepted)) {
+ throw new \RuntimeException(__d('core_enroller', 'error.PetitionAcceptances.processed'));
+ }
+
+ // Next check the create time of the original record. For this, we need the configuration.
+
+ $InvitationAccepters = TableRegistry::getTableLocator()->get('CoreEnroller.InvitationAccepters');
+
+ $ia = $InvitationAccepters->find()
+ ->where(['enrollment_flow_step_id' => $enrollmentFlowStepId])
+ ->firstOrFail();
+
+ // A validity of 0 disables expiration
+
+ if($ia->invitation_validity > 0) {
+ $expires = $acceptance->created->addSeconds($ia->invitation_validity);
+
+ if($expires->isPast()) {
+ throw new \RuntimeException('error.PetitionAcceptances.expired');
+ }
+ }
+
+ // We're finally ready to process the reply
+
+ $acceptance->accepted = $accepted;
+
+ $this->saveOrFail($acceptance);
+
+ // Set the Petition status appropriately
+
+ $petition = $this->Petitions->get($petitionId);
+
+ $petition->status = $accepted ? PetitionStatusEnum::Accepted : PetitionStatusEnum::Declined;
+
+ $this->Petitions->saveOrFail($petition);
+
+ // Record Petition History
+
+ $this->Petitions->PetitionHistoryRecords->record(
+ petitionId: $petitionId,
+ enrollmentFlowStepId: $enrollmentFlowStepId,
+ action: PetitionActionEnum::Finalized,
+ comment: __d('core_enroller', $accepted ? 'result.accept.accepted' : 'result.accept.declined')
+// We don't have $actorPersonId yet...
+// ?int $actorPersonId=null
+ );
+ }
+
+ /**
+ * Set validation rules.
+ *
+ * @since COmanage Registry v5.1.0
+ * @param Validator $validator Validator
+ * @return Validator Validator
+ */
+
+ public function validationDefault(Validator $validator): Validator {
+ $schema = $this->getSchema();
+
+ $validator->add('petition_id', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->notEmptyString('petition_id');
+
+ $validator->add('accepted', [
+ 'content' => ['rule' => ['boolean']]
+ ]);
+ $validator->allowEmptyString('accepted');
+
+ return $validator;
+ }
+}
diff --git a/app/plugins/CoreEnroller/src/Model/Table/BasicPetitionAttributeSetsTable.php b/app/plugins/CoreEnroller/src/Model/Table/PetitionBasicAttributeSetsTable.php
similarity index 97%
rename from app/plugins/CoreEnroller/src/Model/Table/BasicPetitionAttributeSetsTable.php
rename to app/plugins/CoreEnroller/src/Model/Table/PetitionBasicAttributeSetsTable.php
index d0f7bb5bb..a51e31976 100644
--- a/app/plugins/CoreEnroller/src/Model/Table/BasicPetitionAttributeSetsTable.php
+++ b/app/plugins/CoreEnroller/src/Model/Table/PetitionBasicAttributeSetsTable.php
@@ -1,6 +1,6 @@
-
Honorific: = $vv_basic_petition_attribute_set['honorific'] ?? "" ?>
- Given: = $vv_basic_petition_attribute_set['given'] ?? "" ?>
- Middle: = $vv_basic_petition_attribute_set['middle'] ?? "" ?>
- Family: = $vv_basic_petition_attribute_set['family'] ?? "" ?>
- Suffix: = $vv_basic_petition_attribute_set['suffix'] ?? "" ?>
- Email: = $vv_basic_petition_attribute_set['mail'] ?? "" ?>
+ Honorific: = $vv_petition_basic_attribute_set['honorific'] ?? "" ?>
+ Given: = $vv_petition_basic_attribute_set['given'] ?? "" ?>
+ Middle: = $vv_petition_basic_attribute_set['middle'] ?? "" ?>
+ Family: = $vv_petition_basic_attribute_set['family'] ?? "" ?>
+ Suffix: = $vv_petition_basic_attribute_set['suffix'] ?? "" ?>
+ Email: = $vv_petition_basic_attribute_set['mail'] ?? "" ?>
diff --git a/app/plugins/CoreEnroller/templates/BasicAttributeCollectors/fields.inc b/app/plugins/CoreEnroller/templates/BasicAttributeCollectors/fields.inc
index b1dd114b8..23c5a986c 100644
--- a/app/plugins/CoreEnroller/templates/BasicAttributeCollectors/fields.inc
+++ b/app/plugins/CoreEnroller/templates/BasicAttributeCollectors/fields.inc
@@ -1,6 +1,6 @@
welcome_message . "\n";
+
+ // Make the Form fields editable
+ $this->Field->enableFormEditMode();
+
+ print $this->element('form/listItem', [
+ 'arguments' => [
+ 'fieldName' => 'accepted',
+ 'fieldLabel' => "Accept this invitation?",
+ 'fieldOptions' => [
+ 'type' => 'radio',
+ 'options' => [
+ ['value' => '1', 'text' => 'Accept'],
+ ['value' => '0', 'text' => 'Decline'],
+ ],
+ 'empty' => false,
+ 'required' => true
+ ]
+ ]]);
+}
\ No newline at end of file
diff --git a/app/plugins/CoreEnroller/templates/InvitationAccepters/display.php b/app/plugins/CoreEnroller/templates/InvitationAccepters/display.php
new file mode 100644
index 000000000..d1d911100
--- /dev/null
+++ b/app/plugins/CoreEnroller/templates/InvitationAccepters/display.php
@@ -0,0 +1,10 @@
+element('form/listItem', [
+ 'arguments' => [
+ 'fieldName' => 'invitation_validity',
+ 'fieldOptions' => [
+ 'default' => 1440
+ ]
+ ]
+ ]);
+
+ print $this->element('form/listItem', [
+ 'arguments' => ['fieldName' => 'welcome_message']
+ ]);
+}
diff --git a/app/resources/locales/en_US/controller.po b/app/resources/locales/en_US/controller.po
index fcdddd7d2..b4fab7b4a 100644
--- a/app/resources/locales/en_US/controller.po
+++ b/app/resources/locales/en_US/controller.po
@@ -102,6 +102,9 @@ msgstr "{0,plural,=1{Job} other{Jobs}}"
msgid "MessageTemplates"
msgstr "{0,plural,=1{Message Template} other{Message Templates}}"
+msgid "MostlyStaticPages"
+msgstr "{0,plural,=1{Mostly Static Page} other{Mostly Static Pages}}"
+
msgid "Names"
msgstr "{0,plural,=1{Name} other{Names}}"
diff --git a/app/resources/locales/en_US/enumeration.po b/app/resources/locales/en_US/enumeration.po
index 080f6554b..9cd7394fe 100644
--- a/app/resources/locales/en_US/enumeration.po
+++ b/app/resources/locales/en_US/enumeration.po
@@ -381,6 +381,15 @@ msgstr "Resolved"
msgid "NotificationStatusEnum.X"
msgstr "Canceled"
+msgid "PageContextEnum.EH"
+msgstr "Enrollment Handoff"
+
+msgid "PageContextEnum.ER"
+msgstr "Error Landing"
+
+msgid "PageContextEnum.G"
+msgstr "General"
+
msgid "PermittedNameFieldsEnum.given,family"
msgstr "Given, Family"
@@ -432,14 +441,24 @@ msgstr "Country Code, Area Code, Number, Extension"
msgid "PetitionActionEnum.AU"
msgstr "Attributes Updated"
+msgid "PetitionActionEnum.F"
+msgstr "Finalized"
+
+msgid "PetitionActionEnum.IV"
+msgstr "Invitation Viewed"
+
+msgid "PetitionActionEnum.SU"
+msgstr "Status Updated"
+
msgid "PetitionStatusEnum.A"
msgstr "Active"
msgid "PetitionStatusEnum.Y"
msgstr "Approved"
+# This was "Confirmed" in v4
msgid "PetitionStatusEnum.C"
-msgstr "Confirmed"
+msgstr "Accepted"
msgid "PetitionStatusEnum.CR"
msgstr "Created"
@@ -457,7 +476,7 @@ msgid "PetitionStatusEnum.PA"
msgstr "Pending Approval"
msgid "PetitionStatusEnum.PC"
-msgstr "Pending Confirmation"
+msgstr "Pending Acceptance"
msgid "PetitionStatusEnum.PV"
msgstr "Pending Vetting"
diff --git a/app/resources/locales/en_US/error.po b/app/resources/locales/en_US/error.po
index 339832b89..a0c71eb18 100644
--- a/app/resources/locales/en_US/error.po
+++ b/app/resources/locales/en_US/error.po
@@ -124,6 +124,9 @@ msgstr "Email Address is already verified"
msgid "EmailAddresses.mail.verify.force.person"
msgstr "Email Addresses not associated with People cannot be force verified"
+msgid "EnrollmentFlowSteps.message_template"
+msgstr "Enrollment Flow Step {0} is transitioning Actor Types but does not have a Mesasge Template configured"
+
msgid "EnrollmentFlowSteps.none"
msgstr "This Enrollment Flow has no Active steps and so cannot be run"
@@ -241,6 +244,15 @@ msgstr "Job {0} is not in {1} status and cannot be set to {2} (Job is {3})"
msgid "Jobs.status.invalid.cancel"
msgstr "Job {0} is not in a cancelable status (Job is {1})"
+msgid "MostlyStaticPages.default.delete"
+msgstr "This page cannot be deleted"
+
+msgid "MostlyStaticPages.default.modify"
+msgstr "This page cannot be renamed, suspended, or given a different context"
+
+msgid "MostlyStaticPages.slug.invalid"
+msgstr "Slug contains invalid characters"
+
msgid "Names.minimum"
msgstr "At least one name is required"
diff --git a/app/resources/locales/en_US/field.po b/app/resources/locales/en_US/field.po
index c9260bfe6..60f7343fb 100644
--- a/app/resources/locales/en_US/field.po
+++ b/app/resources/locales/en_US/field.po
@@ -113,6 +113,9 @@ msgstr "Ends at:"
msgid "ends_at.tz"
msgstr "Ends at ({0})"
+msgid "enrollee_email"
+msgstr "Enrollee Email"
+
msgid "extension"
msgstr "Extension"
@@ -405,6 +408,18 @@ msgstr "Limit Global Search Scope"
msgid "CoSettings.search_global_limited_models.desc"
msgstr "If true, Global Search will only search Names, Email Addresses, and Identifiers. This may result in faster searches for larger deployments."
+msgid "EnrollmentFlowSteps.actor_type"
+msgstr "Actor Type"
+
+msgid "EnrollmentFlowSteps.message_template_id.desc"
+msgstr "If a handoff is required to start this step, this Message Template will be used to notify the next Actor"
+
+msgid "EnrollmentFlowSteps.redirect_on_handoff"
+msgstr "Redirect on Handoff"
+
+msgid "EnrollmentFlowSteps.redirect_on_handoff.desc"
+msgstr "If a handoff is required to start this step, the original Actor will be redirected here instead of the default landing page"
+
msgid "EnrollmentFlows.authz_cou_id"
msgstr "Authorized COU"
@@ -417,15 +432,9 @@ 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.hash_source_record"
msgstr "Hash Source Records"
@@ -603,6 +612,50 @@ msgstr "Message Subject"
msgid "MessageTemplates.subject.desc"
msgstr "Subject line for message to be sent. See XXX LINK supported substitutions."
+msgid "MostlyStaticPages.body"
+msgstr "Body"
+
+msgid "MostlyStaticPages.name"
+msgstr "Slug"
+
+msgid "MostlyStaticPages.name.desc"
+msgstr "The URL fragment for this Page, which must be unique and use only lowercase alphanumeric characters and dashes (-)"
+
+msgid "MostlyStaticPages.pageUrl"
+msgstr "Page Display URL"
+
+msgid "MostlyStaticPages.pageUrl.desc"
+msgstr "The full (public) URL to access the rendered page (this is a read only field)"
+
+# These are strings for the default Pages that are created for each CO.
+# It's not clear they belong here, but we don't have a better place for them right now.
+msgid "MostlyStaticPages.default.dh.title"
+msgstr "Enrollment Flow Handoff"
+
+msgid "MostlyStaticPages.default.dh.description"
+msgstr "Default Enrollment Flow Handoff Landing Page"
+
+msgid "MostlyStaticPages.default.dh.body"
+msgstr "Thank you for completing this Enrollment Flow Step. No further action is required from you at this time. The next person to act on this Petition has been notified."
+
+msgid "MostlyStaticPages.default.el.title"
+msgstr "An Error Occurred"
+
+msgid "MostlyStaticPages.default.el.description"
+msgstr "Default Error Landing Page"
+
+msgid "MostlyStaticPages.default.el.body"
+msgstr "An unexpected error occurred. Please contact your administrator for further assistance."
+
+msgid "MostlyStaticPages.default.pc.title"
+msgstr "Enrollment Complete"
+
+msgid "MostlyStaticPages.default.pc.description"
+msgstr "Default Petition Finalization Landing Page"
+
+msgid "MostlyStaticPages.default.pc.body"
+msgstr "This Petition is complete and has been finalized. Please contact your administrator for further instructions."
+
msgid "Notifications.actor_person_id"
msgstr "Actor"
diff --git a/app/resources/locales/en_US/information.po b/app/resources/locales/en_US/information.po
index 655ae1706..373e11a11 100644
--- a/app/resources/locales/en_US/information.po
+++ b/app/resources/locales/en_US/information.po
@@ -111,6 +111,9 @@ msgstr "Additional History Records may be available via Petitions and Provisioni
msgid "pagination.format"
msgstr "Page {{page}} of {{pages}}, Viewing {{start}}-{{end}} of {{count}}"
+msgid "Petitions.pending"
+msgstr "This Petition has now been assigned to someone else. There is no further action for you at this time."
+
msgid "plugin.active"
msgstr "Active"
diff --git a/app/resources/locales/en_US/operation.po b/app/resources/locales/en_US/operation.po
index e626a98ad..d478605cb 100644
--- a/app/resources/locales/en_US/operation.po
+++ b/app/resources/locales/en_US/operation.po
@@ -24,6 +24,9 @@
# Operations (Commands)
+msgid "accept"
+msgstr "Accept"
+
msgid "acknowledge"
msgstr "Acknowledge"
@@ -123,6 +126,9 @@ msgstr "{0} Configuration"
msgid "deactivate"
msgstr "Deactivate"
+msgid "decline"
+msgstr "Decline"
+
msgid "delete"
msgstr "Delete"
diff --git a/app/resources/locales/en_US/result.po b/app/resources/locales/en_US/result.po
index 619a44488..70eb5119f 100644
--- a/app/resources/locales/en_US/result.po
+++ b/app/resources/locales/en_US/result.po
@@ -166,6 +166,9 @@ msgstr "Person Role status recalculated from {0} to {1}"
msgid "Petitions.finalized"
msgstr "Petition Finalized"
+msgid "Petitions.viewed.inv"
+msgstr "Invitation Viewed"
+
msgid "Pipelines.complete"
msgstr "Pipeline {0} complete for EIS {1} source key {2}"
diff --git a/app/src/Controller/DashboardsController.php b/app/src/Controller/DashboardsController.php
index 778680f61..08561e985 100644
--- a/app/src/Controller/DashboardsController.php
+++ b/app/src/Controller/DashboardsController.php
@@ -112,6 +112,11 @@ public function configuration() {
'controller' => 'message_templates',
'action' => 'index'
],
+ __d('controller', 'MostlyStaticPages', [99]) => [
+ 'icon' => 'article',
+ 'controller' => 'mostly_static_pages',
+ 'action' => 'index'
+ ],
__d('controller', 'Pipelines', [99]) => [
'icon' => 'cable',
'controller' => 'pipelines',
diff --git a/app/src/Controller/MostlyStaticPagesController.php b/app/src/Controller/MostlyStaticPagesController.php
new file mode 100644
index 000000000..ad1ee8171
--- /dev/null
+++ b/app/src/Controller/MostlyStaticPagesController.php
@@ -0,0 +1,79 @@
+ [
+ 'MostlyStaticPages.title' => 'asc'
+ ]
+ ];
+
+ /**
+ * Callback run prior to the request render.
+ *
+ * @since COmanage Registry v5.1.0
+ * @param EventInterface $event Cake Event
+ * @return \Cake\Http\Response HTTP Response
+ */
+
+ public function beforeRender(\Cake\Event\EventInterface $event) {
+ $this->set('vv_base_url', \Cake\Routing\Router::url(
+ url: "/" . $this->getCOID(),
+ full: true
+ ));
+
+ return parent::beforeRender($event);
+ }
+
+ /**
+ * Indicate whether this Controller will handle some or all authnz.
+ *
+ * @since COmanage Registry v5.1.0
+ * @param EventInterface $event Cake event, ie: from beforeFilter
+ * @return string "no", "open", "authz", or "yes"
+ */
+
+ public function willHandleAuth(\Cake\Event\EventInterface $event): string {
+ $request = $this->getRequest();
+ $action = $request->getParam('action');
+
+ // We only take over authz for display
+
+ if(in_array($action, ['display'])) {
+ return 'open';
+ }
+
+ return 'no';
+ }
+}
\ No newline at end of file
diff --git a/app/src/Controller/PagesController.php b/app/src/Controller/PagesController.php
index 709348c60..669a4100d 100644
--- a/app/src/Controller/PagesController.php
+++ b/app/src/Controller/PagesController.php
@@ -20,7 +20,9 @@
use Cake\Http\Exception\ForbiddenException;
use Cake\Http\Exception\NotFoundException;
use Cake\Http\Response;
+use Cake\ORM\TableRegistry;
use Cake\View\Exception\MissingTemplateException;
+use \App\Lib\Enum\SuspendableStatusEnum;
/**
* Static content controller
@@ -88,6 +90,51 @@ public function display(...$path): ?Response
return $this->render();
}
+ /**
+ * Render a Mostly Static Page.
+ *
+ * @since COmanage Registry v5.1.0
+ * @param string $coid CO ID
+ * @param string $name MSP Name (slug)
+ */
+
+ public function show(string $coid, string $name) {
+ // We use PagesController rather than MostlyStaticPagesController to avoid complexities
+ // with PrimaryLink lookups. We use show() rather than render() because the latter is
+ // used by Controller, and rather than display() so we don't confuse things by
+ // redefining it. We render here rather than redirecting into the MSPController to
+ // reduce URL bar thrashing.
+
+ $MSPTable = TableRegistry::getTableLocator()->get("MostlyStaticPages");
+
+ $msp = $MSPTable->find()
+ ->where([
+ 'co_id' => (int)$coid,
+ 'name' => $name,
+ 'status' => SuspendableStatusEnum::Active
+ ])
+ ->first();
+
+ if(empty($msp)) {
+ if($name == 'error-landing') {
+ // error-landing should always exist, if not throw an error
+
+ throw new NotFoundException();
+ } else {
+ $this->Flash->error(__d('error', 'notfound', $name));
+
+ return $this->redirect("/$coid/error-landing");
+ }
+ }
+
+ $this->set('vv_bc_skip', true); // this doesn't do anything?
+
+ $this->set('vv_title', $msp->title);
+ $this->set('vv_body', $msp->body);
+
+ return $this->render('/MostlyStaticPages/display');
+ }
+
/**
* Indicate whether this Controller will handle some or all authnz.
*
@@ -97,6 +144,17 @@ public function display(...$path): ?Response
*/
public function willHandleAuth(\Cake\Event\EventInterface $event): string {
- return "open";
+ $request = $this->getRequest();
+ $action = $request->getParam('action');
+
+ // We only take over authz for display and show
+ // (These are the only two actions we currently support, but better to require
+ // an explicit action to add to this list)
+
+ if(in_array($action, ['display', 'show'])) {
+ return 'open';
+ }
+
+ return 'no';
}
}
diff --git a/app/src/Controller/PetitionsController.php b/app/src/Controller/PetitionsController.php
index 53e746196..974d63bcf 100644
--- a/app/src/Controller/PetitionsController.php
+++ b/app/src/Controller/PetitionsController.php
@@ -21,7 +21,7 @@
*
* @link https://www.internet2.edu/comanage COmanage Project
* @package registry
- * @since COmanage Registry v5.0.0
+ * @since COmanage Registry v5.1.0
* @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
*/
@@ -57,7 +57,7 @@ class PetitionsController extends StandardController {
* @param EventInterface $event Cake Event
*
* @return Response|void
- * @since COmanage Registry v5.0.0
+ * @since COmanage Registry v5.1.0
*/
public function beforeRender(EventInterface $event) {
@@ -75,7 +75,7 @@ public function beforeRender(EventInterface $event) {
/**
* Calculate authorization for the current request.
*
- * @since COmanage Registry v5.0.0
+ * @since COmanage Registry v5.1.0
* @return bool True if the current request is permitted, false otherwise
*/
@@ -106,18 +106,18 @@ public function calculatePermission(): bool {
/**
* Continue a Petition (re-enter an Enrollment Flow).
*
- * @since COmanage Registry v5.0.0
+ * @since COmanage Registry v5.1.0
* @param string $id Petition ID
*/
public function continue(string $id) {
- return $this->transitionToStep(int($id));
+ return $this->transitionToStep((int)$id);
}
/**
* Finalize a Petition.
*
- * @since COmanage Registry v5.0.0
+ * @since COmanage Registry v5.1.0
* @param string $id Petition ID
*/
@@ -140,23 +140,17 @@ public function finalize(string $id) {
$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 to the default Petition Complete landing page.
+
+ $coId = $this->getCOID();
+
+ return $this->redirect("/$coId/petition-complete");
}
/**
* Redirect into a plugin to render the result of an Enrollment Flow Step.
*
- * @since COmanage Registry v5.0.0
+ * @since COmanage Registry v5.1.0
* @param string $id Petition ID
*/
@@ -205,9 +199,9 @@ public function result(string $id) {
}
/**
- * Resume an Enrollment Flow. More specifically.
+ * Resume an Enrollment Flow.
*
- * @since COmanage Registry v5.0.0
+ * @since COmanage Registry v5.1.0
* @param string $id Petition ID
*/
@@ -286,7 +280,7 @@ public function resume(string $id) {
/**
* Indicate whether this Controller will handle some or all authnz.
*
- * @since COmanage Registry v5.0.0
+ * @since COmanage Registry v5.1.0
* @param EventInterface $event Cake event, ie: from beforeFilter
* @return string "no", "notauth", "open", "authz", or "yes"
*/
@@ -295,17 +289,37 @@ 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.
+ // We take over authz for continue (which is really just a glorified redirect,
+ // but which will send handoff emails under certain circumstances); and for
+ // finalize but only if the request will be authenticated via Petition Token.
- if($action == 'finalize') {
- $petitionId = (int)$this->request->getParam('pass.0');
+ $petitionId = (int)$this->request->getParam('pass.0');
- if(empty($petitionId)) {
- $this->llog('error', "No Petition ID specified for finalize");
- return 'notauth';
+ if(!in_array($action, ['continue', 'finalize'])) {
+ return 'no';
+ }
+
+ if(empty($petitionId)) {
+ $this->llog('error', "No Petition ID specified for finalize");
+ return 'notauth';
+ }
+
+ if($action == 'continue') {
+ // For continue, we mostly just check that if the user type is anonymous
+ // that a token was provided and validates.
+ $actorInfo = $this->getCurrentActor($petitionId);
+
+ if($actorInfo['type'] == 'anonymous') {
+ if(!$actorInfo['token_ok']) {
+ $this->llog('trace', "Token validation failed for Petition " . $petitionId);
+ return 'notauth';
+ }
}
+ // We'll allow any authenticated user through since continue is basically
+ // a redirect
+ return 'yes';
+ } elseif($action == 'finalize') {
// For finalize, the relevant Step is the last one. We'll use calculateNextStep()
// to get the last Step, which will also check if the petition is already completed.
$this->nextStep = $this->Petitions->EnrollmentFlows->calculateNextStep($petitionId);
diff --git a/app/src/Lib/Enum/PageContextEnum.php b/app/src/Lib/Enum/PageContextEnum.php
new file mode 100644
index 000000000..a7e5b5587
--- /dev/null
+++ b/app/src/Lib/Enum/PageContextEnum.php
@@ -0,0 +1,36 @@
+enrollee_identifier) {
$ret['roles'][] = EnrollmentActorEnum::Enrollee;
}
+
+ if(empty($petition->petitioner_identifier)
+ && empty($petition->enrollee_identifier)) {
+ // We have an identifier at run time but none in the petition.
+ // If we can validate a token we can store the identifier and
+ // use it instead. (eg: An Enrollee receives an initial handoff
+ // email/invitation.)
+
+ // Note in general we should only accept an Enrollee identifier
+ // this way. Petitioner identifiers should be collected at Petition
+ // start, and Approvers shouldn't use tokens.
+
+ $tokenRoles = $this->validateToken($petition);
+
+ if(!empty($tokenRoles) && in_array(EnrollmentActorEnum::Enrollee, $tokenRoles)) {
+ $this->llog('trace', "Transitioning Enrollee to authenticated identifier "
+ . $ret['identifier'] . " for Petition " . $petition->id);
+
+ // Update the Petition to store the identifier and remove the token
+ $petition->enrollee_identifier = $ret['identifier'];
+ $petition->token = null;
+
+// XXX Also add petition history?
+ $Petitions->saveOrFail($petition);
+
+ $ret['roles'][] = EnrollmentActorEnum::Enrollee;
+ }
+ }
} elseif($ret['type'] == 'anonymous') {
$ret['roles'] = $this->validateToken($petition);
@@ -143,9 +172,11 @@ protected function getCurrentActor(?int $petitionId=null): array {
* 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
+ * @since COmanage Registry v5.1.0
* @param int $petitionId Petition ID
* @param bool $start True if transitioning from start
+ * @throws Cake\Network\Exception\SocketException On SMTP error
+ * @throws RuntimeException If no SMTP server configured
*/
protected function transitionToStep(int $petitionId, bool $start=false) {
@@ -153,6 +184,9 @@ protected function transitionToStep(int $petitionId, bool $start=false) {
$stepInfo = $EnrollmentFlows->calculateNextStep($petitionId);
$petition = $stepInfo['petition'];
+
+ $coId = $EnrollmentFlows->findCoForRecord($petition->enrollment_flow_id);
+
/* no need to to this, we don't cache on start()
if($start) {
// $actorInfo was cached before the Petition was created, so force it to reload
@@ -161,6 +195,17 @@ protected function transitionToStep(int $petitionId, bool $start=false) {
$actorInfo = $this->getCurrentActor($petitionId);
+ // Before we process the handoff, give the plugin an opportunity to run any
+ // preparatory steps. We don't specifically support errors here, ie: if a plugin
+ // throws an Exception we let it bubble up because it's not really clear what we
+ // should do if a plugin fails.
+
+ // (If this is the last step, 'step' will be null, and there's no prepare() to call.)
+
+ if(!empty($stepInfo['step'])) {
+ $EnrollmentFlows->EnrollmentFlowSteps->prepare($stepInfo['step'], $petition);
+ }
+
// We need to compare the current actor type with the actor type configured for the
// next step. If they are the same, we can simply redirect. If they are different,
// we need to hand off via a notification, and then redirect the current actor to
@@ -205,26 +250,65 @@ protected function transitionToStep(int $petitionId, bool $start=false) {
$token = $EnrollmentFlows->Petitions->getToken($petitionId);
- // Just inject the token into the URL we already have
- $stepInfo['url']['?']['token'] = $token;
+ // For simplicity, we just inject the continue URL into the message.
+ $entryUrl = [
+ 'controller' => 'petitions',
+ 'action' => 'continue',
+ $petition->id,
+ '?' => [
+ 'token' => $token //$this->requestParam('token')
+ ]
+ ];
+
+ // Message Templates handle substitutions, so if none is configured it's an error
+ if(empty($stepInfo['step']->message_template_id)) {
+ throw new \RuntimeException(__d('error', 'EnrollmentFlowSteps.message_template', [ $stepInfo['step']->id ]));
+ }
+
+ $MessageTemplates = TableRegistry::getTableLocator()->get('MessageTemplates');
+
+ // Perform substitutions
- // XXX send an email
+ $msg = $MessageTemplates->generateMessage(
+ id: $stepInfo['step']->message_template_id,
+ entryUrl: $entryUrl,
+ );
+
+ // Send the message. sendEmailToAddress will throw an Exception if SMTP failed,
+ // but if there is no SMTP server configured we'll just get false back.
+
+ if(!DeliveryUtilities::sendEmailToAddress(
+ coId: $coId,
+ recipient: $petition->enrollee_email,
+ subject: $msg['subject'],
+ body_text: $msg['body_text'],
+ body_html: $msg['body_html']
+ )) {
+ throw new \RuntimeException("Message delivery failed"); // XXX I18n. can we get an exception from sendEmailToAddress instead?
+ }
} else {
// XXX Register a notification or send an email or whatever
// (once notification infrastructure is available)
- }
debug("Handing off to actor type " . $nextActorType . " would send a notitication to visit "
. \Cake\Routing\Router::url(url: $stepInfo['url'], full: true));
- }
+ }
- return $this->redirect($stepInfo['url']);
+ // Redirect to a landing page indicating that no further action is required at this time
+ if(!empty($stepInfo['step']->redirect_on_handoff)) {
+ // Use the step specific handoff URL
+ return $this->redirect($stepInfo['step']->redirect_on_handoff);
+ } else {
+ // Redirect to the default Enrollment Handoff URL for this CO
+ return $this->redirect("/$coId/default-handoff");
+ }
+ }
}
/**
* Validate a token associated with the requested petition.
*
- * @since COmanage Registry v5.0.0
+ * @since COmanage Registry v5.1.0
* @param Petition $petition Petition entity
* @return array|bool Roles associated with the token, or false on token error
*/
diff --git a/app/src/Lib/Traits/UpsertTrait.php b/app/src/Lib/Traits/UpsertTrait.php
new file mode 100644
index 000000000..449c4c772
--- /dev/null
+++ b/app/src/Lib/Traits/UpsertTrait.php
@@ -0,0 +1,87 @@
+find()
+ ->where($whereClause)
+ ->epilog('FOR UPDATE')
+ ->first();
+
+ if($entity) {
+ // This is an update
+
+ $entity = $this->patchEntity($entity, $data);
+ } else {
+ // This is an insert
+
+ $entity = $this->newEntity($data);
+ }
+
+ return $this->save($entity);
+ }
+
+ /**
+ * Perform an upsert, or throw an exception on failure.
+ *
+ * @since COmanage Registry v5.1.0
+ * @param array $data Data to persist
+ * @param array $whereClause Conditions to search for current entity
+ * @return Cake\Datasource\EntityInterface|false Persisted entity, or false on failure
+ * @throws Cake\ORM\Exception\PersistenceFailedException
+ */
+
+ public function upsertOrFail(
+ array $data,
+ array $whereClause
+ ): \Cake\Datasource\EntityInterface {
+ $entity = $this->upsert($data, $whereClause);
+
+ if($entity === false) {
+ throw new Cake\ORM\Exception\PersistenceFailedException($entity, ['upsert']);
+ }
+
+ return $entity;
+ }
+}
diff --git a/app/src/Lib/Util/DeliveryUtilities.php b/app/src/Lib/Util/DeliveryUtilities.php
index eb6068806..f5a15a830 100644
--- a/app/src/Lib/Util/DeliveryUtilities.php
+++ b/app/src/Lib/Util/DeliveryUtilities.php
@@ -51,7 +51,8 @@ class DeliveryUtilities {
* @param string $cc Addresses to cc
* @param string $bcc Addresses to bcc
* @param string $replyTo Reply-To address to use, instead of the default
- * @return bool Returns true if mail was sent, false otherwise (including if no SMTP server was sent)
+ * @return bool Returns true if mail was sent, false if no SMTP server was set
+ * @throws Cake\Network\Exception\SocketException
*/
public static function sendEmailToAddress(
@@ -127,9 +128,18 @@ public static function sendEmailToAddress(
'tls' => $smtp->use_tls
]);
- $result = $transport->send($message);
+ try {
+ $result = $transport->send($message);
+ }
+ catch(Cake\Network\Exception\SocketException $e) {
+ self::slog('error', $e->getMessage());
+
+ throw $e;
+ }
self::slog('debug', "Mail for $to sent successfully");
+
+ return true;
}
/**
diff --git a/app/src/Model/Entity/MostlyStaticPage.php b/app/src/Model/Entity/MostlyStaticPage.php
new file mode 100644
index 000000000..ee604e7a9
--- /dev/null
+++ b/app/src/Model/Entity/MostlyStaticPage.php
@@ -0,0 +1,56 @@
+ true,
+ 'id' => false,
+ 'slug' => false,
+ ];
+
+ /**
+ * Determine if this entity is a default Page (shipped out of the box and relied on by other
+ * parts of the Application).
+ *
+ * @since COmanage Registry v5.1.0
+ * @return bool true if this entity is a defalut Page, false otherwise
+ */
+
+ public function isDefaultPage(): bool {
+ // We use the original value because if we're in the middle of a save we'll have
+ // the proposed new value even though we haven't persisted it yet
+ return in_array($this->getOriginal('name'), ['default-handoff', 'error-landing', 'petition-complete']);
+ }
+}
\ No newline at end of file
diff --git a/app/src/Model/Table/CosTable.php b/app/src/Model/Table/CosTable.php
index 061e964b8..8aec0857b 100644
--- a/app/src/Model/Table/CosTable.php
+++ b/app/src/Model/Table/CosTable.php
@@ -86,6 +86,9 @@ public function initialize(array $config): void {
$this->hasMany('Jobs')
->setDependent(true)
->setCascadeCallbacks(true);
+ $this->hasMany('MostlyStaticPages')
+ ->setDependent(true)
+ ->setCascadeCallbacks(true);
$this->hasMany('People')
->setDependent(true)
->setCascadeCallbacks(true);
@@ -440,6 +443,9 @@ public function setup(int $id): bool {
// AR-CO-6 Create the default groups
$this->Groups->addDefaults($id);
+ // AR-MostlyStaticPages-3 Set up the default landing pages
+ $this->MostlyStaticPages->addDefaults($id);
+
// Set up the default settings
$this->CoSettings->addDefaults($id);
diff --git a/app/src/Model/Table/EnrollmentFlowStepsTable.php b/app/src/Model/Table/EnrollmentFlowStepsTable.php
index 3cf26d16d..99f359388 100644
--- a/app/src/Model/Table/EnrollmentFlowStepsTable.php
+++ b/app/src/Model/Table/EnrollmentFlowStepsTable.php
@@ -21,7 +21,7 @@
*
* @link https://www.internet2.edu/comanage COmanage Project
* @package registry
- * @since COmanage Registry v5.0.0
+ * @since COmanage Registry v5.1.0
* @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
*/
@@ -52,7 +52,7 @@ class EnrollmentFlowStepsTable extends Table {
/**
* Perform Cake Model initialization.
*
- * @since COmanage Registry v5.0.0
+ * @since COmanage Registry v5.1.0
* @param array $config Configuration options passed to constructor
*/
@@ -67,6 +67,7 @@ public function initialize(array $config): void {
// Define associations
$this->belongsTo('EnrollmentFlows');
+ $this->belongsTo('MessageTemplates');
$this->hasMany('PetitionStepResults');
$this->setPluginRelations();
@@ -82,6 +83,11 @@ public function initialize(array $config): void {
'type' => 'enum',
'class' => 'EnrollmentActorEnum'
],
+ 'messageTemplates' => [
+ 'type' => 'select',
+ 'model' => 'MessageTemplates',
+ 'where' => ['context' => \App\Lib\Enum\MessageTemplateContextEnum::EnrollmentHandoff]
+ ],
'plugins' => [
'type' => 'plugin',
'pluginType' => 'enroller'
@@ -154,7 +160,7 @@ public function initialize(array $config): void {
/**
* Define business rules.
*
- * @since COmanage Registry v5.0.0
+ * @since COmanage Registry v5.1.0
* @param RulesChecker $rules RulesChecker object
* @return RulesChecker
*/
@@ -169,11 +175,38 @@ public function buildRules(RulesChecker $rules): RulesChecker {
return $rules;
}
+
+ /**
+ * Prepare for handoff to an Enrollment Flow Step.
+ *
+ * @since COmanage Registry v5.1.0
+ * @param EnrollmentFlowStep $step Enrollment Flow Step
+ * @param Petition $petition Petition to prepare for
+ */
+
+ public function prepare(
+ \App\Model\Entity\EnrollmentFlowStep $step,
+ \App\Model\Entity\Petition $petition
+ ) {
+ // We give the plugin for this Step an opportunity to do something before
+ // the handoff to the Step takes place. This is intended, for example, to
+ // allow the Petition to be set to a Pending status prior to the next Actor
+ // taking an action.
+
+ // (We accept a Step entity rather than an ID because in the context in which
+ // we're called we already have the Step, so no need to make another DB call.)
+
+ $Plugin = TableRegistry::getTableLocator()->get($step->plugin);
+
+ if(method_exists($Plugin, "prepare")) {
+ $Plugin->prepare($step, $petition);
+ }
+ }
/**
* Set validation rules.
*
- * @since COmanage Registry v5.0.0
+ * @since COmanage Registry v5.1.0
* @param Validator $validator Validator
* @return Validator Validator
*/
@@ -205,6 +238,16 @@ public function validationDefault(Validator $validator): Validator {
]);
$validator->notEmptyString('actor_type');
+ $validator->add('message_template_id', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->allowEmptyString('message_template_id');
+
+ $validator->add('redirect_on_handoff', [
+ 'content' => ['rule' => 'url']
+ ]);
+ $validator->allowEmptyString('redirect_on_handoff');
+
return $validator;
}
}
\ No newline at end of file
diff --git a/app/src/Model/Table/MessageTemplatesTable.php b/app/src/Model/Table/MessageTemplatesTable.php
index d6aa26421..94ad0d413 100644
--- a/app/src/Model/Table/MessageTemplatesTable.php
+++ b/app/src/Model/Table/MessageTemplatesTable.php
@@ -43,7 +43,6 @@ class MessageTemplatesTable extends Table {
use \App\Lib\Traits\ChangelogBehaviorTrait;
use \App\Lib\Traits\CoLinkTrait;
use \App\Lib\Traits\PermissionsTrait;
- use \App\Lib\Traits\PluggableModelTrait;
use \App\Lib\Traits\PrimaryLinkTrait;
use \App\Lib\Traits\TableMetaTrait;
use \App\Lib\Traits\ValidationTrait;
@@ -65,6 +64,7 @@ public function initialize(array $config): void {
// Define associations
$this->belongsTo('Cos');
+ $this->hasMany('EnrollmentFlowSteps');
$this->hasMany('Notifications');
$this->setDisplayField('description');
@@ -108,8 +108,9 @@ public function initialize(array $config): void {
*
* @since COmanage Registry v5.0.0
* @param int $id Message Template ID
- * @param Person $subjectPerson Subject Person, including Primary Name
+ * @param array $entryUrl Entry URL for responding to a handoff or notification
* @param Notification $notification Notification
+ * @param Person $subjectPerson Subject Person, including Primary Name
* @return array 'subject': Message subject
* 'body_text': Plaintext message
* 'body_html': HTML message
@@ -117,13 +118,15 @@ public function initialize(array $config): void {
public function generateMessage(
int $id,
- \App\Model\Entity\Person $subjectPerson=null,
- \App\Model\Entity\Notification $notification=null
+ array $entryUrl=[],
+ \App\Model\Entity\Notification $notification=null,
+ \App\Model\Entity\Person $subjectPerson=null
): array {
+ // We return "" instead of null by default for compatibility with DeliveryUtilities
$ret = [
- 'subject' => null,
- 'body_text' => null,
- 'body_html' => null
+ 'subject' => "",
+ 'body_text' => "",
+ 'body_html' => ""
];
// First retrieve the requested template
@@ -133,18 +136,29 @@ public function generateMessage(
// entities were provided.
$substitutions = [];
-
- if($subjectPerson && !empty($subjectPerson->primary_name)) {
- $substitutions['SUBJECT_NAME'] = $subjectPerson->primary_name->full_name;
+
+ // Lookup the CO Name
+ $co = $this->Cos->get($template->co_id);
+
+ $substitutions['CO_NAME'] = $co->name;
+
+ if(!empty($entryUrl)) {
+ $substitutions['ENTRY_URL'] = \Cake\Routing\Router::url(
+ array_merge($entryUrl, ['_full' => true])
+ );
}
if($notification) {
$substitutions['NOTIFICATION_COMMENT'] = $notification->comment;
$substitutions['NOTIFICATION_SOURCE'] = $notification->source;
}
+
+ if($subjectPerson && !empty($subjectPerson->primary_name)) {
+ $substitutions['SUBJECT_NAME'] = $subjectPerson->primary_name->full_name;
+ }
// Finally run the substitutions through each of the supported parts
-
+
foreach(array_keys($ret) as $part) {
if(!empty($template->$part)) {
// Process the (@SUBSTITUTIONS) for this part
diff --git a/app/src/Model/Table/MostlyStaticPagesTable.php b/app/src/Model/Table/MostlyStaticPagesTable.php
new file mode 100644
index 000000000..36704cabe
--- /dev/null
+++ b/app/src/Model/Table/MostlyStaticPagesTable.php
@@ -0,0 +1,337 @@
+addBehavior('Changelog');
+ $this->addBehavior('Log');
+ $this->addBehavior('Timestamp');
+
+ $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration);
+
+ // Define associations
+ $this->belongsTo('Cos');
+
+ $this->setDisplayField('name');
+
+ $this->setPrimaryLink('co_id');
+ $this->setRequiresCO(true);
+
+ $this->setAutoViewVars([
+ 'contexts' => [
+ 'type' => 'enum',
+ 'class' => 'PageContextEnum'
+ ],
+ 'statuses' => [
+ 'type' => 'enum',
+ 'class' => 'SuspendableStatusEnum'
+ ]
+ ]);
+
+ $this->setPermissions([
+ // Actions that operate over an entity (ie: require an $id)
+ 'entity' => [
+ 'delete' => ['platformAdmin', 'coAdmin'],
+ 'edit' => ['platformAdmin', 'coAdmin'],
+ 'view' => ['platformAdmin', 'coAdmin']
+ ],
+ // Actions that operate over a table (ie: do not require an $id)
+ 'table' => [
+ 'add' => ['platformAdmin', 'coAdmin'],
+ 'index' => ['platformAdmin', 'coAdmin']
+ ]
+ ]);
+ }
+
+ /**
+ * Add the default Mostly Static Pages.
+ *
+ * @since COmanage Registry v5.1.0
+ * @param int $coId CO ID
+ * @return bool true on success
+ * @throws PersistenceFailedException
+ */
+
+ public function addDefaults(int $coId) {
+ // Any pages added here should also be added to MostlyStaticPage.php::isDefaultPage
+ $records = [
+ [
+ 'co_id' => $coId,
+ 'name' => 'default-handoff',
+ 'title' => __d('field', 'MostlyStaticPages.default.dh.title'),
+ 'description' => __d('field', 'MostlyStaticPages.default.dh.description'),
+ 'status' => SuspendableStatusEnum::Active,
+ 'context' => PageContextEnum::EnrollmentHandoff,
+ 'body' => __d('field', 'MostlyStaticPages.default.dh.body')
+ ],
+ [
+ 'co_id' => $coId,
+ 'name' => 'error-landing',
+ 'title' => __d('field', 'MostlyStaticPages.default.el.title'),
+ 'description' => __d('field', 'MostlyStaticPages.default.el.description'),
+ 'status' => SuspendableStatusEnum::Active,
+ 'context' => PageContextEnum::ErrorLanding,
+ 'body' => __d('field', 'MostlyStaticPages.default.el.body')
+ ],
+ [
+ 'co_id' => $coId,
+ 'name' => 'petition-complete',
+ 'title' => __d('field', 'MostlyStaticPages.default.pc.title'),
+ 'description' => __d('field', 'MostlyStaticPages.default.pc.description'),
+ 'status' => SuspendableStatusEnum::Active,
+ 'context' => PageContextEnum::EnrollmentHandoff,
+ 'body' => __d('field', 'MostlyStaticPages.default.pc.body')
+ ]
+ ];
+
+ // Convert the arrays to entities
+ $entities = $this->newEntities($records);
+
+ // throws PersistenceFailedException on failure
+ $this->saveManyOrFail($entities);
+
+ return true;
+ }
+
+ /**
+ * Define business rules.
+ *
+ * @since COmanage Registry v5.1.0
+ * @param RulesChecker $rules RulesChecker object
+ * @return RulesChecker
+ */
+
+ public function buildRules(RulesChecker $rules): RulesChecker {
+// XXX document these in the wiki
+ // AR-MostlyStaticPage-1 Two Mostly Static Pages within the same CO cannot share the same name
+ $rules->add($rules->isUnique(['name', 'co_id'], __d('error', 'exists', [__d('field', 'MostlyStaticPages.name')])));
+
+ // AR-MostlyStaticPage-3 Default Pages can not be deleted, or have their names, status, or
+ // context changed
+ $rules->addUpdate([$this, 'ruleModifiedDefaultPage'],
+ 'modifiedDefaultPage',
+ ['errorField' => 'name']);
+
+ $rules->addDelete([$this, 'ruleIsDefaultPage'],
+ 'isDefaultPage',
+ ['errorField' => 'name']);
+
+ return $rules;
+ }
+
+ /**
+ * Generate a message based on a Message Template, using the provided entities to
+ * perform variable substitution.
+ *
+ * @since COmanage Registry v5.0.0
+ * @param int $id Message Template ID
+ * @param array $entryUrl Entry URL for responding to a handoff or notification
+ * @param Notification $notification Notification
+ * @param Person $subjectPerson Subject Person, including Primary Name
+ * @return array 'subject': Message subject
+ * 'body_text': Plaintext message
+ * 'body_html': HTML message
+ *
+
+ public function generateMessage(
+ int $id,
+ array $entryUrl=[],
+ \App\Model\Entity\Notification $notification=null,
+ \App\Model\Entity\Person $subjectPerson=null
+ ): array {
+ // We return "" instead of null by default for compatibility with DeliveryUtilities
+ $ret = [
+ 'subject' => "",
+ 'body_text' => "",
+ 'body_html' => ""
+ ];
+
+ // First retrieve the requested template
+ $template = $this->get($id);
+
+ // Next build an array of supported substitutions for which appropriate
+ // entities were provided.
+
+ $substitutions = [];
+
+ // Lookup the CO Name
+ $co = $this->Cos->get($template->co_id);
+
+ $substitutions['CO_NAME'] = $co->name;
+
+ if(!empty($entryUrl)) {
+// debug($entryUrl);
+ $substitutions['ENTRY_URL'] = \Cake\Routing\Router::url(
+ array_merge($entryUrl, ['_full' => true])
+ );
+ }
+
+ if($notification) {
+ $substitutions['NOTIFICATION_COMMENT'] = $notification->comment;
+ $substitutions['NOTIFICATION_SOURCE'] = $notification->source;
+ }
+
+ if($subjectPerson && !empty($subjectPerson->primary_name)) {
+ $substitutions['SUBJECT_NAME'] = $subjectPerson->primary_name->full_name;
+ }
+
+ // Finally run the substitutions through each of the supported parts
+
+// debug($substitutions);
+ foreach(array_keys($ret) as $part) {
+ if(!empty($template->$part)) {
+ // Process the (@SUBSTITUTIONS) for this part
+ $searchKeys = [];
+ $replaceVals = [];
+
+ foreach(array_keys($substitutions) as $k) {
+ $searchKeys[] = "(@" . $k . ")";
+ $replaceVals[] = $substitutions[$k] ?? "(?)";
+ }
+
+ $ret[$part] = str_replace($searchKeys, $replaceVals, $template->$part);
+ }
+ }
+
+ return $ret;
+ }*/
+
+ /**
+ * Application Rule to determine if the current entity is a default Page.
+ *
+ * @param Entity $entity Entity to be validated
+ * @param array $options Application rule options
+ *
+ * @return string|bool true if the Rule check passes, false otherwise
+ * @since COmanage Registry v5.1.0
+ */
+
+ public function ruleIsDefaultPage($entity, array $options): string|bool {
+ if($entity->isDefaultPage()) {
+ return __d('error', 'MostlyStaticPages.default.delete');
+ }
+
+ return true;
+ }
+
+ /**
+ * Application Rule to determine if a default Page has been modified.
+ *
+ * @param Entity $entity Entity to be validated
+ * @param array $options Application rule options
+ *
+ * @return string|bool true if the Rule check passes, false otherwise
+ * @since COmanage Registry v5.1.0
+ */
+
+ public function ruleModifiedDefaultPage($entity, array $options): string|bool {
+ if($entity->isDefaultPage()
+ && ($entity->isDirty('name') || $entity->isDirty('status') || $entity->isDirty('context'))) {
+ return __d('error', 'MostlyStaticPages.default.modify');
+ }
+
+ return true;
+ }
+
+ /**
+ * Set validation rules.
+ *
+ * @since COmanage Registry v5.1.0
+ * @param Validator $validator Validator
+ * @return Validator Validator
+ */
+
+ public function validationDefault(Validator $validator): Validator {
+ $schema = $this->getSchema();
+
+ $validator->add('co_id', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->notEmptyString('co_id');
+
+ $this->registerStringValidation($validator, $schema, 'name', true);
+
+ // AR-MostlyStaticPage-2 A Mostly Static Page name may consist only of lowercase alphanumeric
+ // characters and dashes
+ $validator->add('name', [
+ 'filter' => [
+ 'rule' => ['custom', '/^[a-z0-9-]+$/'],
+ 'message' => __d('error', 'MostlyStaticPages.slug.invalid')
+ ]
+ ]);
+
+ $this->registerStringValidation($validator, $schema, 'title', true);
+
+ $this->registerStringValidation($validator, $schema, 'description', false);
+
+ $validator->add('status', [
+ 'content' => ['rule' => ['inList', SuspendableStatusEnum::getConstValues()]]
+ ]);
+ $validator->notEmptyString('status');
+
+ $validator->add('context', [
+ 'content' => ['rule' => ['inList', PageContextEnum::getConstValues()]]
+ ]);
+ $validator->notEmptyString('context');
+
+ $validator->add('body', [
+ 'filter' => ['rule' => ['validateInput'],
+ 'provider' => 'table']
+ ]);
+ $validator->allowEmptyString('body');
+
+ return $validator;
+ }
+}
\ No newline at end of file
diff --git a/app/src/Model/Table/PetitionsTable.php b/app/src/Model/Table/PetitionsTable.php
index 4b76121ef..8aa38d24c 100644
--- a/app/src/Model/Table/PetitionsTable.php
+++ b/app/src/Model/Table/PetitionsTable.php
@@ -94,7 +94,7 @@ public function initialize(array $config): void {
$this->setPrimaryLink('enrollment_flow_id');
$this->setRequiresCO(true);
- $this->setAllowLookupPrimaryLink(['finalize', 'result', 'resume']);
+ $this->setAllowLookupPrimaryLink(['continue', 'finalize', 'pending', 'result', 'resume']);
// These are required for the link to work from the Artifacts page
$this->setAllowUnkeyedPrimaryCO(['index']);
@@ -146,6 +146,8 @@ public function initialize(array $config): void {
$this->setPermissions([
// Actions that operate over an entity (ie: require an $id)
'entity' => [
+ // We handle continue authorization in the Controller
+ 'continue' => true,
'delete' => false,
'edit' => false,
// We handle finalize authorization in the Controller
diff --git a/app/templates/EnrollmentFlowSteps/fields.inc b/app/templates/EnrollmentFlowSteps/fields.inc
index d24a674fd..6664a3c00 100644
--- a/app/templates/EnrollmentFlowSteps/fields.inc
+++ b/app/templates/EnrollmentFlowSteps/fields.inc
@@ -33,6 +33,8 @@ if($vv_action == 'add' || $vv_action == 'edit') {
'plugin',
'ordr',
'actor_type',
+ 'message_template_id',
+ 'redirect_on_handoff'
] as $field) {
print $this->element('form/listItem', [
'arguments' => [
diff --git a/app/templates/MostlyStaticPages/columns.inc b/app/templates/MostlyStaticPages/columns.inc
new file mode 100644
index 000000000..86f962bac
--- /dev/null
+++ b/app/templates/MostlyStaticPages/columns.inc
@@ -0,0 +1,47 @@
+ [
+ 'type' => 'link',
+ 'sortable' => true
+ ],
+ 'name' => [
+ 'type' => 'echo',
+ 'sortable' => true
+ ],
+ 'status' => [
+ 'type' => 'enum',
+ 'class' => 'SuspendableStatusEnum',
+ 'sortable' => true
+ ],
+ 'context' => [
+ 'type' => 'enum',
+ 'class' => 'PageContextEnum',
+ 'sortable' => true
+ ]
+];
diff --git a/app/templates/MostlyStaticPages/display.php b/app/templates/MostlyStaticPages/display.php
new file mode 100644
index 000000000..b4bf1cf44
--- /dev/null
+++ b/app/templates/MostlyStaticPages/display.php
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+ = $this->Flash->render() ?>
+
+
+
+ = $this->Alert->alert($b, 'warning') ?>
+
+
+
+
+
+ = $this->Alert->alert($b, 'warning') ?>
+
+
+
+
+= $vv_body ?>
\ No newline at end of file
diff --git a/app/templates/MostlyStaticPages/fields.inc b/app/templates/MostlyStaticPages/fields.inc
new file mode 100644
index 000000000..9ec30246d
--- /dev/null
+++ b/app/templates/MostlyStaticPages/fields.inc
@@ -0,0 +1,83 @@
+
+
+
+element('form/listItem', [
+ 'arguments' => [
+ 'fieldName' => 'title'
+ ]]);
+
+ print $this->element('form/listItem', [
+ 'arguments' => [
+ 'fieldName' => 'name',
+ 'fieldOptions' => [
+ 'onChange' => 'jsLocalOnLoad()'
+ ]
+ ]]);
+
+ print $this->element('form/listItem', [
+ 'arguments' => [
+ 'fieldName' => 'pageUrl',
+ 'fieldOptions' => [
+ 'readOnly' => true
+ ]
+ ]]);
+
+ foreach ([
+ 'description',
+ 'status',
+ 'context',
+ 'body'
+ ] as $field) {
+ print $this->element('form/listItem', [
+ 'arguments' => [
+ 'fieldName' => $field
+ ]]);
+ }
+}
\ No newline at end of file
diff --git a/app/templates/Petitions/fields.inc b/app/templates/Petitions/fields.inc
index dba8a6aa7..ca8e86b35 100644
--- a/app/templates/Petitions/fields.inc
+++ b/app/templates/Petitions/fields.inc
@@ -41,6 +41,13 @@ print $this->element('form/listItem', [
]
]);
+// Enrollee Email, if provided
+print $this->element('form/listItem', [
+ 'arguments' => [
+ 'fieldName' => 'enrollee_email',
+ ]
+]);
+
// Enrollee
if (!empty($vv_obj?->enrollee_person?->id)) {
$enrolleeStatus = $vv_obj->enrollee_person->primary_name->full_name ?? __d('field', 'Petitions.enrollee.new');
@@ -58,7 +65,6 @@ if (!empty($vv_obj?->enrollee_person?->id)) {
'link' => !empty($vv_obj->enrollee_person->id) ? $enrolleeLink : [],
]
]);
-
}
// Petitioner