diff --git a/app/composer.json b/app/composer.json
index 7265ff27d..c60e2c661 100644
--- a/app/composer.json
+++ b/app/composer.json
@@ -48,7 +48,8 @@
"PipelineToolkit\\": "availableplugins/PipelineToolkit/src/",
"SqlConnector\\": "availableplugins/SqlConnector/src/",
"SshKeyAuthenticator\\": "plugins/SshKeyAuthenticator/src/",
- "CoreJob\\": "plugins/CoreJob/src/"
+ "CoreJob\\": "plugins/CoreJob/src/",
+ "TermsAgreer\\": "plugins/TermsAgreer/src/"
}
},
"autoload-dev": {
@@ -68,7 +69,8 @@
"PipelineToolkit\\Test\\": "availableplugins/PipelineToolkit/tests/",
"SqlConnector\\Test\\": "availableplugins/SqlConnector/tests/",
"SshKeyAuthenticator\\Test\\": "plugins/SshKeyAuthenticator/tests/",
- "CoreJob\\Test\\": "plugins/CoreJob/tests/"
+ "CoreJob\\Test\\": "plugins/CoreJob/tests/",
+ "TermsAgreer\\Test\\": "plugins/TermsAgreer/tests/"
}
},
"scripts": {
diff --git a/app/config/bootstrap.php b/app/config/bootstrap.php
index 93e4c292f..f4dcc6ecc 100644
--- a/app/config/bootstrap.php
+++ b/app/config/bootstrap.php
@@ -249,11 +249,11 @@
// \Cake\Utility\Inflector::rules('irregular', ['red' => 'redlings']);
// \Cake\Utility\Inflector::rules('uninflected', ['dontinflectme']);
-\Cake\Utility\Inflector::rules('irregular', ['co_terms_and_condition' => 'co_terms_and_conditions']);
-\Cake\Utility\Inflector::rules('uninflected', ['co_terms_and_conditions' => 'co_terms_and_conditions']);
\Cake\Utility\Inflector::rules('irregular', ['cou' => 'cous']);
\Cake\Utility\Inflector::rules('uninflected', ['cous' => 'cous']);
\Cake\Utility\Inflector::rules('irregular', ['meta' => 'meta']);
+// \Cake\Utility\Inflector::rules('irregular', ['terms_and_condition' => 'terms_and_conditions']);
+\Cake\Utility\Inflector::rules('uninflected', ['terms_and_conditions' => 'terms_and_conditions']);
/*
* Define some constants
diff --git a/app/config/schema/schema.json b/app/config/schema/schema.json
index 367d1c0e6..0647a1f37 100644
--- a/app/config/schema/schema.json
+++ b/app/config/schema/schema.json
@@ -29,6 +29,7 @@
"language": { "type": "string", "size": 16 },
"mail": { "type": "string", "size": 256 },
"message_template_id": { "type": "integer", "foreignkey": { "table": "message_templates", "column": "id" } },
+ "mostly_static_page_id": { "type": "integer", "foreignkey": { "table": "mostly_static_pages", "column": "id" } },
"name": { "type": "string", "size": 128, "notnull": true },
"ordr": { "type": "integer" },
"password": { "type": "string", "size": 400 },
@@ -981,6 +982,40 @@
"authenticator_statuses_i2": { "needed": false, "columns": [ "authenticator_id"] },
"authenticator_statuses_i3": { "needed": false, "columns": [ "person_id"] }
}
+ },
+
+ "terms_and_conditions": {
+ "columns": {
+ "id": {},
+ "co_id": {},
+ "description": {},
+ "url": { "type": "url" },
+ "mostly_static_page_id": {},
+ "cou_id": {},
+ "status": {},
+ "ordr": {},
+ "agreement_duration": { "type": "integer" },
+ "agree_to_updates": { "type": "boolean" }
+ },
+ "indexes": {
+ "terms_and_conditions_i1": { "columns": [ "co_id" ] },
+ "terms_and_conditions_i2": { "needed": false, "columns": [ "cou_id" ] },
+ "terms_and_conditions_i3": { "needed": false, "columns": [ "mostly_static_page_id" ] }
+ }
+ },
+
+ "t_and_c_agreements": {
+ "columns": {
+ "id": {},
+ "terms_and_conditions_id": { "type": "integer", "foreignkey": { "table": "terms_and_conditions", "column": "id" } },
+ "person_id": {},
+ "agreement_time": { "type": "datetime" },
+ "identifier": { "type": "string", "size": 512 }
+ },
+ "indexes": {
+ "t_and_c_agreements_i1": { "columns": [ "terms_and_conditions_id" ]},
+ "t_and_c_agreements_i2": { "columns": [ "person_id" ]}
+ }
}
},
diff --git a/app/plugins/EnvSource/resources/locales/en_US/env_source.po b/app/plugins/EnvSource/resources/locales/en_US/env_source.po
index bf902af83..4431ce434 100644
--- a/app/plugins/EnvSource/resources/locales/en_US/env_source.po
+++ b/app/plugins/EnvSource/resources/locales/en_US/env_source.po
@@ -173,4 +173,4 @@ msgid "result.env.saved.login"
msgstr "Env Attributes updated at login"
msgid "result.pipeline.status"
-msgstr "Pipeline completed with status {0}"
\ No newline at end of file
+msgstr "EnvSource Pipeline completed with status {0}"
\ No newline at end of file
diff --git a/app/plugins/OrcidSource/resources/locales/en_US/orcid_source.po b/app/plugins/OrcidSource/resources/locales/en_US/orcid_source.po
index b8cf16f62..decc5298a 100644
--- a/app/plugins/OrcidSource/resources/locales/en_US/orcid_source.po
+++ b/app/plugins/OrcidSource/resources/locales/en_US/orcid_source.po
@@ -110,4 +110,4 @@ msgid "result.orcid.saved"
msgstr "ORCID Token recorded"
msgid "result.pipeline.status"
-msgstr "Pipeline completed with status {0}"
+msgstr "OrcidSource Pipeline completed with status {0}"
diff --git a/app/plugins/TermsAgreer/config/plugin.json b/app/plugins/TermsAgreer/config/plugin.json
new file mode 100644
index 000000000..90cac19ad
--- /dev/null
+++ b/app/plugins/TermsAgreer/config/plugin.json
@@ -0,0 +1,35 @@
+{
+ "types": {
+ "enrollment_flow_step": [
+ "AgreementCollectors"
+ ]
+ },
+ "schema": {
+ "tables": {
+ "agreement_collectors": {
+ "columns": {
+ "id": {},
+ "enrollment_flow_step_id": {},
+ "t_and_c_mode": { "type": "string", "size": 2 }
+ },
+ "indexes": {
+ "agreement_collectors_i1": { "columns": [ "enrollment_flow_step_id" ] }
+ }
+ },
+ "petition_agreements": {
+ "columns": {
+ "id": {},
+ "petition_id": {},
+ "agreement_collector_id": { "type": "integer", "foreignkey": { "table": "agreement_collectors", "column": "id" } },
+ "terms_and_conditions_id": { "type": "integer", "foreignkey": { "table": "terms_and_conditions", "column": "id" } },
+ "identifier": { "type": "string", "size": 512 },
+ "agreement_time": { "type": "datetime" }
+ },
+ "indexes": {
+ "petition_agreements_i1": { "columns": [ "petition_id" ] },
+ "petition_agreements_i2": { "needed": false, "columns": [ "terms_and_conditions_id" ] }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/plugins/TermsAgreer/resources/locales/en_US/terms_agreer.po b/app/plugins/TermsAgreer/resources/locales/en_US/terms_agreer.po
new file mode 100644
index 000000000..76a5a61bc
--- /dev/null
+++ b/app/plugins/TermsAgreer/resources/locales/en_US/terms_agreer.po
@@ -0,0 +1,53 @@
+# COmanage Registry Localizations (terms_agreer domain)
+#
+# Portions licensed to the University Corporation for Advanced Internet
+# Development, Inc. ("UCAID") under one or more contributor license agreements.
+# See the NOTICE file distributed with this work for additional information
+# regarding copyright ownership.
+#
+# UCAID licenses this file to you under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with the
+# License. You may obtain a copy of the License at:
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+# @link https://www.internet2.edu/comanage COmanage Project
+# @package registry-plugins
+# @since COmanage Registry v5.2.0
+# @license Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
+
+msgid "controller.AgreementCollectors"
+msgstr "{0,plural,=1{T&C Agreement Collector} other{T&C Agreement Collectors}}"
+
+msgid "controller.PetitionAgreements"
+msgstr "{0,plural,=1{Petition T&C Agreement} other{Petition T&C Agreements}}"
+
+msgid "enumeration.TAndCEnrollmentModeEnum.EC"
+msgstr "Explicit Consent"
+
+msgid "enumeration.TAndCEnrollmentModeEnum.X"
+msgstr "Ignore"
+
+msgid "enumeration.TAndCEnrollmentModeEnum.IC"
+msgstr "Implied Consent"
+
+msgid "field.AgreementCollectors.t_and_c_mode"
+msgstr "Terms and Conditions Mode"
+
+msgid "information.AgreementCollectors.review"
+msgstr "You must review and agree to these Terms and Conditions before continuing."
+
+msgid "result.AgreementCollectors.ignored"
+msgstr "Terms and Conditions collection disabled"
+
+msgid "result.AgreementCollectors.recorded"
+msgstr "Agreed to Terms and Conditions {0} ({1}, {2})"
+
+msgid "result.AgreementCollectors.recorded.summary"
+msgstr "Agreed to {0} Terms and Conditions"
diff --git a/app/plugins/TermsAgreer/src/Controller/AgreementCollectorsController.php b/app/plugins/TermsAgreer/src/Controller/AgreementCollectorsController.php
new file mode 100644
index 000000000..751918faa
--- /dev/null
+++ b/app/plugins/TermsAgreer/src/Controller/AgreementCollectorsController.php
@@ -0,0 +1,162 @@
+ [
+ 'AgreementCollectors.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();
+ $petition = $this->getPetition();
+ $coId = $this->getCOID();
+
+ // Pull the set of T&C as well as the plugin step configuration.
+ // Explicit vs Implicit is handled by the frontend.
+
+ $cfg = $this->AgreementCollectors->get($id);
+
+ if($cfg->t_and_c_mode == TAndCEnrollmentModeEnum::Ignore) {
+ // If the Plugin is set to Ignore, we simply skip this step and move on.
+
+ return $this->finishStep(
+ enrollmentFlowStepId: $cfg->enrollment_flow_step_id,
+ petitionId: $petition->id,
+ comment: __d('terms_agreer', 'result.AgreementCollectors.ignored')
+ );
+ }
+
+ // We pull Active T&C only, We pull all T&C that are not COU specific,
+ // and if there is a COU associated with this Petition then we also pull T&C
+ // for that COU.
+
+ $TermsAndConditions = TableRegistry::getTableLocator()->get('TermsAndConditions');
+
+ $whereClause = [
+ 'co_id' => $coId,
+ 'status' => SuspendableStatusEnum::Active
+ ];
+
+ if(!empty($petition->cou_id)) {
+ $whereClause['OR'] = [
+ 'cou_id IS NULL',
+ 'cou_id' => $petition->cou_id
+ ];
+ } else {
+ $whereClause[] = 'cou_id IS NULL';
+ }
+
+ $tandc = $TermsAndConditions->find()
+ ->where($whereClause)
+ ->order('ordr ASC')
+ ->all();
+
+ // Calculating status of current Agreements is a bit tricky, because we need to look
+ // in both the Petition (PetitionAgreements) and if this is a Petition for a Person
+ // that already exists in TAndCAgreements. As a first pass, we simply ignore any
+ // existing Agreements (eg if this is an Additional Role Enrollment) and we always
+ // require this Step to be completed. It's valid to have multiple T&C Agreements,
+ // and even desirable if they expired.
+
+ if($request->is('post')) {
+ // Walk the set of $tandc and look for an agreement in the POST data.
+ // We shouldn't really get here without all $tandc agreed to since the
+ // frontend shouldn't allow the enrollee to get here otherwise.
+
+ $data = $request->getData();
+
+ $ok = true;
+
+ foreach($tandc as $tc) {
+ // The post data is keyed on the string "tc" appended to the T&C id,
+ // and the expected value is "1".
+
+ $key = "tc".$tc->id;
+
+ if(!isset($data[$key]) || $data[$key] != "1") {
+ $ok = false;
+
+ $this->Flash->error("Did not find agreement for T&C " . $tc->id); // XXX I18n
+ }
+ }
+
+ if($ok) {
+ // Record the agreements and update the Petition
+
+ // Similar to v4, we use the Petition Token (formerly the Enrollee Token)
+ // for unauthenticated Enrollments.
+
+ $authIdentifier = $request->getSession()->read('Auth.external.user');
+
+ if(empty($authIdentifier)) {
+ $authIdentifier = $petition->token;
+ }
+
+ $this->AgreementCollectors->record(
+ petitionId: $petition->id,
+ agreementCollectorId: $cfg->id,
+ identifier: $authIdentifier,
+ tAndC: $tandc
+ );
+
+ // Redirect to the next step
+
+ return $this->finishStep(
+ enrollmentFlowStepId: $cfg->enrollment_flow_step_id,
+ petitionId: $petition->id,
+ comment: __d('terms_agreer', 'result.AgreementCollectors.recorded.summary', $tandc->count())
+ );
+ }
+ }
+
+ // If there are no pending T&C, redirect to the next step.
+
+ // Otherwise let the view render.
+ $this->set('vv_tandc_mode', $cfg->t_and_c_mode);
+ $this->set('vv_tandc', $tandc);
+
+ $this->render('/Standard/dispatch');
+ }
+}
\ No newline at end of file
diff --git a/app/plugins/TermsAgreer/src/Controller/AppController.php b/app/plugins/TermsAgreer/src/Controller/AppController.php
new file mode 100644
index 000000000..deeefd08f
--- /dev/null
+++ b/app/plugins/TermsAgreer/src/Controller/AppController.php
@@ -0,0 +1,10 @@
+
+ */
+ protected array $_accessible = [
+ '*' => true,
+ 'id' => false,
+ 'slug' => false,
+ ];
+}
diff --git a/app/plugins/TermsAgreer/src/Model/Entity/PetitionAgreement.php b/app/plugins/TermsAgreer/src/Model/Entity/PetitionAgreement.php
new file mode 100644
index 000000000..6bdb3dbf7
--- /dev/null
+++ b/app/plugins/TermsAgreer/src/Model/Entity/PetitionAgreement.php
@@ -0,0 +1,51 @@
+
+ */
+ protected array $_accessible = [
+ '*' => true,
+ 'id' => false,
+ 'slug' => false,
+ ];
+}
diff --git a/app/plugins/TermsAgreer/src/Model/Table/AgreementCollectorsTable.php b/app/plugins/TermsAgreer/src/Model/Table/AgreementCollectorsTable.php
new file mode 100644
index 000000000..6fae56858
--- /dev/null
+++ b/app/plugins/TermsAgreer/src/Model/Table/AgreementCollectorsTable.php
@@ -0,0 +1,254 @@
+addBehavior('Changelog');
+ $this->addBehavior('Log');
+ $this->addBehavior('Timestamp');
+
+ $this->setTableType(TableTypeEnum::Configuration);
+
+ // Define associations
+ $this->belongsTo('EnrollmentFlowSteps');
+
+ $this->hasMany('TermsAgreer.PetitionAgreements')
+ ->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', 'TermsAgreer.AgreementCollectors'],
+ // 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'],
+ 'TermsAgreer.AgreementCollectors' => ['edit']
+ ]
+ ]
+ );
+
+ $this->setAutoViewVars([
+ 'tAndCModes' => [
+ 'type' => 'enum',
+ 'class' => 'TermsAgreer.TAndCEnrollmentModeEnum'
+ ]
+ ]);
+
+ $this->setPermissions([
+ // Actions that operate over an entity (ie: require an $id)
+ 'entity' => [
+ 'delete' => false, // Delete the pluggable object instead
+ 'dispatch' => true,
+ 'display' => true,
+ 'edit' => ['platformAdmin', 'coAdmin'],
+ 'view' => ['platformAdmin', 'coAdmin']
+ ],
+ // Actions that operate over a table (ie: do not require an $id)
+ 'table' => [
+ 'add' => false, // This is added by the parent model
+ 'index' => ['platformAdmin', 'coAdmin']
+ ]
+ ]);
+ }
+
+ /**
+ * Perform steps necessary to 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) {
+ // We don't currently need the configuration for anything
+ // $cfg = $this->get($id);
+
+ if(empty($petition->enrollee_person_id)) {
+ throw new \InvalidArgumentException(__d('error', 'Petitions.enrollee.notfound', [$petition->id]));
+ }
+
+ $TAndCAgreements = TableRegistry::getTableLocator()->get('TAndCAgreements');
+
+ // Retrieve the Petition Agreements and convert each one to a T&C Agreement.
+ // Fon certain types of Enrollments, it's possible that the Enrollee has already
+ // agreed to one or more T&C, but that's OK, we can simply add a new Agreement
+ // that will supercede the previous one.
+
+ $agreements = $this->PetitionAgreements
+ ->find()
+ ->where([
+ 'petition_id' => $petition->id,
+ // Strictly speaking since T&C are an all-or-nothing thing in the
+ // current implementation, we don't really care about the
+ // agreement_collector_id, but we'll filter for it anyway to
+ // maintain consistency
+ 'agreement_collector_id' => $id
+ ])
+ ->all();
+
+ foreach($agreements as $pa) {
+ $TAndCAgreements->record(
+ termsAndConditionsId: $pa->terms_and_conditions_id,
+ personId: $petition->enrollee_person_id,
+ actorPersonId: $petition->enrollee_person_id,
+ identifier: $pa->identifier,
+ agreementTime: $pa->agreement_time->getTimestamp(),
+ petitionId: $petition->id
+ );
+ }
+
+ // We recorded Petition History when the T&C were processed, and TAndCAgreements
+ // will record History above, so we don't really need to record any more here.
+
+ return true;
+ }
+
+ /**
+ * Record T&C Agreements.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param int $petitionId Petition ID
+ * @param int $agreementCollectorId Agreement Collector ID
+ * @param string $identifier Authenticated Identifier of Agreer
+ * @param ResultSet $tAndC Set of TermsAndConditions that were agreed to
+ * @throws \InvalidArgumentException
+ */
+
+ public function record(
+ int $petitionId,
+ int $agreementCollectorId,
+ string $identifier,
+ ResultSet $tAndC
+ ) {
+ $cfg = $this->get($agreementCollectorId);
+
+ $Petitions = TableRegistry::getTableLocator()->get('Petitions');
+
+ $petition = $Petitions->get($petitionId);
+
+ // Walk the set of $tAndC, upserting PetitionAgreements for each
+
+ foreach($tAndC as $tc) {
+ // Store the PetitionAgreement
+
+ $this->PetitionAgreements->upsertOrFail(
+ data: [
+ 'petition_id' => $petitionId,
+ 'agreement_collector_id' => $agreementCollectorId,
+ 'terms_and_conditions_id' => $tc->id,
+ 'identifier' => $identifier,
+ // We could probably use changelog timestamps, but it's clearer to record
+ // an explicit agreement_time. (We'd have to use modified instead of created
+ // in case we upsert, but then other changes could theoretically update the
+ // modified timestamp).
+ 'agreement_time' => date('Y-m-d H:i:s', time())
+ ],
+ whereClause: [
+ 'petition_id' => $petitionId,
+ 'agreement_collector_id' => $agreementCollectorId,
+ 'terms_and_conditions_id' => $tc->id
+ ]
+ );
+
+ // Record PetitionHistory
+
+ $Petitions->PetitionHistoryRecords->record(
+ petitionId: $petitionId,
+ enrollmentFlowStepId: $cfg->enrollment_flow_step_id,
+ action: $cfg->t_and_c_mode == TAndCEnrollmentModeEnum::ExplicitConsent ? PetitionActionEnum::TCExplicitAgreement : PetitionActionEnum::TCImpliedAgreement,
+ comment: __d('terms_agreer', 'result.AgreementCollectors.recorded', [$tc->description, $tc->id, $cfg->t_and_c_mode])
+ );
+ }
+ }
+
+ /**
+ * 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('t_and_c_mode', [
+ 'content' => ['rule' => ['inList', TAndCEnrollmentModeEnum::getConstValues()]]
+ ]);
+ $validator->notEmptyString('t_and_c_mode');
+
+ return $validator;
+ }
+}
diff --git a/app/plugins/TermsAgreer/src/Model/Table/PetitionAgreementsTable.php b/app/plugins/TermsAgreer/src/Model/Table/PetitionAgreementsTable.php
new file mode 100644
index 000000000..2599286b0
--- /dev/null
+++ b/app/plugins/TermsAgreer/src/Model/Table/PetitionAgreementsTable.php
@@ -0,0 +1,133 @@
+addBehavior('Changelog');
+ $this->addBehavior('Log');
+ $this->addBehavior('Timestamp');
+
+ $this->setTableType(\App\Lib\Enum\TableTypeEnum::Artifact);
+
+ // Define associations
+ $this->belongsTo('TermsAgreer.AgreementCollectors');
+ $this->belongsTo('Petitions');
+ $this->belongsTo('TermsAndConditions')
+ // It's unclear why, but Cake isn't inflecting the property key correctly here
+ // even though it does elsewhere (maybe something related to this being a plugin?)
+ ->setProperty('terms_and_conditions')
+ ->setForeignKey('terms_and_conditions_id');
+
+ $this->setDisplayField('agreement_time');
+
+ $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');
+
+ // Strictly speaking we don't require agreement_collector_id because we always
+ // collect all T&C, at least in the current implementation. We use it partly
+ // for consistency with the the CoreEnroller plugins, and partly for future
+ // proofing (in case we eg support collecting different T&C at different times).
+ $validator->add('agreement_collector_id', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->notEmptyString('agreement_collector_id');
+
+ $validator->add('terms_and_conditions_id', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->notEmptyString('terms_and_conditions_id');
+
+ $this->registerStringValidation($validator, $schema, 'identifier', true);
+
+ $validator->add('agreement_time', [
+ 'content' => ['rule' => 'dateTime']
+ ]);
+ $validator->notEmptyString('agreement_time');
+
+ return $validator;
+ }
+}
diff --git a/app/plugins/TermsAgreer/src/TermsAgreerPlugin.php b/app/plugins/TermsAgreer/src/TermsAgreerPlugin.php
new file mode 100644
index 000000000..6d971b990
--- /dev/null
+++ b/app/plugins/TermsAgreer/src/TermsAgreerPlugin.php
@@ -0,0 +1,98 @@
+plugin(
+ 'TermsAgreer',
+ ['path' => '/terms-agreer'],
+ function (RouteBuilder $builder) {
+ // Add custom routes here
+
+ $builder->fallbacks();
+ }
+ );
+ parent::routes($routes);
+ }
+
+ /**
+ * Add middleware for the plugin.
+ *
+ * @param \Cake\Http\MiddlewareQueue $middlewareQueue The middleware queue to update.
+ * @return \Cake\Http\MiddlewareQueue
+ */
+ public function middleware(MiddlewareQueue $middlewareQueue): MiddlewareQueue
+ {
+ // Add your middlewares here
+ // remove this method hook if you don't need it
+
+ return $middlewareQueue;
+ }
+
+ /**
+ * Add commands for the plugin.
+ *
+ * @param \Cake\Console\CommandCollection $commands The command collection to update.
+ * @return \Cake\Console\CommandCollection
+ */
+ public function console(CommandCollection $commands): CommandCollection
+ {
+ // Add your commands here
+ // remove this method hook if you don't need it
+
+ $commands = parent::console($commands);
+
+ return $commands;
+ }
+
+ /**
+ * Register application container services.
+ *
+ * @param \Cake\Core\ContainerInterface $container The Container to update.
+ * @return void
+ * @link https://book.cakephp.org/5/en/development/dependency-injection.html#dependency-injection
+ */
+ public function services(ContainerInterface $container): void
+ {
+ // Add your services here
+ // remove this method hook if you don't need it
+ }
+}
diff --git a/app/plugins/TermsAgreer/src/View/Cell/AgreementCollectorsCell.php b/app/plugins/TermsAgreer/src/View/Cell/AgreementCollectorsCell.php
new file mode 100644
index 000000000..8e9d1a52e
--- /dev/null
+++ b/app/plugins/TermsAgreer/src/View/Cell/AgreementCollectorsCell.php
@@ -0,0 +1,96 @@
+
+ */
+ protected array $_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('TermsAgreer.PetitionAgreements')
+ ->find()
+ ->where([
+ 'agreement_collector_id' => $this->vv_step->agreement_collector->id,
+ 'petition_id' => $this->vv_obj->id
+ ])
+ ->contain('TermsAndConditions')
+ ->all();
+
+ $this->set('vv_pa', $vv_pa);
+ }
+}
diff --git a/app/plugins/TermsAgreer/templates/AgreementCollectors/dispatch.inc b/app/plugins/TermsAgreer/templates/AgreementCollectors/dispatch.inc
new file mode 100644
index 000000000..e5bdfeb61
--- /dev/null
+++ b/app/plugins/TermsAgreer/templates/AgreementCollectors/dispatch.inc
@@ -0,0 +1,74 @@
+element('flash', []);
+
+// Make the Form fields editable
+$this->Field->enableFormEditMode();
+?>
+
+
= __d('terms_agreer', 'information.AgreementCollectors.review'); ?>
+
+Form->create(null, [
+ 'id' => 'agreement-form',
+ 'type' => 'post'
+]);
+
+print "
+
+
+ | Terms and Conditions |
+ I Agree |
+
+";
+
+foreach($vv_tandc as $tc) {
+ print "
+
+ | " . $tc['description'] . " |
+ " . $this->Form->checkbox("tc".$tc['id']) . " |
+
+ ";
+}
+
+print "
+
+";
\ No newline at end of file
diff --git a/app/plugins/TermsAgreer/templates/AgreementCollectors/fields.inc b/app/plugins/TermsAgreer/templates/AgreementCollectors/fields.inc
new file mode 100644
index 000000000..0501b90ca
--- /dev/null
+++ b/app/plugins/TermsAgreer/templates/AgreementCollectors/fields.inc
@@ -0,0 +1,30 @@
+
+
+
+ foreach($vv_pa as $pa): ?>
+ -
+ = __d('terms_agreer', 'result.AgreementCollectors.recorded', [
+ $pa->terms_and_conditions->description,
+ $pa->terms_and_conditions->id,
+ $pa->modified
+ ]); ?>
+
+ endforeach; // $vv_pa ?>
+
+
\ No newline at end of file
diff --git a/app/plugins/TermsAgreer/webroot/.gitkeep b/app/plugins/TermsAgreer/webroot/.gitkeep
new file mode 100644
index 000000000..e69de29bb
diff --git a/app/resources/locales/en_US/controller.po b/app/resources/locales/en_US/controller.po
index 6c2736943..08621ee44 100644
--- a/app/resources/locales/en_US/controller.po
+++ b/app/resources/locales/en_US/controller.po
@@ -153,6 +153,12 @@ msgstr "{0,plural,=1{Server} other{Servers}}"
msgid "TelephoneNumbers"
msgstr "{0,plural,=1{Telephone Number} other{Telephone Numbers}}"
+msgid "TAndCAgreement"
+msgstr "{0,plural,=1{Terms and Conditions Agreement} other{Terms and Conditions Agreements}}"
+
+msgid "TermsAndConditions"
+msgstr "{0,plural,=1{Terms and Conditions} other{Terms and Conditions}}"
+
msgid "TrafficDetours"
msgstr "{0,plural,=1{Traffic Detour} other{Traffic Detours}}"
diff --git a/app/resources/locales/en_US/enumeration.po b/app/resources/locales/en_US/enumeration.po
index e3311f6df..fc17e5966 100644
--- a/app/resources/locales/en_US/enumeration.po
+++ b/app/resources/locales/en_US/enumeration.po
@@ -431,6 +431,9 @@ msgstr "Error Landing"
msgid "PageContextEnum.G"
msgstr "General"
+msgid "PageContextEnum.TC"
+msgstr "Terms and Conditions"
+
msgid "PermittedCharactersEnum.AL"
msgstr "Any"
@@ -690,6 +693,24 @@ msgstr "Suspended"
msgid "TemplateableStatusEnum.T"
msgstr "Template"
+msgid "TAndCLoginModeEnum.R"
+msgstr "Registry Login"
+
+msgid "TAndCLoginModeEnum.X"
+msgstr "Not Enforced"
+
+msgid "TAndCStatusEnum.N"
+msgstr "Not Agreed"
+
+msgid "TAndCStatusEnum.OD"
+msgstr "Outdated"
+
+msgid "TAndCStatusEnum.XP"
+msgstr "Expired"
+
+msgid "TAndCStatusEnum.Y"
+msgstr "Agreed"
+
msgid "VerificationMethodEnum.C"
msgstr "Code"
diff --git a/app/resources/locales/en_US/error.po b/app/resources/locales/en_US/error.po
index 9cd3bb702..6731c0a12 100644
--- a/app/resources/locales/en_US/error.po
+++ b/app/resources/locales/en_US/error.po
@@ -140,7 +140,7 @@ msgid "EmailAddresses.mail.verify.force.person"
msgstr "Email Addresses not associated with People cannot be force verified"
msgid "EnrollmentFlowSteps.message_template"
-msgstr "Enrollment Flow Step {0} is transitioning Actor Types but does not have a Mesasge Template configured"
+msgstr "Enrollment Flow Step {0} is transitioning Actor Types but does not have a Message Template configured"
msgid "EnrollmentFlowSteps.none"
msgstr "This Enrollment Flow has no Active steps and so cannot be run"
@@ -427,6 +427,12 @@ msgstr "Failed to setup COmanage CO"
msgid "smtp_server.none"
msgstr "No outgoing SMTP Server configuration found"
+msgid "TermsAndConditions.document.one"
+msgstr "Exactly one of Mostly Static Page or URL must be specified"
+
+msgid "TermsAndConditions.revoke.none"
+msgstr "No Agreements available to revoke"
+
msgid "tree.parent.invalid"
msgstr "The selected {0} is not a valid parent for the current record"
diff --git a/app/resources/locales/en_US/field.po b/app/resources/locales/en_US/field.po
index 64c18c174..386338c05 100644
--- a/app/resources/locales/en_US/field.po
+++ b/app/resources/locales/en_US/field.po
@@ -348,6 +348,9 @@ msgstr "Tag"
msgid "timestamp"
msgstr "Timestamp"
+msgid "timestamp.tz"
+msgstr "Timestamp ({0})"
+
msgid "title"
msgstr "Title"
@@ -984,6 +987,33 @@ msgstr "Telephone Number"
msgid "TelephoneNumbers.number.ext"
msgstr "x"
+msgid "TermsAndConditions.agree_to_updates"
+msgstr "Require Agreement on Updated T&C"
+
+msgid "TermsAndConditions.agree_to_updates.desc"
+msgstr "If set, a new Agreement will be required whenever the Terms and Conditions are updated"
+
+msgid "TermsAndConditions.agreement_duration"
+msgstr "Agreement Duration"
+
+msgid "TermsAndConditions.agreement_duration.desc"
+msgstr "If set, a new Agreement will be required after the specified duration (in days) has been reached"
+
+msgid "TermsAndConditions.mostly_static_page_id"
+msgstr "T&C via Mostly Static Page"
+
+msgid "TermsAndConditions.mostly_static_page_id.desc"
+msgstr "Content of Terms and Conditions, as defined in a Mostly Static Page (this or URL must be specified)"
+
+msgid "TermsAndConditions.cou_id.desc"
+msgstr "If set, these Terms and Conditions only apply to People with a Person Role in the specified COU"
+
+msgid "TermsAndConditions.url"
+msgstr "T&C via URL"
+
+msgid "TermsAndConditions.url.desc"
+msgstr "URL to web page holding Terms and Conditions (this or Mostly Static Page must be specified)"
+
msgid "Types.case_insensitive"
msgstr "Case Insensitive"
diff --git a/app/resources/locales/en_US/operation.po b/app/resources/locales/en_US/operation.po
index cbcf54286..810994840 100644
--- a/app/resources/locales/en_US/operation.po
+++ b/app/resources/locales/en_US/operation.po
@@ -354,6 +354,18 @@ msgstr "Switch To This CO"
msgid "terminate"
msgstr "Terminate"
+msgid "TermsAndConditions.proxy"
+msgstr "Agree on Behalf"
+
+msgid "TermsAndConditions.proxy.confirm"
+msgstr "Are you sure you wish to accept these Terms and Conditions on behalf of this Person?"
+
+msgid "TermsAndConditions.revoke"
+msgstr "Revoke"
+
+msgid "TermsAndConditions.revoke.confirm"
+msgstr "Are you sure you wish to revoke this Acceptance?"
+
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 e53795f90..26a59a709 100644
--- a/app/resources/locales/en_US/result.po
+++ b/app/resources/locales/en_US/result.po
@@ -298,6 +298,24 @@ msgstr "Not Set"
msgid "TelephoneNumber.deleted"
msgstr "Telephone number deleted"
+msgid "TermsAndConditions.agreed"
+msgstr "Terms and Conditions \"{0}\" agreed to"
+
+msgid "TermsAndConditions.agreed.behalf"
+msgstr "Terms and Conditions \"{0}\" agreed to on behalf of"
+
+msgid "TermsAndConditions.agreed.petition"
+msgstr "Terms and Conditions \"{0}\" agreed to via Petition {1}"
+
+msgid "TermsAndConditions.recorded"
+msgstr "Agreement to Terms and Conditions recorded"
+
+msgid "TermsAndConditions.revoked"
+msgstr "Agreement to Terms and Conditions revoked"
+
+msgid "TermsAndConditions.revoked.admin"
+msgstr "Agreement \"{0}\" to Terms and Conditions \"{1}\" revoked"
+
# These are specifically for test command
msgid "test.database.ok"
msgstr "Database connection established"
diff --git a/app/src/Controller/AppController.php b/app/src/Controller/AppController.php
index a8f59a5e5..a02392f8a 100644
--- a/app/src/Controller/AppController.php
+++ b/app/src/Controller/AppController.php
@@ -308,6 +308,52 @@ protected function primaryLinkOnGet(string $potentialPrimaryLink): Object|bool
);
}
+ // allowLookupRelatedLink() is intended to allow queries like
+ // /provisioning-targets/status?person_id=123
+ // in particular to allow filtering.
+ //
+ // In this initial implementation, we only support keys that can be inflected
+ // directly to a table, eg person_id -> People, not enrollee_person_id. The related
+ // model needs to have a primary link in common (eg: co_id) with the current model.
+ $allowsRelatedLookup = $this->getCurrentTable()->allowLookupRelatedLink($actionParam);
+
+ if(!empty($allowsRelatedLookup)) {
+ // We have a set of related foreign keys (person_id, group_id, etc) that can be used
+ // to lookup a primary link for the current table. If one is populated, see if it
+ // has a value for $potentialPrimaryLink.
+
+ foreach($allowsRelatedLookup as $potentialRelatedKey) {
+ // The value for $potentialRelatedKey, eg person_id = 2298, as passed in the query
+ $potentialRelatedId = $this->request->getQuery($potentialRelatedKey);
+
+ if(is_numeric($potentialRelatedId)) {
+ // We need to find the table for $potentialRelatedKey in order to find
+ // the potential common link.
+
+ // $RelatedTable = (eg) People
+ $RelatedTable = TableRegistry::getTableLocator()->get(
+ StringUtilities::foreignKeyToClassName($potentialRelatedKey)
+ );
+
+ // $relatedEntity = (eg) person
+ $relatedEntity = $RelatedTable->get($potentialRelatedId);
+
+ // Check to see if the current table (eg: ProvisioningTargets) shares
+ // $potentialPrimaryLink (eg: co_id) with the related table (eg: People).
+ // If it does, we can return this primary link.
+ if(!empty($relatedEntity->$potentialPrimaryLink)) {
+ // XXX This is the same structure returned by findPrimaryLink()
+ return (object)[
+ 'plugin' => null, // We don't currently support plugins
+ 'attr' => $potentialPrimaryLink,
+ 'value' => $relatedEntity->$potentialPrimaryLink,
+ 'co_id' => $RelatedTable->calculateCoForRecord($relatedEntity)
+ ];
+ }
+ }
+ }
+ }
+
if(!$allowsLookup || empty($param)) {
return false;
}
diff --git a/app/src/Controller/DashboardsController.php b/app/src/Controller/DashboardsController.php
index 506444343..37b713d63 100644
--- a/app/src/Controller/DashboardsController.php
+++ b/app/src/Controller/DashboardsController.php
@@ -149,6 +149,11 @@ public function configuration() {
// 'controller' => 'reports',
// 'action' => 'index'
// ],
+ __d('controller', 'TermsAndConditions', [99]) => [
+ 'icon' => 'policy',
+ 'controller' => 'terms_and_conditions',
+ 'action' => 'index'
+ ],
__d('controller', 'Types', [99]) => [
'icon' => 'widgets',
'controller' => 'types',
diff --git a/app/src/Controller/PetitionsController.php b/app/src/Controller/PetitionsController.php
index 1f9f94760..8e558e3d7 100644
--- a/app/src/Controller/PetitionsController.php
+++ b/app/src/Controller/PetitionsController.php
@@ -83,7 +83,8 @@ public function beforeRender(EventInterface $event) {
}
/**
- * Calculate authorization for the current request.
+ * Calculate authorization for the current request. Note this only applies to
+ * Petition runtime actions, and not the standard CRUD actions (index, etc).
*
* @since COmanage Registry v5.1.0
* @return bool True if the current request is permitted, false otherwise
diff --git a/app/src/Controller/StandardController.php b/app/src/Controller/StandardController.php
index d0a13334c..a6d8b5ab8 100644
--- a/app/src/Controller/StandardController.php
+++ b/app/src/Controller/StandardController.php
@@ -675,6 +675,23 @@ public function index() {
$query = $table->filterIndexByCO($query, $this->getCOID());
}
+ // Filter on requested filter, if requested
+ // QueryModificationTrait
+ if(method_exists($table, "getIndexFilter")) {
+ $filter = $table->getIndexFilter();
+
+ if(is_callable($filter)) {
+ $query = $query->where($filter($this->request));
+ } else {
+ $query = $query->where($filter);
+ }
+ }
+
+ if(!empty($this->request->getQuery('enrollee_person_id'))) {
+ // Authorization in PetitionsTable will ensure this is only used when authorized
+ $query = $query->where(['enrollee_person_id' => $this->request->getQuery('enrollee_person_id')]);
+ }
+
// Fetch the data and paginate
$paginationLimit = $this->getValue(ApplicationStateEnum::PaginationLimit, DEF_SEARCH_LIMIT);
$resultSet = $this->paginate($query, [
diff --git a/app/src/Controller/StandardDetourController.php b/app/src/Controller/StandardDetourController.php
index b822a911b..dcf405264 100644
--- a/app/src/Controller/StandardDetourController.php
+++ b/app/src/Controller/StandardDetourController.php
@@ -61,6 +61,10 @@ protected function finishDetour(): \Cake\Http\Response {
*/
public function willHandleAuth(\Cake\Event\EventInterface $event): string {
- return 'yes';
+ if($this->request->getParam('action') == 'postlogin') {
+ return 'yes';
+ }
+
+ return 'no';
}
}
\ No newline at end of file
diff --git a/app/src/Controller/TermsAndConditionsController.php b/app/src/Controller/TermsAndConditionsController.php
new file mode 100644
index 000000000..de1fe5ac0
--- /dev/null
+++ b/app/src/Controller/TermsAndConditionsController.php
@@ -0,0 +1,153 @@
+ [
+ 'TermsAndConditions.ordr' => 'asc'
+ ]
+ ];
+
+ /**
+ * Proxy an Agreement on behalf of a Person.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param string $id Terms and Conditions ID
+ */
+
+ public function proxy(string $id) {
+ $personId = $this->request->getQuery('person_id');
+
+ try {
+ $this->TermsAndConditions->TAndCAgreements->record(
+ termsAndConditionsId: (int)$id,
+ personId: (int)$personId,
+ actorPersonId: $this->RegistryAuth->getPersonID($this->getCOID()),
+ identifier: $this->getRequest()->getSession()->read('Auth.external.user')
+ );
+
+ // Request provisioning on success
+
+ $this->llog('trace', "Requesting provisioning after T&C Agreement for Person " . $personId);
+
+ $People = TableRegistry::getTableLocator()->get('People');
+
+ $People->requestProvisioning(
+ id: (int)$personId,
+ context: ProvisioningContextEnum::Automatic
+ );
+ }
+ catch(\Exception $e) {
+ $this->Flash->error($e->getMessage());
+ return $this->generateRedirect(null);
+ }
+
+ $this->Flash->success(__d('result', 'TermsAndConditions.recorded'));
+ return $this->redirect(['action' => 'status', '?' => ['person_id' => $personId]]);
+ }
+
+ /**
+ * Revoke a T&C Agreement.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param string $id Terms and Conditions ID
+ */
+
+ public function revoke(string $id) {
+ // While this could be implemented in TAndCAgreement controller, we'll
+ // keep it here for consistency with other functions. Note that ID is
+ // the T&C ID, not the Agreement ID. Because there could be multiple
+ // Agreements (eg: expired) we'll revoke (delete) ALL TAndCAgreements
+ // for $id for the specified Person.
+
+ $personId = $this->request->getQuery('person_id');
+
+ try {
+ $count = $this->TermsAndConditions->TAndCAgreements->revoke(
+ termsAndConditionsId: (int)$id,
+ personId: (int)$personId,
+ actorPersonId: $this->RegistryAuth->getPersonID($this->getCOID())
+ );
+
+ if($count > 0) {
+ // Request provisioning on success
+
+ $this->llog('trace', "Requesting provisioning after T&C Agreement revoked for Person " . $personId);
+
+ $People = TableRegistry::getTableLocator()->get('People');
+
+ $People->requestProvisioning(
+ id: (int)$personId,
+ context: ProvisioningContextEnum::Automatic
+ );
+
+ $this->Flash->success(__d('result', 'TermsAndConditions.revoked'));
+ } else {
+ // This is effectively an error since the revoke action shouldn't have
+ // been available if there are no T&C Agreements to revoke
+
+ $this->Flash->error(__d('error', 'TermsAndConditions.revoke.none'));
+ }
+
+ return $this->redirect(['action' => 'status', '?' => ['person_id' => $personId]]);
+ }
+ catch(\Exception $e) {
+ $this->Flash->error($e->getMessage());
+ return $this->generateRedirect(null);
+ }
+ }
+
+ /**
+ * Generate a status index.
+ *
+ * @since COmanage Registry v5.2.0
+ */
+
+ public function status() {
+ $personId = $this->request->getQuery('person_id');
+
+ if(empty($personId)) {
+ // We shouldn't actually get here without person_id set since getPrimaryLink()
+ // will (eventually) require it.
+ throw new \InvalidArgumentException(__d('error', 'notprov', 'person_id'));
+ }
+
+ $this->set('vv_tandc_statuses', $this->TermsAndConditions->status((int)$personId));
+ $this->set('vv_person_id', (int)$personId);
+
+ $this->set('vv_title', __d('controller', 'TermsAndConditions'));
+ }
+}
\ No newline at end of file
diff --git a/app/src/Lib/Enum/ActionEnum.php b/app/src/Lib/Enum/ActionEnum.php
index 180932b4b..bdd32c1ec 100644
--- a/app/src/Lib/Enum/ActionEnum.php
+++ b/app/src/Lib/Enum/ActionEnum.php
@@ -72,4 +72,8 @@ class ActionEnum extends StandardEnum {
const PetitionFinalized = 'CPPF';
const PetitionUpdated = 'CPUP';
const ReferenceIdentifierObtained = 'OIDR';
+ const TAndCAgreement = 'TCAG';
+ const TAndCAgreementBehalf = 'TCAB';
+ const TAndCAgreementPetition = 'TCAP';
+ const TAndCAgreementRevoked = 'TCAX';
}
\ No newline at end of file
diff --git a/app/src/Lib/Enum/PageContextEnum.php b/app/src/Lib/Enum/PageContextEnum.php
index a7e5b5587..aa301223d 100644
--- a/app/src/Lib/Enum/PageContextEnum.php
+++ b/app/src/Lib/Enum/PageContextEnum.php
@@ -33,4 +33,5 @@ class PageContextEnum extends StandardEnum {
const EnrollmentHandoff = 'EH';
const ErrorLanding = 'ER';
const General = 'G';
+ const TermsAndConditions = 'TC';
}
\ No newline at end of file
diff --git a/app/src/Lib/Enum/PetitionActionEnum.php b/app/src/Lib/Enum/PetitionActionEnum.php
index f2040873a..9dc089dc8 100644
--- a/app/src/Lib/Enum/PetitionActionEnum.php
+++ b/app/src/Lib/Enum/PetitionActionEnum.php
@@ -40,4 +40,6 @@ class PetitionActionEnum extends StandardEnum {
const FlaggedDuplicate = 'FD';
const InvitationViewed = 'IV';
const StatusUpdated = 'SU';
+ const TCExplicitAgreement = 'TE';
+ const TCImpliedAgreement = 'TI';
}
\ No newline at end of file
diff --git a/app/src/Lib/Enum/TAndCLoginModeEnum.php b/app/src/Lib/Enum/TAndCLoginModeEnum.php
new file mode 100644
index 000000000..646d779eb
--- /dev/null
+++ b/app/src/Lib/Enum/TAndCLoginModeEnum.php
@@ -0,0 +1,35 @@
+getRequest()->getQuery('authenticated_identifier')) {
- $query = $query->where(['authenticated_identifier' => StringUtilities::urlbase64decode($this->getRequest()->getQuery('authenticated_identifier'))]);
- } else {
- // We only allow unfiltered queries for platform users
-
- if(!$this->RegistryAuth->isPlatformAdmin()) {
- throw new \InvalidArgumentException(__d('error', 'input.notprov', 'authenticated_identifier'));
- }
- }
- }
-
return $query;
}
}
\ No newline at end of file
diff --git a/app/src/Lib/Traits/PrimaryLinkTrait.php b/app/src/Lib/Traits/PrimaryLinkTrait.php
index 47dd551d3..a5b3816ac 100644
--- a/app/src/Lib/Traits/PrimaryLinkTrait.php
+++ b/app/src/Lib/Traits/PrimaryLinkTrait.php
@@ -47,6 +47,9 @@ trait PrimaryLinkTrait {
// Actions where the primary link can be obtained by looking up the record ID
private $lookupActions = ['delete', 'edit', 'canvas', 'view'];
+
+ // Actions where the primary link can be calculated by lookup up an associated value
+ private $lookupRelatedActions = [];
// Where to redirect on add or edit, can be 'self', 'index', 'pluggableLink', or 'primaryLink'.
// We use null to mean "index unless we're in a plugin context, in which case pluggableLink".
@@ -76,7 +79,7 @@ public function acceptsCoId(): bool {
* @return boolean true if permitted, false otherwise
*/
- public function allowEmptyPrimaryLink(string $action) {
+ public function allowEmptyPrimaryLink(string $action): bool {
return in_array($action , $this->allowEmptyActions);
}
@@ -89,9 +92,26 @@ public function allowEmptyPrimaryLink(string $action) {
* @return boolean true if permitted, false otherwise
*/
- public function allowLookupPrimaryLink(string $action) {
+ public function allowLookupPrimaryLink(string $action): bool {
return in_array($action, $this->lookupActions, true);
}
+
+ /**
+ * Check to see whether the specific action allows a related foreign key to be passed
+ * in the URL, which can be used to lookup a primary link.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param string $action Action
+ * @return array Array of foreign keys that may be used to lookup a primary link
+ */
+
+ public function allowLookupRelatedLink(string $action): array {
+ if(isset($this->lookupRelatedActions[$action])) {
+ return $this->lookupRelatedActions[$action];
+ }
+
+ return [];
+ }
/**
* Check to see whether the specified action is allowed to assert a primary link ID
@@ -102,7 +122,7 @@ public function allowLookupPrimaryLink(string $action) {
* @return boolean true if permitted, false otherwise
*/
- public function allowUnkeyedPrimaryLink(string $action) {
+ public function allowUnkeyedPrimaryLink(string $action): bool {
return in_array($action, $this->unkeyedActions, true);
}
@@ -443,6 +463,25 @@ public function setAllowLookupPrimaryLink(array $actions) {
$this->lookupActions = array_merge($this->lookupActions, $actions);
}
+ /**
+ * Set whether the primary link can be resolved via a related foreign key.
+ *
+ * The $actions parameter is an array of actions and their permitted primary links,
+ * for example
+ *
+ * ['status' => ['person_id', 'group_id']]
+ *
+ * Only unaliased primary links are currently supported, eg "person_id" but not
+ * "enrollee_person_id".
+ *
+ * @since COmanage Rgistry v5.2.0
+ * @param array $actions Actions where the primary link can be obtained by lookup of a related foreign key, and the foreign keys
+ */
+
+ public function setAllowLookupRelatedPrimaryLink(array $actions) {
+ $this->lookupRelatedActions = array_merge($this->lookupRelatedActions, $actions);
+ }
+
/**
* Set which actions permit a primary link to be passed as a request parameter.
* Defaults to [add, index].
diff --git a/app/src/Lib/Traits/QueryModificationTrait.php b/app/src/Lib/Traits/QueryModificationTrait.php
index b3aa01cb7..b78738059 100644
--- a/app/src/Lib/Traits/QueryModificationTrait.php
+++ b/app/src/Lib/Traits/QueryModificationTrait.php
@@ -44,7 +44,7 @@ trait QueryModificationTrait {
private array $indexContains = [];
// Filter (where clause) for index actions
- private array $indexFilter = [];
+ private array|\Closure $indexFilter = [];
// Array of associated models to save during a patch
private array $patchAssociated = [];
@@ -107,13 +107,24 @@ public function getEditContains(): array {
* Containable models for index actions.
*
* @since COmanage Registry v5.0.0
- * @param boolean $allowEmpty true if the primary link is permitted to be empty
+ * @return array Array of associated models
*/
public function getIndexContains(): array {
return $this->indexContains;
}
+ /**
+ * Obtain the index filter for this model.
+ *
+ * @since COmanage Registry v5.2.0
+ * @return array|Closure Array of index filters or closure that generates an array
+ */
+
+ public function getIndexFilter(): array|\Closure|null {
+ return $this->indexFilter;
+ }
+
/**
* Obtain the set of associated models to save during a patch.
*
diff --git a/app/src/Lib/Util/PaginatedSqlIterator.php b/app/src/Lib/Util/PaginatedSqlIterator.php
index 3dd83a9f7..82fe2c92b 100644
--- a/app/src/Lib/Util/PaginatedSqlIterator.php
+++ b/app/src/Lib/Util/PaginatedSqlIterator.php
@@ -80,7 +80,7 @@ class PaginatedSqlIterator implements \Iterator {
*
* @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 $condititions Query conditions (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
diff --git a/app/src/Model/Entity/TAndCAgreement.php b/app/src/Model/Entity/TAndCAgreement.php
new file mode 100644
index 000000000..bd2044691
--- /dev/null
+++ b/app/src/Model/Entity/TAndCAgreement.php
@@ -0,0 +1,42 @@
+ true,
+ 'id' => false,
+ 'slug' => false,
+ ];
+}
\ No newline at end of file
diff --git a/app/src/Model/Entity/TermsAndConditions.php b/app/src/Model/Entity/TermsAndConditions.php
new file mode 100644
index 000000000..0cd04e432
--- /dev/null
+++ b/app/src/Model/Entity/TermsAndConditions.php
@@ -0,0 +1,42 @@
+ true,
+ 'id' => false,
+ 'slug' => false,
+ ];
+}
\ No newline at end of file
diff --git a/app/src/Model/Table/AuthenticationEventsTable.php b/app/src/Model/Table/AuthenticationEventsTable.php
index 8b1e945fc..942af130e 100644
--- a/app/src/Model/Table/AuthenticationEventsTable.php
+++ b/app/src/Model/Table/AuthenticationEventsTable.php
@@ -113,6 +113,20 @@ public function initialize(array $config): void {
]
];
});
+
+ $this->setIndexFilter(function (\Cake\Http\ServerRequest $r): array {
+ // This will be checked for authz in RegistryAuthComponent
+ $targetIdentifier = $r->getQuery('authenticated_identifier');
+
+ // Note that in setPermissions above we permit index operations when no
+ // targetIdentifier is specified. We reject that here though since index
+ // views require a targetIdentifier.
+ if(!$targetIdentifier) {
+ throw new \InvalidArgumentException(__d('error', 'input.notprov', 'authenticated_identifier'));
+ }
+
+ return ['authenticated_identifier' => StringUtilities::urlbase64decode($targetIdentifier)];
+ });
}
/**
diff --git a/app/src/Model/Table/MostlyStaticPagesTable.php b/app/src/Model/Table/MostlyStaticPagesTable.php
index 16617ec9e..f6d86703b 100644
--- a/app/src/Model/Table/MostlyStaticPagesTable.php
+++ b/app/src/Model/Table/MostlyStaticPagesTable.php
@@ -65,7 +65,9 @@ public function initialize(array $config): void {
// Define associations
$this->belongsTo('Cos');
- $this->setDisplayField('name');
+ $this->hasMany('TermsAndConditions');
+
+ $this->setDisplayField('title');
$this->setPrimaryLink('co_id');
$this->setRequiresCO(true);
diff --git a/app/src/Model/Table/PeopleTable.php b/app/src/Model/Table/PeopleTable.php
index ca744c98e..c4d76b1c4 100644
--- a/app/src/Model/Table/PeopleTable.php
+++ b/app/src/Model/Table/PeopleTable.php
@@ -310,8 +310,10 @@ public function initialize(array $config): void {
'Identifiers',
'Notifications',
'PersonRoles',
+ 'Petitions',
'ProvisioningTargets',
'TelephoneNumbers',
+ 'TermsAndConditions',
'Urls'
],
]
diff --git a/app/src/Model/Table/PetitionsTable.php b/app/src/Model/Table/PetitionsTable.php
index 1432d8fcb..520b9f73a 100644
--- a/app/src/Model/Table/PetitionsTable.php
+++ b/app/src/Model/Table/PetitionsTable.php
@@ -53,6 +53,7 @@ class PetitionsTable extends Table {
use \App\Lib\Traits\PermissionsTrait;
use \App\Lib\Traits\PrimaryLinkTrait;
use \App\Lib\Traits\QueryModificationTrait;
+ use \App\Lib\Traits\SearchFilterTrait;
use \App\Lib\Traits\TabTrait;
use \App\Lib\Traits\TableMetaTrait;
use \App\Lib\Traits\ValidationTrait;
@@ -176,37 +177,69 @@ public function initialize(array $config): void {
]
);
- $this->setPermissions([
- // Actions that operate over an entity (ie: require an $id)
- 'entity' => [
- // We handle assign authorization in the Controller
- // 'assign' => true,
- // We handle continue authorization in the Controller
- 'continue' => true,
- 'delete' => false,
- 'edit' => false,
- // We handle finalize authorization in the Controller
- 'finalize' => true,
- // We handle provision authorization in the Controller
- // 'provision' => true,
- // result just issues a redirect, so we're generous with permissions
- 'result' => ['platformAdmin', 'coAdmin'],
- // resume renders a landing page, the admin can copy a URL and resend it
- // to the appropriate actor if the actor is not also an admin
- 'resume' => ['platformAdmin', 'coAdmin'],
- // 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'],
- // Actions that operate over a table (ie: do not require an $id)
- 'table' => [
- 'add' => false,
- 'index' => ['platformAdmin', 'coAdmin']
- ]
- ]);
+ $this->setPermissions(function (\Cake\Http\ServerRequest $r, \App\Controller\Component\RegistryAuthComponent $auth, ?int $id): array {
+ // We need to dynamically update index permissions if enrollee_person_id was provided.
+ // We're going to be called a bunch of times (once per row on the index view).
+
+ $enrolleePersonId = $r->getQuery('enrollee_person_id');
+ $indexperm = ['platformAdmin'];
+
+ if($enrolleePersonId) {
+ $coId = $auth->getController()->getCOID();
+
+ // Make sure the requested person is in the current CO
+ if($this->EnrolleePeople->findCoForRecord((int)$enrolleePersonId) == $coId
+ // And finally that the current user is a CO Admin
+ && $auth->isCoAdmin($coId)) {
+ $indexperm[] = 'coAdmin';
+ }
+ } else {
+ $indexperm[] = 'coAdmin';
+ }
+
+ return [
+ // Actions that operate over an entity (ie: require an $id)
+ 'entity' => [
+ // We handle assign authorization in the Controller
+ // 'assign' => true,
+ // We handle continue authorization in the Controller
+ 'continue' => true,
+ 'delete' => false,
+ 'edit' => false,
+ // We handle finalize authorization in the Controller
+ 'finalize' => true,
+ // We handle provision authorization in the Controller
+ // 'provision' => true,
+ // result just issues a redirect, so we're generous with permissions
+ 'result' => ['platformAdmin', 'coAdmin'],
+ // resume renders a landing page, the admin can copy a URL and resend it
+ // to the appropriate actor if the actor is not also an admin
+ 'resume' => ['platformAdmin', 'coAdmin'],
+ // 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'],
+ // Actions that operate over a table (ie: do not require an $id)
+ 'table' => [
+ 'add' => false,
+ 'index' => $indexperm
+ ]
+ ];
+ });
+
+ $this->setIndexFilter(function (\Cake\Http\ServerRequest $r): array|null {
+ // We checked authz in setPermissions()
+ $enrolleePersonId = $r->getQuery('enrollee_person_id');
+
+ if($enrolleePersonId) {
+ return ['enrollee_person_id' => $enrolleePersonId];
+ }
+
+ return null;
+ });
$this->setTabsConfig(
[
@@ -857,10 +890,10 @@ public function ruleEnrolleeEmail($entity, $options) {
public function start(
int $enrollmentFlowId,
- string $petitionerIdentifier=null,
- int $petitionerPersonId=null,
+ ?string $petitionerIdentifier=null,
+ ?int $petitionerPersonId=null,
bool $isEnrollee=false,
- string $enrolleeEmail=null
+ ?string $enrolleeEmail=null
): \App\Model\Entity\Petition {
$petition = $this->newEntity([
'enrollment_flow_id' => $enrollmentFlowId,
diff --git a/app/src/Model/Table/ProvisioningTargetsTable.php b/app/src/Model/Table/ProvisioningTargetsTable.php
index d44955927..58d80b40d 100644
--- a/app/src/Model/Table/ProvisioningTargetsTable.php
+++ b/app/src/Model/Table/ProvisioningTargetsTable.php
@@ -88,10 +88,10 @@ public function initialize(array $config): void {
$this->setDisplayField('description');
- $this->setPrimaryLink(['co_id', 'group_id', 'person_id']);
+ $this->setPrimaryLink('co_id');
$this->setRequiresCO(true);
$this->setAllowLookupPrimaryLink(['provision', 'reprovision']);
- $this->setAllowUnkeyedPrimaryLink(['status']);
+ $this->setAllowLookupRelatedPrimaryLink(['status' => ['person_id', 'group_id']]);
$this->setAutoViewVars([
'plugins' => [
diff --git a/app/src/Model/Table/TAndCAgreementsTable.php b/app/src/Model/Table/TAndCAgreementsTable.php
new file mode 100644
index 000000000..d07e47eda
--- /dev/null
+++ b/app/src/Model/Table/TAndCAgreementsTable.php
@@ -0,0 +1,217 @@
+addBehavior('Changelog');
+ $this->addBehavior('Log');
+ $this->addBehavior('Timestamp');
+
+ $this->setTableType(\App\Lib\Enum\TableTypeEnum::Artifact);
+
+ // Define associations
+ $this->belongsTo('TermsAndConditions');
+ $this->belongsTo('People');
+
+ $this->setDisplayField('agreement_time');
+
+ $this->setPrimaryLink('terms_and_conditions_id');
+ $this->setRequiresCO(true);
+ }
+
+ /**
+ * Record a T&C Agreement.
+ *
+ * As of v5 this function no longer triggers provisioning. If provisioning
+ * should happen following T&C Agreement the calling context should request it.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param int $termsAndConditionsId Terms And Conditions ID
+ * @param int $personId Person ID to record Agreement for
+ * @param int $actorPersonId Person ID completing Agreement
+ * @param string $identifier Authenticated Identifier of $actorPersonId
+ * @param int $agreementTime Time of Agreement (if not now)
+ * @param int $petitionId If Agreement was collected via a Petition, the Petition ID
+ * @return TAndCAgreement Newly created TAndCAgreement
+ */
+
+ public function record(
+ int $termsAndConditionsId,
+ int $personId,
+ int $actorPersonId,
+ string $identifier=null,
+ ?int $agreementTime=null,
+ ?int $petitionId=null
+ ): \App\Model\Entity\TAndCAgreement {
+ // GMR-2 will enforce that the various foreign keys all point to entities
+ // in the same CO when we try to save. This also means we don't need to
+ // get($personId) just to verify it exists.
+
+ // Pull the T&C
+ $tandc = $this->TermsAndConditions->get($termsAndConditionsId);
+
+ // Record the T&C Agreement
+
+ $agreement = $this->newEntity([
+ 'terms_and_conditions_id' => $termsAndConditionsId,
+ 'person_id' => $personId,
+ 'agreement_time' => date('Y-m-d H:i:s', $agreementTime ?? time()),
+ // We require $identifier to be passed because it won't always be the same
+ // as Changelog's actor_identifier (eg: Petitions)
+ 'identifier' => $identifier
+ ]);
+
+ $this->saveOrFail($agreement);
+
+ // Create a History Record
+ $action = ActionEnum::TAndCAgreement;
+ $comment = __d('result', 'TermsAndConditions.agreed', [$tandc->description]);
+
+ if($petitionId) {
+ $action = ActionEnum::TAndCAgreementPetition;
+ $comment = __d('result', 'TermsAndConditions.agreed.petition', [$tandc->description, $petitionId]);
+ } elseif($personId != $actorPersonId) {
+ $action = ActionEnum::TAndCAgreementBehalf;
+ $comment = __d('result', 'TermsAndConditions.agreed.behalf', [$tandc->description]);
+ }
+
+ $this->People->HistoryRecords->recordForPerson(
+ personId: $personId,
+ action: $action,
+ comment: $comment,
+ actorPersonId: $actorPersonId
+ );
+
+ return $agreement;
+ }
+
+ /**
+ * Revoke a T&C Agreement.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param int $termsAndConditionsId Terms And Conditions ID
+ * @param int $personId Person ID to record Agreement for
+ * @param int $actorPersonId Person ID completing Agreement
+ * @return int Count of TAndCAgreements revoked, including 0 if no Agreements are found to revoke
+ */
+
+ public function revoke(
+ int $termsAndConditionsId,
+ int $personId,
+ int $actorPersonId
+ ): int {
+ // Pull the T&C
+ $tandc = $this->TermsAndConditions->get($termsAndConditionsId);
+
+ // There can be more than one TAndCAgreement to revoke, eg if
+ // expired Agreements also exists we'll revoke those too.
+ // We don't currently handle outdated Agreements (since that requires
+ // walking the changelog data), but conceptually those should probably
+ // also be revoked.
+
+ // We don't need to validate that $personId and $termsAndConditionsId
+ // are in the same CO since this shouldn't return any results if they aren't.
+ $agreements = $this->find()
+ ->where([
+ 'terms_and_conditions_id' => $termsAndConditionsId,
+ 'person_id' => $personId
+ ])
+ ->all();
+
+ foreach($agreements as $a) {
+ // We could delete the set using deleteMany but we want to record history as we go
+
+ $this->deleteOrFail($a);
+
+ $this->People->HistoryRecords->recordForPerson(
+ personId: $personId,
+ action: ActionEnum::TAndCAgreementRevoked,
+ comment: __d('result', 'TermsAndConditions.revoked.admin', [$a->id, $tandc->description]),
+ actorPersonId: $actorPersonId
+ );
+ }
+
+ return $agreements->count();
+ }
+
+ /**
+ * 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('terms_and_conditions_id', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->notEmptyString('terms_and_conditions_id');
+
+ $validator->add('person_id', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->notEmptyString('person_id');
+
+ $validator->add('agreement_time', [
+ 'content' => ['rule' => 'dateTime']
+ ]);
+ $validator->notEmptyString('agreement_time');
+
+ $this->registerStringValidation($validator, $schema, 'identifier', true);
+
+ return $validator;
+ }
+}
\ No newline at end of file
diff --git a/app/src/Model/Table/TermsAndConditionsTable.php b/app/src/Model/Table/TermsAndConditionsTable.php
new file mode 100644
index 000000000..a9627ec99
--- /dev/null
+++ b/app/src/Model/Table/TermsAndConditionsTable.php
@@ -0,0 +1,332 @@
+addBehavior('Changelog');
+ $this->addBehavior('Log');
+ $this->addBehavior('Orderable');
+ $this->addBehavior('Timestamp');
+
+ $this->setTableType(\App\Lib\Enum\TableTypeEnum::Configuration);
+
+ // Define associations
+ $this->belongsTo('Cos');
+ $this->belongsTo('Cous');
+ $this->belongsTo('MostlyStaticPages');
+ $this->hasMany('TAndCAgreements');
+
+ $this->setDisplayField('description');
+
+ $this->setPrimaryLink('co_id');
+ $this->setRequiresCO(true);
+ $this->setAllowLookupPrimaryLink(['proxy', 'revoke']);
+ $this->setAllowLookupRelatedPrimaryLink(['status' => ['person_id']]);
+
+ $this->setAutoViewVars([
+ 'cous' => [
+ 'type' => 'select',
+ 'model' => 'Cous'
+ ],
+ 'mostlyStaticPages' => [
+ 'type' => 'select',
+ 'model' => 'MostlyStaticPages',
+ 'where' => ['context' => \App\Lib\Enum\PageContextEnum::TermsAndConditions]
+ ],
+ 'statuses' => [
+ 'type' => 'enum',
+ 'class' => 'SuspendableStatusEnum'
+ ]
+ ]);
+
+ $this->setPermissions([
+ // Actions that operate over an entity (ie: require an $id)
+ 'entity' => [
+ 'delete' => ['platformAdmin', 'coAdmin'],
+ 'edit' => ['platformAdmin', 'coAdmin'],
+ // We specifically exclude platform admins from proxying because
+ // they may not be registered as People in the CO, and we won't be
+ // able to record the actor foreign key for audit purposes.
+ 'proxy' => ['coAdmin'],
+ 'revoke' => ['platformAdmin', 'coAdmin'],
+ 'view' => ['platformAdmin', 'coAdmin']
+ ],
+ // Actions that operate over a table (ie: do not require an $id)
+ 'table' => [
+ 'add' => ['platformAdmin', 'coAdmin'],
+ 'index' => ['platformAdmin', 'coAdmin'],
+ 'status' => ['platformAdmin', 'coAdmin']
+ ]
+ ]);
+ }
+
+ /**
+ * Define business rules.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param RulesChecker $rules RulesChecker object
+ * @return RulesChecker
+ */
+
+ public function buildRules(RulesChecker $rules): RulesChecker {
+ // This isn't strictly an Application Rule, but either a URL or a Mostly Static Page
+ // must be specified, and it's easier to enforce that here than in validation.
+ $rules->add([$this, 'ruleSpecifyDocument'],
+ 'specifyDocument',
+ ['errorField' => 'url']);
+
+ return $rules;
+ }
+
+ /**
+ * Application Rule to determine if a T&C document was specified.
+ *
+ * @since COmanage Registyr v5.2.0
+ * @param Entity $entity Entity to be validated
+ * @param array $options Application rule options
+ * @return boolean true if the Rule check passes, false otherwise
+ */
+
+ public function ruleSpecifyDocument($entity, $options) {
+ if((empty($entity->url) && empty($entity->mostly_static_page_id))
+ || (!empty($entity->url) && !empty($entity->mostly_static_page_id))) {
+ return __d('error' , 'TermsAndConditions.document.one');
+ }
+
+ return true;
+ }
+
+ /**
+ * Obtain T&C Agreement status for the specified Person.
+ *
+ * @since COmanage Registry v5.2.0
+ * @param int $personId Person ID
+ * @return array
+ */
+
+ public function status(int $personId): array {
+ $ret = [];
+
+ // Pull the Person and their roles
+ $People = TableRegistry::getTableLocator()->get('People');
+
+ $person = $People->get($personId, contain: ['PersonRoles']);
+
+ // Extract the COUs for use below
+ $couIds = Hash::extract($person->person_roles, "{n}.cou_id");
+
+ // Pull active T&C for the CO/COU $copersonid is a member of. This will NOT include
+ // any outdated T&C, we'll pull those separately, below.
+
+ $tandc = $this->find()
+ ->where([
+ 'co_id' => $person->co_id,
+ 'status' => SuspendableStatusEnum::Active,
+ 'OR' => [
+ 'cou_id IS NULL',
+ 'cou_id IN' => array_values($couIds)
+ ]
+ ])
+ ->order('ordr ASC')
+ ->all();
+
+ if(!empty($tandc)) {
+ // Pull all the Agreements for $personId at once, we'll walk through them as needed.
+ // Agreements always track the version of the T&C in effect when the Agreement was
+ // recorded, regardless of the agree_to_updates setting. We'll walk the Agreements
+ // below in accordance with the configuration.
+
+ $agreements = $this->TAndCAgreements->find()
+ ->where(['person_id' => $personId])
+ ->order('agreement_time DESC')
+ // This contain will pull the version of the T&C
+ // that were in effect when the Agreement was made,
+ // even if it is now outdated
+ ->contain([
+ 'TermsAndConditions' => [
+ // Cake appears to incorrectly inflects the
+ // foreign key for the contain
+ 'foreignKey' => 'terms_and_conditions_id',
+ ]])
+ ->all();
+
+ // Walk through each T&C and merge in any existing agreements. There's probably
+ // a more optimal way to handle this, but typically there will only be a handful
+ // of agreements. We'll inject a status value, which is not part of the physical
+ // data model, but by doing so here we make it easier for the invoking code to
+ // see what's going on for each T&C.
+
+ foreach($tandc as $t) {
+ $r = [
+ // Default is Not Agreed until calculated otherwise
+ 'status' => TAndCStatusEnum::NotAgreed,
+ // The current T&C
+ 'tandc' => $t,
+ // The agreement we found (if any)
+ 'agreement' => null,
+ // If agreement was to outdated T&C, the outdated T&C
+ 'oldtandc' => null
+ ];
+
+ foreach($agreements as $a) {
+ // We can have more than one Agreement to the same T&C (subsequent Agreements
+ // to address Expired or Outdated Agreements won't delete the older ones), but
+ // since we order by agreement_time DESC we should always get the newest one first.
+
+ if($a->terms_and_conditions_id == $t->id) {
+ // Agreement is to the current T&C
+ $r['agreement'] = $a;
+
+ // Calculate status
+ $r['status'] = TAndCStatusEnum::Agreed;
+
+ if(!empty($t->agreement_duration)) {
+ // Check to see if the agreement time is older than the policy allows
+
+ $agreetime = new DateTime($a->agreement_time);
+ $nowtime = new DateTime();
+
+ $timediff = $agreetime->diff($nowtime);
+
+ if($timediff->days > $t->agreement_duration) {
+ $r['status'] = TAndCStatusEnum::Expired;
+ }
+ }
+
+ break;
+ // Because of the Cake inflection bug in the find(), the related model
+ // is available via the incorrect property name
+ } elseif($a->terms_and_condition->terms_and_conditions_id == $t->id) {
+ // Agreement is to a previous version of the current T&C. Whether this is
+ // sufficient or not depends on the configuration on the _current_ T&C.
+
+ $r['agreement'] = $a;
+ $r['oldtandc'] = $a->terms_and_conditions;
+
+ if($t->agree_to_updates) {
+ // This Agreement is _not_ sufficient
+ $r['status'] = TAndCStatusEnum::Outdated;
+ } else {
+ // Agreement is to a previous version of the current T&C, which is sufficient
+ $r['status'] = TAndCStatusEnum::Agreed;
+ }
+
+ break;
+ }
+ }
+
+ $ret[] = $r;
+ }
+ }
+
+ return $ret;
+ }
+
+ /**
+ * 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('co_id', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->notEmptyString('co_id');
+
+ $this->registerStringValidation($validator, $schema, 'description', true);
+
+ // One of url or mostly_static_page_id is required, we enforce this via application rules
+ $this->registerStringValidation($validator, $schema, 'url', false);
+
+ $validator->add('mostly_static_page_id', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->allowEmptyString('mostly_static_page_id');
+
+ $validator->add('cou_id', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->allowEmptyString('cou_id');
+
+ $validator->add('status', [
+ 'content' => ['rule' => ['inList', SuspendableStatusEnum::getConstValues()]]
+ ]);
+ $validator->notEmptyString('status');
+
+ $validator->add('ordr', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->allowEmptyString('ordr');
+
+ $validator->add('agreement_duration', [
+ 'content' => ['rule' => 'isInteger']
+ ]);
+ $validator->allowEmptyString('agreement_duration');
+
+ $validator->add('agree_to_updates', [
+ 'content' => ['rule' => ['boolean']]
+ ]);
+ $validator->allowEmptyString('agree_to_updates');
+
+ return $validator;
+ }
+}
\ No newline at end of file
diff --git a/app/templates/People/fields.inc b/app/templates/People/fields.inc
index 60f5d1849..31945f409 100644
--- a/app/templates/People/fields.inc
+++ b/app/templates/People/fields.inc
@@ -107,6 +107,21 @@ $topLinks = [
],
'class' => ''
],
+ [
+ 'icon' => 'note_stack',
+ 'order' => 'Default',
+ 'label' => __d('controller', 'Petitions', [99]),
+ 'link' => [
+ 'controller' => 'petitions',
+ 'action' => 'index',
+ '?' => [
+ // The current implementation requires both the CO ID and the Enrollee Person ID
+ 'co_id' => $vv_obj->co_id,
+ 'enrollee_person_id' => $vv_obj->id
+ ]
+ ],
+ 'class' => ''
+ ],
[
'icon' => 'lock',
'order' => 'Default',
@@ -136,6 +151,19 @@ $topLinks = [
],
'class' => ''
],
+ [
+ 'icon' => 'policy',
+ 'order' => 'Default',
+ 'label' => __d('controller', 'TermsAndConditions', [99]),
+ 'link' => [
+ 'controller' => 'terms_and_conditions',
+ 'action' => 'status',
+ '?' => [
+ 'person_id' => $vv_obj->id
+ ]
+ ],
+ 'class' => ''
+ ],
[
'icon' => 'badge',
'iconClass' => 'material-symbols-outlined',
diff --git a/app/templates/Petitions/petition.inc b/app/templates/Petitions/petition.inc
index 12b8ab911..6e37a7f06 100644
--- a/app/templates/Petitions/petition.inc
+++ b/app/templates/Petitions/petition.inc
@@ -306,7 +306,7 @@ if (!empty($vv_obj?->petitioner_person?->id)) {