Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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 <a target=\"_top\" href=\"https://spaces.at.internet2.edu/display/COmanage/Attribute+Collector+Enroller+Plugin\">Attribute Collector documentation</a> for privacy considerations"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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);
Expand Down
21 changes: 17 additions & 4 deletions app/plugins/CoreEnroller/templates/element/field.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<?php
declare(strict_types=1);

namespace CoreEnroller\Test\TestCase\Model\Table;

use Cake\I18n\FrozenTime;
use Cake\ORM\Entity;
use Cake\ORM\TableRegistry;
use Cake\TestSuite\TestCase;
use CoreEnroller\Model\Table\EnrollmentAttributesTable;

/**
* CoreEnroller\Model\Table\EnrollmentAttributesTable Test Case
*/
class EnrollmentAttributesTableTest extends TestCase
{
protected EnrollmentAttributesTable $EnrollmentAttributes;

protected FrozenTime $now;

protected function setUp(): void
{
parent::setUp();
$this->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)
);
}
}
1 change: 1 addition & 0 deletions app/src/Model/Table/PeopleTable.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
12 changes: 1 addition & 11 deletions app/src/Model/Table/PersonRolesTable.php
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down
13 changes: 9 additions & 4 deletions app/src/View/Helper/FieldHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
};
Expand Down Expand Up @@ -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) {
Expand Down