diff --git a/app/plugins/CoreEnroller/resources/locales/en_US/core_enroller.po b/app/plugins/CoreEnroller/resources/locales/en_US/core_enroller.po index 89ac48b3f..5fde94d25 100644 --- a/app/plugins/CoreEnroller/resources/locales/en_US/core_enroller.po +++ b/app/plugins/CoreEnroller/resources/locales/en_US/core_enroller.po @@ -124,6 +124,9 @@ msgstr "Charset must consist of UPPER case characters only." msgid "error.EmailVerifiers.verified" msgstr "Requested address is already verified" +msgid "error.EnrollmentAttributes.valid_through.default_value" +msgstr "Days After Finalization must be a whole number of zero or more" + msgid "error.PetitionAcceptances.exists" msgstr "An Invitation for this Petition already exists" @@ -196,6 +199,9 @@ msgstr "If set, the Approver must add a comment when approving or denying Petiti msgid "field.AttributeCollectors.valid_through.default.after.desc" msgstr "Days After Finalization" +msgid "field.AttributeCollectors.valid_through.default.after.hint" +msgstr "If left blank, defaults to {0} days after finalization" + msgid "field.AttributeCollectors.enable_person_find.desc" msgstr "Enable people picker for self-service enrollments, see Attribute Collector documentation for privacy considerations" diff --git a/app/plugins/CoreEnroller/src/Model/Table/AttributeCollectorsTable.php b/app/plugins/CoreEnroller/src/Model/Table/AttributeCollectorsTable.php index b1bac256f..62065c7a8 100644 --- a/app/plugins/CoreEnroller/src/Model/Table/AttributeCollectorsTable.php +++ b/app/plugins/CoreEnroller/src/Model/Table/AttributeCollectorsTable.php @@ -37,6 +37,7 @@ use Cake\Database\Connection; use Cake\Database\Expression\QueryExpression; use Cake\Datasource\EntityInterface; +use Cake\I18n\FrozenTime; use Cake\ORM\Query; use Cake\ORM\Table; use Cake\ORM\TableRegistry; @@ -270,6 +271,19 @@ public function hydrate(int $id, \App\Model\Entity\Petition $petition): bool // Save the Person Role only if we collected something meaningful for it, if(!empty($fieldsForPersonRole)) { + $now = FrozenTime::now(); + foreach($fieldsForPersonRole as $fld) { + $attrName = $fld->enrollment_attribute->attribute ?? null; + if($attrName === 'valid_from' || $attrName === 'valid_through') { + $fld->value = $this->EnrollmentAttributes->resolveValidityDate( + $attrName, + $fld->value, + $fld->enrollment_attribute, + $now + ); + } + } + $personRoleObj = TableRegistry::getTableLocator()->get('PersonRoles'); $role = $personRoleObj->saveAttributeCollectorPetitionAttributes((int)$person->id, $fieldsForPersonRole); } diff --git a/app/plugins/CoreEnroller/src/Model/Table/EnrollmentAttributesTable.php b/app/plugins/CoreEnroller/src/Model/Table/EnrollmentAttributesTable.php index 55f70bf50..c96f0134d 100644 --- a/app/plugins/CoreEnroller/src/Model/Table/EnrollmentAttributesTable.php +++ b/app/plugins/CoreEnroller/src/Model/Table/EnrollmentAttributesTable.php @@ -29,7 +29,9 @@ namespace CoreEnroller\Model\Table; +use Cake\Datasource\EntityInterface; use Cake\Event\EventInterface; +use Cake\I18n\FrozenTime; use Cake\ORM\Table; use Cake\Validation\Validator; use \App\Lib\Enum\GroupTypeEnum; @@ -195,6 +197,81 @@ public function initialize(array $config): void { ]); } + /** + * Callback before data is marshaled into an entity. + * + * @since COmanage Registry v5.3.0 + * @param EventInterface $event beforeMarshal event + * @param ArrayObject $data Entity data + * @param ArrayObject $options Callback options + */ + + public function beforeMarshal(EventInterface $event, \ArrayObject $data, \ArrayObject $options) + { + // valid_from uses default_value_datetime for its default. Clear any default_value + // so it can't take precedence over the configured date when the petition form is rendered. + if(($data['attribute'] ?? null) === 'valid_from') { + $data['default_value'] = null; + } + } + + /** + * Resolve a valid_from/valid_through Petition Attribute value into a database + * datetime string. For valid_through, the Enrollment Attribute default_value + * is an offset in days after finalization, applied when no value was provided + * or when the field is not modifiable by the enrollee (read only or hidden). + * The resulting date is set to the end of that day (23:59:59). + * + * @param string $attribute Attribute name (valid_from or valid_through) + * @param mixed $value Petition Attribute value + * @param EntityInterface|null $enrollmentAttribute Enrollment Attribute configuration + * @param FrozenTime|null $now Reference timestamp (defaults to current time) + * @return string|null Datetime string, or null if no date applies + * @since COmanage Registry v5.3.0 + */ + + public function resolveValidityDate( + string $attribute, + mixed $value, + ?EntityInterface $enrollmentAttribute = null, + ?FrozenTime $now = null + ): ?string { + $dateFormat = 'yyyy-MM-dd HH:mm:ss'; + $now ??= FrozenTime::now(); + + $isEmpty = ($value === null || (is_string($value) && trim($value) === '')); + + // valid_through: determine days-after-finalization offset if configured + $offsetDays = null; + if($attribute === 'valid_through' + && $enrollmentAttribute !== null + && is_numeric($enrollmentAttribute->default_value ?? null)) { + $offsetDays = (int)$enrollmentAttribute->default_value; + } + + $isLocked = $enrollmentAttribute !== null + && ((isset($enrollmentAttribute->modifiable) && !$enrollmentAttribute->modifiable) + || !empty($enrollmentAttribute->hidden)); + + // valid_through: apply offset from finalization time when field is blank or locked (read-only/hidden), + // expiring at the end of that day + if($offsetDays !== null && ($isEmpty || $isLocked)) { + return $now->addDays($offsetDays)->endOfDay()->i18nFormat($dateFormat); + } + + // valid_from & valid_through: an empty value means no date applies (null, not "now") + if($isEmpty) { + return null; + } + + // valid_from & valid_through: format explicit datetime value entered or provided + if($value instanceof \DateTimeInterface) { + return FrozenTime::instance($value)->i18nFormat($dateFormat); + } + + return FrozenTime::parse((string)$value)->i18nFormat($dateFormat); + } + /** * Obtain the set of supported attributes. * @@ -421,6 +498,15 @@ public function validationDefault(Validator $validator): Validator { $this->registerStringValidation($validator, $schema, 'default_value', false); + // For valid_through, default_value is the number of days after finalization + $validator->add('default_value', [ + 'validThroughDays' => [ + 'rule' => ['naturalNumber', true], + 'on' => fn(array $context): bool => ($context['data']['attribute'] ?? null) === 'valid_through', + 'message' => __d('core_enroller', 'error.EnrollmentAttributes.valid_through.default_value') + ] + ]); + $validator->allowEmptyString('default_value_datetime'); $this->registerStringValidation($validator, $schema, 'default_value_env_name', false); diff --git a/app/plugins/CoreEnroller/templates/element/field.php b/app/plugins/CoreEnroller/templates/element/field.php index f5a0653c8..1156ca4db 100644 --- a/app/plugins/CoreEnroller/templates/element/field.php +++ b/app/plugins/CoreEnroller/templates/element/field.php @@ -45,15 +45,17 @@ // // NOTE: name/address/telephoneNumber are MVEAs and are rendered as grouped sub-fields. // Their per-component defaults must be applied at the sub-field level, not here. +// +// NOTE: for valid_through, default_value is an offset in days after finalization, +// not a value. It is applied server side at finalization (see EnrollmentAttributesTable::resolveValidityDate()). +$isOffsetDefault = ($attr->attribute === 'valid_through'); + $options['default'] = match(true) { + $isOffsetDefault => '', $attr->attribute === 'valid_from' && !empty($attr->default_value_datetime) => $attr->default_value_datetime, $attr->attribute === 'valid_from' && empty($attr->default_value_datetime) => \Cake\I18n\FrozenTime::now($tz)->startOfDay(), - $attr->attribute === 'valid_through' && !empty($attr->default_value) && is_numeric($attr->default_value) - => \Cake\I18n\FrozenTime::now($tz)->addDays((int)$attr->default_value)->setTime(23, 59, 59), - $attr->attribute === 'valid_through' && empty($attr->default_value) - => \Cake\I18n\FrozenTime::now($tz)->setTime(23, 59, 59), isset($attr->default_value) => $attr->default_value, isset($attr->default_value_env_name) && $attr->attribute !== 'name' @@ -83,6 +85,17 @@ 'fieldNameAlias' => $attr->attribute // the field name to its enrollment attribute field name ]; +// Explain to the enrollee what happens if the valid_through field is left blank. +// A required field can't be left blank, so the hint would be misleading there. +if($isOffsetDefault + && is_numeric($attr->default_value ?? null) + && empty($attr->required)) { + $formArguments['fieldDescription'] = trim( + ($attr->description ?? '') . ' ' + . __d('core_enroller', 'field.AttributeCollectors.valid_through.default.after.hint', [(int)$attr->default_value]) + ); +} + /* * Get the values for the attributes ending with _id diff --git a/app/plugins/CoreEnroller/tests/TestCase/Model/Table/EnrollmentAttributesTableTest.php b/app/plugins/CoreEnroller/tests/TestCase/Model/Table/EnrollmentAttributesTableTest.php new file mode 100644 index 000000000..633ee5a4d --- /dev/null +++ b/app/plugins/CoreEnroller/tests/TestCase/Model/Table/EnrollmentAttributesTableTest.php @@ -0,0 +1,109 @@ +EnrollmentAttributes = TableRegistry::getTableLocator()->get('CoreEnroller.EnrollmentAttributes'); + $this->now = new FrozenTime('2026-10-19 13:30:00'); + } + + protected function tearDown(): void + { + unset($this->EnrollmentAttributes); + parent::tearDown(); + } + + private function enrollmentAttribute(array $data): Entity + { + return new Entity($data + ['modifiable' => true, 'hidden' => false]); + } + + public function testValidThroughOffsetAppliedWhenBlank(): void + { + $ea = $this->enrollmentAttribute(['attribute' => 'valid_through', 'default_value' => '365']); + + $this->assertSame('2027-10-19 23:59:59', $this->EnrollmentAttributes->resolveValidityDate('valid_through', '', $ea, $this->now)); + $this->assertSame('2027-10-19 23:59:59', $this->EnrollmentAttributes->resolveValidityDate('valid_through', null, $ea, $this->now)); + } + + public function testValidThroughExplicitDateKeptWhenModifiable(): void + { + $ea = $this->enrollmentAttribute(['attribute' => 'valid_through', 'default_value' => '365']); + + $this->assertSame( + '2030-01-01 00:00:00', + $this->EnrollmentAttributes->resolveValidityDate('valid_through', '2030-01-01 00:00:00', $ea, $this->now) + ); + } + + public function testValidThroughOffsetForcedWhenReadOnly(): void + { + $ea = $this->enrollmentAttribute(['attribute' => 'valid_through', 'default_value' => '365', 'modifiable' => false]); + + $this->assertSame('2027-10-19 23:59:59', $this->EnrollmentAttributes->resolveValidityDate('valid_through', '2030-01-01', $ea, $this->now)); + } + + public function testValidThroughOffsetForcedWhenHidden(): void + { + $ea = $this->enrollmentAttribute(['attribute' => 'valid_through', 'default_value' => '30', 'hidden' => true]); + + $this->assertSame('2026-11-18 23:59:59', $this->EnrollmentAttributes->resolveValidityDate('valid_through', '365', $ea, $this->now)); + } + + public function testEmptyValueWithoutDefaultIsNull(): void + { + $from = $this->enrollmentAttribute(['attribute' => 'valid_from']); + $through = $this->enrollmentAttribute(['attribute' => 'valid_through']); + + $this->assertNull($this->EnrollmentAttributes->resolveValidityDate('valid_from', '', $from, $this->now)); + $this->assertNull($this->EnrollmentAttributes->resolveValidityDate('valid_from', null, $from, $this->now)); + $this->assertNull($this->EnrollmentAttributes->resolveValidityDate('valid_through', ' ', $through, $this->now)); + } + + public function testValidFromIgnoresDefaultValueOffset(): void + { + $ea = $this->enrollmentAttribute(['attribute' => 'valid_from', 'default_value' => '365', 'modifiable' => false]); + + $this->assertNull($this->EnrollmentAttributes->resolveValidityDate('valid_from', '', $ea, $this->now)); + $this->assertSame( + '2026-12-01 08:00:00', + $this->EnrollmentAttributes->resolveValidityDate('valid_from', '2026-12-01 08:00:00', $ea, $this->now) + ); + } + + public function testZeroOffsetReturnsEndOfToday(): void + { + $ea = $this->enrollmentAttribute(['attribute' => 'valid_through', 'default_value' => '0']); + + $this->assertSame('2026-10-19 23:59:59', $this->EnrollmentAttributes->resolveValidityDate('valid_through', '', $ea, $this->now)); + } + + public function testNonNumericDefaultIsIgnored(): void + { + $ea = $this->enrollmentAttribute(['attribute' => 'valid_through', 'default_value' => 'next year', 'modifiable' => false]); + + $this->assertNull($this->EnrollmentAttributes->resolveValidityDate('valid_through', '', $ea, $this->now)); + $this->assertSame( + '2030-01-01 00:00:00', + $this->EnrollmentAttributes->resolveValidityDate('valid_through', '2030-01-01', $ea, $this->now) + ); + } +} diff --git a/app/src/Model/Table/PeopleTable.php b/app/src/Model/Table/PeopleTable.php index 602c73e52..a08ffe436 100644 --- a/app/src/Model/Table/PeopleTable.php +++ b/app/src/Model/Table/PeopleTable.php @@ -40,6 +40,7 @@ use \App\Lib\Enum\ActionEnum; use \App\Lib\Enum\AuthenticatorStatusEnum; use \App\Lib\Enum\GroupTypeEnum; +use \App\Lib\Enum\PetitionActionEnum; use \App\Lib\Enum\ProvisioningContextEnum; use \App\Lib\Enum\ProvisioningEligibilityEnum; use \App\Lib\Enum\StatusEnum; diff --git a/app/src/Model/Table/PersonRolesTable.php b/app/src/Model/Table/PersonRolesTable.php index 62f986d30..68a656f95 100644 --- a/app/src/Model/Table/PersonRolesTable.php +++ b/app/src/Model/Table/PersonRolesTable.php @@ -747,23 +747,13 @@ public function validationDefault(Validator $validator): Validator { */ public function saveAttributeCollectorPetitionAttributes(int $personId, array $fields): PersonRole { - $dateFormat = 'yyyy-MM-dd HH:mm:ss'; - $role = [ 'person_id' => $personId, ]; foreach($fields as $fld) { $attribute = $fld->enrollment_attribute->attribute; - $value = $fld->value; - if( - ($attribute === 'valid_from' || $attribute === 'valid_through') - && is_string($value) - ) { - $dob = FrozenTime::parse($value); - $value = $dob->i18nFormat($dateFormat); - } - $role[$attribute] = $value; + $role[$attribute] = $fld->value; } return $this->saveOrFail($this->newEntity($role)); diff --git a/app/src/View/Helper/FieldHelper.php b/app/src/View/Helper/FieldHelper.php index 4498d8ac4..8ac0ec0e6 100644 --- a/app/src/View/Helper/FieldHelper.php +++ b/app/src/View/Helper/FieldHelper.php @@ -331,10 +331,12 @@ public function dateField(string $fieldName, // filtering block !empty($queryParams[$fieldName]) => FrozenTime::parse($queryParams[$fieldName]), // Petition View/ Value saved as string - isset($fieldArgs['default']) && is_string($fieldArgs['default']) => FrozenTime::parse($fieldArgs['default']), + !empty($fieldArgs['default']) + && is_string($fieldArgs['default']) + && trim($fieldArgs['default']) !== '' => FrozenTime::parse($fieldArgs['default']), // Petition View/ Value saved as FrozenTime or DateTime - isset($fieldArgs['default']) - && ($fieldArgs['default'] instanceof \DateTimeInterface || is_a($fieldArgs['default'], 'Cake\I18n\FrozenTime')) => $fieldArgs['default'], + !empty($fieldArgs['default']) + && $fieldArgs['default'] instanceof \DateTimeInterface => $fieldArgs['default'], // Table record/ Retrieve it from the Entity object default => $this->getEntity()?->$fieldName, }; @@ -395,7 +397,10 @@ public function dateField(string $fieldName, ) { // return the date as plaintext // Add a hidden field - $element = $this->getView()->element('form/notSetDiv', [], [ + // Always emit the hidden field, even when empty, so the field is still posted + // (e.g. a read only valid_through whose offset is applied at finalization) + $element = $this->Form->hidden($fieldName, ['value' => '', 'id' => $coptions['id']]) + . $this->getView()->element('form/notSetDiv', [], [ 'cache' => '_html_elements', ]); if ($date_object !== null) {