diff --git a/app/config/schema/schema.json b/app/config/schema/schema.json
index 7cc67284f..483044454 100644
--- a/app/config/schema/schema.json
+++ b/app/config/schema/schema.json
@@ -575,10 +575,10 @@
"columns": {
"id": {},
"subject_person_id": { "type": "integer", "foreignkey": { "table": "people", "column": "id" } },
- "subject_group_id": { "type": "integer", "foreignkey": { "table": "people", "column": "id" } },
+ "subject_group_id": { "type": "integer", "foreignkey": { "table": "groups", "column": "id" } },
"actor_person_id": { "type": "integer", "foreignkey": { "table": "people", "column": "id" } },
"recipient_person_id": { "type": "integer", "foreignkey": { "table": "people", "column": "id" } },
- "recipient_group_id": { "type": "integer", "foreignkey": { "table": "people", "column": "id" } },
+ "recipient_group_id": { "type": "integer", "foreignkey": { "table": "groups", "column": "id" } },
"resolver_person_id": { "type": "integer", "foreignkey": { "table": "people", "column": "id" } },
"action": {
"comment": "revert this to use the library definition after feature-cfm31 merge",
@@ -636,12 +636,18 @@
"authz_group_id": { "type": "integer", "foreignkey": { "table": "groups", "column": "id" }},
"collect_enrollee_email": { "type": "boolean" },
"redirect_on_duplicate": { "type": "string", "size": 256 },
- "redirect_on_finalize": { "type": "string", "size": 256 }
+ "redirect_on_finalize": { "type": "string", "size": 256 },
+ "finalization_message_template_id": { "type": "integer", "foreignkey": { "table": "message_templates", "column": "id" }},
+ "notification_group_id": { "type": "integer", "foreignkey": { "table": "groups", "column": "id" }},
+ "notification_message_template_id": { "type": "integer", "foreignkey": { "table": "message_templates", "column": "id" }}
},
"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_flows_i3": { "needed": false, "columns": [ "authz_group_id" ]},
+ "enrollment_flows_i4": { "needed": false, "columns": [ "finalization_message_template_id" ]},
+ "enrollment_flows_i5": { "needed": false, "columns": [ "notification_group_id" ]},
+ "enrollment_flows_i6": { "needed": false, "columns": [ "notification_message_template_id" ]}
}
},
@@ -654,12 +660,18 @@
"plugin": {},
"ordr": {},
"actor_type": { "type": "string", "size": 2 },
+ "approver_group_id": { "type": "integer", "foreignkey": { "table": "groups", "column": "id" }},
"message_template_id": {},
- "redirect_on_handoff": { "type": "string", "size": 256 }
+ "redirect_on_handoff": { "type": "string", "size": 256 },
+ "notification_group_id": { "type": "integer", "foreignkey": { "table": "groups", "column": "id" }},
+ "notification_message_template_id": { "type": "integer", "foreignkey": { "table": "message_templates", "column": "id" }}
},
"indexes": {
"enrollment_flow_steps_i1": { "columns": [ "enrollment_flow_id" ]},
- "enrollment_flow_steps_i2": { "needed": false, "columns": [ "message_template_id" ]}
+ "enrollment_flow_steps_i2": { "needed": false, "columns": [ "message_template_id" ]},
+ "enrollment_flow_steps_i3": { "needed": false, "columns": [ "notification_group_id" ]},
+ "enrollment_flow_steps_i4": { "needed": false, "columns": [ "notification_message_template_id" ]},
+ "enrollment_flow_steps_i5": { "needed": false, "columns": [ "approver_group_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 d53ad5dfb..b34f6ad04 100644
--- a/app/plugins/CoreEnroller/resources/locales/en_US/core_enroller.po
+++ b/app/plugins/CoreEnroller/resources/locales/en_US/core_enroller.po
@@ -64,14 +64,8 @@ msgstr "DefaultCodeLength"
msgid "enumeration.VerificationDefaultsEnum.60"
msgstr "DefaultVerificationValidity"
-msgid "enumeration.VerificationModeEnum.0"
-msgstr "None"
-
-msgid "enumeration.VerificationModeEnum.1"
-msgstr "One"
-
-msgid "enumeration.VerificationModeEnum.A"
-msgstr "All"
+msgid "error.ApprovalCollectors.comment"
+msgstr "Comment is required"
msgid "error.EmailVerifiers.candidate"
msgstr "Requested address is not a valid candidate"
@@ -109,6 +103,9 @@ msgstr "This Invitation has expired"
msgid "error.PetitionAcceptances.processed"
msgstr "This Invitation has already been processed"
+msgid "information.ApprovalCollectors.review"
+msgstr "Please approve or deny Petition {0}."
+
msgid "information.EmailVerifiers.done"
msgstr "All email addresses in this Petition have been verified. You may continue on to the next Enrollment Step."
@@ -142,6 +139,27 @@ msgstr "New code sent"
msgid "information.EmailVerifiers.abort"
msgstr "Abort"
+msgid "field.ApprovalCollectors.denial_message_template_id"
+msgstr "Denial Message Template"
+
+msgid "field.ApprovalCollectors.denial_message_template_id.desc"
+msgstr "Message Template to use when notifying the Enrollee of a denial (no denial message is sent if not set)"
+
+msgid "field.ApprovalCollectors.mode.desc"
+msgstr "How many members of the Approver Group must approve Petitions for this Enrollment Flow Step"
+
+msgid "field.ApprovalCollectors.redirect_on_denial"
+msgstr "Redirect on Denial"
+
+msgid "field.ApprovalCollectors.redirect_on_denial.desc"
+msgstr "If the Petition is denied, the Approver will be redirected here instead of the default handoff page"
+
+msgid "field.ApprovalCollectors.require_comment"
+msgstr "Require Comment"
+
+msgid "field.ApprovalCollectors.require_comment.desc"
+msgstr "If set, the Approver must add a comment when approving or denying Petitions for this Enrollment Flow Step"
+
msgid "field.AttributeCollectors.valid_through.default.after.desc"
msgstr "Days After Finalization"
@@ -302,6 +320,15 @@ msgstr "Petition Attributes recorded"
msgid "result.basicattr.finalized"
msgstr "Name, Email Address, and Person Role created during finalization"
+msgid "result.ApprovalCollectors.approved"
+msgstr "Petition Approved"
+
+msgid "result.ApprovalCollectors.denied"
+msgstr "Petition Denied"
+
+msgid "result.ApprovalCollectors.status"
+msgstr "Petition {0} by {1} at {2} ({3})"
+
msgid "result.EmailVerifiers.verified"
msgstr "Verified {0} of {1} available {2}"
diff --git a/app/plugins/CoreEnroller/src/Controller/ApprovalCollectorsController.php b/app/plugins/CoreEnroller/src/Controller/ApprovalCollectorsController.php
new file mode 100644
index 000000000..f2df9c905
--- /dev/null
+++ b/app/plugins/CoreEnroller/src/Controller/ApprovalCollectorsController.php
@@ -0,0 +1,151 @@
+ [
+ 'ApprovalCollectors.id' => 'asc'
+ ]
+ ];
+
+ /**
+ * Dispatch an Enrollment Flow Step.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param string $id Approval Collector ID
+ */
+
+ public function dispatch(string $id) {
+ $request = $this->getRequest();
+ $session = $request->getSession();
+ // $username = $session->read('Auth.external.user');
+
+ $petition = $this->getPetition();
+ $coId = $this->getCOID();
+
+ if($request->is('post')) {
+ $cfg = $this->ApprovalCollectors->get($id);
+
+ try {
+ // Record approval or denial
+
+ $approved = $this->requestParam('approved');
+ $comment = $this->requestParam('comment');
+
+ // record() will handle updatind the Petition status and performing other
+ // recordkeeping transactions, including enforcing comment if required
+
+ $this->ApprovalCollectors->record(
+ petitionId: $petition->id,
+ approvalCollectorId: (int)$id,
+ approverPersonId: $this->RegistryAuth->getPersonID($coId),
+ approved: $approved == StatusEnum::Approved,
+ comment: $comment
+ );
+
+ if($approved == StatusEnum::Denied) {
+ // If we have a denial Message Template, send the notification to the enrollee
+ // email address. We don't currently support using a Notification, since in most
+ // cases the Enrollee will not have a Person record yet. (There are some edge
+ // cases around processes like Additional Role Enrollment where we might want
+ // to be able to Notify the Person using their existing preferred Email Address,
+ // but for now we don't support that.)
+
+ if(!empty($cfg->denial_message_template_id)
+ && !empty($petition->enrollee_email)) {
+ $MessageTemplates = TableRegistry::getTableLocator()->get('MessageTemplates');
+
+ // Generate the message and send
+
+ $template = $MessageTemplates->get($cfg->denial_message_template_id);
+
+ $template->setContextPetition($petition);
+
+ $template->generateMessage();
+
+ // 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: $this->getCOID(),
+ recipient: $petition->enrollee_email,
+ subject: $template->getMessagePart('subject'),
+ body_text: $template->getMessagePart('body_text'),
+ body_html: $template->getMessagePart('body_html')
+ )) {
+ throw new \RuntimeException("Message delivery failed"); // XXX I18n. can we get an exception from sendEmailToAddress instead?
+ }
+ }
+
+ // If we have a redirect on denial configured, send the Approver there
+ if(!empty($cfg->redirect_on_denial)) {
+ return $this->redirect($cfg->redirect_on_denial);
+ } else {
+ // Redirect to the default Enrollment Handoff URL for this CO
+ return $this->redirect("/$coId/default-handoff");
+ }
+ }
+
+ // Where do we redirect? On approval, it's possible that the next step has the
+ // same Approver's group on handoff, in which case we just let the flow continue.
+ // However on denial, we need to stop the flow. So basically we need a separate
+ // "redirect on denial" target (or we use the default Enrollment Flow handoff if
+ // not configured).
+
+ // Redirect to the next step
+
+ return $this->finishStep(
+ enrollmentFlowStepId: $cfg->enrollment_flow_step_id,
+ petitionId: $petition->id,
+ comment: __d('core_enroller', 'result.ApprovalCollectors.' . ($approved == StatusEnum::Approved ? 'approved' : 'denied'))
+ );
+ }
+ catch(\Exception $e) {
+ $this->llog('error', $e->getMessage());
+
+ $this->Flash->error($e->getMessage());
+ }
+ }
+
+ // Check for existing values in case we're re-running the step
+ $this->set('petition_approvals',
+ $this->ApprovalCollectors->PetitionApprovals->find()
+ ->where(['petition_id' => $petition->id, 'approval_collector_id' => $id])
+ ->first());
+
+ $this->render('/Standard/dispatch');
+ }
+}
\ 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 285a7fb9b..2e6a942d3 100644
--- a/app/plugins/CoreEnroller/src/Controller/BasicAttributeCollectorsController.php
+++ b/app/plugins/CoreEnroller/src/Controller/BasicAttributeCollectorsController.php
@@ -78,7 +78,6 @@ public function dispatch(string $id) {
$this->set('vv_required_name_fields', $settings->name_required_fields_array());
if($this->request->is(['post', 'put'])) {
-
try {
$this->BasicAttributeCollectors->upsert(
id: (int)$id,
@@ -102,6 +101,12 @@ public function dispatch(string $id) {
$this->Flash->error($e->getMessage());
}
}
+
+ // Check for existing values in case we're re-running the step
+ $this->set('petition_basic_attribute_sets',
+ $this->BasicAttributeCollectors->PetitionBasicAttributeSets->find()
+ ->where(['petition_id' => $petition->id, 'basic_attribute_collector_id' => $id])
+ ->first());
// Fall through and let the form render
diff --git a/app/plugins/CoreEnroller/src/Controller/EmailVerifiersController.php b/app/plugins/CoreEnroller/src/Controller/EmailVerifiersController.php
index e05a16748..ed54b76d0 100644
--- a/app/plugins/CoreEnroller/src/Controller/EmailVerifiersController.php
+++ b/app/plugins/CoreEnroller/src/Controller/EmailVerifiersController.php
@@ -36,7 +36,7 @@
use App\Lib\Util\StringUtilities;
use Cake\Http\Exception\BadRequestException;
use Cake\ORM\TableRegistry;
-use CoreEnroller\Lib\Enum\VerificationModeEnum;
+use \App\Lib\Enum\AllTernaryEnum;
use \App\Lib\Enum\HttpStatusCodesEnum;
class EmailVerifiersController extends StandardEnrollerController {
@@ -168,10 +168,10 @@ public function dispatch(string $id) {
$doneCount = count($verifiedAddresses);
$totalCount = count($candidateAddresses);
$allDone = $doneCount == $totalCount;
- $minimumMet = $cfg->mode == VerificationModeEnum::None
- || ($cfg->mode == VerificationModeEnum::One
+ $minimumMet = $cfg->mode == AllTernaryEnum::None
+ || ($cfg->mode == AllTernaryEnum::One
&& $doneCount > 0)
- || ($cfg->mode == VerificationModeEnum::All
+ || ($cfg->mode == AllTernaryEnum::All
&& $allDone);
$this->set('vv_all_done', $allDone);
diff --git a/app/plugins/CoreEnroller/src/Model/Entity/ApprovalCollector.php b/app/plugins/CoreEnroller/src/Model/Entity/ApprovalCollector.php
new file mode 100644
index 000000000..5e70d595c
--- /dev/null
+++ b/app/plugins/CoreEnroller/src/Model/Entity/ApprovalCollector.php
@@ -0,0 +1,51 @@
+
+ */
+ protected $_accessible = [
+ '*' => true,
+ 'id' => false,
+ 'slug' => false,
+ ];
+}
diff --git a/app/plugins/CoreEnroller/src/Model/Entity/PetitionApproval.php b/app/plugins/CoreEnroller/src/Model/Entity/PetitionApproval.php
new file mode 100644
index 000000000..d59aad765
--- /dev/null
+++ b/app/plugins/CoreEnroller/src/Model/Entity/PetitionApproval.php
@@ -0,0 +1,49 @@
+
+ */
+ protected $_accessible = [
+ '*' => true,
+ 'id' => false,
+ 'slug' => false,
+ ];
+}
diff --git a/app/plugins/CoreEnroller/src/Model/Table/ApprovalCollectorsTable.php b/app/plugins/CoreEnroller/src/Model/Table/ApprovalCollectorsTable.php
new file mode 100644
index 000000000..29c0bd856
--- /dev/null
+++ b/app/plugins/CoreEnroller/src/Model/Table/ApprovalCollectorsTable.php
@@ -0,0 +1,297 @@
+addBehavior('Changelog');
+ $this->addBehavior('Log');
+ $this->addBehavior('Timestamp');
+
+ $this->setTableType(TableTypeEnum::Configuration);
+
+ // Define associations
+ $this->belongsTo('EnrollmentFlowSteps');
+ $this->belongsTo('Groups');
+ $this->belongsTo('MessageTemplates')
+ ->setForeignKey('denial_message_template_id')
+ ->setProperty('denial_message_template');
+
+ $this->hasMany('CoreEnroller.PetitionApprovals')
+ ->setDependent(true)
+ ->setCascadeCallbacks(true);
+
+ $this->setDisplayField('id');
+
+ $this->setPrimaryLink('enrollment_flow_step_id');
+ $this->setRequiresCO(true);
+ $this->setAllowLookupPrimaryLink(['dispatch', 'display']);
+
+ // All the tabs share the same configuration in the ModelTable file
+ $this->setTabsConfig(
+ [
+ // Ordered list of Tabs
+ 'tabs' => ['EnrollmentFlowSteps', 'CoreEnroller.ApprovalCollectors'],
+ // What actions will include the subnavigation header
+ 'action' => [
+ // If a model renders in a subnavigation mode in edit/view mode, it cannot
+ // render in index mode for the same use case/context
+ // XXX edit should go first.
+ 'EnrollmentFlowSteps' => ['edit', 'view'],
+ 'CoreEnroller.ApprovalCollectors' => ['edit']
+ ]
+ ]
+ );
+
+ $this->setAutoViewVars([
+ 'modes' => [
+ 'type' => 'enum',
+ 'class' => 'AllTernaryEnum'
+ ],
+ 'denialMessageTemplates' => [
+ 'type' => 'select',
+ 'model' => 'MessageTemplates',
+ 'where' => ['context' => \App\Lib\Enum\MessageTemplateContextEnum::EnrollmentApproval]
+ ]
+ ]);
+
+ $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'],
+ // 'resend' => true,
+ '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 hydrate the Person record as part of Petition finalization.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param int $id Approval Collector ID
+ * @param Petition $petition Petition
+ * @return bool true on success
+ */
+
+ public function hydrate(int $id, \App\Model\Entity\Petition $petition) {
+ // $cfg = $this->get($id);
+ // Approval Collector just affects the flow as it happens, and so nothing is
+ // currently required at finalization.
+
+ return true;
+ }
+
+ /**
+ * Perform tasks prior to transitioning to this step.
+ *
+ * @since COmanage Registry v5.2.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 {
+ // Set this petition to Pending Verification
+
+ $Petitions = TableRegistry::getTableLocator()->get('Petitions');
+
+ $petition->status = PetitionStatusEnum::PendingApproval;
+
+ $Petitions->saveOrFail($petition);
+
+ return true;
+ }
+
+ /**
+ * Record an Approval (or denial).
+ *
+ * @since COmanage Registry v5.2.0
+ * @param int $petitionId Petition ID
+ * @param int $approvalCollectorId Approval Collector ID
+ * @param int $approverPersonId Approver Person ID
+ * @param bool $approved true if the Approval is granted, false otherwise
+ * @param string $comment Approval (or denial) Comment
+ * @throws \InvalidArgumentException
+ */
+
+ public function record(
+ int $petitionId,
+ int $approvalCollectorId,
+ int $approverPersonId,
+ bool $approved=true,
+ ?string $comment=null
+ ) {
+ $Petitions = TableRegistry::getTableLocator()->get('Petitions');
+
+ $cfg = $this->get($approvalCollectorId);
+
+ $petition = $Petitions->get($petitionId);
+
+ // First check if require_comment but no comment was provided
+
+ if($cfg->require_comment && (!$comment || strlen(trim($comment)) == 0)) {
+ throw new \InvalidArgumentException(__d('core_enroller', 'error.ApprovalCollectors.comment'));
+ }
+
+ // Record a PetitionApproval (which is also used for denials).
+
+ $pa = [
+ 'petition_id' => $petitionId,
+ 'approval_collector_id' => $approvalCollectorId,
+ 'approver_person_id' => $approverPersonId,
+ 'approved' => $approved,
+ 'comment' => $comment
+ ];
+
+ $this->PetitionApprovals->upsertOrFail(
+ $pa,
+ ['petition_id' => $petitionId, 'approval_collector_id' => $approvalCollectorId]
+ );
+
+ // Next, update the Petition status.
+
+ // If there'a another Approval step after this one we'll bounce back to Pending
+ // Approval as soon as we switch to it, which creates a bit of noise but in some
+ // ways is sort of correct. We could try to calculate if there is another Approval
+ // step, but that's a bit complicated and not really worth the effort. We don't
+ // calculate Approval during finalization because the status wouldn't stick around
+ // long enough to be useful for an administrator.
+ $petition->status = $approved ? PetitionStatusEnum::Approved : PetitionStatusEnum::Denied;
+
+ $Petitions->saveOrFail($petition);
+
+ // Record PetitionHistory.
+
+ $this->llog('debug', "Petition " . $petition->id . ($approved ? " approved" : " denied") . " by Person " . $approverPersonId);
+
+ $Petitions->PetitionHistoryRecords->record(
+ petitionId: $petitionId,
+ enrollmentFlowStepId: $cfg->enrollment_flow_step_id,
+ action: $approved ? PetitionActionEnum::Approved : PetitionActionEnum::Denied,
+ comment: __d('core_enroller', 'result.ApprovalCollectors.' . ($approved ? 'approved' : 'denied'))
+ );
+
+ // Finally, resolved the Notification created by the handoff.
+
+ $Notifications = TableRegistry::getTableLocator()->get('Notifications');
+
+ // The URL was originally created by $EnrollmentFlows->calculateNextStep, but we
+ // can easily reconstruct what it should be
+
+ $url = [
+ 'plugin' => 'CoreEnroller',
+ 'controller' => 'approval_collectors',
+ 'action' => 'dispatch',
+ $approvalCollectorId,
+ '?' => ['petition_id' => $petitionId]
+ ];
+
+ $Notifications->resolveFromSource(
+ source: $url,
+ resolution: NotificationStatusEnum::Resolved,
+ resolverPersonId: $approverPersonId
+ );
+ }
+
+ /**
+ * Set validation rules.
+ *
+ * @since COmanage Registry v5.2.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('require_comment', [
+ 'content' => ['rule' => ['boolean']]
+ ]);
+ $validator->allowEmptyString('require_comment');
+
+ $validator->add('denial_message_template_id', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->allowEmptyString('denial_message_template_id');
+
+ $validator->add('redirect_on_denial', [
+ 'content' => ['rule' => 'url']
+ ]);
+ $validator->allowEmptyString('redirect_on_denial');
+
+ return $validator;
+ }
+}
diff --git a/app/plugins/CoreEnroller/src/Model/Table/BasicAttributeCollectorsTable.php b/app/plugins/CoreEnroller/src/Model/Table/BasicAttributeCollectorsTable.php
index 0a99fa869..3a1fcd516 100644
--- a/app/plugins/CoreEnroller/src/Model/Table/BasicAttributeCollectorsTable.php
+++ b/app/plugins/CoreEnroller/src/Model/Table/BasicAttributeCollectorsTable.php
@@ -149,6 +149,46 @@ public function initialize(array $config): void {
]);
}
+ /**
+ * Obtain a name (as a string) for the Enrollee associated with the specified Petition.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param EntityInterface $config Configuration entity for this plugin
+ * @param int $petitionId Petition ID
+ * @return string Name, or null if thre is no name data
+ */
+
+ public function enrolleeName(
+ EntityInterface $config,
+ int $petitionId
+ ): ?string {
+ $set = $this->PetitionBasicAttributeSets->find()
+ ->where([
+ 'basic_attribute_collector_id' => $config->id,
+ 'petition_id' => $petitionId
+ ])
+ ->first();
+
+ if(!empty($set)) {
+ // We construct a throw-away Name entity so we can use it to generate the string
+ // representation of the name data we have.
+
+ $Names = TableRegistry::getTableLocator()->get('Names');
+
+ $name = $Names->newEntity([
+ 'honorific' => $set->honorific,
+ 'given' => $set->given,
+ 'middle' => $set->middle,
+ 'family' => $set->family,
+ 'suffix' => $set->suffix
+ ]);
+
+ return $name->full_name;
+ }
+
+ return null;
+ }
+
/**
* Perform steps necessary to hydrate the Person record as part of Petition finalization.
*
@@ -232,8 +272,6 @@ public function hydrate(int $id, \App\Model\Entity\Petition $petition) {
enrollmentFlowStepId: $cfg->enrollment_flow_step_id,
action: PetitionActionEnum::Finalized,
comment: __d('core_enroller', 'result.basicattr.finalized')
-// We don't have $actorPersonId yet...
-// ?int $actorPersonId=null
);
return true;
@@ -253,35 +291,35 @@ public function hydrate(int $id, \App\Model\Entity\Petition $petition) {
public function upsert(int $id, int $petitionId, array $attributes) {
$basicAttributeCollector = $this->get($id);
- // Do we have existing attributes for this petition? Note this will pull
- // _all_ attributes for the Petition, not just those associated with this
- // particular Attribute Collector; however we'll only look at the attributes
- // we need below.
- $entity = $this->PetitionBasicAttributeSets
- ->find()
- ->where([
- 'petition_id' => $petitionId,
- // Strictly speaking we only support one instance per Flow,
- // but we'll filter on the $id anyway since we have it
- 'basic_attribute_collector_id' => $id
- ])
- ->first();
-
- if(!$entity) {
- // insert, not update
-
- $entity = $this->PetitionBasicAttributeSets->newEntity([
- 'basic_attribute_collector_id' => $id,
- 'petition_id' => $petitionId
- ]);
- }
+ $data = [
+ 'basic_attribute_collector_id' => $id,
+ 'petition_id' => $petitionId
+ ];
foreach(['honorific', 'given', 'middle', 'family', 'suffix', 'mail'] as $f) {
// XXX we should probably check CoSettings for name settings
- $entity->$f = $attributes[$f] ?? null;
+ $data[$f] = $attributes[$f] ?? null;
}
- $this->PetitionBasicAttributeSets->saveOrFail($entity);
+ $pei = $this->PetitionBasicAttributeSets->upsertOrFail(
+ data: $data,
+ whereClause: [
+ 'petition_id' => $petitionId,
+ 'basic_attribute_collector_id' => $id,
+ ]
+ );
+
+ if(!empty($basicAttributeCollector->cou_id)) {
+ // Insert the COU ID into the primary Petition artifact
+
+ $Petitions = TableRegistry::getTableLocator()->get('Petitions');
+
+ $petition = $Petitions->get($petitionId);
+
+ $petition->cou_id = $basicAttributeCollector->cou_id;
+
+ $Petitions->save($petition);
+ }
// Record Petition History
@@ -292,8 +330,6 @@ public function upsert(int $id, int $petitionId, array $attributes) {
enrollmentFlowStepId: $basicAttributeCollector->enrollment_flow_step_id,
action: PetitionActionEnum::AttributesUpdated,
comment: __d('core_enroller', 'result.attr.saved')
-// We don't have $actorPersonId yet...
-// ?int $actorPersonId=null
);
return true;
diff --git a/app/plugins/CoreEnroller/src/Model/Table/EmailVerifiersTable.php b/app/plugins/CoreEnroller/src/Model/Table/EmailVerifiersTable.php
index 03b5e2313..3771e6205 100644
--- a/app/plugins/CoreEnroller/src/Model/Table/EmailVerifiersTable.php
+++ b/app/plugins/CoreEnroller/src/Model/Table/EmailVerifiersTable.php
@@ -29,6 +29,7 @@
namespace CoreEnroller\Model\Table;
+use App\Lib\Enum\AllTernaryEnum;
use App\Lib\Enum\EnrollmentActorEnum;
use App\Lib\Enum\PermittedCharactersEnum;
use App\Lib\Enum\PetitionStatusEnum;
@@ -39,7 +40,6 @@
use Cake\ORM\Table;
use Cake\ORM\TableRegistry;
use Cake\Validation\Validator;
-use CoreEnroller\Lib\Enum\VerificationModeEnum;
use CoreEnroller\Lib\Enum\VerificationDefaultsEnum;
use CoreEnroller\Model\Entity\EmailVerifier;
@@ -105,7 +105,7 @@ public function initialize(array $config): void {
$this->setAutoViewVars([
'modes' => [
'type' => 'enum',
- 'class' => 'CoreEnroller.VerificationModeEnum'
+ 'class' => 'AllTernaryEnum'
],
'defaults' => [
'type' => 'enum',
@@ -540,7 +540,7 @@ public function validationDefault(Validator $validator): Validator {
$validator->notEmptyString('enrollment_flow_step_id');
$validator->add('mode', [
- 'content' => ['rule' => ['inList', VerificationModeEnum::getConstValues()]]
+ 'content' => ['rule' => ['inList', AllTernaryEnum::getConstValues()]]
]);
$validator->notEmptyString('mode');
diff --git a/app/plugins/CoreEnroller/src/Model/Table/PetitionAcceptancesTable.php b/app/plugins/CoreEnroller/src/Model/Table/PetitionAcceptancesTable.php
index 1c5e47831..9a0afedbd 100644
--- a/app/plugins/CoreEnroller/src/Model/Table/PetitionAcceptancesTable.php
+++ b/app/plugins/CoreEnroller/src/Model/Table/PetitionAcceptancesTable.php
@@ -144,10 +144,8 @@ public function processReply(int $petitionId, int $enrollmentFlowStepId, bool $a
$this->Petitions->PetitionHistoryRecords->record(
petitionId: $petitionId,
enrollmentFlowStepId: $enrollmentFlowStepId,
- action: PetitionActionEnum::StatusUpdated,
+ action: $accepted ? PetitionActionEnum::Accepted : PetitionActionEnum::Declined,
comment: __d('core_enroller', $accepted ? 'result.accept.accepted' : 'result.accept.declined')
-// We don't have $actorPersonId yet...
-// ?int $actorPersonId=null
);
}
diff --git a/app/plugins/CoreEnroller/src/Model/Table/PetitionApprovalsTable.php b/app/plugins/CoreEnroller/src/Model/Table/PetitionApprovalsTable.php
new file mode 100644
index 000000000..f348589a3
--- /dev/null
+++ b/app/plugins/CoreEnroller/src/Model/Table/PetitionApprovalsTable.php
@@ -0,0 +1,127 @@
+addBehavior('Changelog');
+ $this->addBehavior('Log');
+ $this->addBehavior('Timestamp');
+
+ $this->setTableType(\App\Lib\Enum\TableTypeEnum::Artifact);
+
+ // Define associations
+ $this->belongsTo('CoreEnroller.ApprovalCollectors');
+ $this->belongsTo('Petitions');
+ $this->belongsTo('ApproverPeople')
+ ->setClassName('People')
+ ->setForeignKey('approver_person_id')
+ ->setProperty('approver_person');
+
+ $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' => false
+ ]
+ ]);
+ }
+
+ /**
+ * Set validation rules.
+ *
+ * @since COmanage Registry v5.2.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('approval_collector_id', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->notEmptyString('approval_collector_id');
+
+ $validator->add('approver_person_id', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->notEmptyString('approver_person_id');
+
+ $validator->add('approved', [
+ 'content' => ['rule' => ['boolean']]
+ ]);
+ $validator->allowEmptyString('approved');
+
+ $this->registerStringValidation($validator, $schema, 'comment', false);
+
+ return $validator;
+ }
+}
diff --git a/app/plugins/CoreEnroller/src/Model/Table/PetitionBasicAttributeSetsTable.php b/app/plugins/CoreEnroller/src/Model/Table/PetitionBasicAttributeSetsTable.php
index a51e31976..7cc0662a7 100644
--- a/app/plugins/CoreEnroller/src/Model/Table/PetitionBasicAttributeSetsTable.php
+++ b/app/plugins/CoreEnroller/src/Model/Table/PetitionBasicAttributeSetsTable.php
@@ -39,6 +39,7 @@ class PetitionBasicAttributeSetsTable extends Table {
use \App\Lib\Traits\PermissionsTrait;
use \App\Lib\Traits\PrimaryLinkTrait;
use \App\Lib\Traits\TableMetaTrait;
+ use \App\Lib\Traits\UpsertTrait;
use \App\Lib\Traits\ValidationTrait;
/**
diff --git a/app/plugins/CoreEnroller/src/View/Cell/ApprovalCollectorsCell.php b/app/plugins/CoreEnroller/src/View/Cell/ApprovalCollectorsCell.php
new file mode 100644
index 000000000..04839c430
--- /dev/null
+++ b/app/plugins/CoreEnroller/src/View/Cell/ApprovalCollectorsCell.php
@@ -0,0 +1,81 @@
+
+ */
+ protected $_validCellOptions = [
+ 'vv_obj',
+ 'vv_step',
+ 'viewVars',
+ ];
+
+ /**
+ * Initialization logic run at the end of object construction.
+ *
+ * @return void
+ */
+ public function initialize(): void
+ {
+ }
+
+ /**
+ * Default display method.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param int $petitionId
+ * @return void
+ */
+
+ public function display(int $petitionId): void {
+ $vv_pa = $this->fetchTable('CoreEnroller.PetitionApprovals')
+ ->find()
+ ->where([
+ 'approval_collector_id' => $this->vv_step->approval_collector->id,
+ 'petition_id' => $this->vv_obj->id
+ ])
+ ->contain(['ApproverPeople' => 'PrimaryName'])
+ ->first();
+
+ $this->set('vv_pa', $vv_pa);
+ }
+}
diff --git a/app/plugins/CoreEnroller/src/config/plugin.json b/app/plugins/CoreEnroller/src/config/plugin.json
index 35efbcfe6..8870e9358 100644
--- a/app/plugins/CoreEnroller/src/config/plugin.json
+++ b/app/plugins/CoreEnroller/src/config/plugin.json
@@ -1,6 +1,7 @@
{
"types": {
"enroller": [
+ "ApprovalCollectors",
"AttributeCollectors",
"BasicAttributeCollectors",
"EmailVerifiers",
@@ -10,6 +11,19 @@
},
"schema": {
"tables": {
+ "approval_collectors": {
+ "columns": {
+ "id": {},
+ "enrollment_flow_step_id": {},
+ "require_comment": { "type": "boolean" },
+ "denial_message_template_id": { "type": "integer", "foreignkey": { "table": "message_templates", "column": "id" }},
+ "redirect_on_denial": { "type": "string", "size": 256 }
+ },
+ "indexes": {
+ "approval_collectors_i1": { "columns": [ "enrollment_flow_step_id" ] },
+ "approval_collectors_i2": { "needed": false, "columns": [ "denial_message_template_id" ] }
+ }
+ },
"attribute_collectors": {
"columns": {
"id": {},
@@ -110,6 +124,21 @@
"petition_acceptances_i1": { "columns": [ "petition_id" ] }
}
},
+ "petition_approvals": {
+ "columns": {
+ "id": {},
+ "petition_id": {},
+ "approval_collector_id": { "type": "integer", "foreignkey": { "table": "approval_collectors", "column": "id" } },
+ "approver_person_id": { "type": "integer", "foreignkey": { "table": "people", "column": "id" } },
+ "approved": { "type": "boolean" },
+ "comment": {}
+ },
+ "indexes": {
+ "petition_approvals_i1": { "columns": [ "petition_id" ] },
+ "petition_approvals_i2": { "needed": false, "columns": [ "approval_collector_id" ] },
+ "petition_approvals_i3": { "needed": false, "columns": [ "approver_person_id" ] }
+ }
+ },
"petition_attributes": {
"columns": {
"id": {},
diff --git a/app/plugins/CoreEnroller/templates/ApprovalCollectors/dispatch.inc b/app/plugins/CoreEnroller/templates/ApprovalCollectors/dispatch.inc
new file mode 100644
index 000000000..62375a0fb
--- /dev/null
+++ b/app/plugins/CoreEnroller/templates/ApprovalCollectors/dispatch.inc
@@ -0,0 +1,72 @@
+element('flash', []);
+
+// Make the Form fields editable
+$this->Field->enableFormEditMode();
+?>
+
+
= __d('core_enroller', 'information.ApprovalCollectors.review', [$vv_petition->id]); ?>
+
+= $this->Html->link(__d('operation', 'view'), ['plugin' => null, 'controller' => 'petitions', 'action' => 'view', $vv_petition->id]); ?>
+
+Form->create(null, [
+ 'id' => 'approval-form',
+ 'type' => 'post'
+ ]);
+
+ print $this->Form->radio(
+ 'approved',
+ [
+ StatusEnum::Approved => __d('enumeration', 'StatusEnum.'.StatusEnum::Approved),
+ StatusEnum::Denied => __d('enumeration', 'StatusEnum.'.StatusEnum::Denied)
+ ],
+ [
+ 'value' => !empty($petition_approvals)
+ ? ($petition_approvals['approved'] === true
+ ? StatusEnum::Approved
+ : ($petition_approvals['approved'] === false
+ ? StatusEnum::Denied
+ : ""))
+ : ""
+ ]
+ );
+
+ print $this->element('form/listItem', [
+ 'arguments' => [
+ 'fieldName' => 'comment',
+ 'fieldOptions' => [
+ 'default' => $petition_approvals['comment'] ?? ""
+ ]
+ ]]);
diff --git a/app/plugins/CoreEnroller/templates/ApprovalCollectors/fields.inc b/app/plugins/CoreEnroller/templates/ApprovalCollectors/fields.inc
new file mode 100644
index 000000000..5b70e4daa
--- /dev/null
+++ b/app/plugins/CoreEnroller/templates/ApprovalCollectors/fields.inc
@@ -0,0 +1,40 @@
+element('form/listItem', [
+ 'arguments' => ['fieldName' => $field]
+ ]);
+}
\ No newline at end of file
diff --git a/app/plugins/CoreEnroller/templates/BasicAttributeCollectors/dispatch.inc b/app/plugins/CoreEnroller/templates/BasicAttributeCollectors/dispatch.inc
index 564c21b3a..8333415ae 100644
--- a/app/plugins/CoreEnroller/templates/BasicAttributeCollectors/dispatch.inc
+++ b/app/plugins/CoreEnroller/templates/BasicAttributeCollectors/dispatch.inc
@@ -27,10 +27,8 @@
declare(strict_types = 1);
-// This view is intended to work with dispatch
-if($vv_action == 'dispatch') {
- // Make the Form fields editable
- $this->Field->enableFormEditMode();
+// Make the Form fields editable
+$this->Field->enableFormEditMode();
?>
@@ -46,9 +44,9 @@ if($vv_action == 'dispatch') {
= $this->element('form/infoDiv/default', [
'vv_field_arguments' => [
'fieldName' => $n,
- 'fieldLabel' => __d('field', $n),
'fieldOptions' => [
- 'required' => in_array($n, $vv_required_name_fields)
+ 'required' => in_array($n, $vv_required_name_fields),
+ 'default' => $petition_basic_attribute_sets[$n] ?? ""
]
]]);
?>
@@ -64,6 +62,7 @@ if($vv_action == 'dispatch') {
print $this->element('form/listItem', [
'arguments' => [
'fieldName' => 'mail',
- 'fieldLabel' => __d('field', 'mail')
+ 'fieldOptions' => [
+ 'default' => $petition_basic_attribute_sets['mail'] ?? ""
+ ]
]]);
-}
\ No newline at end of file
diff --git a/app/plugins/CoreEnroller/templates/cell/ApprovalCollectors/display.php b/app/plugins/CoreEnroller/templates/cell/ApprovalCollectors/display.php
new file mode 100644
index 000000000..118393be5
--- /dev/null
+++ b/app/plugins/CoreEnroller/templates/cell/ApprovalCollectors/display.php
@@ -0,0 +1,64 @@
+approver_person_id)) {
+ $enum = ($vv_pa->approved ? StatusEnum::Approved : StatusEnum::Denied);
+
+ $approver = $this->Html->link(
+ $vv_pa->approver_person->primary_name->full_name,
+ [
+ 'plugin' => null,
+ 'controller' => 'people',
+ 'action' => 'edit',
+ $vv_pa->approver_person_id
+ ]
+ );
+
+ $status = __d('core_enroller', 'result.ApprovalCollectors.status', [
+ __d('enumeration', 'StatusEnum.'.$enum),
+ $approver,
+ $vv_pa->modified,
+ $vv_pa->comment
+ ]);
+}
+?>
+
+
+
\ No newline at end of file
diff --git a/app/plugins/CoreEnroller/templates/element/emailVerifiers/list.php b/app/plugins/CoreEnroller/templates/element/emailVerifiers/list.php
index 2c068d4e3..f4a44b24b 100644
--- a/app/plugins/CoreEnroller/templates/element/emailVerifiers/list.php
+++ b/app/plugins/CoreEnroller/templates/element/emailVerifiers/list.php
@@ -27,7 +27,7 @@
declare(strict_types = 1);
-use CoreEnroller\Lib\Enum\VerificationModeEnum;
+use App\Lib\Enum\AllTernaryEnum;
use App\Lib\Util\StringUtilities;
// Render the list of known email addresses and their verification statuses.
@@ -38,12 +38,12 @@
$title = __d('core_enroller', 'information.EmailVerifiers.done');
} else {
$title = match ($vv_config->mode) {
- VerificationModeEnum::All => __d('core_enroller', 'information.EmailVerifiers.A'),
- VerificationModeEnum::None => __d('core_enroller', 'information.EmailVerifiers.0'),
- VerificationModeEnum::One => $vv_minimum_met
+ AllTernaryEnum::All => __d('core_enroller', 'information.EmailVerifiers.A'),
+ AllTernaryEnum::None => __d('core_enroller', 'information.EmailVerifiers.0'),
+ AllTernaryEnum::One => $vv_minimum_met
? __d('core_enroller', 'information.EmailVerifiers.1.met')
: __d('core_enroller', 'information.EmailVerifiers.1.none'),
- default => 'Unknown Verification Mode' // Optional fallback for unexpected cases
+ default => __d('error', 'Verifications.mode.unknown', [$vv_config->mode]) // Optional fallback for unexpected cases
};
}
diff --git a/app/resources/locales/en_US/command.po b/app/resources/locales/en_US/command.po
index 8fe923810..25976181c 100644
--- a/app/resources/locales/en_US/command.po
+++ b/app/resources/locales/en_US/command.po
@@ -120,6 +120,9 @@ msgstr "This Notification must be resolved, not acknowledged"
msgid "opt.notify.recipientid"
msgstr "Recipient Identifier"
+msgid "opt.notify.recipientgroupid"
+msgstr "Recipient Group Identifier"
+
msgid "opt.notify.resolve"
msgstr "Resolve the Notification associated with this source"
@@ -129,6 +132,9 @@ msgstr "Notification source"
msgid "opt.notify.subjectid"
msgstr "Subject Identifier"
+msgid "opt.notify.subjectgroupid"
+msgstr "Subject Group Identifier"
+
msgid "opt.notify.templateid"
msgstr "Message Template ID"
diff --git a/app/resources/locales/en_US/enumeration.po b/app/resources/locales/en_US/enumeration.po
index efffd05dc..5aa881643 100644
--- a/app/resources/locales/en_US/enumeration.po
+++ b/app/resources/locales/en_US/enumeration.po
@@ -24,6 +24,15 @@
# Enumerations
+msgid "AllTernaryEnum.0"
+msgstr "None"
+
+msgid "AllTernaryEnum.1"
+msgstr "One"
+
+msgid "AllTernaryEnum.A"
+msgstr "All"
+
msgid "AuthenticationEventEnum.AI"
msgstr "API Login"
@@ -174,6 +183,9 @@ msgstr "Active Members"
msgid "GroupTypeEnum.A"
msgstr "Admins"
+msgid "GroupTypeEnum.AP"
+msgstr "Approvers"
+
msgid "GroupTypeEnum.M"
msgstr "All Members"
@@ -351,23 +363,22 @@ msgstr "HTML"
msgid "MessageFormatEnum.text"
msgstr "Plain Text"
-# msgid "MessageTemplateContextEnum.AP"
-# msgstr "Enrollment Approver"
-
# msgid "MessageTemplateContextEnum.AU"
# msgstr "Authenticator"
-# msgid "MessageTemplateContextEnum.EA"
-# msgstr "Enrollment Approval"
+msgid "MessageTemplateContextEnum.EA"
+# We no longer have notifications on Approval (use Finalization or Handoff instead)
+# but we maintain the use of EA from v4 and just change the text string
+msgstr "Enrollment Denial"
-# msgid "MessageTemplateContextEnum.EF"
-# msgstr "Enrollment Finalization"
+msgid "MessageTemplateContextEnum.EF"
+msgstr "Enrollment Finalization"
msgid "MessageTemplateContextEnum.EH"
msgstr "Enrollment Handoff"
-# msgid "MessageTemplateContextEnum.EI"
-# msgstr "Enrollment Invitation"
+msgid "MessageTemplateContextEnum.ES"
+msgstr "Enrollment Step Completed"
msgid "MessageTemplateContextEnum.PL"
msgstr "Plugin"
@@ -496,6 +507,9 @@ msgstr "Accepted"
msgid "PetitionStatusEnum.CR"
msgstr "Created"
+msgid "PetitionStatusEnum.CX"
+msgstr "Terminated"
+
msgid "PetitionStatusEnum.D2"
msgstr "Duplicate"
diff --git a/app/resources/locales/en_US/error.po b/app/resources/locales/en_US/error.po
index b9040adb6..6753ebf83 100644
--- a/app/resources/locales/en_US/error.po
+++ b/app/resources/locales/en_US/error.po
@@ -427,6 +427,9 @@ msgstr "Invalid code"
msgid "Verifications.expired"
msgstr "Verification request has expired"
+msgid "Verifications.mode.unknown"
+msgstr "Unknown Verification Mode \"{0}\""
+
msgid "Verifications.petition"
msgstr "Verification does not match requested Petition"
diff --git a/app/resources/locales/en_US/field.po b/app/resources/locales/en_US/field.po
index 95a8849a7..5ec0e79b6 100644
--- a/app/resources/locales/en_US/field.po
+++ b/app/resources/locales/en_US/field.po
@@ -441,9 +441,27 @@ msgstr "If true, Global Search will only search Names, Email Addresses, and Iden
msgid "EnrollmentFlowSteps.actor_type"
msgstr "Actor Type"
+msgid "EnrollmentFlowSteps.approver_group_id"
+msgstr "Approver Group"
+
+msgid "EnrollmentFlowSteps.approver_group_id.desc"
+msgstr "The members of this Group may approve Petitions for this Enrollment Flow Step, as per the mode configuration"
+
+msgid "EnrollmentFlowSteps.message_template_id"
+msgstr "Handoff Message Template"
+
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.notification_group_id"
+msgstr "Notification Group"
+
+msgid "EnrollmentFlowSteps.notification_group_id.desc"
+msgstr "If set, this Group will be notified when this Step is completed"
+
+msgid "EnrollmentFlowSteps.notification_message_template_id"
+msgstr "Group Notification Message Template"
+
msgid "EnrollmentFlowSteps.redirect_on_handoff"
msgstr "Redirect on Handoff"
@@ -462,6 +480,24 @@ msgstr "Petitioner Authorization"
msgid "EnrollmentFlows.collect_enrollee_email"
msgstr "Collect Enrollee Email"
+msgid "EnrollmentFlows.finalization_message_template_id"
+msgstr "Finalization Message Template"
+
+msgid "EnrollmentFlows.finalization_message_template_id.desc"
+msgstr "If set, this Message Template will be used to notify the Enrollee"
+
+msgid "EnrollmentFlows.notification_group_id"
+msgstr "Notification Group"
+
+msgid "EnrollmentFlows.notification_group_id.desc"
+msgstr "If set, this Group will be notified whenever any Step associated with this Enrollment Flow is completed"
+
+msgid "EnrollmentFlows.notification_message_template_id"
+msgstr "Group Notification Message Template"
+
+msgid "EnrollmentFlows.notification_message_template_id.desc"
+msgstr "This Message Template will be used to notify the Notification Group"
+
msgid "EnrollmentFlows.redirect_on_duplicate"
msgstr "Redirect on Duplicate"
@@ -513,6 +549,9 @@ msgstr "Where the current group will be nested"
msgid "Groups.desc.admins"
msgstr "{0} Administrators"
+msgid "Groups.desc.approvers"
+msgstr "{0} Approvers"
+
msgid "Groups.desc.members"
msgstr "{0} Members"
@@ -713,8 +752,11 @@ msgstr "Notification Email Subject"
msgid "Notifications.notification_time"
msgstr "Notification Time"
+msgid "Notifications.recipient_group_id"
+msgstr "Recipient (Group)"
+
msgid "Notifications.recipient_person_id"
-msgstr "Recipient"
+msgstr "Recipient (Person)"
msgid "Notifications.resolver_person_id"
msgstr "Resolved By"
@@ -728,8 +770,11 @@ msgstr "Resolution Email Subject"
msgid "Notifications.resolution_time"
msgstr "Resolution Time"
+msgid "Notifications.subject_group_id"
+msgstr "Subject (Group)"
+
msgid "Notifications.subject_person_id"
-msgstr "Subject"
+msgstr "Subject (Person)"
msgid "Petitions.enrollee_email"
msgstr "Enrollee Email Address"
@@ -737,6 +782,12 @@ msgstr "Enrollee Email Address"
msgid "Petitions.enrollee_email.desc"
msgstr "The Email Address provided at the creation of the Petition"
+msgid "Petitions.enrollee_identifier"
+msgstr "Enrollee Identifier"
+
+msgid "Petitions.enrollee_name"
+msgstr "Enrollee Name"
+
msgid "Petitions.enrollee.new"
msgstr "New Enrollee"
diff --git a/app/resources/locales/en_US/information.po b/app/resources/locales/en_US/information.po
index 96ae32f41..f91d17a3e 100644
--- a/app/resources/locales/en_US/information.po
+++ b/app/resources/locales/en_US/information.po
@@ -126,15 +126,18 @@ msgstr "Petition History"
msgid "petition.information"
msgstr "Petition Information"
-msgid "Petitions.pending"
-msgstr "This Petition has now been assigned to someone else. There is no further action for you at this time."
-
msgid "petition.step.toggle.details"
msgstr "toggle petition step details"
msgid "petition.step.toggle.details.all"
msgstr "toggle all petition step details"
+msgid "Petitions.pending"
+msgstr "This Petition has now been assigned to someone else. There is no further action for you at this time."
+
+msgid "Petitions.pending.approval"
+msgstr "Petition {0} is now Pending Approval for Enrollment Flow Step {1}"
+
msgid "plugin.active"
msgstr "Active"
@@ -174,7 +177,13 @@ msgstr "Current version: {0}"
msgid "ug.version.target"
msgstr "Target version: {0}"
-msgid "ug.installMostlyStaticPages"
+msgid "ug.tasks.createApproverGroups.co"
+msgstr "Creating Approver Groups for CO {0}"
+
+msgid "ug.tasks.createApproverGroups.cou"
+msgstr "Creating Approver Groups for COU {0}"
+
+msgid "ug.tasks.installMostlyStaticPages.co"
msgstr "Installing default Mostly Static Pages for CO {0}"
msgid "value.copied"
diff --git a/app/resources/locales/en_US/operation.po b/app/resources/locales/en_US/operation.po
index 65f4d6b07..01aafaf1c 100644
--- a/app/resources/locales/en_US/operation.po
+++ b/app/resources/locales/en_US/operation.po
@@ -252,6 +252,9 @@ msgstr "Go to page"
msgid "Petitions.rerun"
msgstr "Rerun"
+msgid "Petitions.terminate.confirm"
+msgstr "Are you sure you want to terminate Petition {0}? This action cannot be undone."
+
msgid "pick"
msgstr "Pick"
@@ -318,6 +321,9 @@ msgstr "Start"
msgid "Cos.switch"
msgstr "Switch To This CO"
+msgid "terminate"
+msgstr "Terminate"
+
msgid "toggle.all"
msgstr "Toggle All"
diff --git a/app/resources/locales/en_US/result.po b/app/resources/locales/en_US/result.po
index 848744e1e..f60b5b1e3 100644
--- a/app/resources/locales/en_US/result.po
+++ b/app/resources/locales/en_US/result.po
@@ -196,6 +196,12 @@ msgstr "Petition Finalized"
msgid "Petitions.flaggedduplicate"
msgstr "Petition flagged as Duplicate"
+msgid "Petitions.step.completed"
+msgstr "Petition {0} Step {1} completed"
+
+msgid "Petitions.terminated"
+msgstr "Petition Terminated"
+
msgid "Petitions.viewed.inv"
msgstr "Invitation Viewed"
diff --git a/app/src/Command/NotificationCommand.php b/app/src/Command/NotificationCommand.php
index 9e34860e7..b97bf4970 100644
--- a/app/src/Command/NotificationCommand.php
+++ b/app/src/Command/NotificationCommand.php
@@ -64,10 +64,17 @@ public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionPar
)->addOption(
'subjectIdentifier',
[
- 'required' => true,
+ 'required' => false,
'short' => 's',
'help' => __d('command', 'opt.notify.subjectid')
]
+ )->addOption(
+ 'subjectGroupIdentifier',
+ [
+ 'required' => false,
+ 'short' => 'g',
+ 'help' => __d('command', 'opt.notify.subjectgroupid')
+ ]
)->addOption(
'actorIdentifier',
[
@@ -78,10 +85,17 @@ public function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionPar
)->addOption(
'recipientIdentifier',
[
- 'required' => true,
+ 'required' => false,
'short' => 'r',
'help' => __d('command', 'opt.notify.recipientid')
]
+ )->addOption(
+ 'recipientGroupIdentifier',
+ [
+ 'required' => false,
+ 'short' => 'G',
+ 'help' => __d('command', 'opt.notify.recipientgroupid')
+ ]
)->addOption(
'action',
[
@@ -154,28 +168,46 @@ public function execute(Arguments $args, ConsoleIo $io)
$typeId = $Types->getTypeId($coId, 'Identifiers.type', $args->getOption('typeLabel'));
// Subject
+ $subjectGroupId = null;
+ $subjectPersonId = null;
- $subjectPersonId = $Identifiers->lookupPerson($typeId, $args->getOption('subjectIdentifier'));
+ if($args->getOption('subjectGroupIdentifier')) {
+ $subjectGroupId = $Identifiers->lookupGroup($typeId, $args->getOption('subjectGroupIdentifier'));
+ }
+
+ if($args->getOption('subjectIdentifier')) {
+ $subjectPersonId = $Identifiers->lookupPerson($typeId, $args->getOption('subjectIdentifier'));
+ }
// Actor
$actorPersonId = $Identifiers->lookupPerson($typeId, $args->getOption('actorIdentifier'));
// Recipient
+ $recipientGroupId = null;
+ $recipientPersonId = null;
+
+ if($args->getOption('recipientGroupIdentifier')) {
+ $recipientGroupId = $Identifiers->lookupGroup($typeId, $args->getOption('recipientGroupIdentifier'));
+ }
- $recipientPersonId = $Identifiers->lookupPerson($typeId, $args->getOption('recipientIdentifier'));
+ if($args->getOption('recipientIdentifier')) {
+ $recipientPersonId = $Identifiers->lookupPerson($typeId, $args->getOption('recipientIdentifier'));
+ }
$io->out("Registering notification:");
$io->out("- Subject Person ID: " . $subjectPersonId);
+ $io->out("- Subject Group ID: " . $subjectGroupId);
$io->out("- Actor Person ID: " . $actorPersonId);
$io->out("- Recipient Person ID: " . $recipientPersonId);
+ $io->out("- Recipient Group ID: " . $recipientGroupId);
$notificationIds = $Notifications->register(
subjectPersonId: $subjectPersonId,
- subjectGroupId: null,
+ subjectGroupId: $subjectGroupId,
actorPersonId: $actorPersonId,
recipientPersonId: $recipientPersonId,
- recipientGroupId: null,
+ recipientGroupId: $recipientGroupId,
action: $args->getOption('action'),
comment: $args->getOption('comment'),
messageTemplateId: (int)$args->getOption('messageTemplateId'),
diff --git a/app/src/Command/UpgradeCommand.php b/app/src/Command/UpgradeCommand.php
index e3edf9e84..6dbc461a2 100644
--- a/app/src/Command/UpgradeCommand.php
+++ b/app/src/Command/UpgradeCommand.php
@@ -58,7 +58,7 @@ class UpgradeCommand extends Command
// compare version strings. You must specify the 'block' parameter. If you flag
// a version as blocking, be sure to document why.
- // As of v5, pre and post are now a list of tasks instead of a single function
+ // As of v5, pre and post are now a list of tasks instead of a single function.
protected $versions = [
"5.0.0" => [
@@ -67,9 +67,21 @@ class UpgradeCommand extends Command
"5.1.0" => [
'block' => false,
'post' => ['installMostlyStaticPages']
+ ],
+ "5.2.0" => [
+ 'block' => false,
+ 'post' => ['createApproverGroups']
]
];
+ // For descriptions of task parameters, see dispatch(). We store these separately
+ // to make them easier to use regardless of context (pre/post/manual).
+
+ protected $taskParams = [
+ 'createApproverGroups' => ['perCO' => true, 'perCOU' => true],
+ 'installMostlyStaticPages' => ['perCO' => true]
+ ];
+
/**
* Register command specific options.
*
@@ -252,17 +264,58 @@ public function execute(Arguments $args, ConsoleIo $io)
*/
protected function dispatch(string $task) {
+ // We support tasks being flagged to behave in certain ways.
+
+ // global: The task should be run once
+ // perCO: The task should be run once per CO
+ // perCOU: The task should be run once per COU
+ // - perCOU does NOT imply perCO, though tasks will also be passed the CO ID
+
+ $global = isset($this->taskParams[$task]['global'])
+ && $this->taskParams[$task]['global'];
+ $perCO = isset($this->taskParams[$task]['perCO'])
+ && $this->taskParams[$task]['perCO'];
+ $perCOU = isset($this->taskParams[$task]['perCOU'])
+ && $this->taskParams[$task]['perCOU'];
+
if(method_exists($this, $task)) {
- // Pull the set of COs. We'll generally apply changes to _all_ COs, even if
- // they're Suspended or Templates.
+ if($global) {
+ $this->io->out(__d('information', 'ug.tasks.'.$task));
+ $this->$task();
+ }
+
+ $cos = [];
+
+ if($perCO || $perCOU) {
+ // Pull the list of COs once. We'll generally apply changes to _all_ COs,
+ // even if they're Suspended or Templates.
+
+ $CosTable = $this->getTableLocator()->get('Cos');
+
+ $cos = $CosTable->find()->all();
+ }
- $CosTable = $this->getTableLocator()->get('Cos');
+ if($perCO) {
+ foreach($cos as $co) {
+ $this->io->out(__d('information', 'ug.tasks.'.$task.'.co', [$co->id]));
+ $this->$task(coId: $co->id);
+ }
+ }
+
+ if($perCOU) {
+ $CousTable = $this->getTableLocator()->get('Cous');
+
+ // We iterate per CO rather than pull all COUs at once in case the task
+ // wants to know the CO for each COU.
- $cos = $CosTable->find()->all();
+ foreach($cos as $co) {
+ $cous = $CousTable->find()->where(['co_id' => $co->id])->all();
- foreach($cos as $co) {
- $this->io->out(__d('information', 'ug.'.$task, [$co->id]));
- $this->$task($co->id);
+ foreach($cous as $cou) {
+ $this->io->out(__d('information', 'ug.tasks.'.$task.'.cou', [$cou->id]));
+ $this->$task(coId: $co->id, couId: $cou->id);
+ }
+ }
}
$this->io->out(__d('result', 'ug.task.done', [$task]));
@@ -271,6 +324,22 @@ protected function dispatch(string $task) {
}
}
+ /**
+ * Create Approver Groups.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param int $coId CO ID
+ * @param int $couId COU ID
+ */
+
+ protected function createApproverGroups(int $coId, int $couId=null) {
+ $GroupsTable = $this->getTableLocator()->get('Groups');
+
+ // Technically this will try to add all the default Groups, which is fine since
+ // it will skip the ones that already exist.
+ $GroupsTable->addDefaults($coId, $couId);
+ }
+
/**
* Update the default set of Mostly Static Pages.
*
diff --git a/app/src/Controller/Component/BreadcrumbComponent.php b/app/src/Controller/Component/BreadcrumbComponent.php
index f6e54df4f..17e42d509 100644
--- a/app/src/Controller/Component/BreadcrumbComponent.php
+++ b/app/src/Controller/Component/BreadcrumbComponent.php
@@ -178,6 +178,11 @@ public function injectPrimaryLink(object $link, bool $index=true, string $linkLa
{
// eg: "People"
$modelsName = StringUtilities::foreignKeyToClassName($link->attr);
+ if(!empty($this->getController()->viewBuilder()->getVar('vv_primary_link_model'))) {
+ // $link doesn't seem to handle table aliases (eg "Groups" instead of "RecipientGroups" for
+ // Notifications)).
+ $modelsName = $this->getController()->viewBuilder()->getVar('vv_primary_link_model');
+ }
$modelPath = $modelsName;
if(!empty($link->plugin)) {
diff --git a/app/src/Controller/Component/RegistryAuthComponent.php b/app/src/Controller/Component/RegistryAuthComponent.php
index 6cd37df5c..f68e218eb 100644
--- a/app/src/Controller/Component/RegistryAuthComponent.php
+++ b/app/src/Controller/Component/RegistryAuthComponent.php
@@ -357,8 +357,23 @@ protected function calculatePermissions(?int $id=null): array {
// Is this me?
$selfMember = $this->isSelf($controller->getCOID(), $id);
- // Get the action
- $reqAction = $controller->getRequest()->getParam('action');
+ // Is the user an approver for the current Petition?
+ // In order to calculate this we need to have a Petition ID. This will primarily
+ // come from the URL in the form /petitions/view/{id}, but we'll also accept a
+ // petition_id in the query parameter. (It's not ideal to hardcode a Controller
+ // like this, but Approvers are something of a special case and we don't have a
+ // better approach at the moment.)
+ $petitionId = null;
+
+ if($modelsName == 'Petitions'
+ && in_array($reqAction, ['continue', 'view'])
+ && !empty($controller->getRequest()->getParam('pass.0'))) {
+ $petitionId = $controller->getRequest()->getParam('pass.0');
+ } elseif(!empty($controller->getRequest()->getQuery('petition_id'))) {
+ $petitionId = $controller->getRequest()->getQuery('petition_id');
+ }
+
+ $approver = !empty($petitionId) ? $this->isApprover((int)$petitionId) : false;
// Is this record read only?
$readOnly = false;
@@ -780,6 +795,49 @@ public function isAdminForIdentifier(string $identifier): bool {
public function isApiUser(): bool {
return $this->authenticatedApiUser;
}
+
+ /**
+ * Determine if the current user is an approver for the specified Petition.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param int $petitionId Petition ID
+ * @return bool true if the current user is an Approver, false otherwise
+ */
+
+ public function isApprover(int $petitionId): bool {
+ if(!isset($this->cache['isApprover'][$petitionId])) {
+ $this->cache['isApprover'][$petitionId] = false;
+
+ if(!empty($this->authenticatedUser)) {
+ // We need to map the authenticated user to a Person in the same CO as the Petition
+
+ $Petitions = TableRegistry::getTableLocator()->get('Petitions');
+ $Identifiers = TableRegistry::getTableLocator()->get('Identifiers');
+
+ // Pull the Petition to find its CO
+ $petition = $Petitions->get($petitionId, ['contain' => 'EnrollmentFlows']);
+
+ try {
+ // Map the authenticated user to a Person ID
+ $personId = $Identifiers->lookupPersonByLogin(
+ coId: $petition->enrollment_flow->co_id,
+ identifier: $this->authenticatedUser
+ );
+
+ // Finally determine if the Person ID is an approver for the flow
+ $this->cache['isApprover'][$petitionId] = $Petitions->isApproverForFlow(
+ id: $petition->id,
+ personId: $personId
+ );
+ }
+ catch(RecordNotFoundException $e) {
+ // This is an unregistered user running a Petition, just ignore this exception
+ }
+ }
+ }
+
+ return $this->cache['isApprover'][$petitionId];
+ }
/**
* Determine if the current user is authenticated.
diff --git a/app/src/Controller/DashboardsController.php b/app/src/Controller/DashboardsController.php
index f3021cadc..5c26a3d89 100644
--- a/app/src/Controller/DashboardsController.php
+++ b/app/src/Controller/DashboardsController.php
@@ -232,6 +232,12 @@ public function configuration() {
'controller' => 'jobs',
'action' => 'index'
],
+ __d('controller', 'Notifications', [99]) => [
+ 'icon' => 'notifications_active',
+ 'iconClass' => 'material-symbols-outlined',
+ 'controller' => 'notifications',
+ 'action' => 'index'
+ ],
__d('controller', 'Petitions', [99]) => [
'icon' => 'pending_actions',
'iconClass' => 'material-symbols-outlined',
diff --git a/app/src/Controller/EmailAddressesController.php b/app/src/Controller/EmailAddressesController.php
index ab962178c..9113c879d 100644
--- a/app/src/Controller/EmailAddressesController.php
+++ b/app/src/Controller/EmailAddressesController.php
@@ -48,7 +48,7 @@ class EmailAddressesController extends MVEAController {
public function forceVerify(string $id) {
try {
- $addr = $this->EmailAddresses->forceVerify((int)$id, $this->RegistryAuth->getPersonID($this->getCOID()));
+ $addr = $this->EmailAddresses->forceVerify((int)$id);
$this->Flash->success(__d('result', 'EmailAddresses.verify.manual', [$addr]));
}
catch(Exception $e) {
diff --git a/app/src/Controller/NotificationsController.php b/app/src/Controller/NotificationsController.php
index 5160eda4c..c6a388608 100644
--- a/app/src/Controller/NotificationsController.php
+++ b/app/src/Controller/NotificationsController.php
@@ -35,7 +35,7 @@
class NotificationsController extends StandardController {
public $paginate = [
'order' => [
- 'Notifications.comment' => 'asc'
+ 'Notifications.modified' => 'desc'
]
];
diff --git a/app/src/Controller/PetitionsController.php b/app/src/Controller/PetitionsController.php
index 223d5863c..c3ff383dc 100644
--- a/app/src/Controller/PetitionsController.php
+++ b/app/src/Controller/PetitionsController.php
@@ -69,6 +69,14 @@ public function beforeRender(EventInterface $event) {
$this->set('vv_bc_parent_primarykey', $this->Petitions->EnrollmentFlows->getPrimaryKey());
}
+ if($this->request->getParam('action') == 'view') {
+ $id = $this->request->getParam('pass.0');
+
+ if($id) {
+ $this->set('vv_enrollee_name', $this->Petitions->getEnrolleeName((int)$id));
+ }
+ }
+
return parent::beforeRender($event);
}
@@ -85,17 +93,30 @@ public function calculatePermission(): bool {
$authorized = false;
- // We're currently only used for finalize
+ if($action == 'continue') {
+ // If we're called here it's because we have an authenticated user step.
+ // (The token for unauthenticated users was validated by willHandleAuth()).
+
+ $currentActor = $this->getCurrentActor(
+ petitionId: $this->nextStep['petition']->id,
+ stepId: $this->nextStep['step']->id
+ );
- if($action == 'finalize') {
+ $authorized = in_array($this->nextStep['step']->actor_type, $currentActor['roles']);
+ } elseif($action == 'finalize' && ($this->nextStep['finalize'] === true)) {
// 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'));
+ // also set $this->nextStep. We need to specifically request the permissions
+ // for the previous step to ensure Approver permissions are calculated properly.
+
+ $currentActor = $this->getCurrentActor(
+ petitionId: $this->nextStep['petition']->id,
+ stepId: $this->nextStep['lastStep']->id
+ );
$authorized = in_array($this->nextStep['lastStep']->actor_type, $currentActor['roles']);
}
@@ -358,6 +379,25 @@ public function resume(string $id) {
}
}
+ /**
+ * Terminate an in-progress Petition.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param string $id Petition ID
+ */
+
+ public function terminate(string $id) {
+ try {
+ $this->Petitions->terminate((int)$id);
+ $this->Flash->success(__d('result', 'Petitions.terminated'));
+ }
+ catch(\Exception $e) {
+ $this->Flash->error($e->getMessage());
+ }
+
+ return $this->generateRedirect(null);
+ }
+
/**
* Indicate whether this Controller will handle some or all authnz.
*
@@ -386,20 +426,39 @@ public function willHandleAuth(\Cake\Event\EventInterface $event): string {
}
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);
+ // We can't reliably determine the actor type yet since we haven't necessarily
+ // run authentication. In particular, a new enrollee clicking a continue link
+ // sent out of band and an approver clicking a continue link sent out of band
+ // will look the same to us most likely (unless the approver happend to login
+ // already).
+
+ // We are basically just issuing a redirect here (the target of which will
+ // implement authnz anyway), but there is a small bit of information leakage
+ // possible by introspecting the redirect target, so we try to make sure we at
+ // least have some sort of authenticated user.
+
+ $this->nextStep = $this->Petitions->EnrollmentFlows->calculateNextStep($petitionId);
+
+ if($this->nextStep['petition']->useToken($this->nextStep['step']->actor_type)) {
+ // A token is required, so make sure we have one and that it validates
+
+ $actorInfo = $this->getCurrentActor($petitionId);
+
+ if($actorInfo['token_ok']) {
+ // We can return 'yes' without calling calculatePermission since we're
+ // just issuing a redirect, and presumably the person with the token already
+ // more or less knows the state of the Petition.
- if($actorInfo['type'] == 'anonymous') {
- if(!$actorInfo['token_ok']) {
+ return 'yes';
+ } else {
$this->llog('trace', "Token validation failed for Petition " . $petitionId);
return 'notauth';
}
- }
+ } else {
+ // No token is needed, but we do need authentication to run.
- // We'll allow any authenticated user through since continue is basically
- // a redirect
- return 'yes';
+ return 'authz';
+ }
} 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.
diff --git a/app/src/Controller/StandardController.php b/app/src/Controller/StandardController.php
index 07afec6e2..2e33fef2c 100644
--- a/app/src/Controller/StandardController.php
+++ b/app/src/Controller/StandardController.php
@@ -654,6 +654,17 @@ public function index() {
$query = $table->findIndexed($query);
}
+ if(method_exists($table, 'filterIndexByCO')) {
+ // Sometimes (in particular for Artifacts) we want to filter the index by CO
+ // but CO is not a direct primary link. We could try to figure out the CO
+ // via primary links, but that gets complicated especially for artifacts which
+ // have multiple primary links. We could look up each entity in the result
+ // set, but that's a lot of work and messes up pagination. So instead we let
+ // the model figure out the best way to do it.
+
+ $query = $table->filterIndexByCO($query, $this->getCOID());
+ }
+
// Fetch the data and paginate
$paginationLimit = $this->getValue(ApplicationStateEnum::PaginationLimit, DEF_SEARCH_LIMIT);
$resultSet = $this->paginate($query, [
diff --git a/app/src/Controller/StandardEnrollerController.php b/app/src/Controller/StandardEnrollerController.php
index ebe7b4446..e61423499 100644
--- a/app/src/Controller/StandardEnrollerController.php
+++ b/app/src/Controller/StandardEnrollerController.php
@@ -93,9 +93,24 @@ public function calculatePermission(): bool {
return false;
}
- $actorInfo = $this->getCurrentActor((int)$petitionId);
- $this->petition = $actorInfo['petition'];
+ // We need the Step configuration to properly calculate the current Actor
+ // for Approval Steps.
+
+ $modelsName = $this->name;
+ $modelId = $this->request->getParam('pass.0');
+
+ if(!$modelId) {
+ $this->llog('error', "Model ID missing from request");
+ return false;
+ }
+
+ $stepConfig = $this->$modelsName->get($modelId, ['contain' => ['EnrollmentFlowSteps' => ['EnrollmentFlows']]]);
+ $this->set('vv_step_config', $stepConfig);
+ $this->set('vv_title', $stepConfig['enrollment_flow_step']['enrollment_flow']['name']);
+ $actorInfo = $this->getCurrentActor((int)$petitionId, $stepConfig->enrollment_flow_step->id);
+ $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') {
@@ -116,18 +131,6 @@ public function calculatePermission(): bool {
if($action == 'dispatch') {
// We already validated the petition state in willHandleAuth
- $modelsName = $this->name;
- $modelId = $this->request->getParam('pass.0'); // XXX check if empty
-
- if(!$modelId) {
- $this->llog('error', "Model ID missing from request");
- return false;
- }
-
- $stepConfig = $this->$modelsName->get($modelId, ['contain' => ['EnrollmentFlowSteps' => ['EnrollmentFlows']]]);
- $this->set('vv_step_config', $stepConfig);
- $this->set('vv_title', $stepConfig['enrollment_flow_step']['enrollment_flow']['name']);
-
// Check that the current actor has the role required for this step.
// Note that role validation has already been performed for anonymous access
// via tokens (via getcurrentActor) so we don't have to recheck that here.
@@ -137,6 +140,12 @@ public function calculatePermission(): bool {
$this->llog('trace', "Authorizing access to petition " . $petitionId . " step " . $stepConfig->enrollment_flow_step_id);
return true;
}
+
+ // As a special case, we allow the Platform adminstrator to perform all actions.
+ if(in_array('cmpadmin', $actorInfo['roles'])) {
+ $this->llog('trace', "Authorizing access to petition " . $petitionId . " step " . $stepConfig->enrollment_flow_step_id) . " for platform administrator";
+ return true;
+ }
} elseif($action == 'display') {
// XXX need to replace this with better logic
return true;
diff --git a/app/src/Lib/Enum/ActionEnum.php b/app/src/Lib/Enum/ActionEnum.php
index 01bd08bfb..dc5799bde 100644
--- a/app/src/Lib/Enum/ActionEnum.php
+++ b/app/src/Lib/Enum/ActionEnum.php
@@ -64,5 +64,8 @@ class ActionEnum extends StandardEnum {
const PersonPipelineStarted = 'SCPL';
const PersonRoleRelinked = 'LCPR';
const PersonStatusRecalculated = 'RCPS';
+ const PetitionCreated = 'CPPC';
+ const PetitionFinalized = 'CPPF';
+ const PetitionUpdated = 'CPUP';
const ReferenceIdentifierObtained = 'OIDR';
}
\ No newline at end of file
diff --git a/app/plugins/CoreEnroller/src/Lib/Enum/VerificationModeEnum.php b/app/src/Lib/Enum/AllTernaryEnum.php
similarity index 79%
rename from app/plugins/CoreEnroller/src/Lib/Enum/VerificationModeEnum.php
rename to app/src/Lib/Enum/AllTernaryEnum.php
index 4416de0f5..1fbaab310 100644
--- a/app/plugins/CoreEnroller/src/Lib/Enum/VerificationModeEnum.php
+++ b/app/src/Lib/Enum/AllTernaryEnum.php
@@ -1,6 +1,6 @@
cache['actor'])) {
- return $this->cache['actor'];
+ protected function getCurrentActor(?int $petitionId=null, ?int $stepId=null): array {
+ // We only check the cache if we have a Petition ID, see below. We also track
+ // the stepId since we might get different results for different Steps (in particular
+ // for Approvers).
+
+ if($petitionId && !empty($this->cache['actor'][$stepId ?? "null"])) {
+ return $this->cache['actor'][$stepId ?? "null"];
}
$ret = [
@@ -81,6 +86,9 @@ protected function getCurrentActor(?int $petitionId=null): array {
try {
$ret['person_id'] = $Identifiers->lookupPersonByLogin($this->getCOID(), $ret['identifier']);
+
+ // We also store platform administator as a role to faciliate superuser access.
+ // (We don't currently recognize CO Admins the same way pending further requirements.)
} catch(RecordNotFoundException $e) {
$ret['person_id'] = null;
}
@@ -90,6 +98,14 @@ protected function getCurrentActor(?int $petitionId=null): array {
} else {
$ret['type'] = 'identifier';
}
+
+ if($this->RegistryAuth->isPlatformAdmin()) {
+ // We don't define an Enum for this because we currently don't support
+ // configuring a Step to be runnable only by a Platform Administrator.
+ // (Note a Platform Admin will have a valid identifier, but may not have
+ // a person_id if they're not also registered in the current CO.)
+ $ret['roles'][] = 'cmpadmin';
+ }
}
if($petitionId) {
@@ -109,6 +125,17 @@ protected function getCurrentActor(?int $petitionId=null): array {
if($ret['person_id'] === $petition->enrollee_person_id) {
$ret['roles'][] = EnrollmentActorEnum::Enrollee;
}
+
+ // Only People (as opposed to authenticated, unregistered Enrollees)
+ // can be Approvers, so check here. We only check for the current Step
+ // because different Steps can have different Approvers (and we don't
+ // currently have a use case for "Any Approver for the entire Petition").
+
+ if($stepId) {
+ if($Petitions->isApprover(id: $petitionId, stepId: $stepId, personId: $ret['person_id'])) {
+ $ret['roles'][] = EnrollmentActorEnum::Approver;
+ }
+ }
} elseif($ret['type'] == 'identifier' && !empty($ret['identifier'])) {
if($ret['identifier'] === $petition->petitioner_identifier) {
$ret['roles'][] = EnrollmentActorEnum::Petitioner;
@@ -219,8 +246,8 @@ protected function transitionToStep(int $petitionId, bool $start=false) {
$stepInfo = $EnrollmentFlows->calculateNextStep($petitionId);
$petition = $stepInfo['petition'];
-
- $coId = $EnrollmentFlows->findCoForRecord($petition->enrollment_flow_id);
+ $flow = $EnrollmentFlows->get($petition->enrollment_flow_id);
+ $coId = $flow->co_id;
/* no need to to this, we don't cache on start()
if($start) {
@@ -228,12 +255,91 @@ protected function transitionToStep(int $petitionId, bool $start=false) {
unset($this->cache['actor']);
}*/
- $actorInfo = $this->getCurrentActor($petitionId);
+ // Approvers vary according to the current step. If there is no current step, we
+ // want the approvers from the last step so finalization can run. Note that
+ // because approvers can vary from step to step, two consecutive Approval steps
+ // that use two different Approvers Groups will generate a handoff (unless the
+ // current Actor is in both Groups).
+
+ $stepId = ($stepInfo['step']->id ?? ($stepInfo['lastStep']->id ?? null));
+
+ $actorInfo = $this->getCurrentActor($petitionId, $stepId);
+
+ // Before we process the handoff, we take care of some other tasks that we want
+ // to run before any redirect is issued. First, issue any Notifications configured
+ // on either the Enrollment Flow or the _previous_ Step. (If we're transitioning
+ // from start, there are no notifications to send.)
+
+ if(!$start) {
+ $MessageTemplates = TableRegistry::getTableLocator()->get('MessageTemplates');
+
+ if(!empty($flow->notification_group_id)) {
+ // There is a Notification Group on the Flow, make sure we have a Message Template
+
+ if(!empty($flow->notification_message_template_id)) {
+ $template = $MessageTemplates->get($flow->notification_message_template_id);
+
+ $template->setContextPetition($petition);
+ $template->setContextEnrollmentFlowSteps($stepInfo['lastStep'], $stepInfo['step']);
+
+ $MessageTemplates->Notifications->register(
+ subjectPersonId: $petition->enrollee_person_id,
+ subjectGroupId: null,
+ actorPersonId: $actorInfo['person_id'],
+ recipientPersonId: null,
+ recipientGroupId: $flow->notification_group_id,
+ action: ActionEnum::PetitionUpdated,
+ comment: __d('result', 'Petitions.step.completed', [$petition->id, $stepInfo['lastStep']->id]),
+ messageTemplate: $template,
+ // We'll set the source to be the URL to the Petition itself
+ source: [
+ 'controller' => 'petitions',
+ 'action' => 'view',
+ $petitionId
+ ],
+ mustResolve: false
+ );
+ } else {
+ $this->log('debug', "Enrollment Flow " . $flow->id . " has a Notification Group configured, but no Message Template");
+ }
+ }
+
+ if(!empty($stepInfo['lastStep']->notification_group_id)) {
+ // There is a Notification Group on this Step. Note we do _not_ check 'lastStep'
+ // since if we're finalizing notifications will be handled by PetitionsTable::finalize.
+
+ if(!empty($stepInfo['lastStep']->notification_message_template_id)) {
+ $template = $MessageTemplates->get($stepInfo['lastStep']->notification_message_template_id);
+
+ $template->setContextPetition($petition);
+ $template->setContextEnrollmentFlowSteps($stepInfo['lastStep'], $stepInfo['step']);
+
+ $MessageTemplates->Notifications->register(
+ subjectPersonId: $petition->enrollee_person_id,
+ subjectGroupId: null,
+ actorPersonId: $actorInfo['person_id'],
+ recipientPersonId: null,
+ recipientGroupId: $stepInfo['lastStep']->notification_group_id,
+ action: ActionEnum::PetitionUpdated,
+ comment: __d('result', 'Petitions.step.completed', [$petition->id, $stepInfo['lastStep']->id]),
+ messageTemplate: $template,
+ // We'll set the source to be the URL to the Petition itself
+ source: [
+ 'controller' => 'petitions',
+ 'action' => 'view',
+ $id
+ ],
+ mustResolve: false
+ );
+ } else {
+ $this->log('debug', "Enrollment Flow Step " . $stepInfo['step']->id . " has a Notification Group configured, but no Message Template");
+ }
+ }
+ }
- // 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.
+ // Next, 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.)
@@ -277,6 +383,23 @@ protected function transitionToStep(int $petitionId, bool $start=false) {
// Note that we only permit a single non-authenticated email address since
// we don't support different anonymous petitioners and enrollees.
+ // Pull the MessageTemplate and attach context to it.
+ $MessageTemplates = TableRegistry::getTableLocator()->get('MessageTemplates');
+
+ // Message Template is _not_ a required field in the Enrollment Flow Step data model
+ // because steps can transition without it. However, for approval notifications to be
+ // sent we need a Message Template. We can't easily enforce this properly at configuration
+ // time, but at run time this will throw an Exception.
+
+ if(empty($stepInfo['step']->message_template_id)) {
+ // Throw a more helpful error than "Record not found"
+ throw new \RuntimeException(__d('error', 'EnrollmentFlowSteps.message_template', [ $stepInfo['step']->id ]));
+ }
+
+ $template = $MessageTemplates->get($stepInfo['step']->message_template_id);
+
+ $template->setContextPetition($petition);
+
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),
@@ -286,47 +409,68 @@ protected function transitionToStep(int $petitionId, bool $start=false) {
$token = $EnrollmentFlows->Petitions->getToken($petitionId);
// For simplicity, we just inject the continue URL into the message.
- $entryUrl = [
+
+ $template->setContextEntryUrl([
+ 'plugin' => null,
'controller' => 'petitions',
'action' => 'continue',
$petition->id,
'?' => [
- 'token' => $token //$this->requestParam('token')
+ 'token' => $token
]
- ];
-
- // Message Templates handle substitutions, so if none is configured it's an error
- if(empty($stepInfo['step']->message_template_id)) {
- throw new \RuntimeException(__d('error', 'EnrollmentFlowSteps.message_template', [ $stepInfo['step']->id ]));
- }
-
- $MessageTemplates = TableRegistry::getTableLocator()->get('MessageTemplates');
-
- // Perform substitutions
-
- $msg = $MessageTemplates->generateMessage(
- id: $stepInfo['step']->message_template_id,
- entryUrl: $entryUrl,
- );
+ ]);
// Send the message. sendEmailToAddress will throw an Exception if SMTP failed,
// but if there is no SMTP server configured we'll just get false back.
+ // Because we're calling DeliveryUtilities directly we need to generate the
+ // message from the template here.
+
+ $template->generateMessage();
+
if(!DeliveryUtilities::sendEmailToAddress(
coId: $coId,
recipient: $petition->enrollee_email,
- subject: $msg['subject'],
- body_text: $msg['body_text'],
- body_html: $msg['body_html']
+ subject: $template->getMessagePart('subject'),
+ body_text: $template->getMessagePart('body_text'),
+ body_html: $template->getMessagePart('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)
+ // For simplicity, we just inject the continue URL. Note that SUBJECT_NAME
+ // (and subjectPersonId) is (probably) not available yet, at least for new
+ // enrollments where a Person hasn't been created yet.
-debug("Handing off to actor type " . $nextActorType . " would send a notitication to visit "
- . \Cake\Routing\Router::url(url: $stepInfo['url'], full: true));
+ $template->setContextEntryUrl([
+ 'plugin' => null,
+ 'controller' => 'petitions',
+ 'action' => 'continue',
+ $petition->id,
+ ]);
+
+ $Notifications = TableRegistry::getTableLocator()->get('Notifications');
+
+ // Although we register the Notification here, the Plugin must resolve it
+ // since we don't know at what point the Plugin will consider the Notification
+ // handled.
+
+ $Notifications->register(
+ subjectPersonId: $petition->enrollee_person_id,
+ subjectGroupId: null,
+ actorPersonId: $actorInfo['person_id'],
+ recipientPersonId: null,
+ recipientGroupId: $EnrollmentFlows->Petitions->approverGroupId($petition->id, $stepId),
+ action: ActionEnum::PetitionUpdated,
+ comment: __d('information', 'Petitions.pending.approval', [$petition->id, $stepInfo['step']->description]),
+ // Message Template is _not_ a required field in the Enrollment Flow Step data model
+ // because steps can transition without it. However, for approval notifications to be
+ // sent we need a Message Template. We can't easily enforce this properly at configuration
+ // time, but at run time DeliveryUtilities::sendEmailFromTemplate will throw an error.
+ messageTemplate: $template,
+ source: $stepInfo['url'],
+ mustResolve: true
+ );
}
// Redirect to a landing page indicating that no further action is required at this time
diff --git a/app/src/Lib/Util/DeliveryUtilities.php b/app/src/Lib/Util/DeliveryUtilities.php
index 8b7c7db42..66039d9d3 100644
--- a/app/src/Lib/Util/DeliveryUtilities.php
+++ b/app/src/Lib/Util/DeliveryUtilities.php
@@ -35,6 +35,7 @@
use Cake\Mailer\Message;
use Cake\Mailer\Transport\SmtpTransport;
use Cake\ORM\TableRegistry;
+use App\Model\Entity\MessageTemplate;
class DeliveryUtilities {
use \App\Lib\Traits\LabeledLogTrait;
@@ -153,67 +154,41 @@ public static function sendEmailToAddress(
* if $subject and $body are used intead.
*
* @since COmanage Registry v5.0.0
- * @param int $messageTemplateId Message Template ID
- * @param int $personId Recipient Person ID
- * @param string $address Recipient Email Address
- * @param Person $subjectPerson Subject Person, including Primary Name
- * @param Notification $notification Notification
- * @param string $code Verification code
- * @return array 'recipient': Recipient email address ("to" only, not "cc" or "bcc")
+ * @param MessageTemplate $template Message Template
+ * @param int $personId Recipient Person ID
+ * @param string $address Recipient Email Address
+ * @param int $groupId Recipient Group ID
+ * @return array 'recipient': Recipient email address ("to" only, not "cc" or "bcc")
*/
public static function sendEmailFromTemplate(
- int $messageTemplateId,
+ MessageTemplate $template,
?int $personId=null,
?string $address=null,
- ?\App\Model\Entity\Person $subjectPerson=null,
- ?\App\Model\Entity\Notification $notification=null,
- ?string $code=null
+ ?int $groupId=null
): array {
- $MessageTemplates = TableRegistry::getTableLocator()->get('MessageTemplates');
-
- $messageTemplate = $MessageTemplates->get($messageTemplateId);
-
- // Generate the message from the template
- $message = $MessageTemplates->generateMessage(
- id: $messageTemplateId,
- subjectPerson: $subjectPerson,
- notification: $notification,
- code: $code
- );
-
- if($notification) {
- // Since we have the notification, we'll store the message here rather than
- // make the calling code do it.
-
- $notification->email_subject = $message['subject'];
- $notification->email_body_text = $message['body_text'];
- $notification->email_body_html = $message['body_html'];
-
- $Notifications = TableRegistry::getTableLocator()->get('Notifications');
- $Notifications->save($notification);
- }
+ $template->generateMessage();
if($personId) {
return self::sendEmailToPerson(
personId: $personId,
- subject: $message['subject'],
- body_text: $message['body_text'] ?? "",
- body_html: $message['body_html'] ?? "",
- cc: $messageTemplate->cc,
- bcc: $messageTemplate->bcc,
- replyTo: $messageTemplate->reply_to
+ subject: $template->getMessagePart('subject'),
+ body_text: $template->getMessagePart('body_text'),
+ body_html: $template->getMessagePart('body_html'),
+ cc: $template->cc,
+ bcc: $template->bcc,
+ replyTo: $template->reply_to
);
} else {
self::sendEmailToAddress(
- coId: $messageTemplate->co_id,
+ coId: $template->co_id,
recipient: $address,
- subject: $message['subject'],
- body_text: $message['body_text'] ?? "",
- body_html: $message['body_html'] ?? "",
- cc: $messageTemplate->cc,
- bcc: $messageTemplate->bcc,
- replyTo: $messageTemplate->reply_to
+ subject: $template->getMessagePart('subject'),
+ body_text: $template->getMessagePart('body_text'),
+ body_html: $template->getMessagePart('body_html'),
+ cc: $template->cc,
+ bcc: $template->bcc,
+ replyTo: $template->reply_to
);
return [
diff --git a/app/src/Lib/Util/PaginatedSqlIterator.php b/app/src/Lib/Util/PaginatedSqlIterator.php
index 10f790ec9..581a2430d 100644
--- a/app/src/Lib/Util/PaginatedSqlIterator.php
+++ b/app/src/Lib/Util/PaginatedSqlIterator.php
@@ -34,7 +34,9 @@ class PaginatedSqlIterator implements \Iterator {
// This is suitable for iterating large datasets sequentially, but not so
// good for random pagination (eg: via the UI) of large datasets.
- // Number of results to pull at one time, for now this is not configurable
+ // Number of results to pull at one time, for now this is not configurable,
+ // though note that when $filter is used the actual result set may be smaller
+ // on any given page load.
private $pageSize = 100;
// For now we only iterate over id, which we know is indexed and is an integer
@@ -61,10 +63,34 @@ class PaginatedSqlIterator implements \Iterator {
// Options for find()
private $options = [];
- public function __construct($table, $conditions=null, $options=[]) {
+ // Filter to apply on individual results.
+
+ // Why use a Filter instead of $conditions? Primarily to facilitate the encapsulation of
+ // logic, rather than copy/paste. $filter will be passed entities, which can be worked with
+ // in standard Cake style, avoiding the need to rewrite logic in direct $conditions.
+ // Given that PaginatedSqlIterator is usually called in the context of processing (large
+ // sets of) records anyway, there is already a cost being incurred in processing time, and
+ // the savings in most cases of optimizing the database query will be trivial. Where they
+ // are non-trivial, don't use a filter. Note that (additionally) $count will no longer be
+ // accurate when $filter is used (it will be an upper bound instead).
+ private $filter = null;
+
+ /**
+ * Construct a new PaginatedSqlIterator.
+ *
+ * @since COmanage Registry v3.3.0
+ * @param Table $table Table
+ * @param array $condititions Query condittions (use direct queries only, avoid joins due to ChangelogBehavior complications)
+ * @param array $options Options to pass to cake find()
+ * @param callable $filter Optional filter to apply to individual results
+ * @return PaginatedSqlIterator
+ */
+
+ public function __construct($table, $conditions=null, $options=[], $filter=null) {
$this->table = $table;
$this->conditions = $conditions;
$this->options = $options;
+ $this->filter = $filter;
$this->position = 0;
}
@@ -161,9 +187,32 @@ protected function loadPage(): void {
$this->maxid = $max->id;
}
// else no remaining rows. valid() will return false.
-
- // Convert the result set to an array for our own iterator use
- $this->results = $resultSet->toArray();
+
+ if($this->filter) {
+ // Build our result cache by manually applying the callback to each result
+ // and converting that result to an array.
+
+ foreach($resultSet as $k => $entity) {
+ // We expect a simple boolean true/false from $filter
+ $filter = $this->filter;
+
+ if($filter($entity)) {
+ $this->results[] = $entity;
+ }
+ }
+
+ if(empty($this->results) && $max) {
+ // If we didn't get at least one non-filtered result and we're not at the
+ // end of the table then current() won't return correctly. Proactively call
+ // loadPage() again.
+
+ $this->loadPage();
+ }
+ } else {
+ // Convert the result set to an array for our own iterator use
+ // (Note this is an array of entities, not an array of arrays)
+ $this->results = $resultSet->toArray();
+ }
}
/**
diff --git a/app/src/Lib/Util/StringUtilities.php b/app/src/Lib/Util/StringUtilities.php
index ba4da3cd7..571e39b60 100644
--- a/app/src/Lib/Util/StringUtilities.php
+++ b/app/src/Lib/Util/StringUtilities.php
@@ -33,6 +33,31 @@
use \Cake\Utility\Inflector;
class StringUtilities {
+ /**
+ * Converts a token by adding dashes for improved readability.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param string $token The token to be formatted
+ * @param int $jump Characters to skip before adding a dash
+ * @return string The formatted token with dashes
+ */
+
+ public static function addDashesToToken(string $token, int $jump = 4): string {
+ // Insert some dashes to improve readability
+ $dtoken = '';
+
+ for($i = 0, $iMax = strlen($token); $i < $iMax; $i++) {
+ $dtoken .= $token[$i];
+
+ if((($i + 1) % $jump == 0)
+ && ($i + 1 < strlen($token))) {
+ $dtoken .= '-';
+ }
+ }
+
+ return $dtoken;
+ }
+
/**
* Determine the foreign key name to point to a Cake Class Name (eg: foo_id for Foo).
*
diff --git a/app/src/Model/Entity/Group.php b/app/src/Model/Entity/Group.php
index b960892c3..d60936101 100644
--- a/app/src/Model/Entity/Group.php
+++ b/app/src/Model/Entity/Group.php
@@ -96,6 +96,7 @@ public function isSystem(): bool {
GroupTypeEnum::ActiveMembers,
GroupTypeEnum::Admins,
GroupTypeEnum::AllMembers,
+ GroupTypeEnum::Approvers,
GroupTypeEnum::Owners
]);
}
diff --git a/app/src/Model/Entity/MessageTemplate.php b/app/src/Model/Entity/MessageTemplate.php
index a5a0ce58f..a87a62a13 100644
--- a/app/src/Model/Entity/MessageTemplate.php
+++ b/app/src/Model/Entity/MessageTemplate.php
@@ -30,6 +30,8 @@
namespace App\Model\Entity;
use Cake\ORM\Entity;
+use Cake\ORM\TableRegistry;
+use Cake\Routing\Router;
class MessageTemplate extends Entity {
use \App\Lib\Traits\ReadOnlyEntityTrait;
@@ -39,4 +41,222 @@ class MessageTemplate extends Entity {
'id' => false,
'slug' => false,
];
+
+ // Unlike most Entities, MessageTemplate has a bit more going on here. The idea is that
+ // code that needs to send messages from templates can retrieve a MessageTemplate
+ // configuration from the database, then attach context to it and pass the entity around
+ // (eg: to Notifications and DeliveryUtilities) instead of passing a bunch of context.
+ // The MessageTemplate itself then creates the populated messages. (This requires the
+ // Entity to call other Tables, which isn't the standard pattern, but makes the code
+ // that relies on templates much cleaner.)
+
+ // (Attaching the context to the Table instead complicates things when a single page action
+ // could use multiple Templates.)
+
+ protected ?string $ctx_code = null;
+ protected ?array $ctx_entryUrl = null;
+ protected ?Notification $ctx_notification = null;
+ protected ?Petition $ctx_petition = null;
+ protected ?EnrollmentFlowStep $ctx_last_step = null;
+ protected ?EnrollmentFlowStep $ctx_next_step = null;
+ protected ?Person $ctx_subjectPerson = null;
+
+ // We also cache the generated message, which is less clunky than returning an array
+ // of the essage components
+
+ protected array $msg = [
+ 'subject' => "",
+ 'body_html' => "",
+ 'body_text' => ""
+ ];
+
+ /**
+ * Generate a message based on this Message Template, using the provided entities to
+ * perform variable substitution.
+ *
+ * @since COmanage Registry v5.2.0
+ * @return void
+ */
+
+ public function generateMessage(): void {
+ // We generate "" instead of null by default for compatibility with DeliveryUtilities
+
+ // First build an array of supported substitutions for which appropriate context was provided.
+
+ $substitutions = [];
+
+ // Lookup the CO Name
+ $Cos = TableRegistry::getTableLocator()->get('Cos');
+
+ $co = $Cos->get($this->co_id);
+
+ $substitutions['CO_NAME'] = $co->name;
+
+ if($this->ctx_code) {
+ $substitutions['VERIFICATION_CODE'] = $this->ctx_code;
+ }
+
+ if($this->ctx_entryUrl) {
+ $substitutions['ENTRY_URL'] = Router::url(
+ array_merge($this->ctx_entryUrl, ['_full' => true])
+ );
+ }
+
+ if($this->ctx_notification) {
+ $substitutions['NOTIFICATION_COMMENT'] = $this->ctx_notification->comment;
+ $substitutions['NOTIFICATION_SOURCE'] = $this->ctx_notification->source;
+ }
+
+ if($this->ctx_petition) {
+ $Petitions = TableRegistry::getTableLocator()->get('Petitions');
+
+ $substitutions['ENROLLEE_EMAIL'] = $this->ctx_petition->enrollee_email;
+ $substitutions['ENROLLEE_NAME'] = $Petitions->getEnrolleeName($this->ctx_petition->id)
+ ?? __d('field', 'Petitions.enrollee.new');
+ $substitutions['PETITION_URL'] = Router::url([
+ 'plugin' => null,
+ 'controller' => 'petitions',
+ 'action' => 'view',
+ $this->ctx_petition->id,
+ '_full' => true
+ ]);
+
+ if($this->ctx_petition->cou_id) {
+ $cou = $Cos->Cous->get($this->ctx_petition->cou_id);
+
+ $substitutions['COU_NAME'] = $cou->name;
+ }
+ }
+
+ if($this->ctx_last_step) {
+ $substitutions['EF_LAST_STEP_DESC'] = $this->ctx_last_step->description;
+ }
+
+ if($this->ctx_next_step) {
+ $substitutions['EF_NEXT_STEP_DESC'] = $this->ctx_next_step->description;
+ }
+
+ if($this->ctx_subjectPerson && !empty($this->ctx_subjectPerson->primary_name)) {
+ $substitutions['SUBJECT_NAME'] = $this->ctx_subjectPerson->primary_name->full_name;
+ }
+
+ // Finally run the substitutions through each of the supported parts
+
+ foreach(array_keys($this->msg) as $part) {
+ if(!empty($this->$part)) {
+ // Process the (@SUBSTITUTIONS) for this part
+ $searchKeys = [];
+ $replaceVals = [];
+
+ foreach(array_keys($substitutions) as $k) {
+ $searchKeys[] = "(@" . $k . ")";
+ $replaceVals[] = $substitutions[$k] ?? "(?)";
+ }
+
+ $this->msg[$part] = str_replace($searchKeys, $replaceVals, $this->$part);
+ }
+ }
+ }
+
+ /**
+ * Obtain the post-substitution message part, available after generateMessage()
+ * is called.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param string $part "subject", "body_html", or "body_text"
+ * @return string Requested message part
+ */
+
+ public function getMessagePart(string $part): string {
+ return $this->msg[$part];
+ }
+
+ /**
+ * Set the code for the Message Template context.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param string $code Code, eg for Email verification
+ * @return MessageTemplate
+ */
+
+ public function setContextCode(string $code) {
+ $this->ctx_code = $code;
+
+ return $this;
+ }
+
+ /**
+ * Set the Enrollment Flow Step for the Message Template context.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param EnrollmentFlowStep $lastStep Enrollment Flow Step that just completed
+ * @param EnrollmentFlowStep $nextStep Enrollment Flow Step that is next to run
+ * @return MessageTemplate
+ */
+
+ public function setContextEnrollmentFlowSteps(
+ ?EnrollmentFlowStep $lastStep,
+ ?EnrollmentFlowStep $nextStep
+ ) {
+ $this->ctx_last_step = $lastStep;
+ $this->ctx_next_step = $nextStep;
+
+ return $this;
+ }
+
+ /**
+ * Set the entry URL for the Message Template context.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param array $url Cake URL array for notification re-entry
+ * @return MessageTemplate
+ */
+
+ public function setContextEntryUrl(array $url) {
+ $this->ctx_entryUrl = $url;
+
+ return $this;
+ }
+
+ /**
+ * Set the Notification for the Message Template context.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param Notification $notification Notification
+ * @return MessageTemplate
+ */
+
+ public function setContextNotification(Notification $notification) {
+ $this->ctx_notification = $notification;
+
+ return $this;
+ }
+
+ /**
+ * Set the Petition for the Message Template context.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param Petition $petition Petition
+ * @return MessageTemplate
+ */
+
+ public function setContextPetition(Petition $petition) {
+ $this->ctx_petition = $petition;
+
+ return $this;
+ }
+
+ /**
+ * Set the Subject Person for the Message Template context.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param Person $subjectPerson Subject Person with PrimaryName
+ * @return MessageTemplate
+ */
+
+ public function setContextSubjectPerson(Person $subjectPerson) {
+ $this->ctx_subjectPerson = $subjectPerson;
+
+ return $this;
+ }
}
\ No newline at end of file
diff --git a/app/src/Model/Entity/Petition.php b/app/src/Model/Entity/Petition.php
index 161c75285..5090acd77 100644
--- a/app/src/Model/Entity/Petition.php
+++ b/app/src/Model/Entity/Petition.php
@@ -58,9 +58,10 @@ public function isComplete(): bool {
PetitionStatusEnum::Denied,
PetitionStatusEnum::Duplicate,
PetitionStatusEnum::Failed,
- PetitionStatusEnum::Finalized
+ PetitionStatusEnum::Finalized,
// A Finalizing Petition is NOT complete
// PetitionStatusEnum::Finalizing
+ PetitionStatusEnum::Terminated
]);
}
diff --git a/app/src/Model/Table/EmailAddressesTable.php b/app/src/Model/Table/EmailAddressesTable.php
index 05a572f67..b3332ba86 100644
--- a/app/src/Model/Table/EmailAddressesTable.php
+++ b/app/src/Model/Table/EmailAddressesTable.php
@@ -220,15 +220,11 @@ public function getDeliveryAddress(int $personId): string {
*
* @since COmanage Registry v5.0.0
* @param int $id EmailAddress ID
- * @param int $actorPersonId Actor Person ID
* @return string The verified Email Address
* @throws InvalidArgumentException
*/
- public function forceVerify(
- int $id,
- int $actorPersonId,
- ): string {
+ public function forceVerify(int $id): string {
$email = $this->get($id);
// We only permit Email Addresses associated with a Person (not External Identity)
diff --git a/app/src/Model/Table/EnrollmentFlowStepsTable.php b/app/src/Model/Table/EnrollmentFlowStepsTable.php
index 3de024281..295b8e2d9 100644
--- a/app/src/Model/Table/EnrollmentFlowStepsTable.php
+++ b/app/src/Model/Table/EnrollmentFlowStepsTable.php
@@ -66,8 +66,20 @@ public function initialize(array $config): void {
$this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration);
// Define associations
+ $this->belongsTo('ApproverGroups')
+ ->setClassName('Groups')
+ ->setForeignKey('approver_group_id')
+ ->setProperty('approver_group');
$this->belongsTo('EnrollmentFlows');
$this->belongsTo('MessageTemplates');
+ $this->belongsTo('NotificationGroups')
+ ->setClassName('Groups')
+ ->setForeignKey('notification_group_id')
+ ->setProperty('notification_group');
+ $this->belongsTo('NotificationMessageTemplates')
+ ->setClassName('MessageTemplates')
+ ->setForeignKey('notification_message_template_id')
+ ->setProperty('notification_message_template');
$this->hasMany('PetitionStepResults');
$this->setPluginRelations();
@@ -84,11 +96,24 @@ public function initialize(array $config): void {
'type' => 'enum',
'class' => 'EnrollmentActorEnum'
],
+ 'approverGroups' => [
+ 'type' => 'select',
+ 'model' => 'Groups'
+ ],
'messageTemplates' => [
'type' => 'select',
'model' => 'MessageTemplates',
'where' => ['context' => \App\Lib\Enum\MessageTemplateContextEnum::EnrollmentHandoff]
],
+ 'notificationGroups' => [
+ 'type' => 'select',
+ 'model' => 'Groups'
+ ],
+ 'notificationMessageTemplates' => [
+ 'type' => 'select',
+ 'model' => 'MessageTemplates',
+ 'where' => ['context' => \App\Lib\Enum\MessageTemplateContextEnum::EnrollmentStepCompleted]
+ ],
'plugins' => [
'type' => 'plugin',
'pluginType' => 'enroller'
@@ -240,6 +265,20 @@ public function validationDefault(Validator $validator): Validator {
]);
$validator->notEmptyString('actor_type');
+ $validator->add('approver_group_id', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->allowEmptyString('approver_group_id');
+ /*
+ // An approvers group is required when the actor_type is Approver
+ $validator->notEmptyString(
+ field: 'approver_group_id',
+ when: function($context) {
+ return (!empty($context['data']['actor_type'])
+ && ($context['data']['actor_type'] == EnrollmentActorEnum::Approver));
+ }
+ );*/
+
$validator->add('message_template_id', [
'content' => ['rule' => 'isInteger']
]);
@@ -250,6 +289,16 @@ public function validationDefault(Validator $validator): Validator {
]);
$validator->allowEmptyString('redirect_on_handoff');
+ $validator->add('notification_group_id', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->allowEmptyString('notification_group_id');
+
+ $validator->add('notification_message_template_id', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->allowEmptyString('notification_message_template_id');
+
return $validator;
}
}
\ No newline at end of file
diff --git a/app/src/Model/Table/EnrollmentFlowsTable.php b/app/src/Model/Table/EnrollmentFlowsTable.php
index 2151eacd8..7a3e20fee 100644
--- a/app/src/Model/Table/EnrollmentFlowsTable.php
+++ b/app/src/Model/Table/EnrollmentFlowsTable.php
@@ -80,7 +80,19 @@ public function initialize(array $config): void {
// 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->belongsTo('NotificationGroups')
+ ->setClassName('Groups')
+ ->setForeignKey('notification_group_id')
+ ->setProperty('notification_group');
+ $this->belongsTo('FinalizationMessageTemplates')
+ ->setClassName('MessageTemplates')
+ ->setForeignKey('finalization_message_template_id')
+ ->setProperty('finalization_message_template');
+ $this->belongsTo('NotificationMessageTemplates')
+ ->setClassName('MessageTemplates')
+ ->setForeignKey('notification_message_template_id')
+ ->setProperty('notification_message_template');
+
$this->hasMany('Petitions');
$this->hasMany('EnrollmentFlowSteps')
->setDependent(true)
@@ -98,6 +110,20 @@ public function initialize(array $config): void {
'type' => 'enum',
'class' => 'EnrollmentAuthzEnum'
],
+ 'finalizationMessageTemplates' => [
+ 'type' => 'select',
+ 'model' => 'MessageTemplates',
+ 'where' => ['context' => \App\Lib\Enum\MessageTemplateContextEnum::EnrollmentFinalization]
+ ],
+ 'notificationGroups' => [
+ 'type' => 'select',
+ 'model' => 'Groups'
+ ],
+ 'notificationMessageTemplates' => [
+ 'type' => 'select',
+ 'model' => 'MessageTemplates',
+ 'where' => ['context' => \App\Lib\Enum\MessageTemplateContextEnum::EnrollmentStepCompleted]
+ ],
'statuses' => [
'type' => 'enum',
'class' => 'TemplateableStatusEnum'
@@ -151,9 +177,9 @@ public function initialize(array $config): void {
* @since COmanage Registry v5.1.0
* @param int $petitionId Petition ID
* @return array url: URL to redirect to
- * step: EnrollmentFlowStep
+ * step: EnrollmentFlowStep (null if finalize is true)
* finalize: True if there are no further steps
- * lastStep: If finalize is true, the last EnrollmentFlowStep
+ * lastStep: The prior EnrollmentFlowStep (the one before 'step')
* petition: The Petition entity
* @throws InvalidArgumentException
*/
@@ -202,6 +228,8 @@ public function calculateNextStep(int $petitionId): array {
throw new \InvalidArgumentException(__d('error', 'EnrollmentFlowSteps.none'));
}
+ $prior = null;
+
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
@@ -217,15 +245,17 @@ public function calculateNextStep(int $petitionId): array {
'action' => 'dispatch',
$step->$pluginName->id,
'?' => [
- 'petition_id' => $petition->id
+ 'petition_id' => $petition->id
]
],
'step' => $step,
'finalize' => false,
- 'lastStep' => null,
+ 'lastStep' => $prior,
'petition' => $petition
];
}
+
+ $prior = $step;
}
// If we didn't find a Step, it's time to Finalize the Petition.
@@ -301,6 +331,21 @@ public function validationDefault(Validator $validator): Validator {
]);
$validator->allowEmptyString('redirect_on_finalize');
+ $validator->add('finalization_message_template_id', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->allowEmptyString('finalization_message_template_id');
+
+ $validator->add('notification_group_id', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->allowEmptyString('notification_group_id');
+
+ $validator->add('notification_message_template_id', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->allowEmptyString('notification_message_template_id');
+
return $validator;
}
}
\ No newline at end of file
diff --git a/app/src/Model/Table/ExtIdentitySourceRecordsTable.php b/app/src/Model/Table/ExtIdentitySourceRecordsTable.php
index b9a8fda8a..4d5252edb 100644
--- a/app/src/Model/Table/ExtIdentitySourceRecordsTable.php
+++ b/app/src/Model/Table/ExtIdentitySourceRecordsTable.php
@@ -154,6 +154,19 @@ public function initialize(array $config): void {
]);
}
+ /**
+ * Modify an index Query to specify how to filter on the requested CO.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param Query $query Query object
+ * @param int $coId CO ID to filter on
+ * @return Query Modified query
+ */
+
+ public function filterIndexByCO(Query $query, int $coId): Query {
+ return $query->where(['ExternalIdentitySources.co_id' => $coId]);
+ }
+
/**
* Set validation rules.
*
diff --git a/app/src/Model/Table/GroupsTable.php b/app/src/Model/Table/GroupsTable.php
index 4df6db0cd..73304dcb2 100644
--- a/app/src/Model/Table/GroupsTable.php
+++ b/app/src/Model/Table/GroupsTable.php
@@ -95,6 +95,8 @@ public function initialize(array $config): void {
->setClassName('Groups')
->setForeignKey('owners_group_id');
+ $this->hasMany('EnrollmentFlowSteps')
+ ->setForeignKey('notification_group_id');
$this->hasMany('GroupMembers')
->setDependent(true)
->setCascadeCallbacks(true);
@@ -290,6 +292,14 @@ public function addDefaults(int $coId, int $couId=null, bool $rename=false): boo
'status' => SuspendableStatusEnum::Active,
'cou_id' => ($couId ?: null)
],
+ ':approvers' => array(
+ 'group_type' => GroupTypeEnum::Approvers,
+ 'auto' => false,
+ 'description' => __d('field', 'Groups.desc.approvers', [$couName ?: $co->name]),
+ 'open' => false,
+ 'status' => SuspendableStatusEnum::Active,
+ 'cou_id' => ($couId ?: null)
+ ),
':members:active' => [
'group_type' => GroupTypeEnum::ActiveMembers,
'auto' => true,
@@ -506,28 +516,114 @@ public function getAdminGroupId(int $coId): int {
return $g->id;
}
+ /**
+ * Get the Approvers Group for a CO or COU.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param int $coId CO ID
+ * @param int $couId COU ID
+ * @return int Group ID
+ * @throws \Cake\Datasource\Exception\RecordNotFoundException
+ */
+
+ public function getApproversGroupId(?int $coId=null, ?int $couId=null): int {
+ $whereClause = [
+ 'status' => SuspendableStatusEnum::Active,
+ 'group_type' => GroupTypeEnum::Approvers
+ ];
+
+ if($coId) {
+ $whereClause['co_id'] = $coId;
+ }
+
+ if($couId) {
+ $whereClause['cou_id'] = $couId;
+ } else {
+ // Make sure not to pull the CO level Approvers group
+ $whereClause['cou_ID IS'] = null;
+ }
+
+ $group = $this->find()->where($whereClause)->firstOrFail();
+
+ return $group->id;
+ }
+
/**
* Obtain an iterator for all members of the requested Group.
- *
+ *
* @since COmanage Registry v5.0.0
- * @param int $id Group ID
- * @param int $groupNestingId If provided, only members due to this Group Nesting ID
- * @return PaginatedSqlIterator Iterator for GroupMembers
+ * @param int $id Group ID
+ * @param int $groupNestingId If provided, only members due to this Group Nesting ID
+ * @param bool $valid If true, only return members with valid validity dates
+ * @param bool $active If true, only return members with an active Person status
+ * @param bool $activeGroup If true, the Group itself must be Active
+ * @return PaginatedSqlIterator Iterator for GroupMembers
+ * @throws InvalidArgumentException
*/
- public function getMembers(int $id, int $groupNestingId=null): PaginatedSqlIterator {
+ public function getMembers(
+ int $id,
+ int $groupNestingId=null,
+ bool $valid=true,
+ bool $active=true,
+ bool $activeGroup=true
+ ): PaginatedSqlIterator {
$conditions = [
'group_id' => $id,
-// XXX add check for valid_from/through and test
-// 'valid_from'
-// 'valid_through'
];
+
+ $filter = null;
+
+ if($activeGroup) {
+ // Do a separate check on the Group itself since we only need to do that once
+
+ $group = $this->get($id);
+
+ if($group->status != SuspendableStatusEnum::Active) {
+ throw new \InvalidArgumentException(__d('error', 'inactive', [__d('controller', 'Groups', [1]), $id]));
+ }
+ }
if($groupNestingId) {
$conditions['group_nesting_id'] = $groupNestingId;
}
+
+ if($active || $valid) {
+ // We handle these constraints via filters to avoid reproducing application
+ // logic in multiple places. This means the Paginator count will be an upper
+ // bound instead of an exact count, but we don't need that here.
+
+ // Retrieve the PeopleTable once and pass it to the callback to use.
+ $People = TableRegistry::getTableLocator()->get('People');
+
+ $filter = function ($entity) use ($People, $active, $valid) {
+ // $entity is a GroupMember object from the result set.
+
+ if($active) {
+ // Retrieve the Person to check its status
+
+ // person_id is a required field, so this get() shouldn't fail.
+ // If it does, an exception will be thrown.
+ $person = $People->get($entity->person_id);
+
+ if(!$person->isActive()) {
+ return false;
+ }
+ }
+
+ if($valid && !$entity->isValid()) {
+ return false;
+ }
+
+ return true;
+ };
+ }
- return new PaginatedSqlIterator($this->GroupMembers->getTarget(), $conditions);
+ return new PaginatedSqlIterator(
+ table: $this->GroupMembers->getTarget(),
+ conditions: $conditions,
+ filter: $filter
+ );
}
/**
@@ -541,7 +637,7 @@ public function getMembers(int $id, int $groupNestingId=null): PaginatedSqlItera
*/
public function getMembersViaNesting(int $id, int $groupNestingId): PaginatedSqlIterator {
- return $this->getMembers($id, $groupNestingId);
+ return $this->getMembers($id, $groupNestingId, false, false, false);
}
/**
@@ -718,7 +814,7 @@ protected function reconcileAutomaticGroup(\Cake\Datasource\EntityInterface $ent
// First, we pull the current members of the Group, and for each member
// make sure they are still eligible.
- $iterator = $this->getMembers($entity->id);
+ $iterator = $this->getMembers($entity->id, null, false, false, false);
foreach($iterator as $k => $groupMember) {
if(!empty($entity->cou_id)) {
@@ -840,7 +936,7 @@ protected function reconcileNestedMemberships(\Cake\Datasource\EntityInterface $
// to check here.)
foreach($groupNestings->toArray() as $groupNesting) {
- $iterator = $this->getMembers($groupNesting->group_id);
+ $iterator = $this->getMembers($groupNesting->group_id, null, false, false, false);
foreach($iterator as $k => $sourceGroupMember) {
$this->GroupMembers->syncNestedMembership($sourceGroupMember->person_id,
diff --git a/app/src/Model/Table/IdentifiersTable.php b/app/src/Model/Table/IdentifiersTable.php
index 052e29705..348ffdb2e 100644
--- a/app/src/Model/Table/IdentifiersTable.php
+++ b/app/src/Model/Table/IdentifiersTable.php
@@ -72,6 +72,7 @@ class IdentifiersTable extends Table {
'reference',
'pairwiseid',
'subjectid',
+ // sorid is now localized as "source key" but we still use "sorid" for compatibility
'sorid',
'uid'
]
@@ -202,6 +203,31 @@ public function localAfterSave(\Cake\Event\EventInterface $event, \Cake\Datasour
return true;
}
+
+ /**
+ * Look up a Group ID from an identifier and identifier type ID.
+ * Only active Identifiers can be used for lookups.
+ *
+ * @param int $typeId Identifier Type ID
+ * @param string $identifier Identifier
+ *
+ * @return int Group ID
+ * @since COmanage Registry v5.2.0
+ */
+
+ public function lookupGroup(int $typeId, string $identifier): int {
+ // Note this function signature is intentionally the same as lookupPerson()
+ $id = $this->find()
+ ->where([
+ 'identifier' => $identifier,
+ 'type_id' => $typeId,
+ 'status' => SuspendableStatusEnum::Active,
+ 'group_id IS NOT NULL'
+ ])
+ ->firstOrFail();
+
+ return $id->group_id;
+ }
/**
* Look up a Person ID from an identifier and identifier type ID.
diff --git a/app/src/Model/Table/MessageTemplatesTable.php b/app/src/Model/Table/MessageTemplatesTable.php
index 903897660..92e18d66a 100644
--- a/app/src/Model/Table/MessageTemplatesTable.php
+++ b/app/src/Model/Table/MessageTemplatesTable.php
@@ -32,11 +32,13 @@
use Cake\ORM\Query;
use Cake\ORM\RulesChecker;
use Cake\ORM\Table;
-use Cake\ORM\TableRegistry;
use Cake\Validation\Validator;
use App\Lib\Enum\MessageFormatEnum;
use App\Lib\Enum\MessageTemplateContextEnum;
use App\Lib\Enum\SuspendableStatusEnum;
+use \App\Model\Entity\Notification;
+use \App\Model\Entity\Person;
+use \App\Model\Entity\Petition;
class MessageTemplatesTable extends Table {
use \App\Lib\Traits\AutoViewVarsTrait;
@@ -46,7 +48,7 @@ class MessageTemplatesTable extends Table {
use \App\Lib\Traits\PrimaryLinkTrait;
use \App\Lib\Traits\TableMetaTrait;
use \App\Lib\Traits\ValidationTrait;
-
+
/**
* Perform Cake Model initialization.
*
@@ -65,6 +67,8 @@ public function initialize(array $config): void {
// Define associations
$this->belongsTo('Cos');
$this->hasMany('EnrollmentFlowSteps');
+ $this->hasMany('EnrollmentFlowStepNotifications')
+ ->setForeignKey('notification_message_template_id');
$this->hasMany('Notifications');
$this->setDisplayField('description');
@@ -101,85 +105,6 @@ public function initialize(array $config): void {
]
]);
}
-
- /**
- * 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
- * @param string $code Verification code
- * @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,
- ?string $code=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)) {
- $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;
- }
-
- $substitutions['VERIFICATION_CODE'] = $code;
-
- // 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
- $searchKeys = [];
- $replaceVals = [];
-
- foreach(array_keys($substitutions) as $k) {
- $searchKeys[] = "(@" . $k . ")";
- $replaceVals[] = $substitutions[$k] ?? "(?)";
- }
-
- $ret[$part] = str_replace($searchKeys, $replaceVals, $template->$part);
- }
- }
-
- return $ret;
- }
/**
* Set validation rules.
diff --git a/app/src/Model/Table/NotificationsTable.php b/app/src/Model/Table/NotificationsTable.php
index 7ff140422..6ede4b5a0 100644
--- a/app/src/Model/Table/NotificationsTable.php
+++ b/app/src/Model/Table/NotificationsTable.php
@@ -29,13 +29,17 @@
namespace App\Model\Table;
+use Cake\Event\EventInterface;
use Cake\Mailer\Mailer;
+use Cake\ORM\Query;
use Cake\ORM\Table;
use Cake\ORM\TableRegistry;
+use Cake\Routing\Router;
use Cake\Validation\Validator;
use \App\Lib\Enum\ActionEnum;
use \App\Lib\Enum\NotificationStatusEnum;
use \App\Lib\Util\DeliveryUtilities;
+use \App\Model\Entity\MessageTemplate;
use \App\Model\Entity\Notification;
class NotificationsTable extends Table {
@@ -81,6 +85,10 @@ public function initialize(array $config): void {
->setClassName('People')
->setForeignKey('resolver_person_id')
->setProperty('resolver_person');
+ $this->belongsTo('SubjectGroups')
+ ->setClassName('Groups')
+ ->setForeignKey('subject_group_id')
+ ->setProperty('subject_group');
$this->belongsTo('SubjectPeople')
->setClassName('People')
->setForeignKey('subject_person_id')
@@ -89,12 +97,21 @@ public function initialize(array $config): void {
$this->setDisplayField('comment');
$this->setPrimaryLink([
+ // The subject could be null, eg during an Enrollment Flow where no Person has
+ // been allocated yet, so we also accept recipients as primary keys (since there
+ // has to be at least one recipient within the CO)
'subject_person_id' => 'People',
- 'subject_group_id' => 'Groups'
+ 'subject_group_id' => 'Groups',
+ 'recipient_person_id' => 'People',
+ 'recipient_group_id' => 'Groups'
]);
$this->setRequiresCO(true);
$this->setAllowLookupPrimaryLink(['acknowledge', 'cancel', 'resend']);
+ // These are required for the link to work from the Artifacts page
+ $this->setAllowUnkeyedPrimaryCO(['index']);
+ $this->setAllowEmptyPrimaryLink(['index']);
+
$this->setAutoViewVars([
'statuses' => [
'type' => 'enum',
@@ -102,10 +119,19 @@ public function initialize(array $config): void {
]
]);
+ $this->setIndexContains([
+ 'RecipientGroups',
+ 'RecipientPeople' => ['PrimaryName'],
+ 'SubjectGroups',
+ 'SubjectPeople' => ['PrimaryName']
+ ]);
+
$this->setViewContains([
'ActorPeople' => ['PrimaryName'],
+ 'RecipientGroups',
'RecipientPeople' => ['PrimaryName'],
'ResolverPeople' => ['PrimaryName'],
+ 'SubjectGroups',
'SubjectPeople' => ['PrimaryName']
]);
@@ -141,7 +167,23 @@ public function acknowledge(
int $id,
int $personId
) {
- $this->processResolution($id, NotificationStatusEnum::Acknowledged, $personId);
+ $this->processResolution($this->get($id), NotificationStatusEnum::Acknowledged, $personId);
+ }
+
+ /**
+ * Callback before data is marshaled into an entity.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param EventInterface $event beforeMarshal event
+ * @param ArrayObject $data Entity data
+ * @param ArrayObject $options Callback options
+ */
+
+ public function beforeMarshal(EventInterface $event, \ArrayObject $data, \ArrayObject $options) {
+ if(!empty($data['source'] && is_array($data['source']))) {
+ // Convert the URL array to a string so we can store it in the database
+ $data['source'] = Router::url(url: $data['source'], full: true);
+ }
}
/**
@@ -157,21 +199,23 @@ public function cancel(
int $id,
int $personId
) {
- $this->processResolution($id, NotificationStatusEnum::Canceled, $personId);
+ $this->processResolution($this->get($id), NotificationStatusEnum::Canceled, $personId);
}
/**
* Deliver a notification.
*
* @since COmanage Registry v5.0.0
- * @param Notification $notification Notification
- * @param int $actorPersonId Person ID that caused the Notification to be generated
- * @param bool Return true on success
+ * @param Notification $notification Notification
+ * @param MessageTemplate $messageTemplate MessageTemplate
+ * @param int $actorPersonId Person ID that caused the Notification to be generated
+ * @param bool Return true on success
*/
public function deliver(
- Notification $notification,
- int $actorPersonId
+ Notification $notification,
+ MessageTemplate $messageTemplate,
+ ?int $actorPersonId=null
) {
// We call this function "deliver" because ultimately we might support other
// mechanisms besides email, but for now this basically just delivers the
@@ -182,48 +226,98 @@ public function deliver(
throw new \InvalidArgumentException(__d('error', 'Notifications.notify.status'));
}
- $result = DeliveryUtilities::sendEmailFromTemplate(
- personId: $notification->recipient_person_id,
- messageTemplateId: $notification->message_template_id,
- subjectPerson: !empty($notification->subject_person_id)
- ? $this->SubjectPeople->get($notification->subject_person_id)
- : null,
- notification: $notification
- );
+ // Construct the message content. We do this here rather than rely on DeliveryUtilities
+ // so we can update the Notification, and so DeliveryUtilities doesn't have to generate
+ // the message multiple times when there are multiple recipients.
- // If we are redelivering, DeliveryUtilities will update the existing Notification
- // but changelog will maintain the previously sent messages
+ $messageTemplate->generateMessage();
- if(!empty($result['recipient'])) {
- // Create a History Record
+ // Update the notification with the message content
+ $notification->email_subject = $messageTemplate->getMessagePart('subject');
+ $notification->email_body_text = $messageTemplate->getMessagePart('body_text');
+ $notification->email_body_html = $messageTemplate->getMessagePart('body_html');
- $HistoryRecords = TableRegistry::getTableLocator()->get('HistoryRecords');
+ $this->save($notification);
- $HistoryRecords->recordForPerson(
- personId: $notification->recipient_person_id,
- action: ActionEnum::NotificationDelivered,
- comment: __d('result', 'Notifications.delivered', [$notification->id, $result['recipient']]),
- actorPersonId: $actorPersonId
+ // If we have a recipient Group, convert it to a list of People
+ $recipients = [];
+
+ if(!empty($notification->recipient_group_id)) {
+ // The iterator will only return active, valid members. Since notification groups
+ // are typically small, we simply store the member IDs in $recipients and do the
+ // processing below.
+
+ $iterator = $this->RecipientGroups->getMembers($notification->recipient_group_id);
+
+ foreach($iterator as $k => $gm) {
+ $recipients[] = $gm->person_id;
+ }
+ }
+
+ if(!empty($notification->recipient_person_id)) {
+ $recipients[] = $notification->recipient_person_id;
+ }
+
+ $HistoryRecords = TableRegistry::getTableLocator()->get('HistoryRecords');
+
+ foreach($recipients as $rpid) {
+ $result = DeliveryUtilities::sendEmailToPerson(
+ personId: $rpid,
+ subject: $messageTemplate->getMessagePart('subject'),
+ body_text: $messageTemplate->getMessagePart('body_text'),
+ body_html: $messageTemplate->getMessagePart('body_html'),
+ cc: $messageTemplate->cc,
+ bcc: $messageTemplate->bcc,
+ replyTo: $messageTemplate->reply_to
);
+
+ if(!empty($result['recipient'])) {
+ // Create a History Record
+
+ $HistoryRecords->recordForPerson(
+ personId: $rpid,
+ action: ActionEnum::NotificationDelivered,
+ comment: __d('result', 'Notifications.delivered', [$notification->id, $result['recipient']]),
+ actorPersonId: $actorPersonId
+ );
+ }
}
return true;
}
+ /**
+ * Modify an index Query to specify how to filter on the requested CO.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param Query $query Query object
+ * @param int $coId CO ID to filter on
+ * @return Query Modified query
+ */
+
+ public function filterIndexByCO(Query $query, int $coId): Query {
+ return $query->where([
+ 'OR' => [
+ 'RecipientPeople.co_id' => $coId,
+ 'RecipientGroups.co_id' => $coId
+ ]
+ ]);
+ }
+
/**
* Process the resolution of a Notification.
*
* @since COmanage Registry v5.0.0
- * @param int $id Notification ID
- * @param string $resolution NotificationStatusEnum
- * @param int $resolverPersonId Resolver Person ID
+ * @param Notification $notification Notification
+ * @param NotificationStatusEnum $resolution NotificationStatusEnum
+ * @param int $resolverPersonId Resolver Person ID
* @throws InvalidArgumentException
*/
protected function processResolution(
- int $id,
- string $resolution,
- int $resolverPersonId
+ Notification $notification,
+ string $resolution,
+ ?int $resolverPersonId
) {
$actions = [
NotificationStatusEnum::Acknowledged => ActionEnum::NotificationAcknowledged,
@@ -235,17 +329,15 @@ protected function processResolution(
// implemented via buildRules(), but for now we'll just put it here since all
// resolutions are handled via this function.
- $obj = $this->get($id);
-
// Resolutions require a current corresponding status
if($resolution == NotificationStatusEnum::Acknowledged
- && $obj->status != NotificationStatusEnum::PendingAcknowledgment) {
+ && $notification->status != NotificationStatusEnum::PendingAcknowledgment) {
throw new \InvalidArgumentException(_d('error', 'Notifications.acknowledge'));
} elseif($resolution == NotificationStatusEnum::Canceled
- && !$obj->canCancel()) {
+ && !$notification->canCancel()) {
throw new \InvalidArgumentException(__d('error', 'Notifications.cancel'));
} elseif($resolution == NotificationStatusEnum::Resolved
- && $obj->status != NotificationStatusEnum::PendingResolution) {
+ && $notification->status != NotificationStatusEnum::PendingResolution) {
throw new \InvalidArgumentException(__d('error', 'Notifications.resolve'));
} elseif(!isset($actions[$resolution])) {
// This status is not a valid resolution
@@ -254,22 +346,25 @@ protected function processResolution(
// Update the Notification
- $obj->status = $resolution;
- $obj->resolver_person_id = $resolverPersonId;
- $obj->resolution_time = date('Y-m-d H:i:s');
+ $notification->status = $resolution;
+ $notification->resolver_person_id = $resolverPersonId;
+ $notification->resolution_time = date('Y-m-d H:i:s');
- $this->save($obj);
+ $this->save($notification);
- // Create a History Record
+ // Create a History Record, though note during Enrollment Flows we won't necessarily
+ // have a Subject Person, in which case there's no point recording History.
- $HistoryRecords = TableRegistry::getTableLocator()->get('HistoryRecords');
+ if(!empty($notification->subject_person_id)) {
+ $HistoryRecords = TableRegistry::getTableLocator()->get('HistoryRecords');
- $HistoryRecords->recordForPerson(
- personId: $obj->subject_person_id,
- action: $actions[$resolution],
- comment: __d('result', 'Notifications.'.$resolution, [$obj->comment]),
- actorPersonId: $resolverPersonId
- );
+ $HistoryRecords->recordForPerson(
+ personId: $notification->subject_person_id,
+ action: $actions[$resolution],
+ comment: __d('result', 'Notifications.'.$resolution, [$notification->comment]),
+ actorPersonId: $resolverPersonId
+ );
+ }
// If a notification is resolved (not acknowledged) and had a receipient group,
// send email to the non-actor group members notifying them it was resolved.
@@ -281,43 +376,41 @@ protected function processResolution(
* Register a Notification.
*
* @since COmanage Registry v5.0.0
- * @param int $subjectPersonId Person ID the Notification is about
- * @param int $subjectGroupId Group ID the Notification is about
- * @param int $actorPersonId Person ID that caused the Notification to be generated
- * @param int $recipientPersonId Person ID to receive the Notification (either this or $recipientGroupId must be specified)
- * @param int $recipientGroupId Group ID to receive the Notification (either this or $recipientPersonId must be specified)
- * @param string $action Action code related to this Notification
- * @param string $comment Summary human readable comment for this Notification
- * @param int $messageTemplateId Message Template ID to be used when sending email for this Notification
- * @param mixed $source Source URL for this Notification, either as a string or a Cake URL array
- * @param bool $mustResolve If true, this Notification must be resolved, it cannot be acknowledged
- * @return array Array of Notification IDs (one per Notification recipient)
+ * @param int $subjectPersonId Person ID the Notification is about
+ * @param int $subjectGroupId Group ID the Notification is about
+ * @param int $actorPersonId Person ID that caused the Notification to be generated
+ * @param int $recipientPersonId Person ID to receive the Notification (either this or $recipientGroupId must be specified)
+ * @param int $recipientGroupId Group ID to receive the Notification (either this or $recipientPersonId must be specified)
+ * @param string $action Action code related to this Notification
+ * @param string $comment Summary human readable comment for this Notification
+ * @param MessageTemplate $messageTemplate Message Template to be used when sending email for this Notification
+ * @param mixed $source Source URL for this Notification, either as a string or a Cake URL array
+ * @param bool $mustResolve If true, this Notification must be resolved, it cannot be acknowledged
+ * @return array Array of Notification IDs (one per Notification recipient)
*/
public function register(
- int $subjectPersonId,
- ?int $subjectGroupId,
- int $actorPersonId,
- ?int $recipientPersonId,
- ?int $recipientGroupId,
- string $action,
- string $comment,
- int $messageTemplateId,
- mixed $source,
- bool $mustResolve=false
+ ?int $subjectPersonId,
+ ?int $subjectGroupId,
+ ?int $actorPersonId,
+ ?int $recipientPersonId,
+ ?int $recipientGroupId,
+ string $action,
+ string $comment,
+ MessageTemplate $messageTemplate,
+ mixed $source,
+ bool $mustResolve=false
): array {
$obj = $this->newEntity([
'subject_person_id' => $subjectPersonId,
-// XXX subject group ID not yet implemented
- 'subject_group_id' => null,
+ 'subject_group_id' => $subjectGroupId,
'actor_person_id' => $actorPersonId,
'recipient_person_id' => $recipientPersonId,
-// XXX recipient group ID not yet implemented
'recipient_group_id' => $recipientGroupId,
'resolver_person_id' => null,
'action' => $action,
'comment' => $comment,
- 'message_template_id' => $messageTemplateId,
+ 'message_template_id' => $messageTemplate->id,
'source' => $source,
'status' => ($mustResolve
? NotificationStatusEnum::PendingResolution
@@ -326,20 +419,67 @@ public function register(
$this->saveOrFail($obj);
+ // Attach the Notification to the Message Template
+ $messageTemplate->setContextNotification($obj);
+
+ if($subjectPersonId) {
+ // Retrieve and attach the Subject Person
+ $messageTemplate->setContextSubjectPerson(
+ $this->SubjectPeople->get($subjectPersonId, ['contain' => 'PrimaryName'])
+ );
+ }
+
// Note the various message fields will be set by DeliveryUtilities.
- $this->deliver($obj, $actorPersonId);
+ $this->deliver($obj, $messageTemplate, $actorPersonId);
return [ $obj->id ];
}
/**
* Resolve a notification.
- */
+ *
public function resolve(
-
+ int $id
) {
- echo 'hello';
+ // XXX call processResolution()
+ }*/
+
+ /**
+ * Resolve all outstanding notifications from the specified source.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param mixed $source Source array or URL, exactly matching what was provided previously to register()
+ * @param int $resolverCoPersonId Person ID who resolved the notification
+ * @param NotificationStatusEnum $resolution NotificationStatusEnum
+ */
+
+ public function resolveFromSource(
+ mixed $source,
+ string $resolution=NotificationStatusEnum::Resolved,
+ ?int $resolverPersonId,
+ ) {
+ $sourceUrl = $source;
+
+ if(is_array($sourceUrl)) {
+ $sourceUrl = Router::url(url: $source, full: true);
+ }
+
+ // Pull any Notifications from $source that are Pending Resolution
+ $notifications = $this->find()
+ ->where([
+ 'source' => $sourceUrl,
+ 'status' => NotificationStatusEnum::PendingResolution
+ ])
+ ->all();
+
+ foreach($notifications as $n) {
+ $this->processResolution(
+ $n,
+ $resolution,
+ $resolverPersonId
+ );
+ }
}
/**
@@ -356,16 +496,15 @@ public function validationDefault(Validator $validator): Validator {
$schema = $this->getSchema();
// XXX one of subject_person_id or subject_group_id should be required,
-// update these rules when subject_group_id is implemented
$validator->add('subject_person_id', [
'content' => ['rule' => 'isInteger']
]);
- $validator->notEmptyString('subject_person_id');
+ $validator->allowEmptyString('subject_person_id');
- // $validator->add('subject_group_id', [
- // 'content' => ['rule' => 'isInteger']
- // ]);
- // $validator->notEmptyString('subject_group_id');
+ $validator->add('subject_group_id', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->allowEmptyString('subject_group_id');
$validator->add('actor_person_id', [
'content' => ['rule' => 'isInteger']
diff --git a/app/src/Model/Table/PeopleTable.php b/app/src/Model/Table/PeopleTable.php
index cce7f4456..275c58328 100644
--- a/app/src/Model/Table/PeopleTable.php
+++ b/app/src/Model/Table/PeopleTable.php
@@ -269,7 +269,9 @@ public function initialize(array $config): void {
'delete' => ['platformAdmin', 'coAdmin'],
'edit' => ['platformAdmin', 'coAdmin'],
'provision' => ['platformAdmin', 'coAdmin'],
- 'view' => ['platformAdmin', 'coAdmin', 'selfMember']
+ // selfMember removed pending further discussion around permissions
+ // 'view' => ['platformAdmin', 'coAdmin', 'selfMember']
+ 'view' => ['platformAdmin', 'coAdmin']
],
// Actions that operate over a table (ie: do not require an $id)
'table' => [
diff --git a/app/src/Model/Table/PetitionsTable.php b/app/src/Model/Table/PetitionsTable.php
index 43b6b69f7..b67199cc0 100644
--- a/app/src/Model/Table/PetitionsTable.php
+++ b/app/src/Model/Table/PetitionsTable.php
@@ -29,12 +29,14 @@
namespace App\Model\Table;
+use Cake\ORM\Query;
use Cake\ORM\RulesChecker;
use Cake\ORM\Table;
use Cake\ORM\TableRegistry;
use Cake\Utility\Inflector;
use Cake\Validation\Validator;
use \App\Lib\Enum\ActionEnum;
+use \App\Lib\Enum\EnrollmentActorEnum;
use \App\Lib\Enum\PetitionActionEnum;
use \App\Lib\Enum\PetitionStatusEnum;
use \App\Lib\Enum\ProvisioningContextEnum;
@@ -102,7 +104,15 @@ public function initialize(array $config): void {
$this->setPrimaryLink('enrollment_flow_id');
$this->setRequiresCO(true);
- $this->setAllowLookupPrimaryLink(['continue', 'finalize', 'pending', 'result', 'resume']);
+ $this->setAllowLookupPrimaryLink([
+ 'continue',
+ 'finalize',
+ 'pending',
+ 'result',
+ 'resume',
+ 'terminate'
+ ]);
+ $this->setRedirectGoal(action: 'terminate', goal: 'self');
// These are required for the link to work from the Artifacts page
$this->setAllowUnkeyedPrimaryCO(['index']);
@@ -116,10 +126,16 @@ public function initialize(array $config): void {
]);
$viewContainsRelations = [
- 'EnrollmentFlows' => ['EnrollmentFlowSteps' => ['sort' => ['ordr' => 'ASC']]],
+ 'EnrollmentFlows' => ['EnrollmentFlowSteps' =>
+ // This magic will pass the Step plugin configuration to each Cell automatically
+ array_merge($this->EnrollmentFlows->EnrollmentFlowSteps->getPluginRelations(),
+ ['sort' => ['ordr' => 'ASC']])
+ ],
'EnrolleePeople' => ['PrimaryName' => ['foreignKey' => 'person_id']],
'PetitionerPeople' => ['PrimaryName' => ['foreignKey' => 'person_id']],
- 'PetitionHistoryRecords',
+ 'PetitionHistoryRecords' => [
+ 'ActorPeople' => ['PrimaryName' => ['foreignKey' => 'person_id']]
+ ],
'PetitionStepResults',
];
@@ -177,7 +193,10 @@ public function initialize(array $config): void {
// 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']
+ // terminate an in-progress Petition
+ 'terminate' => ['platformAdmin', 'coAdmin'],
+ // Any approver for the associated Enrollment Flow can view the entire Petition
+ 'view' => ['platformAdmin', 'coAdmin', 'approver']
],
// Actions that are permitted on readonly entities (besides view)
'readOnly' => ['result'],
@@ -207,6 +226,42 @@ public function initialize(array $config): void {
);
}
+ /**
+ * Find the Approver Group for the specified Step for this Petition.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param int $id Petition ID
+ * @param int $stepId Enrollment Flow Step ID
+ * @return int Group ID
+ * @return bool true if $personId is an Approver for this Step for this Petition, false otherwise
+ */
+
+ public function approverGroupId(int $id, int $stepId): int {
+ $EnrollmentFlowSteps = TableRegistry::getTableLocator()->get("EnrollmentFlowSteps");
+ $Groups = TableRegistry::getTableLocator()->get("Groups");
+
+ $petition = $this->get($id);
+
+ $step = $EnrollmentFlowSteps->get($stepId);
+
+ if(!empty($step->approver_group_id)) {
+ // If there is an Approver Group set, use that.
+
+ return $step->approver_group_id;
+ } else {
+ if(!empty($petition->cou_id)) {
+ // If there is a COU set on the Petition (which is why this function is here and
+ // not in EnrollmentFlowStepsTable) use the Approvers Group for that COU.
+
+ return $Groups->getApproversGroupId(couId: $petition->cou_id);
+ } else {
+ // $personId must be a member of the Approvers Group for the CO.
+
+ return $Groups->getApproversGroupId(coId: $this->calculateCoForRecord($petition));
+ }
+ }
+ }
+
/**
* Assign Identifiers for a Petition.
*
@@ -325,7 +380,7 @@ public function derive(int $id) {
*/
public function finalize(int $id) {
- $petition = $this->get($id);
+ $petition = $this->get($id, ['contain' => 'EnrollmentFlows']);
if($petition->isComplete()) {
throw new \InvalidArgumentException(__d('error', 'Petitions.completed', [$id]));
@@ -334,7 +389,7 @@ public function finalize(int $id) {
// Update the Petition status and create a History Record.
$petition->status = PetitionStatusEnum::Finalized;
- $this->saveOrFail($petition);
+ $this->saveOrFail($petition, ['associated' => false]);
$this->PetitionHistoryRecords->record(
petitionId: $petition->id,
@@ -343,6 +398,76 @@ public function finalize(int $id) {
comment: __d('result', 'Petitions.finalized')
// actorPersonId
);
+
+ if(!empty($petition->enrollment_flow->finalization_message_template_id)) {
+ // A finalization Message Template was specified, use it to notify the Enrollee.
+ // We use the enrollee_email address, if populated, otherwise we generate a
+ // Notification to the Enrollee Person (which may or may not have a deliverable
+ // email address).
+
+ $MessageTemplates = TableRegistry::getTableLocator()->get('MessageTemplates');
+
+ $template = $MessageTemplates->get($petition->enrollment_flow->finalization_message_template_id);
+
+ $template->setContextPetition($petition);
+
+ if(!empty($petition->enrollee_email)) {
+ // 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.
+
+ // Because we're calling DeliveryUtilities directly we need to generate the message
+ // from the template here.
+
+ $template->generateMessage();
+
+ if(!DeliveryUtilities::sendEmailToAddress(
+ coId: $coId,
+ recipient: $petition->enrollee_email,
+ subject: $template->getMessagePart('subject'),
+ body_text: $template->getMessagePart('body_text'),
+ body_html: $template->getMessagePart('body_html')
+ )) {
+ throw new \RuntimeException("Message delivery failed"); // XXX I18n. can we get an exception from sendEmailToAddress instead?
+ }
+ } elseif(!empty($petition->enrollee_person)) {
+ // Register a Notification.
+
+ $Notifications = TableRegistry::getTableLocator()->get('Notifications');
+
+ $Notifications->register(
+ subjectPersonId: $petition->enrollee_person_id,
+ subjectGroupId: null,
+ actorPersonId: null, //$actorInfo['person_id'],
+ recipientPersonId: $petition->enrollee_person_id,
+ recipientGroupId: null,
+ action: ActionEnum::PetitionFinalized,
+ comment: __d('result', 'Petitions.finalized'),
+ messageTemplate: $template,
+ // We'll set the source to be the URL to the Petition itself
+ source: [
+ 'controller' => 'petitions',
+ 'action' => 'view',
+ $id
+ ],
+ mustResolve: false
+ );
+ } else {
+ $this->llog('debug', "Cannot send finalization notification for Petition " . $id . " due to lack of email or enrollee Person rocerd");
+ }
+ }
+ }
+
+ /**
+ * Modify an index Query to specify how to filter on the requested CO.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param Query $query Query object
+ * @param int $coId CO ID to filter on
+ * @return Query Modified query
+ */
+
+ public function filterIndexByCO(Query $query, int $coId): Query {
+ return $query->where(['EnrollmentFlows.co_id' => $coId]);
}
/**
@@ -385,6 +510,57 @@ public function flagDuplicate(int $id, ?int $stepId=null, ?string $comment=null)
return true;
}
+ /**
+ * Determine the Name associated with the Enrollee for this Petition. Not all Petitions
+ * will have Enrollee Names, and some Petitions could have more than one Name. This
+ * function will try to find a suitable Name, but no guarantees can be made as to the
+ * result; different values can be returned across subsequent calls, especially if the
+ * Petition state changes.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param int $id Petition ID
+ * @return string A name if found, null otherwise
+ */
+
+ public function getEnrolleeName(int $id): ?string {
+ $ret = null;
+
+ // First see if there is an Enrollee Person associated with the Petition, which would
+ // be the case for (eg) account linking. If so, use their Primary Name.
+
+ $petition = $this->get($id, ['contain' => [
+ 'EnrolleePeople' => 'PrimaryName',
+ 'PetitionStepResults' => [
+ 'EnrollmentFlowSteps' => $this->PetitionStepResults->EnrollmentFlowSteps->getPluginRelations()
+ ]
+ ]]);
+
+ if(!empty($petition->enrollee_person->primary_name)) {
+ return $petition->enrollee_person->primary_name->full_name;
+ }
+
+ // Next walk through the Petition Step Results (ie: the completed Steps) and query the
+ // associated plugins for a Name. We'll stop at the first one we find, which might or
+ // might not be the correct thing to do under all circumstances. Note the step results
+ // are returned in a non-deterministic order.
+
+ foreach($petition->petition_step_results as $psr) {
+ $PluginTable = TableRegistry::getTableLocator()->get($psr->enrollment_flow_step->plugin);
+
+ if(method_exists($PluginTable, "enrolleeName")) {
+ $pmodel = StringUtilities::pluginToEntityField($psr->enrollment_flow_step->plugin);
+
+ $name = $PluginTable->enrolleeName($psr->enrollment_flow_step->$pmodel, $petition->id);
+
+ if(!empty($name)) {
+ return $name;
+ }
+ }
+ }
+
+ return $ret;
+ }
+
/**
* Perform Petition hydration.
*
@@ -539,6 +715,74 @@ public function getToken(int $id): string {
return $petition->token;
}
+ /**
+ * Determine if a Person can approve the specified Step of the specified Petition.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param int $id Petition ID
+ * @param int $stepId Enrollment Flow Step ID
+ * @param int $personId Person ID
+ * @return bool true if $personId is an Approver for this Step for this Petition, false otherwise
+ */
+
+ public function isApprover(int $id, int $stepId, int $personId): bool {
+ // This function is here and not in EnrollmentFlowStepsTable because the Approvers Group
+ // can be influenced by the COU ID of the Petition.
+
+ // $EnrollmentFlowSteps = TableRegistry::getTableLocator()->get("EnrollmentFlowSteps");
+
+ $step = $this->EnrollmentFlows->EnrollmentFlowSteps->get($stepId);
+
+ if($step->actor_type == EnrollmentActorEnum::Approver) {
+ $GroupMembers = TableRegistry::getTableLocator()->get("GroupMembers");
+
+ return $GroupMembers->isMember(
+ groupId: $this->approverGroupId($id, $stepId),
+ personId: $personId
+ );
+ }
+
+ return false;
+ }
+
+ /**
+ * Determine if a Person can Approve any Step for the specified Petition..
+ *
+ * @since COmanage Registry v5.2.0
+ * @param int $id Petition ID
+ * @param int $personId Person ID
+ * @return bool true if $personId is an Approver for this Flow, false otherwise
+ */
+
+ public function isApproverForFlow(int $id, int $personId): bool {
+ // This function is here and not in EnrollmentFlowStepsTable because the Approvers Group
+ // can be influenced by the COU ID of the Petition.
+
+ $petition = $this->get($id);
+
+ $steps = $this->EnrollmentFlows->EnrollmentFlowSteps
+ ->find()
+ ->where([
+ 'enrollment_flow_id' => $petition->enrollment_flow_id,
+ 'status' => SuspendableStatusEnum::Active
+ ])
+ ->order(['EnrollmentFlowSteps.ordr' => 'ASC'])
+ ->all();
+
+ foreach($steps as $step) {
+ if($step->actor_type == EnrollmentActorEnum::Approver) {
+ if($this->EnrollmentFlows->EnrollmentFlowSteps->ApproverGroups->GroupMembers->isMember(
+ groupId: $this->approverGroupId($id, $step->id),
+ personId: $personId
+ )) {
+ return true;
+ }
+ }
+ }
+
+ return false;
+ }
+
/**
* Run Provisioning for a Petition.
*
@@ -635,6 +879,25 @@ public function start(
return $petition;
}
+ /**
+ * Terminate a Petition.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param int $id Petition ID
+ */
+
+ public function terminate(int $id) {
+ $petition = $this->get($id);
+
+ if($petition->isComplete()) {
+ throw new \InvalidArgumentException(__d('error', 'Petitions.completed', [$id]));
+ }
+
+ $petition->status = PetitionStatusEnum::Terminated;
+
+ $this->save($petition);
+ }
+
/**
* Set validation rules.
*
diff --git a/app/src/Model/Table/VerificationsTable.php b/app/src/Model/Table/VerificationsTable.php
index d17867d61..49de805a6 100644
--- a/app/src/Model/Table/VerificationsTable.php
+++ b/app/src/Model/Table/VerificationsTable.php
@@ -38,6 +38,7 @@
use App\Lib\Enum\VerificationMethodEnum;
use App\Lib\Random\RandomString;
use App\Lib\Util\DeliveryUtilities;
+use App\Lib\Util\StringUtilities;
use App\Model\Entity\Verification;
use Random\RandomException;
@@ -297,10 +298,15 @@ public function requestCodeForPetition(
// Send the verification message
+ $MessageTemplates = TableRegistry::getTableLocator()->get('MessageTemplates');
+
+ $template = $MessageTemplates->get($messageTemplateId);
+
+ $template->setContextCode(StringUtilities::addDashesToToken($code));
+
DeliveryUtilities::sendEmailFromTemplate(
- address: $mail,
- messageTemplateId: $messageTemplateId,
- code: $this->tokenToD($code)
+ template: $template,
+ address: $mail
);
// We'll try to record history, but most likely it'll fail due to lack of a Person
@@ -442,32 +448,6 @@ public function verifyFromPetition(int $id, int $emailAddressId) {
$this->saveOrFail($newVerification);
}
-
- /**
- * Converts a token by adding dashes for improved readability.
- *
- * @param string $token The token to be formatted
- * @param int $jump Characters to skip before adding a dash
- * @return string The formatted token with dashes
- * @since COmanage Registry v5.2.0
- */
- public function tokenToD(string $token, int $jump = 4): string
- {
- // Insert some dashes to improve readability
- $dtoken = '';
-
- for($i = 0, $iMax = strlen($token); $i < $iMax; $i++) {
- $dtoken .= $token[$i];
-
- if((($i + 1) % $jump == 0)
- && ($i + 1 < strlen($token))) {
- $dtoken .= '-';
- }
- }
-
- return $dtoken;
- }
-
/**
* Set validation rules.
*
diff --git a/app/templates/EnrollmentFlowSteps/fields.inc b/app/templates/EnrollmentFlowSteps/fields.inc
index 6664a3c00..ffe5137ec 100644
--- a/app/templates/EnrollmentFlowSteps/fields.inc
+++ b/app/templates/EnrollmentFlowSteps/fields.inc
@@ -25,16 +25,77 @@
* @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
*/
+use \App\Lib\Enum\EnrollmentActorEnum;
+?>
+
+
+element('form/listItem', [
+ 'arguments' => [
+ 'fieldName' => $field
+ ]]);
+ }
+
+ print $this->element('form/listItem', [
+ 'arguments' => [
+ 'fieldName' => 'actor_type',
+ 'fieldOptions' => [
+ 'onChange' => 'updateGadgets()'
+ ]
+ ]]);
+
+ foreach (['approver_group_id',
+ 'message_template_id',
+ 'redirect_on_handoff'
+ ] as $field) {
+ print $this->element('form/listItem', [
+ 'arguments' => [
+ 'fieldName' => $field
+ ]]);
+ }
+
+ print $this->element('form/listItem', [
+ 'arguments' => [
+ 'fieldName' => 'notification_group_id',
+ 'fieldOptions' => [
+ 'onChange' => 'updateGadgets()'
+ ]
+ ]]);
+
+ foreach (['notification_message_template_id'
] as $field) {
print $this->element('form/listItem', [
'arguments' => [
diff --git a/app/templates/EnrollmentFlows/fields.inc b/app/templates/EnrollmentFlows/fields.inc
index 2b6018ace..411633f7c 100644
--- a/app/templates/EnrollmentFlows/fields.inc
+++ b/app/templates/EnrollmentFlows/fields.inc
@@ -33,7 +33,7 @@ declare(strict_types = 1);
function updateGadgets(isPageLoad) {
// Hide or show gadgets according to current state
- var authz = document.getElementById('authz-type').value;
+ //var authz = document.getElementById('authz-type').value;
// Handle page interaction
/* CFM-31 not yet implemented
@@ -49,6 +49,14 @@ declare(strict_types = 1);
}*/
hideFields(['authz-cou-id', 'authz-group-id'], isPageLoad);
+
+ var notificationGroup = document.getElementById('notification-group-id').value;
+
+ if(notificationGroup) {
+ showFields(['notification-message-template-id'], isPageLoad);
+ } else {
+ hideFields(['notification-message-template-id'], isPageLoad);
+ }
}
function jsLocalOnLoad() {
@@ -84,6 +92,24 @@ if($vv_action == 'add' || $vv_action == 'edit') {
'collect_enrollee_email',
'redirect_on_duplicate',
'redirect_on_finalize',
+ 'finalization_message_template_id'
+ ] as $field) {
+ print $this->element('form/listItem', [
+ 'arguments' => [
+ 'fieldName' => $field
+ ]]);
+ }
+
+ print $this->element('form/listItem', [
+ 'arguments' => [
+ 'fieldName' => 'notification_group_id',
+ 'fieldOptions' => [
+ 'onChange' => 'updateGadgets()'
+ ]
+ ]
+ ]);
+
+ foreach (['notification_message_template_id'
] as $field) {
print $this->element('form/listItem', [
'arguments' => [
diff --git a/app/templates/GroupMembers/columns.inc b/app/templates/GroupMembers/columns.inc
index 512771713..92c07e715 100644
--- a/app/templates/GroupMembers/columns.inc
+++ b/app/templates/GroupMembers/columns.inc
@@ -40,6 +40,12 @@ $indexColumns = [
// XXX not clear how to sort on submodel, but maybe just leave it for UX revisions?
// 'sortable' => 'PrimaryName.family'
],
+ 'status' => [
+ 'type' => 'enum',
+ 'model' => 'person',
+ 'field' => 'status',
+ 'class' => 'StatusEnum'
+ ],
'source' => [
'type' => 'closure',
'function' => function ($entity) {
diff --git a/app/templates/Notifications/columns.inc b/app/templates/Notifications/columns.inc
index eb43ede49..407345ed0 100644
--- a/app/templates/Notifications/columns.inc
+++ b/app/templates/Notifications/columns.inc
@@ -26,9 +26,22 @@
*/
$indexColumns = [
+ 'id' => [
+ 'type' => 'echo'
+ ],
'action' => [
'type' => 'echo'
],
+ 'subject_person_id' => [
+ 'type' => 'relatedLink',
+ 'action' => 'edit',
+ 'label' => __d('field', 'Notifications.subject_person_id'),
+ 'model' => 'subject_person',
+ 'submodel' => 'primary_name',
+ 'field' => 'full_name'
+// XXX see comments in whichever controller this came from about sorting on given vs family
+// 'sortable' => 'PrimaryName.family'
+ ],
'comment' => [
'type' => 'link'
],
diff --git a/app/templates/Notifications/fields.inc b/app/templates/Notifications/fields.inc
index 449c5da13..769b6428b 100644
--- a/app/templates/Notifications/fields.inc
+++ b/app/templates/Notifications/fields.inc
@@ -78,6 +78,20 @@ if($vv_action == 'view') {
]
]);
+ print $this->element('form/listItem', [
+ 'arguments' => [
+ 'fieldName' => 'subject_group_id',
+ 'status' => $vv_obj->subject_group->name ?? '',
+ 'link' => !empty($vv_obj->subject_group)
+ ? ['url' => [
+ 'controller' => 'groups',
+ 'action' => 'edit',
+ $vv_obj->subject_group->id
+ ]]
+ : []
+ ]
+ ]);
+
print $this->element('form/listItem', [
'arguments' => [
'fieldName' => 'recipient_person_id',
@@ -92,6 +106,20 @@ if($vv_action == 'view') {
]
]);
+ print $this->element('form/listItem', [
+ 'arguments' => [
+ 'fieldName' => 'recipient_group_id',
+ 'status' => $vv_obj->recipient_group->name ?? '',
+ 'link' => !empty($vv_obj->recipient_group)
+ ? ['url' => [
+ 'controller' => 'groups',
+ 'action' => 'edit',
+ $vv_obj->recipient_group->id
+ ]]
+ : []
+ ]
+ ]);
+
print $this->element('form/listItem', [
'arguments' => [
'fieldName' => 'actor_person_id',
diff --git a/app/templates/Petitions/fields-nav.inc b/app/templates/Petitions/fields-nav.inc
index 1609fca83..962ecc198 100644
--- a/app/templates/Petitions/fields-nav.inc
+++ b/app/templates/Petitions/fields-nav.inc
@@ -26,6 +26,22 @@
*/
$topLinks = [
+ // Note we don't need an 'if' test for cancel or resume because the generalized permission
+ // calculation will call isReadOnly() (and not insert these items when true) which is sufficient.
+ [
+ 'icon' => 'cancel',
+ 'order' => 'Default',
+ // We localize the text as "terminate" to make the confirmation dialog less confusing
+ 'label' => __d('operation', 'terminate'),
+ 'link' => [
+ 'action' => 'terminate',
+ $vv_obj->id
+ ],
+ 'confirm' => [
+ 'dg_body_txt' => __d('operation', 'Petitions.terminate.confirm', [$vv_obj->id]),
+ 'dg_confirm_btn' => __d('operation', 'terminate')
+ ]
+ ],
[
'icon' => 'resume',
'order' => 'Default',
diff --git a/app/templates/Petitions/fields.inc b/app/templates/Petitions/fields.inc
index c3efb180f..c732f6175 100644
--- a/app/templates/Petitions/fields.inc
+++ b/app/templates/Petitions/fields.inc
@@ -209,6 +209,19 @@ if (!empty($vv_obj?->petitioner_person?->id)) {
+
+
+
+
+
+ = __d('field','Petitions.enrollee_name') ?>
+
+
+
+ = $vv_enrollee_name ?? __d('field', 'Petitions.enrollee.new') ?>
+
+
+
@@ -222,6 +235,19 @@ if (!empty($vv_obj?->petitioner_person?->id)) {
+
+
+
+
+
+ = __d('field','Petitions.enrollee_identifier') ?>
+
+
+
+ = $vv_obj->enrollee_identifier ?>
+
+
+
@@ -325,10 +351,31 @@ if (!empty($vv_obj?->petitioner_person?->id)) {
petition_history_records as $hr): ?>
- | = __d('enumeration','PetitionActionEnum.' . $hr['action']) ?> |
- = $hr['actor_person_id'] // XXX Replace with link to actor and name of ?> |
- = $hr['comment'] ?> |
- = $hr['created'] ?> |
+
+ =
+ // We only render the code here (not the localization) partly because some
+ // codes may not be localized (especially local plugins) but also because
+ // it's noisy to render the full string when mostly 'comment' has the content
+ // of actual interest
+ $hr->action //__d('enumeration','PetitionActionEnum.' . $hr['action'])
+ ?>
+ |
+
+ =
+ !empty($hr->actor_person_id)
+ ? $this->Html->link(
+ $hr->actor_person->primary_name->full_name,
+ [
+ 'controller' => 'people',
+ 'action' => 'edit',
+ $hr->actor_person_id
+ ]
+ )
+ : ""
+ ?>
+ |
+ = $hr->comment ?> |
+ = $hr->created ?> |
diff --git a/app/templates/Standard/index.php b/app/templates/Standard/index.php
index 419d99486..5d4583567 100644
--- a/app/templates/Standard/index.php
+++ b/app/templates/Standard/index.php
@@ -436,8 +436,13 @@
}
break;
case 'enum':
- if($entity->$col) {
- // XXX Need to add badging - see index.php in Match
+// XXX Need to add badging - see index.php in Match
+ if(!empty($cfg['model']) && !empty($cfg['field'])) {
+ $m = $cfg['model'];
+ $f = $cfg['field'];
+
+ print __d('enumeration', $cfg['class'].'.'.$entity->$m->$f) . $suffix;
+ } elseif($entity->$col) {
print __d('enumeration', $cfg['class'].'.'.$entity->$col) . $suffix;
}
break;