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(); +?> + +

+ +Form->create(null, [ + 'id' => 'agreement-form', + 'type' => 'post' +]); + +print " + + + + + +"; + +foreach($vv_tandc as $tc) { + print " + + + + + "; +} + +print " +
Terms and ConditionsI Agree
" . $tc['description'] . "" . $this->Form->checkbox("tc".$tc['id']) . "
+"; \ 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 @@ + + + + \ 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)) {
- Time->nice($step->modified, $vv_tz) ?> + Time->nice($result[0]->modified, $vv_tz) : '' ?>