diff --git a/CHANGELOG.md b/CHANGELOG.md index 9456862..7830fb4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added - Add translations management through Transifex +- "Email followup" destination field to disable the followup per actor source, with a command to import it from Formcreator ### Fixed diff --git a/src/Console/ImportFormcreatorActorsNotificationCommand.php b/src/Console/ImportFormcreatorActorsNotificationCommand.php new file mode 100644 index 0000000..8e3d21b --- /dev/null +++ b/src/Console/ImportFormcreatorActorsNotificationCommand.php @@ -0,0 +1,592 @@ + [target table, destination type] */ + private const TARGETS = [ + 'PluginFormcreatorTargetTicket' => ['glpi_plugin_formcreator_targettickets', FormDestinationTicket::class], + 'PluginFormcreatorTargetChange' => ['glpi_plugin_formcreator_targetchanges', FormDestinationChange::class], + 'PluginFormcreatorTargetProblem' => ['glpi_plugin_formcreator_targetproblems', FormDestinationProblem::class], + ]; + + /** PluginFormcreatorTarget_Actor::ACTOR_ROLE_* => actor role */ + private const ROLES = [ + 1 => 'requester', + 2 => 'observer', + 3 => 'assign', + ]; + + // PluginFormcreatorTarget_Actor::ACTOR_TYPE_* having an email followup + private const ACTOR_TYPE_CREATOR = 1; + + private const ACTOR_TYPE_PERSON = 3; + + private const ACTOR_TYPE_QUESTION_PERSON = 4; + + private const ACTOR_TYPE_SUPPLIER = 7; + + private const ACTOR_TYPE_QUESTION_SUPPLIER = 8; + + private const ACTOR_TYPE_QUESTION_ACTORS = 9; + + private const QUESTION_ACTOR_TYPES = [ + self::ACTOR_TYPE_QUESTION_PERSON, + self::ACTOR_TYPE_QUESTION_SUPPLIER, + self::ACTOR_TYPE_QUESTION_ACTORS, + ]; + + /** @var list */ + private array $not_found = []; + + private ActorNotificationField $field; + + /** @var array Destinations with sources to disable, by id */ + private array $destinations = []; + + /** @var array Destinations imported by a previous run */ + private array $imported_destinations = []; + + private bool $force = false; + + /** @var array */ + private array $skipped_destinations = []; + + private bool $has_failed_destinations = false; + + /** @var array>> Sources by destination id and role */ + private array $sources = []; + + /** @var array>>> Sources to disable by destination id, role and list */ + private array $sources_to_disable = []; + + #[Override] + protected function configure(): void + { + parent::configure(); + + $this + ->setName('plugins:advancedforms:import-formcreator-actors-notification') + ->setDescription(__('Disable the email followup of the actors for which it was unchecked in Formcreator', 'advancedforms')) + ->addOption( + 'dry-run', + null, + InputOption::VALUE_NONE, + __('Display the changes without saving them', 'advancedforms'), + ) + ->addOption( + 'force', + null, + InputOption::VALUE_NONE, + __('Import again the destinations already imported, even the sources enabled again since', 'advancedforms'), + ); + } + + #[Override] + protected function execute(InputInterface $input, OutputInterface $output): int + { + $missing_tables = $this->getMissingFormcreatorTables(); + if ($missing_tables !== []) { + $output->writeln('' . sprintf( + __('Formcreator tables not found: %s. Run this command after the Formcreator migration, before removing its tables.', 'advancedforms'), + implode(', ', $missing_tables), + ) . ''); + return Command::FAILURE; + } + + $dry_run = (bool) $input->getOption('dry-run'); + + $this->field = new ActorNotificationField(); + $this->force = (bool) $input->getOption('force'); + $this->not_found = []; + $this->destinations = []; + $this->imported_destinations = $this->getImportedDestinations(); + $this->skipped_destinations = []; + $this->has_failed_destinations = false; + $this->sources = []; + $this->sources_to_disable = []; + foreach ($this->getUncheckedActors() as $actor) { + $this->handleUncheckedActor($actor); + } + + $changes = []; + $updated_destinations = 0; + foreach ($this->sources_to_disable as $destination_id => $sources) { + $destination_changes = $this->disableSources($this->destinations[$destination_id], $sources, $dry_run); + if ($destination_changes === null) { + // Retried by the next run + unset($this->destinations[$destination_id]); + $this->has_failed_destinations = true; + continue; + } + + if ($destination_changes !== []) { + $updated_destinations++; + array_push($changes, ...$destination_changes); + } + } + + if (!$dry_run) { + $this->saveImportedDestinations(); + } + + $this->outputSummary($output, $changes, $updated_destinations, $dry_run); + + return $this->has_failed_destinations ? Command::FAILURE : Command::SUCCESS; + } + + /** @return list */ + private function getMissingFormcreatorTables(): array + { + $tables = [self::ACTORS_TABLE, self::FORMS_TABLE, self::QUESTIONS_TABLE]; + foreach (self::TARGETS as [$table]) { + $tables[] = $table; + } + + return array_values(array_filter( + $tables, + fn(string $table): bool => !$this->getDb()->tableExists($table, usecache: false), + )); + } + + /** @return array */ + private function getImportedDestinations(): array + { + $destinations = []; + foreach (Config::getConfigurationValues(self::IMPORTED_DESTINATIONS_CONFIG_CONTEXT) as $id) { + $destinations[$this->toInt($id)] = true; + } + + return $destinations; + } + + /** Remembers the destinations of this run not imported yet. */ + private function saveImportedDestinations(): void + { + $values = []; + foreach (array_keys(array_diff_key($this->destinations, $this->imported_destinations)) as $id) { + $values['destination_' . $id] = (string) $id; + } + + if ($values !== []) { + Config::setConfigurationValues(self::IMPORTED_DESTINATIONS_CONFIG_CONTEXT, $values); + } + } + + /** @return iterable> */ + private function getUncheckedActors(): iterable + { + /** @var iterable> */ + return $this->getDb()->request([ + 'SELECT' => ['itemtype', 'items_id', 'actor_role', 'actor_type', 'actor_value'], + 'FROM' => self::ACTORS_TABLE, + 'WHERE' => [ + 'use_notification' => 0, + 'itemtype' => array_keys(self::TARGETS), + ], + 'ORDER' => ['id'], + ]); + } + + /** @param array $actor */ + private function handleUncheckedActor(array $actor): void + { + $role = self::ROLES[$this->toInt($actor['actor_role'])] ?? null; + $actor_type = $this->toInt($actor['actor_type']); + $actor_value = $this->toInt($actor['actor_value']); + if ($role === null || !$this->hasEmailFollowup($actor_type)) { + return; + } + + $target_itemtype = is_string($actor['itemtype']) ? $actor['itemtype'] : ''; + $target_id = $this->toInt($actor['items_id']); + $target_label = sprintf('%s #%d', $target_itemtype, $target_id); + [$target_table, $destination_class] = self::TARGETS[$target_itemtype]; + + $target = $this->getDb()->request([ + 'SELECT' => ['name', 'plugin_formcreator_forms_id'], + 'FROM' => $target_table, + 'WHERE' => ['id' => $target_id], + ])->current(); + if (!is_array($target)) { + $this->addNotFound($target_label, $target_label, __('Formcreator target not found', 'advancedforms')); + return; + } + + $target_name = is_string($target['name']) ? $target['name'] : ''; + $target_label = sprintf('%s "%s" (#%d)', $target_itemtype, $target_name, $target_id); + + $fc_form_id = $this->toInt($target['plugin_formcreator_forms_id']); + $form = $this->findMigratedForm($fc_form_id); + if (!$form instanceof Form) { + $this->addNotFound($target_label, sprintf('PluginFormcreatorForm #%d', $fc_form_id), __('Form not found', 'advancedforms')); + return; + } + + $destinations = (new FormDestination())->find([ + Form::getForeignKeyField() => $form->getID(), + 'itemtype' => $destination_class, + 'name' => $target_name, + ]); + if (count($destinations) !== 1) { + $this->addNotFound( + $target_label, + $target_name, + $destinations === [] + ? __('Destination not found', 'advancedforms') + : __('Several destinations match, target ignored', 'advancedforms'), + ); + return; + } + + // find() returns the rows by id + $destination = FormDestination::getById($this->toInt(array_key_first($destinations))); + if (!$destination instanceof FormDestination) { + return; + } + + if (!$this->force && isset($this->imported_destinations[$destination->getID()])) { + $this->skipped_destinations[$destination->getID()] = true; + return; + } + + [$list, $value, $element] = $this->getSource($form, $actor_type, $actor_value); + if ($value === null) { + $this->addNotFound( + $target_label, + $element, + in_array($actor_type, self::QUESTION_ACTOR_TYPES, true) + ? __('Question not found', 'advancedforms') + : __('Actor not found', 'advancedforms'), + ); + return; + } + + if (!$this->isSourceConfigured($destination, $role, $list, $value)) { + $this->addNotFound($target_label, $element, __('Source not found in the destination actors', 'advancedforms')); + return; + } + + $this->destinations[$destination->getID()] = $destination; + + $values = $this->sources_to_disable[$destination->getID()][$role][$list] ?? []; + if (!in_array($value, $values, true)) { + $this->sources_to_disable[$destination->getID()][$role][$list][] = $value; + } + } + + private function hasEmailFollowup(int $actor_type): bool + { + return in_array($actor_type, [ + self::ACTOR_TYPE_CREATOR, + self::ACTOR_TYPE_PERSON, + self::ACTOR_TYPE_SUPPLIER, + ...self::QUESTION_ACTOR_TYPES, + ], true); + } + + /** By the Formcreator uuid, or the one generated from the id by the migration. */ + private function findMigratedForm(int $fc_form_id): ?Form + { + $fc_form = $this->getDb()->request([ + 'SELECT' => ['uuid'], + 'FROM' => self::FORMS_TABLE, + 'WHERE' => ['id' => $fc_form_id], + ])->current(); + if (!is_array($fc_form)) { + return null; + } + + $uuids = [(string) Uuid::uuid5(Uuid::NAMESPACE_URL, 'formcreator-form-' . $fc_form_id)]; + if (is_string($fc_form['uuid']) && $fc_form['uuid'] !== '') { + $uuids[] = $fc_form['uuid']; + } + + foreach ($uuids as $uuid) { + $forms = (new Form())->find(['uuid' => $uuid]); + if (count($forms) === 1) { + $form = Form::getById($this->toInt(array_key_first($forms))); + return $form instanceof Form ? $form : null; + } + } + + return null; + } + + /** + * Source of a Formcreator actor, value null if not found. + * + * @return array{ActorNotificationFieldConfig::DISABLED_*, string|int|null, string} + */ + private function getSource(Form $form, int $actor_type, int $actor_value): array + { + if ($actor_type === self::ACTOR_TYPE_CREATOR) { + $strategy = ITILActorFieldStrategy::FORM_FILLER->value; + return [ActorNotificationFieldConfig::DISABLED_STRATEGIES, $strategy, $strategy]; + } + + if (in_array($actor_type, self::QUESTION_ACTOR_TYPES, true)) { + $element = sprintf('PluginFormcreatorQuestion #%d', $actor_value); + $question = $this->findMigratedQuestion($form, $actor_value); + return [ActorNotificationFieldConfig::DISABLED_QUESTION_IDS, $question?->getID(), $element]; + } + + $itemtype = $actor_type === self::ACTOR_TYPE_SUPPLIER ? Supplier::class : User::class; + $element = sprintf('%s #%d', $itemtype, $actor_value); + $item = new $itemtype(); + $value = $item->getFromDB($actor_value) ? ActorNotificationField::getActorKey($itemtype, $actor_value) : null; + + return [ActorNotificationFieldConfig::DISABLED_ACTORS, $value, $element]; + } + + private function findMigratedQuestion(Form $form, int $fc_question_id): ?Question + { + $fc_question = $this->getDb()->request([ + 'SELECT' => ['uuid'], + 'FROM' => self::QUESTIONS_TABLE, + 'WHERE' => ['id' => $fc_question_id], + ])->current(); + if (!is_array($fc_question) || !is_string($fc_question['uuid']) || $fc_question['uuid'] === '') { + return null; + } + + foreach (array_keys((new Question())->find(['uuid' => $fc_question['uuid']])) as $question_id) { + $question = Question::getById($this->toInt($question_id)); + if ($question instanceof Question && $question->getForm()->getID() === $form->getID()) { + return $question; + } + } + + return null; + } + + private function isSourceConfigured( + FormDestination $destination, + string $role, + string $list, + string|int $value, + ): bool { + foreach ($this->getDestinationSources($destination, $role) as $source) { + if ($source['list'] === $list && $source['value'] === $value) { + return true; + } + } + + return false; + } + + /** + * Sources of a role of the destination, cached. + * + * @return list + */ + private function getDestinationSources(FormDestination $destination, string $role): array + { + $destination_id = $destination->getID(); + if (!isset($this->sources[$destination_id])) { + $field_config = $this->field->getConfig($destination->getForm(), $destination->getConfig()); + $this->sources[$destination_id] = []; + foreach ($this->field->getActorFields() as $actor_role => $actor_field) { + $actor_config = $field_config->getActorsConfig($actor_role); + $this->sources[$destination_id][$actor_role] = $actor_config instanceof ITILActorFieldConfig + ? $this->field->getSources($actor_field, $actor_config) + : []; + } + } + + return $this->sources[$destination_id][$role] ?? []; + } + + /** + * Adds the sources to the disabled ones. + * + * @param array>> $sources + * @return list|null null if not saved + */ + private function disableSources(FormDestination $destination, array $sources, bool $dry_run): ?array + { + $form = $destination->getForm(); + $config = $destination->getConfig(); + $field_config = $this->field->getConfig($form, $config); + + $changes = []; + $roles = []; + foreach ($this->field->getActorFields() as $role => $actor_field) { + $role_config = $field_config->getRoleConfig($role); + + foreach ($this->getDestinationSources($destination, $role) as $source) { + $list = $source['list']; + if ( + !in_array($source['value'], $sources[$role][$list] ?? [], true) + || in_array($source['value'], $role_config[$list], true) + ) { + continue; + } + + if ($list === ActorNotificationFieldConfig::DISABLED_QUESTION_IDS) { + $role_config[$list][] = (int) $source['value']; + } else { + $role_config[$list][] = (string) $source['value']; + } + + $changes[] = [ + 'form' => $form->getName(), + 'destination' => $destination->getName(), + 'role' => $actor_field->getLabel(), + 'source' => $this->field->getSourceLabel($actor_field, $source), + ]; + } + + $roles[$role] = $role_config; + } + + if ($changes === [] || $dry_run) { + return $changes; + } + + $config[ActorNotificationField::getKey()] = (new ActorNotificationFieldConfig($roles))->jsonSerialize(); + + // Config already prepared + $updated = $destination->update([ + 'id' => $destination->getID(), + 'config' => $config, + '_from_import' => true, + ]); + + return $updated ? $changes : null; + } + + /** @param list $changes */ + private function outputSummary(OutputInterface $output, array $changes, int $updated_destinations, bool $dry_run): void + { + if ($changes !== []) { + $output->writeln($dry_run + ? '' . __('Changes to apply (dry run, nothing is saved):', 'advancedforms') . '' + : '' . __('Applied changes:', 'advancedforms') . ''); + $table = new Table($output); + $table->setHeaders([ + __('Form', 'advancedforms'), + __('Destination', 'advancedforms'), + __('Role', 'advancedforms'), + __('Disabled source', 'advancedforms'), + ]); + foreach ($changes as $change) { + $table->addRow(array_values($change)); + } + + $table->render(); + } + + $output->writeln(sprintf( + $dry_run + ? __('Destinations to update: %d', 'advancedforms') + : __('Updated destinations: %d', 'advancedforms'), + $updated_destinations, + )); + $output->writeln(sprintf(__('Disabled sources: %d', 'advancedforms'), count($changes))); + + if ($this->skipped_destinations !== []) { + $output->writeln('' . sprintf( + __('Destinations already imported, skipped: %d. Use --force to import them again.', 'advancedforms'), + count($this->skipped_destinations), + ) . ''); + } + + if ($this->not_found !== []) { + $output->writeln('' . sprintf(__('Elements not found: %d', 'advancedforms'), count($this->not_found)) . ''); + $table = new Table($output); + $table->setHeaders([ + __('Formcreator target', 'advancedforms'), + __('Element', 'advancedforms'), + __('Reason', 'advancedforms'), + ]); + foreach ($this->not_found as $not_found) { + $table->addRow(array_values($not_found)); + } + + $table->render(); + } + } + + private function addNotFound(string $target, string $element, string $reason): void + { + $this->not_found[] = ['target' => $target, 'element' => $element, 'reason' => $reason]; + } + + private function toInt(mixed $value): int + { + return is_numeric($value) ? (int) $value : 0; + } +} diff --git a/src/Model/Destination/ActorNotificationField.php b/src/Model/Destination/ActorNotificationField.php new file mode 100644 index 0000000..a3a2b11 --- /dev/null +++ b/src/Model/Destination/ActorNotificationField.php @@ -0,0 +1,828 @@ + $config */ + #[Override] + public function getConfig(Form $form, array $config, ?AnswersSet $answers_set = null): ActorNotificationFieldConfig + { + $field_config = parent::getConfig($form, $config, $answers_set); + if (!$field_config instanceof ActorNotificationFieldConfig) { + $field_config = $this->getDefaultConfig($form); + } + + $actors_configs = []; + foreach ($this->getActorFields() as $role => $actor_field) { + $actor_config = $actor_field->getConfig($form, $config, $answers_set); + if ($actor_config instanceof ITILActorFieldConfig) { + $actors_configs[$role] = $actor_config; + } + } + + return $field_config->withActorsConfigs($actors_configs); + } + + /** @param array $display_options */ + #[Override] + public function renderConfigForm( + Form $form, + FormDestination $destination, + JsonFieldInterface $config, + string $input_name, + array $display_options, + ): string { + if (!$config instanceof ActorNotificationFieldConfig) { + throw new InvalidArgumentException("Unexpected config class"); + } + + $roles = []; + foreach ($this->getActorFields() as $role => $actor_field) { + $actor_config = $config->getActorsConfig($role); + if (!$actor_config instanceof ITILActorFieldConfig) { + continue; + } + + $rows = []; + foreach ($this->getSources($actor_field, $actor_config) as $source) { + $rows[] = [ + 'label' => $this->getSourceLabel($actor_field, $source), + 'strategy_label' => $source['list'] === ActorNotificationFieldConfig::DISABLED_STRATEGIES + ? null + : $source['strategy']->getLabel($actor_field->getLabel()), + 'input_name' => sprintf( + '%s[%s][%s][%s][%s]', + $input_name, + ActorNotificationFieldConfig::ROLES, + $role, + $source['list'], + $source['value'], + ), + 'enabled' => !$this->isSourceDisabled($config, $role, $source), + ]; + } + + if ($rows !== []) { + $roles[] = [ + 'label' => $actor_field->getLabel(), + 'rows' => $rows, + ]; + } + } + + $twig = TemplateRenderer::getInstance(); + return $twig->render('@advancedforms/destination/actor_notification_config_field.html.twig', [ + 'roles' => $roles, + 'options' => $display_options, + ]); + } + + /** + * @param array $input + * @return array + */ + #[Override] + public function applyConfiguratedValueToInputUsingAnswers( + JsonFieldInterface $config, + array $input, + AnswersSet $answers_set, + ): array { + if (!$config instanceof ActorNotificationFieldConfig) { + throw new InvalidArgumentException("Unexpected config class"); + } + + foreach ($this->getActorFields() as $role => $actor_field) { + $actor_config = $config->getActorsConfig($role); + if (!$actor_config instanceof ITILActorFieldConfig || !$config->hasDisabledSources($role)) { + continue; + } + + [$disabled_persons, $enabled_persons] = $this->getPersons($config, $role, $actor_field, $actor_config, $answers_set); + + // An enabled source wins + $persons_to_mute = array_diff_key($disabled_persons, $enabled_persons); + if ($persons_to_mute === []) { + continue; + } + + foreach (self::NOTIFIABLE_ITEMTYPES as $itemtype) { + $input = $this->muteActorsInInput($input, $itemtype, $role, $persons_to_mute); + } + } + + return $input; + } + + /** + * @param array $input + * @return array + */ + #[Override] + public function prepareInput(array $input): array + { + $input = parent::prepareInput($input); + + $key = self::getKey(); + if (!array_key_exists($key, $input)) { + return $input; + } + + $raw_config = is_array($input[$key]) ? $input[$key] : []; + $raw_roles = is_array($raw_config[ActorNotificationFieldConfig::ROLES] ?? null) + ? $raw_config[ActorNotificationFieldConfig::ROLES] + : []; + + $roles = []; + foreach ($this->getActorFields() as $role => $actor_field) { + // Actors fields are already prepared (lower weight) + $actor_config = $this->getActorFieldConfigFromInput($actor_field, $input); + $raw_role = is_array($raw_roles[$role] ?? null) ? $raw_roles[$role] : []; + + $allowed = []; + foreach ($this->getSources($actor_field, $actor_config) as $source) { + $allowed[$source['list']][$source['value']] = true; + } + + $disabled = []; + foreach (array_keys(ActorNotificationFieldConfig::buildRoleConfig()) as $list) { + $disabled[$list] = array_values(array_filter( + $this->normalizeDisabledValues($raw_role[$list] ?? []), + fn(string $value): bool => isset($allowed[$list][$value]), + )); + } + + $roles[$role] = ActorNotificationFieldConfig::buildRoleConfig( + strategies: $disabled[ActorNotificationFieldConfig::DISABLED_STRATEGIES], + actors: $disabled[ActorNotificationFieldConfig::DISABLED_ACTORS], + question_ids: array_map( + intval(...), + $disabled[ActorNotificationFieldConfig::DISABLED_QUESTION_IDS], + ), + ); + } + + $input[$key] = [ActorNotificationFieldConfig::ROLES => $roles]; + + return $input; + } + + /** @param array $config */ + #[Override] + public function exportDynamicConfig( + array $config, + AbstractCommonITILFormDestination $destination, + ): DynamicExportDataField { + $roles = $config[ActorNotificationFieldConfig::ROLES] ?? null; + if (!is_array($roles)) { + return parent::exportDynamicConfig($config, $destination); + } + + // Question ids are mapped by the form serializer + $requirements = []; + foreach ($roles as $role => $role_config) { + if (!is_array($role_config) || !is_array($role_config[ActorNotificationFieldConfig::DISABLED_ACTORS] ?? null)) { + continue; + } + + $exported_actors = []; + foreach ($role_config[ActorNotificationFieldConfig::DISABLED_ACTORS] as $actor_key) { + $item = is_string($actor_key) ? $this->getItemFromActorKey($actor_key) : null; + if (!$item instanceof CommonDBTM) { + // Deleted actors are removed from the export + continue; + } + + $requirement = DataRequirementSpecification::fromItem($item); + $requirements[] = $requirement; + $exported_actors[] = $item::getForeignKeyField() . '-' . $requirement->name; + } + + $role_config[ActorNotificationFieldConfig::DISABLED_ACTORS] = $exported_actors; + $roles[$role] = $role_config; + } + + $config[ActorNotificationFieldConfig::ROLES] = $roles; + return new DynamicExportDataField($config, $requirements); + } + + /** + * @param array $config + * @return array + */ + #[Override] + public static function prepareDynamicConfigDataForImport( + array $config, + AbstractCommonITILFormDestination $destination, + DatabaseMapper $mapper, + ): array { + $roles = $config[ActorNotificationFieldConfig::ROLES] ?? null; + if (!is_array($roles)) { + return $config; + } + + foreach ($roles as $role => $role_config) { + if (!is_array($role_config)) { + continue; + } + + $actors = []; + $exported_actors = $role_config[ActorNotificationFieldConfig::DISABLED_ACTORS] ?? []; + foreach (is_array($exported_actors) ? $exported_actors : [] as $actor) { + if (!is_string($actor)) { + continue; + } + + $parsed_actor = self::parseActorKey($actor); + if ($parsed_actor === null) { + continue; + } + + [$itemtype, $name] = $parsed_actor; + $actors[] = self::getActorKey($itemtype, $mapper->getItemId($itemtype, $name)); + } + + $questions_ids = []; + $exported_questions_ids = $role_config[ActorNotificationFieldConfig::DISABLED_QUESTION_IDS] ?? []; + foreach (is_array($exported_questions_ids) ? $exported_questions_ids : [] as $question_id) { + if (is_int($question_id) || is_string($question_id)) { + $questions_ids[] = $mapper->getItemId(Question::class, $question_id); + } + } + + $role_config[ActorNotificationFieldConfig::DISABLED_ACTORS] = $actors; + $role_config[ActorNotificationFieldConfig::DISABLED_QUESTION_IDS] = $questions_ids; + $roles[$role] = $role_config; + } + + $config[ActorNotificationFieldConfig::ROLES] = $roles; + return $config; + } + + /** + * Actors fields of the destination, by actor role. + * + * @return array + */ + public function getActorFields(): array + { + $fields = []; + foreach ([new RequesterField(), new ObserverField(), new AssigneeField()] as $field) { + $fields[$field->getActorType()] = $field; + } + + return $fields; + } + + /** + * Toggleable sources: strategies, specific users or suppliers, questions. + * + * @return list + */ + public function getSources(ITILActorField $actor_field, ITILActorFieldConfig $actor_config): array + { + $sources = []; + foreach ($this->getUniqueStrategies($actor_config) as $strategy) { + if (in_array($strategy, self::STRATEGY_LEVEL_SOURCES, true)) { + $sources[] = [ + 'list' => ActorNotificationFieldConfig::DISABLED_STRATEGIES, + 'value' => $strategy->value, + 'strategy' => $strategy, + ]; + } elseif ($strategy === ITILActorFieldStrategy::SPECIFIC_VALUES) { + $notifiable_itemtypes = $this->getNotifiableItemtypes($actor_field); + foreach ($actor_config->getSpecificITILActorsIds() ?? [] as $itemtype => $ids) { + if (!in_array($itemtype, $notifiable_itemtypes, true) || !is_array($ids)) { + continue; + } + + foreach ($ids as $id) { + if (!is_numeric($id)) { + continue; + } + + $sources[] = [ + 'list' => ActorNotificationFieldConfig::DISABLED_ACTORS, + 'value' => self::getActorKey($itemtype, (int) $id), + 'strategy' => $strategy, + ]; + } + } + } elseif ($strategy === ITILActorFieldStrategy::SPECIFIC_ANSWERS) { + foreach ($actor_config->getSpecificQuestionIds() ?? [] as $question_id) { + $question = is_numeric($question_id) ? Question::getById((int) $question_id) : null; + if (!$question instanceof Question || !$this->isNotifiableQuestion($actor_field, $question)) { + continue; + } + + // Kept for the label + $sources[] = [ + 'list' => ActorNotificationFieldConfig::DISABLED_QUESTION_IDS, + 'value' => $question->getID(), + 'strategy' => $strategy, + 'question' => $question, + ]; + } + } + } + + return $sources; + } + + /** @param class-string $itemtype */ + public static function getActorKey(string $itemtype, int $id): string + { + return getForeignKeyFieldForItemType($itemtype) . '-' . $id; + } + + /** + * Users and suppliers accepted by the field. + * + * @return list> + */ + private function getNotifiableItemtypes(ITILActorField $actor_field): array + { + return array_values(array_filter( + self::NOTIFIABLE_ITEMTYPES, + fn(string $itemtype): bool => in_array($itemtype, $actor_field->getAllowedActorTypes(), true), + )); + } + + /** @return list */ + private function getUniqueStrategies(ITILActorFieldConfig $actor_config): array + { + $strategies = []; + foreach ($actor_config->getStrategies() as $strategy) { + if ($strategy instanceof ITILActorFieldStrategy && !in_array($strategy, $strategies, true)) { + $strategies[] = $strategy; + } + } + + return $strategies; + } + + /** @param Source $source */ + private function isSourceDisabled(ActorNotificationFieldConfig $config, string $role, array $source): bool + { + return match ($source['list']) { + ActorNotificationFieldConfig::DISABLED_STRATEGIES => $config->isStrategyDisabled($role, $source['strategy']), + ActorNotificationFieldConfig::DISABLED_ACTORS => $config->isActorDisabled($role, (string) $source['value']), + ActorNotificationFieldConfig::DISABLED_QUESTION_IDS => $config->isQuestionDisabled($role, (int) $source['value']), + }; + } + + /** @param Source $source */ + public function getSourceLabel(ITILActorField $actor_field, array $source): string + { + switch ($source['list']) { + case ActorNotificationFieldConfig::DISABLED_ACTORS: + $item = $this->getItemFromActorKey((string) $source['value']); + return $item instanceof CommonDBTM ? $item->getFriendlyName() : (string) $source['value']; + + case ActorNotificationFieldConfig::DISABLED_QUESTION_IDS: + return isset($source['question']) ? $source['question']->getName() : (string) $source['value']; + + default: + return $source['strategy']->getLabel($actor_field->getLabel()); + } + } + + /** + * Splits `-` (`-` in an export). + * + * @return array{class-string, string}|null + */ + private static function parseActorKey(string $actor_key): ?array + { + [$fkey, $value] = array_pad(explode('-', $actor_key, 2), 2, ''); + $itemtype = getItemtypeForForeignKeyField($fkey); + if (!in_array($itemtype, self::NOTIFIABLE_ITEMTYPES, true)) { + return null; + } + + return [$itemtype, $value]; + } + + private function getItemFromActorKey(string $actor_key): ?CommonDBTM + { + $parsed_actor = self::parseActorKey($actor_key); + if ($parsed_actor === null || !is_numeric($parsed_actor[1])) { + return null; + } + + [$itemtype, $id] = $parsed_actor; + $item = getItemForItemtype($itemtype); + if (!$item instanceof CommonDBTM || !$item->getFromDB((int) $id)) { + return null; + } + + return $item; + } + + /** Question whose answer can give users or suppliers. */ + private function isNotifiableQuestion(ITILActorField $actor_field, Question $question): bool + { + $type = $question->getQuestionType(); + $notifiable_itemtypes = $this->getNotifiableItemtypes($actor_field); + + if ($type instanceof QuestionTypeEmail) { + return true; + } + + if ($type instanceof QuestionTypeItem) { + return in_array($type->getDefaultValueItemtype($question), $notifiable_itemtypes, true); + } + + if ($type instanceof AbstractQuestionTypeActors) { + $extra_data = $question->getExtraDataConfig(); + foreach ($notifiable_itemtypes as $itemtype) { + if (!in_array($itemtype, $type->getAllowedActorTypes(), true)) { + continue; + } + + if (!$extra_data instanceof QuestionTypeActorsExtraDataConfig || $extra_data->isTypeEnabled($itemtype)) { + return true; + } + } + } + + return false; + } + + /** + * Persons found by the disabled and the enabled sources, each resolved once. + * + * @return array{array, array} Disabled and enabled persons + */ + private function getPersons( + ActorNotificationFieldConfig $config, + string $role, + ITILActorField $actor_field, + ITILActorFieldConfig $actor_config, + AnswersSet $answers_set, + ): array { + $disabled_persons = []; + $enabled_persons = []; + + foreach ($this->getUniqueStrategies($actor_config) as $strategy) { + if ($strategy === ITILActorFieldStrategy::SPECIFIC_VALUES) { + $notifiable_itemtypes = $this->getNotifiableItemtypes($actor_field); + foreach ($actor_config->getSpecificITILActorsIds() ?? [] as $itemtype => $ids) { + if (!in_array($itemtype, $notifiable_itemtypes, true)) { + continue; + } + + foreach (is_array($ids) ? $ids : [] as $id) { + $person = $this->getPersonKey(['itemtype' => $itemtype, 'items_id' => $id]); + if ($person === null) { + continue; + } + + if ($config->isActorDisabled($role, $person)) { + $disabled_persons[$person] = true; + } else { + $enabled_persons[$person] = true; + } + } + } + + continue; + } + + if ($strategy === ITILActorFieldStrategy::SPECIFIC_ANSWERS) { + foreach ($actor_config->getSpecificQuestionIds() ?? [] as $question_id) { + if (!is_numeric($question_id)) { + continue; + } + + $question_disabled = $config->isQuestionDisabled($role, (int) $question_id); + foreach ($this->getActorsForQuestion($actor_field, (int) $question_id, $answers_set) as $actor) { + $person = $this->getPersonKey($actor); + if ($person === null) { + continue; + } + + if ($question_disabled) { + $disabled_persons[$person] = true; + } else { + $enabled_persons[$person] = true; + } + } + } + + continue; + } + + // Template and groups strategies give no user or supplier + if (!in_array($strategy, self::STRATEGY_LEVEL_SOURCES, true)) { + continue; + } + + $strategy_disabled = $config->isStrategyDisabled($role, $strategy); + foreach ($strategy->getITILActors($actor_field, $actor_config, $answers_set) ?? [] as $actor) { + $person = $this->getPersonKey($actor); + if ($person === null) { + continue; + } + + // The delegated user keeps the delegation block choice + if ($strategy_disabled && !$this->isDelegatedFormFiller($strategy, $actor, $answers_set)) { + $disabled_persons[$person] = true; + } else { + $enabled_persons[$person] = true; + } + } + } + + return [$disabled_persons, $enabled_persons]; + } + + /** Form filler sent on behalf of another user. */ + private function isDelegatedFormFiller(ITILActorFieldStrategy $strategy, mixed $actor, AnswersSet $answers_set): bool + { + $delegated_users_id = $answers_set->getDelegation()->users_id; + if ( + $strategy !== ITILActorFieldStrategy::FORM_FILLER + || $delegated_users_id === null + || !is_array($actor) + ) { + return false; + } + + // Selecting oneself is not a delegation + $author_id = is_numeric($answers_set->fields['users_id'] ?? null) ? (int) $answers_set->fields['users_id'] : 0; + $items_id = is_numeric($actor['items_id'] ?? null) ? (int) $actor['items_id'] : 0; + + return $items_id === $delegated_users_id && $delegated_users_id !== $author_id; + } + + /** @return array */ + private function getActorsForQuestion(ITILActorField $actor_field, int $question_id, AnswersSet $answers_set): array + { + $config_class = $actor_field->getConfigClass(); + if (!is_a($config_class, ITILActorFieldConfig::class, true)) { + return []; + } + + $question_config = $config_class::jsonDeserialize([ + ITILActorFieldConfig::STRATEGIES => [ITILActorFieldStrategy::SPECIFIC_ANSWERS->value], + ITILActorFieldConfig::SPECIFIC_QUESTION_IDS => [$question_id], + ]); + if (!$question_config instanceof ITILActorFieldConfig) { + return []; + } + + return ITILActorFieldStrategy::SPECIFIC_ANSWERS->getITILActors( + $actor_field, + $question_config, + $answers_set, + ) ?? []; + } + + /** + * Actor key, or `email-
` without account. Null for groups. + */ + private function getPersonKey(mixed $actor): ?string + { + if (!is_array($actor)) { + return null; + } + + $itemtype = $actor['itemtype'] ?? null; + if (!in_array($itemtype, self::NOTIFIABLE_ITEMTYPES, true)) { + return null; + } + + $items_id = is_numeric($actor['items_id'] ?? null) ? (int) $actor['items_id'] : 0; + if ($items_id > 0) { + return self::getActorKey($itemtype, $items_id); + } + + $email = $actor['alternative_email'] ?? ''; + if ($itemtype === User::class && is_string($email) && $email !== '') { + return 'email-' . $email; + } + + return null; + } + + /** + * Turns off the followup of the given persons. Template lines come first, + * without notification entry, and keep the followup. + * + * @param array $input + * @param class-string $itemtype + * @param array $persons_to_mute + * @return array + */ + private function muteActorsInInput(array $input, string $itemtype, string $role, array $persons_to_mute): array + { + $actors_key = sprintf('_%s_%s', getForeignKeyFieldForItemType($itemtype), $role); + $notif_key = $actors_key . '_notif'; + + $actors = $input[$actors_key] ?? null; + $notif = $input[$notif_key] ?? []; + if (!is_array($actors) || !is_array($notif)) { + return $input; + } + + $actors = array_values($actors); + $use_notification = is_array($notif['use_notification'] ?? null) ? array_values($notif['use_notification']) : []; + $alternative_email = is_array($notif['alternative_email'] ?? null) ? array_values($notif['alternative_email']) : []; + + $count_actors = count($actors); + $count_notif = count($use_notification); + $template_lines = $count_actors >= $count_notif ? $count_actors - $count_notif : $count_actors; + + for ($line = 0; $line < $template_lines; $line++) { + $person = $this->getPersonKey(['itemtype' => $itemtype, 'items_id' => $actors[$line]]); + if ($person !== null) { + unset($persons_to_mute[$person]); + } + } + + $new_use_notification = []; + $new_alternative_email = []; + $has_changes = false; + for ($line = $template_lines; $line < $count_actors; $line++) { + $notif_index = $line - $template_lines; + $use = $use_notification[$notif_index] ?? '1'; + $email = $alternative_email[$notif_index] ?? ''; + + $person = $this->getPersonKey([ + 'itemtype' => $itemtype, + 'items_id' => $actors[$line], + 'alternative_email' => $email, + ]); + if ($person !== null && isset($persons_to_mute[$person])) { + $use = 0; + $has_changes = true; + } + + $new_use_notification[$line] = $use; + $new_alternative_email[$line] = $email; + } + + if (!$has_changes) { + return $input; + } + + $input[$actors_key] = $actors; + $input[$notif_key] = [ + 'use_notification' => $new_use_notification, + 'alternative_email' => $new_alternative_email, + ]; + + return $input; + } + + /** @param array $input */ + private function getActorFieldConfigFromInput(ITILActorField $actor_field, array $input): ITILActorFieldConfig + { + $raw_config = $input[$actor_field::getKey()] ?? []; + $config_class = $actor_field->getConfigClass(); + $config = $config_class::jsonDeserialize(is_array($raw_config) ? $raw_config : []); + if (!$config instanceof ITILActorFieldConfig) { + throw new InvalidArgumentException("Unexpected actor field config class"); + } + + return $config; + } + + /** + * Accepts a list (saved) or a map value => 0|1 (form toggles). + * + * @return list + */ + private function normalizeDisabledValues(mixed $raw): array + { + if (!is_array($raw)) { + return []; + } + + $values = array_is_list($raw) + ? $raw + : array_keys(array_filter($raw, fn($disabled): bool => is_scalar($disabled) && (bool) $disabled)); + + $normalized = []; + foreach ($values as $value) { + if (is_string($value) || is_int($value)) { + $normalized[] = (string) $value; + } + } + + return array_values(array_unique($normalized)); + } +} diff --git a/src/Model/Destination/ActorNotificationFieldConfig.php b/src/Model/Destination/ActorNotificationFieldConfig.php new file mode 100644 index 0000000..bc49451 --- /dev/null +++ b/src/Model/Destination/ActorNotificationFieldConfig.php @@ -0,0 +1,193 @@ +, + * disabled_actors: list, + * disabled_question_ids: list + * } + */ +#[HasFieldWithQuestionId(self::DISABLED_QUESTION_IDS, is_array: true, list_of_strategies_field: self::ROLES)] +final class ActorNotificationFieldConfig implements JsonFieldInterface +{ + public const ROLES = 'roles'; + + public const DISABLED_STRATEGIES = 'disabled_strategies'; + + public const DISABLED_ACTORS = 'disabled_actors'; + + public const DISABLED_QUESTION_IDS = 'disabled_question_ids'; + + /** @var array */ + private array $actors_configs = []; + + /** @param array $roles */ + public function __construct( + private readonly array $roles = [], + ) {} + + /** @param array $data */ + #[Override] + public static function jsonDeserialize(array $data): self + { + $raw_roles = $data[self::ROLES] ?? []; + if (!is_array($raw_roles)) { + return new self(); + } + + $roles = []; + foreach ($raw_roles as $role => $raw_role) { + if (!is_string($role) || !is_array($raw_role)) { + continue; + } + + $roles[$role] = self::buildRoleConfig( + strategies: self::filterStrings($raw_role[self::DISABLED_STRATEGIES] ?? []), + actors: self::filterStrings($raw_role[self::DISABLED_ACTORS] ?? []), + question_ids: self::filterIds($raw_role[self::DISABLED_QUESTION_IDS] ?? []), + ); + } + + return new self($roles); + } + + /** @return array{roles: array} */ + #[Override] + public function jsonSerialize(): array + { + return [self::ROLES => $this->roles]; + } + + /** + * Every list is always present: the core needs the question ids on duplication. + * + * @param list $strategies + * @param list $actors + * @param list $question_ids + * @return RoleConfig + */ + public static function buildRoleConfig( + array $strategies = [], + array $actors = [], + array $question_ids = [], + ): array { + return [ + self::DISABLED_STRATEGIES => $strategies, + self::DISABLED_ACTORS => $actors, + self::DISABLED_QUESTION_IDS => $question_ids, + ]; + } + + /** @param array $actors_configs */ + public function withActorsConfigs(array $actors_configs): self + { + $config = clone $this; + $config->actors_configs = $actors_configs; + + return $config; + } + + public function getActorsConfig(string $role): ?ITILActorFieldConfig + { + return $this->actors_configs[$role] ?? null; + } + + /** @return RoleConfig */ + public function getRoleConfig(string $role): array + { + return $this->roles[$role] ?? self::buildRoleConfig(); + } + + public function hasDisabledSources(string $role): bool + { + $role_config = $this->getRoleConfig($role); + + return $role_config[self::DISABLED_STRATEGIES] !== [] + || $role_config[self::DISABLED_ACTORS] !== [] + || $role_config[self::DISABLED_QUESTION_IDS] !== []; + } + + public function isStrategyDisabled(string $role, ITILActorFieldStrategy $strategy): bool + { + return in_array($strategy->value, $this->getRoleConfig($role)[self::DISABLED_STRATEGIES], true); + } + + /** @param string $actor_key `-`, e.g. `users_id-12` */ + public function isActorDisabled(string $role, string $actor_key): bool + { + return in_array($actor_key, $this->getRoleConfig($role)[self::DISABLED_ACTORS], true); + } + + public function isQuestionDisabled(string $role, int $question_id): bool + { + return in_array($question_id, $this->getRoleConfig($role)[self::DISABLED_QUESTION_IDS], true); + } + + /** @return list */ + private static function filterStrings(mixed $values): array + { + if (!is_array($values)) { + return []; + } + + return array_values(array_filter($values, is_string(...))); + } + + /** @return list */ + private static function filterIds(mixed $values): array + { + if (!is_array($values)) { + return []; + } + + $ids = []; + foreach ($values as $value) { + if (is_numeric($value) && (int) $value > 0) { + $ids[] = (int) $value; + } + } + + return $ids; + } +} diff --git a/src/Service/InitManager.php b/src/Service/InitManager.php index d657c10..fa8519f 100644 --- a/src/Service/InitManager.php +++ b/src/Service/InitManager.php @@ -34,13 +34,16 @@ namespace GlpiPlugin\Advancedforms\Service; use Config; +use Glpi\Form\Destination\FormDestinationChange; use Glpi\Form\Destination\FormDestinationManager; +use Glpi\Form\Destination\FormDestinationProblem; use Glpi\Form\Destination\FormDestinationTicket; use Glpi\Form\Migration\TypesConversionMapper; use Glpi\Form\QuestionType\QuestionTypesManager; use Glpi\Plugin\Hooks; use Glpi\Toolbox\SingletonTrait; use GlpiPlugin\Advancedforms\Model\Config\ConfigTab; +use GlpiPlugin\Advancedforms\Model\Destination\ActorNotificationField; use GlpiPlugin\Advancedforms\Model\Destination\PreReservationField; use GlpiPlugin\Advancedforms\Model\QuestionType\AdvancedCategory; use GlpiPlugin\Advancedforms\Model\QuestionType\LegacyQuestionTypeInterface; @@ -123,21 +126,26 @@ private function registerPluginTypes(): void private function registerDestinationFields(): void { + $fields = [ + [FormDestinationTicket::class, new PreReservationField()], + [FormDestinationTicket::class, new ActorNotificationField()], + [FormDestinationChange::class, new ActorNotificationField()], + [FormDestinationProblem::class, new ActorNotificationField()], + ]; + $destination_manager = FormDestinationManager::getInstance(); + foreach ($fields as [$destination_class, $field]) { + // init() may run multiple times per process; avoid double-registering the field. + $already_registered = array_filter( + $destination_manager->getPluginCommonITILConfigFields($destination_class), + fn($registered_field) => $registered_field instanceof $field, + ); + if ($already_registered !== []) { + continue; + } - // init() may run multiple times per process; avoid double-registering the field. - $already_registered = array_filter( - $destination_manager->getPluginCommonITILConfigFields(FormDestinationTicket::class), - fn($field) => $field instanceof PreReservationField, - ); - if ($already_registered !== []) { - return; + $destination_manager->registerPluginCommonITILConfigField($destination_class, $field); } - - $destination_manager->registerPluginCommonITILConfigField( - FormDestinationTicket::class, - new PreReservationField(), - ); } private function registerTimelineHooks(): void diff --git a/src/Service/InstallManager.php b/src/Service/InstallManager.php index 5e56baa..1fd3e2c 100644 --- a/src/Service/InstallManager.php +++ b/src/Service/InstallManager.php @@ -38,6 +38,7 @@ use DBConnection; use Glpi\DBAL\QueryExpression; use Glpi\Toolbox\SingletonTrait; +use GlpiPlugin\Advancedforms\Console\ImportFormcreatorActorsNotificationCommand; use GlpiPlugin\Advancedforms\Model\TicketReservationRequest; use Migration; use Notification; @@ -239,7 +240,10 @@ public function uninstall(): bool global $DB; $config = new Config(); - $config->deleteByCriteria(['context' => 'advancedforms']); + $config->deleteByCriteria(['context' => [ + 'advancedforms', + ImportFormcreatorActorsNotificationCommand::IMPORTED_DESTINATIONS_CONFIG_CONTEXT, + ]]); $this->uninstallNotifications(); diff --git a/templates/destination/actor_notification_config_field.html.twig b/templates/destination/actor_notification_config_field.html.twig new file mode 100644 index 0000000..f3a58dc --- /dev/null +++ b/templates/destination/actor_notification_config_field.html.twig @@ -0,0 +1,69 @@ +{# + # ------------------------------------------------------------------------- + # advancedforms plugin for GLPI + # ------------------------------------------------------------------------- + # + # MIT License + # + # Permission is hereby granted, free of charge, to any person obtaining a copy + # of this software and associated documentation files (the "Software"), to deal + # in the Software without restriction, including without limitation the rights + # to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + # copies of the Software, and to permit persons to whom the Software is + # furnished to do so, subject to the following conditions: + # + # The above copyright notice and this permission notice shall be included in all + # copies or substantial portions of the Software. + # + # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + # AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + # OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + # SOFTWARE. + # ------------------------------------------------------------------------- + # @copyright Copyright (C) 2025 by the advancedforms plugin team. + # @license MIT https://opensource.org/licenses/mit-license.php + # @link https://github.com/pluginsGLPI/advancedforms + # ------------------------------------------------------------------------- + #} + +
+
+ {{ __('Actors found only by disabled sources are added without email followup. Sources added to the actors fields are listed once the destination is saved.', 'advancedforms') }} +
+ + {% set disabled = options.disabled|default(false) %} + {% for role in roles %} +
+ {{ role.label }} + {% for row in role.rows %} + {# The checkbox value overrides the hidden one: an unchecked toggle + submits "1", meaning the source is disabled. A disabled field + submits nothing. #} + + {% endfor %} +
+ {% else %} + + {{ __('No user or supplier source is configured in the actors fields.', 'advancedforms') }} + + {% endfor %} +
diff --git a/tests/Console/ImportFormcreatorActorsNotificationCommandTest.php b/tests/Console/ImportFormcreatorActorsNotificationCommandTest.php new file mode 100644 index 0000000..7bfed37 --- /dev/null +++ b/tests/Console/ImportFormcreatorActorsNotificationCommandTest.php @@ -0,0 +1,347 @@ +migrateFormcreatorFormWithUncheckedActors(); + $config_before = $this->getDestination($form)->fields['config']; + + $tester = $this->runCommand(dry_run: true); + + $this->assertSame(Command::SUCCESS, $tester->getStatusCode()); + $this->assertStringContainsString('dry run', $tester->getDisplay()); + $this->assertStringContainsString('Destinations to update: 1', $tester->getDisplay()); + $this->assertStringContainsString('Disabled sources: 3', $tester->getDisplay()); + $this->assertSame($config_before, $this->getDestination($form)->fields['config']); + + // A dry run does not mark the destination as imported + $this->assertStringContainsString('Updated destinations: 1', $this->runCommand()->getDisplay()); + } + + public function testUncheckedActorsSourcesAreDisabled(): void + { + $form = $this->migrateFormcreatorFormWithUncheckedActors(); + + $tester = $this->runCommand(); + + $this->assertSame(Command::SUCCESS, $tester->getStatusCode()); + $this->assertStringContainsString('Updated destinations: 1', $tester->getDisplay()); + $this->assertStringContainsString('Disabled sources: 3', $tester->getDisplay()); + $this->assertNotificationConfigSame( + $this->expectedConfig($form), + $this->getDestination($form)->getConfig()[ActorNotificationField::getKey()], + ); + } + + public function testRerunSkipsImportedDestinations(): void + { + $form = $this->migrateFormcreatorFormWithUncheckedActors(); + $this->runCommand(); + $expected = $this->changeSettingsByHand($form); + + $tester = $this->runCommand(); + + $this->assertStringContainsString('Updated destinations: 0', $tester->getDisplay()); + $this->assertStringContainsString('Disabled sources: 0', $tester->getDisplay()); + $this->assertStringContainsString('Destinations already imported, skipped: 1', $tester->getDisplay()); + $this->assertNotificationConfigSame( + $expected, + $this->getDestination($form)->getConfig()[ActorNotificationField::getKey()], + ); + } + + public function testForcedRerunImportsAgainWithoutDuplicates(): void + { + $form = $this->migrateFormcreatorFormWithUncheckedActors(); + $this->runCommand(); + $expected = $this->changeSettingsByHand($form); + + $tester = $this->runCommand(force: true); + + $this->assertStringContainsString('Updated destinations: 1', $tester->getDisplay()); + $this->assertStringContainsString('Disabled sources: 1', $tester->getDisplay()); + $expected['roles']['requester']['disabled_strategies'] = ['form_filler']; + $this->assertNotificationConfigSame( + $expected, + $this->getDestination($form)->getConfig()[ActorNotificationField::getKey()], + ); + } + + public function testDestinationWithoutSourceFoundIsNotRemembered(): void + { + /** @var DBmysql $DB */ + global $DB; + + $form = $this->migrateFormcreatorFormWithUncheckedActors(); + // Only the technician stays unchecked + $DB->update( + 'glpi_plugin_formcreator_targets_actors', + ['use_notification' => 1], + ['NOT' => ['actor_role' => self::ROLE_ASSIGN]], + ); + $config = $this->getDestination($form)->getConfig(); + $config_without_tech = $config; + $config_without_tech[AssigneeField::getKey()][ITILActorFieldConfig::SPECIFIC_ITILACTORS_IDS] = [ + User::class => [getItemByTypeName(User::class, 'normal', true)], + ]; + $this->saveDestinationConfig($form, $config_without_tech); + + $tester = $this->runCommand(); + + $this->assertStringContainsString('Source not found in the destination actors', $tester->getDisplay()); + $this->assertStringContainsString('Updated destinations: 0', $tester->getDisplay()); + + // Once the technician is added to the destination, the next run imports it + $this->saveDestinationConfig($form, $config); + $tester = $this->runCommand(); + + $this->assertStringContainsString('Updated destinations: 1', $tester->getDisplay()); + $this->assertStringContainsString('Disabled sources: 1', $tester->getDisplay()); + } + + public function testFailedUpdateIsReportedAndNotRemembered(): void + { + global $PLUGIN_HOOKS; + + $form = $this->migrateFormcreatorFormWithUncheckedActors(); + $config_before = $this->getDestination($form)->fields['config']; + + // A plugin refuses the update of the destination + $plugin_hooks = $PLUGIN_HOOKS; + $PLUGIN_HOOKS[Hooks::PRE_ITEM_UPDATE]['advancedforms'][FormDestination::class] = static function (FormDestination $destination): void { + $destination->input = false; + }; + try { + $tester = $this->runCommand(); + } finally { + $PLUGIN_HOOKS = $plugin_hooks; + } + + $this->assertSame(Command::FAILURE, $tester->getStatusCode()); + $this->assertStringContainsString('Updated destinations: 0', $tester->getDisplay()); + $this->assertSame($config_before, $this->getDestination($form)->fields['config']); + + $this->assertStringContainsString('Updated destinations: 1', $this->runCommand()->getDisplay()); + } + + public function testMissingQuestionIsReportedAndOtherSourcesImported(): void + { + $form = $this->migrateFormcreatorFormWithUncheckedActors(); + $expected = $this->expectedConfig($form); + $expected['roles']['observer']['disabled_question_ids'] = []; + $this->deleteItem(Question::class, $this->getQuestionId($form, 'FC actors question'), purge: true); + + $tester = $this->runCommand(); + + $this->assertSame(Command::SUCCESS, $tester->getStatusCode()); + $this->assertStringContainsString('Question not found', $tester->getDisplay()); + $this->assertStringContainsString('Disabled sources: 2', $tester->getDisplay()); + $this->assertNotificationConfigSame( + $expected, + $this->getDestination($form)->getConfig()[ActorNotificationField::getKey()], + ); + } + + public function testMissingFormcreatorTablesFails(): void + { + /** @var DBmysql $DB */ + global $DB; + + // The command uses the global connection: a stub hides the actors table. + $db_stub = $this->createStub(DBmysql::class); + $db_stub->connected = true; + $db_stub->method('tableExists')->willReturnCallback( + fn(string $table): bool => $table !== 'glpi_plugin_formcreator_targets_actors', + ); + + $real_db = $DB; + $DB = $db_stub; + try { + $tester = $this->runCommand(); + } finally { + $DB = $real_db; + } + + $this->assertSame(Command::FAILURE, $tester->getStatusCode()); + $this->assertStringContainsString( + 'Formcreator tables not found: glpi_plugin_formcreator_targets_actors.', + $tester->getDisplay(), + ); + } + + private function migrateFormcreatorFormWithUncheckedActors(): Form + { + /** @var DBmysql $DB */ + global $DB; + + $this->login(); + $tech_id = getItemByTypeName(User::class, 'tech', true); + $normal_id = getItemByTypeName(User::class, 'normal', true); + $group = $this->createItem(Group::class, ['name' => 'FC observers group']); + + $this->createSimpleFormcreatorForm( + name: 'FC form with unchecked actors', + questions: [[ + 'name' => 'FC actors question', + 'fieldtype' => 'actor', + 'uuid' => 'fc-actors-question-uuid', + ]], + ticket_destinations: [['name' => 'FC ticket target', 'content' => '##FULLFORM##']], + properties: ['uuid' => 'fc-form-uuid'], + ); + $target_id = (int) $DB->request([ + 'SELECT' => 'id', + 'FROM' => 'glpi_plugin_formcreator_targettickets', + 'WHERE' => ['name' => 'FC ticket target'], + ])->current()['id']; + $question_id = $this->getFormCreatorQuestionId('FC actors question'); + + $actors = [ + [self::ROLE_REQUESTER, 1, 0, 0], // Form author, unchecked + [self::ROLE_OBSERVER, 9, $question_id, 0], // Actors question, unchecked + [self::ROLE_OBSERVER, 5, $group->getID(), 0], // Group, ignored + [self::ROLE_ASSIGN, 3, $tech_id, 0], // Specific user, unchecked + [self::ROLE_ASSIGN, 3, $normal_id, 1], // Specific user, checked + ]; + foreach ($actors as [$role, $type, $value, $use_notification]) { + $DB->insert('glpi_plugin_formcreator_targets_actors', [ + 'itemtype' => 'PluginFormcreatorTargetTicket', + 'items_id' => $target_id, + 'actor_role' => $role, + 'actor_type' => $type, + 'actor_value' => $value, + 'use_notification' => $use_notification, + ]); + } + + $result = (new FormMigration($DB, FormAccessControlManager::getInstance()))->execute(); + $this->assertTrue($result->isFullyProcessed()); + + $form = getItemByTypeName(Form::class, 'FC form with unchecked actors'); + $this->assertInstanceOf(Form::class, $form); + + return $form; + } + + private function runCommand(bool $dry_run = false, bool $force = false): CommandTester + { + $tester = new CommandTester(new ImportFormcreatorActorsNotificationCommand()); + $tester->execute(array_filter(['--dry-run' => $dry_run, '--force' => $force])); + + return $tester; + } + + /** + * Enables again the imported form filler and disables another source. + * + * @return array The new config + */ + private function changeSettingsByHand(Form $form): array + { + $config_key = ActorNotificationField::getKey(); + $destination = $this->getDestination($form); + $config = $destination->getConfig(); + $config[$config_key]['roles']['requester']['disabled_strategies'] = []; + $config[$config_key]['roles']['assign']['disabled_actors'][] = 'users_id-' . getItemByTypeName(User::class, 'normal', true); + $this->saveDestinationConfig($form, $config); + + return $this->getDestination($form)->getConfig()[$config_key]; + } + + /** @param array $config Saved config, already prepared */ + private function saveDestinationConfig(Form $form, array $config): void + { + $this->updateItem( + FormDestination::class, + $this->getDestination($form)->getID(), + ['config' => $config, '_from_import' => true], + ['config', '_from_import'], + ); + } + + private function getDestination(Form $form): FormDestination + { + $destinations = $form->getDestinations(); + $this->assertCount(1, $destinations); + + $destination = current($destinations); + $this->assertSame('FC ticket target', $destination->fields['name']); + + return $destination; + } + + /** @return array */ + private function expectedConfig(Form $form): array + { + return ['roles' => [ + 'requester' => ActorNotificationFieldConfig::buildRoleConfig(strategies: ['form_filler']), + 'observer' => ActorNotificationFieldConfig::buildRoleConfig( + question_ids: [$this->getQuestionId($form, 'FC actors question')], + ), + 'assign' => ActorNotificationFieldConfig::buildRoleConfig( + actors: ['users_id-' . getItemByTypeName(User::class, 'tech', true)], + ), + ]]; + } +} diff --git a/tests/Model/Destination/ActorNotificationConfigAssertionsTrait.php b/tests/Model/Destination/ActorNotificationConfigAssertionsTrait.php new file mode 100644 index 0000000..51d0f3b --- /dev/null +++ b/tests/Model/Destination/ActorNotificationConfigAssertionsTrait.php @@ -0,0 +1,63 @@ + $expected */ + protected function assertNotificationConfigSame(array $expected, mixed $actual): void + { + $this->assertIsArray($actual); + $this->assertSame($this->sortKeys($expected), $this->sortKeys($actual)); + } + + /** + * Sorts the keys of the maps, the lists keep their order. + * + * @param array $array + * @return array + */ + private function sortKeys(array $array): array + { + if (!array_is_list($array)) { + ksort($array); + } + + return array_map(fn(mixed $value): mixed => is_array($value) ? $this->sortKeys($value) : $value, $array); + } +} diff --git a/tests/Model/Destination/ActorNotificationFieldTest.php b/tests/Model/Destination/ActorNotificationFieldTest.php new file mode 100644 index 0000000..260926a --- /dev/null +++ b/tests/Model/Destination/ActorNotificationFieldTest.php @@ -0,0 +1,740 @@ +assertInstanceOf( + ActorNotificationField::class, + $destination->getConfigurableFieldByKey(ActorNotificationField::getKey()), + ); + } + } + + public function testSectionListsUsersAndSuppliersSourcesOnly(): void + { + $this->login(); + $tech = $this->createTechnician('Section tech'); + $group = $this->createItem(Group::class, ['name' => 'Section group', 'is_assign' => 1]); + $form = $this->createFormWithActorsQuestions(); + + $this->setDestinationConfig($form, [ + RequesterField::getKey() => $this->requesterConfig([ITILActorFieldStrategy::FORM_FILLER]), + ObserverField::getKey() => $this->observerConfig([ITILActorFieldStrategy::FROM_TEMPLATE]), + AssigneeField::getKey() => $this->assigneeConfig( + [ITILActorFieldStrategy::SPECIFIC_VALUES], + actors: ['users_id-' . $tech->getID(), 'groups_id-' . $group->getID()], + ), + ]); + + $html = $this->renderField($form); + + $this->assertStringContainsString('User who filled the form', $html); + $this->assertStringContainsString($tech->getFriendlyName(), $html); + $this->assertStringContainsString('[roles][assign][disabled_actors][users_id-' . $tech->getID() . ']', $html); + $this->assertStringNotContainsString('groups_id-' . $group->getID(), $html); + $this->assertStringNotContainsString('[roles][observer]', $html); + // Every toggle is on by default + $this->assertSame(2, substr_count($html, 'checked')); + } + + public function testSupplierIsNotListedWhereTheCoreIgnoresIt(): void + { + $this->login(); + $supplier = $this->createItem(Supplier::class, [ + 'name' => 'Observer supplier', + 'entities_id' => $this->getTestRootEntity(true), + ]); + $form = $this->createFormWithActorsQuestions(); + + // Observers are users or groups only + $this->setDestinationConfig($form, [ + ObserverField::getKey() => (new ObserverFieldConfig( + [ITILActorFieldStrategy::SPECIFIC_VALUES], + ['suppliers_id-' . $supplier->getID()], + ))->jsonSerialize(), + ]); + + $this->assertStringNotContainsString('[roles][observer]', $this->renderField($form)); + } + + public function testDisabledSectionSubmitsNothing(): void + { + $this->login(); + $form = $this->createFormWithActorsQuestions(); + $this->setDestinationConfig($form, [ + RequesterField::getKey() => $this->requesterConfig([ITILActorFieldStrategy::FORM_FILLER]), + ]); + + $html = $this->renderField($form, ['disabled' => true]); + + // The hidden input and the toggle of the form filler source + $this->assertSame(2, substr_count($html, 'assertSame(2, preg_match_all('/]*\sdisabled\s*>/', $html)); + } + + public function testDisabledSpecificActorHasNoFollowup(): void + { + $this->login(); + $tech = $this->createTechnician('Specific tech'); + $form = $this->createFormWithActorsQuestions(); + + $this->setDestinationConfig($form, [ + AssigneeField::getKey() => $this->assigneeConfig( + [ITILActorFieldStrategy::SPECIFIC_VALUES], + actors: ['users_id-' . $tech->getID()], + ), + ActorNotificationField::getKey() => $this->notificationConfig( + assign: ['disabled_actors' => ['users_id-' . $tech->getID()]], + ), + ]); + + $ticket = $this->sendFormAndGetCreatedTicket($form); + + $this->assertSame( + [$this->userKey($tech) => 0], + $this->getActorsNotifications($ticket, CommonITILActor::ASSIGN), + ); + $requester = User::getById(Session::getLoginUserID()); + $this->assertInstanceOf(User::class, $requester); + $this->assertSame( + [$this->userKey($requester) => (int) $requester->isUserNotificationEnable()], + $this->getActorsNotifications($ticket, CommonITILActor::REQUESTER), + ); + } + + public function testDisabledFormFillerHasNoFollowup(): void + { + $this->login(); + $form = $this->createFormWithActorsQuestions(); + + $this->setDestinationConfig($form, [ + RequesterField::getKey() => $this->requesterConfig([ITILActorFieldStrategy::FORM_FILLER]), + ActorNotificationField::getKey() => $this->notificationConfig( + requester: ['disabled_strategies' => [ITILActorFieldStrategy::FORM_FILLER->value]], + ), + ]); + + $ticket = $this->sendFormAndGetCreatedTicket($form); + + $this->assertSame( + ['User-' . Session::getLoginUserID() => 0], + $this->getActorsNotifications($ticket, CommonITILActor::REQUESTER), + ); + } + + public static function provideDelegationFollowups(): iterable + { + yield 'followup chosen' => [true]; + yield 'followup refused' => [false]; + } + + #[DataProvider('provideDelegationFollowups')] + public function testDisabledFormFillerKeepsDelegationFollowup(bool $use_notification): void + { + $delegated_user = $this->createItem(User::class, ['name' => 'Delegated requester']); + $post_only_id = getItemByTypeName(User::class, 'post-only', true); + $group = $this->createItem(Group::class, ['name' => 'Delegation group']); + $this->createItem(Group_User::class, [ + 'groups_id' => $group->getID(), + 'users_id' => $post_only_id, + 'is_userdelegate' => 1, + ]); + $this->createItem(Group_User::class, [ + 'groups_id' => $group->getID(), + 'users_id' => $delegated_user->getID(), + ]); + + $this->login(); + $form = $this->createFormWithActorsQuestions(); + $this->setDestinationConfig($form, [ + RequesterField::getKey() => $this->requesterConfig([ITILActorFieldStrategy::FORM_FILLER]), + ActorNotificationField::getKey() => $this->notificationConfig( + requester: ['disabled_strategies' => [ITILActorFieldStrategy::FORM_FILLER->value]], + ), + ]); + + $this->login('post-only', 'postonly'); + $created_items = AnswersHandler::getInstance()->saveAnswers( + $form, + [], + $post_only_id, + delegation: new DelegationData($delegated_user->getID(), $use_notification), + )->getCreatedItems(); + $this->assertCount(1, $created_items); + $ticket = current($created_items); + $this->assertInstanceOf(CommonITILObject::class, $ticket); + + $this->assertSame( + [$this->userKey($delegated_user) => (int) $use_notification], + $this->getActorsNotifications($ticket, CommonITILActor::REQUESTER), + ); + } + + public function testDisabledFormFillerSelectingSelfHasNoFollowup(): void + { + $this->login(); + $form = $this->createFormWithActorsQuestions(); + $this->setDestinationConfig($form, [ + RequesterField::getKey() => $this->requesterConfig([ITILActorFieldStrategy::FORM_FILLER]), + ActorNotificationField::getKey() => $this->notificationConfig( + requester: ['disabled_strategies' => [ITILActorFieldStrategy::FORM_FILLER->value]], + ), + ]); + + $this->login('post-only', 'postonly'); + $post_only_id = getItemByTypeName(User::class, 'post-only', true); + $created_items = AnswersHandler::getInstance()->saveAnswers( + $form, + [], + $post_only_id, + delegation: new DelegationData($post_only_id, true), + )->getCreatedItems(); + $this->assertCount(1, $created_items); + $ticket = current($created_items); + $this->assertInstanceOf(CommonITILObject::class, $ticket); + + $this->assertSame( + ['User-' . $post_only_id => 0], + $this->getActorsNotifications($ticket, CommonITILActor::REQUESTER), + ); + } + + public function testDisabledQuestionMutesEveryAnsweredPerson(): void + { + $this->login(); + $user_1 = $this->createItem(User::class, ['name' => 'Observer 1']); + $user_2 = $this->createItem(User::class, ['name' => 'Observer 2']); + $form = $this->createFormWithActorsQuestions(); + $question_id = $this->getQuestionId($form, 'Observers'); + + $this->setDestinationConfig($form, [ + ObserverField::getKey() => $this->observerConfig( + [ITILActorFieldStrategy::SPECIFIC_ANSWERS], + question_ids: [$question_id], + ), + ActorNotificationField::getKey() => $this->notificationConfig( + observer: ['disabled_question_ids' => [$question_id]], + ), + ]); + + $ticket = $this->sendFormAndGetCreatedTicket($form, [ + 'Observers' => ['users_id-' . $user_1->getID(), 'users_id-' . $user_2->getID()], + ]); + + $this->assertEquals( + [$this->userKey($user_1) => 0, $this->userKey($user_2) => 0], + $this->getActorsNotifications($ticket, CommonITILActor::OBSERVER), + ); + } + + public function testDisabledEmailQuestionMutesTheAddress(): void + { + $this->login(); + $form = $this->createFormWithActorsQuestions(); + $question_id = $this->getQuestionId($form, 'Email'); + + $this->setDestinationConfig($form, [ + ObserverField::getKey() => $this->observerConfig( + [ITILActorFieldStrategy::SPECIFIC_ANSWERS], + question_ids: [$question_id], + ), + ActorNotificationField::getKey() => $this->notificationConfig( + observer: ['disabled_question_ids' => [$question_id]], + ), + ]); + + $ticket = $this->sendFormAndGetCreatedTicket($form, ['Email' => 'observer@example.com']); + + $this->assertSame( + ['email-observer@example.com' => 0], + $this->getActorsNotifications($ticket, CommonITILActor::OBSERVER), + ); + } + + public static function provideStrategiesOrders(): iterable + { + yield 'specific actor first' => [true]; + yield 'question first' => [false]; + } + + #[DataProvider('provideStrategiesOrders')] + public function testEnabledSourceWinsOverDisabledOne(bool $specific_first): void + { + $this->login(); + $tech = $this->createTechnician('Two sources tech'); + $form = $this->createFormWithActorsQuestions(); + $question_id = $this->getQuestionId($form, 'Assignee'); + + $strategies = [ITILActorFieldStrategy::SPECIFIC_VALUES, ITILActorFieldStrategy::SPECIFIC_ANSWERS]; + $this->setDestinationConfig($form, [ + AssigneeField::getKey() => $this->assigneeConfig( + $specific_first ? $strategies : array_reverse($strategies), + actors: ['users_id-' . $tech->getID()], + question_ids: [$question_id], + ), + ActorNotificationField::getKey() => $this->notificationConfig( + assign: ['disabled_actors' => ['users_id-' . $tech->getID()]], + ), + ]); + + $ticket = $this->sendFormAndGetCreatedTicket($form, ['Assignee' => ['users_id-' . $tech->getID()]]); + + $this->assertSame( + [$this->userKey($tech) => 1], + $this->getActorsNotifications($ticket, CommonITILActor::ASSIGN), + ); + } + + public function testTemplateActorKeepsFollowup(): void + { + $this->login(); + $tech = $this->createTechnician('Template tech'); + $other_tech = $this->createTechnician('Template other tech'); + $this->createItem(TicketTemplatePredefinedField::class, [ + 'tickettemplates_id' => getItemByTypeName(TicketTemplate::class, 'Default', true), + 'num' => 5, // User assignee + 'value' => $tech->getID(), + ]); + $form = $this->createFormWithActorsQuestions(); + + $this->setDestinationConfig($form, [ + AssigneeField::getKey() => $this->assigneeConfig( + [ITILActorFieldStrategy::SPECIFIC_VALUES], + actors: ['users_id-' . $tech->getID(), 'users_id-' . $other_tech->getID()], + ), + ActorNotificationField::getKey() => $this->notificationConfig( + assign: ['disabled_actors' => ['users_id-' . $tech->getID(), 'users_id-' . $other_tech->getID()]], + ), + ]); + + $ticket = $this->sendFormAndGetCreatedTicket($form); + + // The template actor shifts the actors lines: the other technician + // must still be the muted one. + $this->assertEquals( + [$this->userKey($tech) => 1, $this->userKey($other_tech) => 0], + $this->getActorsNotifications($ticket, CommonITILActor::ASSIGN), + ); + } + + public function testDisabledSupplierHasNoFollowup(): void + { + $this->login(); + $supplier = $this->createItem(Supplier::class, [ + 'name' => 'Muted supplier', + 'email' => 'supplier@example.com', + 'entities_id' => $this->getTestRootEntity(true), + ]); + $form = $this->createFormWithActorsQuestions(); + + $this->setDestinationConfig($form, [ + AssigneeField::getKey() => $this->assigneeConfig( + [ITILActorFieldStrategy::SPECIFIC_VALUES], + actors: ['suppliers_id-' . $supplier->getID()], + ), + ActorNotificationField::getKey() => $this->notificationConfig( + assign: ['disabled_actors' => ['suppliers_id-' . $supplier->getID()]], + ), + ]); + + $ticket = $this->sendFormAndGetCreatedTicket($form); + + $this->assertSame( + ['Supplier-' . $supplier->getID() => 0], + $this->getActorsNotifications($ticket, CommonITILActor::ASSIGN), + ); + } + + public static function provideOtherDestinations(): iterable + { + yield 'change' => [FormDestinationChange::class]; + yield 'problem' => [FormDestinationProblem::class]; + } + + /** @param class-string $destination_class */ + #[DataProvider('provideOtherDestinations')] + public function testDisabledSpecificActorOnOtherDestinations(string $destination_class): void + { + $this->login(); + $tech = $this->createTechnician('Other destination tech'); + $form = $this->createFormWithActorsQuestions($destination_class); + + $this->setDestinationConfig($form, [ + AssigneeField::getKey() => $this->assigneeConfig( + [ITILActorFieldStrategy::SPECIFIC_VALUES], + actors: ['users_id-' . $tech->getID()], + ), + ActorNotificationField::getKey() => $this->notificationConfig( + assign: ['disabled_actors' => ['users_id-' . $tech->getID()]], + ), + ]); + + $created_items = $this->sendFormAndGetAnswerSet($form)->getCreatedItems(); + $this->assertCount(1, $created_items); + $item = current($created_items); + $this->assertInstanceOf(CommonITILObject::class, $item); + + $this->assertSame( + [$this->userKey($tech) => 0], + $this->getActorsNotifications($item, CommonITILActor::ASSIGN), + ); + } + + public function testDestinationWithoutSettingIsUnchanged(): void + { + $this->login(); + $tech = $this->createTechnician('Untouched tech'); + $form = $this->createFormWithActorsQuestions(); + + $this->setDestinationConfig($form, [ + AssigneeField::getKey() => $this->assigneeConfig( + [ITILActorFieldStrategy::SPECIFIC_VALUES], + actors: ['users_id-' . $tech->getID()], + ), + ]); + + $ticket = $this->sendFormAndGetCreatedTicket($form); + + $this->assertSame( + [$this->userKey($tech) => 1], + $this->getActorsNotifications($ticket, CommonITILActor::ASSIGN), + ); + } + + public function testFormTogglesAreConvertedToDisabledSources(): void + { + $this->login(); + $tech = $this->createTechnician('Toggles tech'); + $other_tech = $this->createTechnician('Toggles other tech'); + $form = $this->createFormWithActorsQuestions(); + + // Unchecked toggles submit "1", checked ones "0". + $config = $this->setDestinationConfig($form, [ + RequesterField::getKey() => $this->requesterConfig([ITILActorFieldStrategy::FORM_FILLER]), + AssigneeField::getKey() => $this->assigneeConfig( + [ITILActorFieldStrategy::SPECIFIC_VALUES], + actors: ['users_id-' . $tech->getID(), 'users_id-' . $other_tech->getID()], + ), + ActorNotificationField::getKey() => [ + 'roles' => [ + 'requester' => ['disabled_strategies' => ['form_filler' => '0']], + 'assign' => ['disabled_actors' => [ + 'users_id-' . $tech->getID() => '1', + 'users_id-' . $other_tech->getID() => '0', + ]], + ], + ], + ]); + + $this->assertNotificationConfigSame( + $this->notificationConfig(assign: ['disabled_actors' => ['users_id-' . $tech->getID()]]), + $config[ActorNotificationField::getKey()], + ); + } + + public function testUnknownKeysAndRemovedSourcesAreNotSaved(): void + { + $this->login(); + $tech = $this->createTechnician('Removed tech'); + $form = $this->createFormWithActorsQuestions(); + $question_id = $this->getQuestionId($form, 'Observers'); + + $config = $this->setDestinationConfig($form, [ + RequesterField::getKey() => $this->requesterConfig([ITILActorFieldStrategy::FORM_FILLER]), + ObserverField::getKey() => $this->observerConfig([ITILActorFieldStrategy::FROM_TEMPLATE]), + AssigneeField::getKey() => $this->assigneeConfig([ITILActorFieldStrategy::FROM_TEMPLATE]), + ActorNotificationField::getKey() => [ + 'unknown' => 'value', + 'roles' => [ + 'unknown_role' => ['disabled_strategies' => ['form_filler']], + 'requester' => [ + 'disabled_strategies' => ['form_filler', 'from_template', 'unknown_strategy'], + 'unknown_list' => ['foo'], + ], + // Sources no longer configured in the actors fields + 'observer' => ['disabled_question_ids' => [$question_id]], + 'assign' => ['disabled_actors' => ['users_id-' . $tech->getID()]], + ], + ], + ]); + + $this->assertNotificationConfigSame( + $this->notificationConfig(requester: ['disabled_strategies' => ['form_filler']]), + $config[ActorNotificationField::getKey()], + ); + } + + public function testSettingsSurviveExportAndImport(): void + { + $this->login(); + $tech = $this->createTechnician('Exported tech'); + $form = $this->createFormWithActorsQuestions(); + $question_id = $this->getQuestionId($form, 'Observers'); + + $this->setDestinationConfig($form, [ + ObserverField::getKey() => $this->observerConfig( + [ITILActorFieldStrategy::SPECIFIC_ANSWERS], + question_ids: [$question_id], + ), + AssigneeField::getKey() => $this->assigneeConfig( + [ITILActorFieldStrategy::SPECIFIC_VALUES], + actors: ['users_id-' . $tech->getID()], + ), + ActorNotificationField::getKey() => $this->notificationConfig( + observer: ['disabled_question_ids' => [$question_id]], + assign: ['disabled_actors' => ['users_id-' . $tech->getID()]], + ), + ]); + + $json = self::$serializer->exportFormsToJson([$form])->getJsonContent(); + $mapper = new DatabaseMapper([$this->getTestRootEntity(only_id: true)]); + $imported_forms = self::$serializer->importFormsFromJson($json, $mapper)->getImportedForms(); + $this->assertCount(1, $imported_forms); + $imported_form = current($imported_forms); + $this->assertNotSame($form->getID(), $imported_form->getID()); + + $this->assertNotificationConfigSame( + $this->notificationConfig( + observer: ['disabled_question_ids' => [$this->getQuestionId($imported_form, 'Observers')]], + assign: ['disabled_actors' => ['users_id-' . $tech->getID()]], + ), + $this->getDestination($imported_form)->getConfig()[ActorNotificationField::getKey()], + ); + } + + public function testDuplicatedFormUsesItsOwnQuestions(): void + { + $this->login(); + $form = $this->createFormWithActorsQuestions(); + $question_id = $this->getQuestionId($form, 'Observers'); + + $this->setDestinationConfig($form, [ + ObserverField::getKey() => $this->observerConfig( + [ITILActorFieldStrategy::SPECIFIC_ANSWERS], + question_ids: [$question_id], + ), + ActorNotificationField::getKey() => $this->notificationConfig( + observer: ['disabled_question_ids' => [$question_id]], + ), + ]); + + $clone = Form::getById($form->clone()); + $this->assertInstanceOf(Form::class, $clone); + $clone_question_id = $this->getQuestionId($clone, 'Observers'); + $this->assertNotSame($question_id, $clone_question_id); + + $this->assertNotificationConfigSame( + $this->notificationConfig(observer: ['disabled_question_ids' => [$clone_question_id]]), + $this->getDestination($clone)->getConfig()[ActorNotificationField::getKey()], + ); + } + + /** @param class-string $destination_class */ + private function createFormWithActorsQuestions(string $destination_class = FormDestinationTicket::class): Form + { + $multiple_actors = json_encode(new QuestionTypeActorsExtraDataConfig(is_multiple_actors: true)); + + $builder = new FormBuilder('Email followup form'); + $builder->addQuestion('Observers', QuestionTypeObserver::class, '', $multiple_actors); + $builder->addQuestion('Assignee', QuestionTypeAssignee::class, ''); + $builder->addQuestion('Email', QuestionTypeEmail::class); + $builder->addDestination($destination_class, 'Destination'); + $builder->setShouldInitDestinations(false); + + return $this->createForm($builder); + } + + private function createTechnician(string $name): User + { + return $this->createItem(User::class, [ + 'name' => $name, + '_profiles_id' => getItemByTypeName(Profile::class, 'Technician', true), + ]); + } + + private function getDestination(Form $form): FormDestination + { + $destinations = $form->getDestinations(); + $this->assertCount(1, $destinations); + + return current($destinations); + } + + /** + * @param array $config + * @return array The saved config + */ + private function setDestinationConfig(Form $form, array $config): array + { + $destination = $this->getDestination($form); + $this->updateItem( + $destination::class, + $destination->getID(), + ['config' => $config], + ['config'], + ); + + $destination->getFromDB($destination->getID()); + return $destination->getConfig(); + } + + /** @param array $display_options */ + private function renderField(Form $form, array $display_options = []): string + { + $destination = $this->getDestination($form); + $field = new ActorNotificationField(); + + return $field->renderConfigForm( + $form, + $destination, + $field->getConfig($form, $destination->getConfig()), + 'config[' . ActorNotificationField::getKey() . ']', + $display_options, + ); + } + + /** + * @param array> $requester + * @param array> $observer + * @param array> $assign + * @return array + */ + private function notificationConfig(array $requester = [], array $observer = [], array $assign = []): array + { + $roles = []; + foreach (['requester' => $requester, 'observer' => $observer, 'assign' => $assign] as $role => $lists) { + $roles[$role] = array_merge(ActorNotificationFieldConfig::buildRoleConfig(), $lists); + } + + return ['roles' => $roles]; + } + + /** @param list $strategies */ + private function requesterConfig(array $strategies): array + { + return (new RequesterFieldConfig($strategies))->jsonSerialize(); + } + + /** + * @param list $strategies + * @param list $question_ids + */ + private function observerConfig(array $strategies, array $question_ids = []): array + { + return (new ObserverFieldConfig($strategies, [], $question_ids))->jsonSerialize(); + } + + /** + * @param list $strategies + * @param list $actors + * @param list $question_ids + */ + private function assigneeConfig(array $strategies, array $actors = [], array $question_ids = []): array + { + return (new AssigneeFieldConfig($strategies, $actors, $question_ids))->jsonSerialize(); + } + + private function userKey(User $user): string + { + return 'User-' . $user->getID(); + } + + /** @return array use_notification by actor */ + private function getActorsNotifications(CommonITILObject $item, int $type): array + { + $notifications = []; + foreach ($item->getActorsForType($type) as $actor) { + if ($actor['itemtype'] === Group::class) { + continue; + } + + $key = (int) $actor['items_id'] > 0 + ? $actor['itemtype'] . '-' . $actor['items_id'] + : 'email-' . $actor['alternative_email']; + $notifications[$key] = (int) $actor['use_notification']; + } + + return $notifications; + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 4716ab6..9e6fe45 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -42,5 +42,6 @@ require __DIR__ . "/ConfigurableItemsTrait.php"; require __DIR__ . "/AdvancedFormsTestCase.php"; require __DIR__ . "/Front/FrontTestCase.php"; +require __DIR__ . "/Model/Destination/ActorNotificationConfigAssertionsTrait.php"; require __DIR__ . "/Model/Mapper/MapperTestCase.php"; require __DIR__ . "/Model/QuestionType/QuestionTypeTestCase.php";