diff --git a/CHANGELOG.md b/CHANGELOG.md index 9019c5b..970ec5f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,4 +23,3 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Fixed the issue where a ticket could be solved without a solution ## [1.0.0-rc1] - diff --git a/public/css/moreoptions.scss b/public/css/moreoptions.scss index 5d04a19..cd28a87 100644 --- a/public/css/moreoptions.scss +++ b/public/css/moreoptions.scss @@ -83,7 +83,9 @@ display: none; } - .form-field:has(.mo-seg-inherit:checked) .mo-inherited { + // Same for the status dropdowns, whose "Inherit" option is Config::CONFIG_PARENT (-2). + .form-field:has(.mo-seg-inherit:checked) .mo-inherited, + .form-field:has(.mo-status option[value="-2"]:checked) .mo-inherited { display: inline-flex; align-items: center; } diff --git a/src/Config.php b/src/Config.php index 458227c..660021f 100644 --- a/src/Config.php +++ b/src/Config.php @@ -37,13 +37,17 @@ namespace GlpiPlugin\Moreoptions; use DBmysql; +use Change; use CommonDBTM; use CommonGLPI; +use CommonITILObject; use Entity; use Glpi\Application\View\TemplateRenderer; use Migration; use Plugin; +use Problem; use Session; +use Ticket; class Config extends CommonDBTM { @@ -141,7 +145,36 @@ private static function getActorGroupConfigFields(): array */ private static function getAllConfigFields(): array { - return array_merge(self::getItilConfigFields(), self::getActorGroupConfigFields()); + return array_merge( + self::getItilConfigFields(), + self::getActorGroupConfigFields(), + array_keys(self::getStatusConfigFields()), + ); + } + + /** + * Fields of kind `status`, with the ITIL itemtype whose statuses they hold. + * + * @return array> + */ + private static function getStatusConfigFields(): array + { + return [ + 'escalade_status_after_escalation_ticket' => Ticket::class, + 'escalade_status_after_escalation_change' => Change::class, + 'escalade_status_after_escalation_problem' => Problem::class, + ]; + } + + /** + * Choices of a `status` field: "No change" (0), then every status of the itemtype. + * + * @param class-string $itemtype + * @return array + */ + public static function getSelectableStatus(string $itemtype): array + { + return [0 => __('No change', 'moreoptions')] + $itemtype::getAllStatusArray(); } /** @@ -290,6 +323,14 @@ public static function showForEntity(Entity $item): void 'entities_id' => $item->getID(), ]); + $status_options = []; + foreach (self::getStatusConfigFields() as $field => $itemtype) { + $status_options[$field] = self::getSelectableStatus($itemtype); + if ($item->getID() > 0) { + $status_options[$field] = [self::CONFIG_PARENT => __('Inherit', 'moreoptions')] + $status_options[$field]; + } + } + $tabs = self::getScreenTabs(); $sections_by_tab = []; foreach ($tabs as $tab) { @@ -305,6 +346,7 @@ public static function showForEntity(Entity $item): void 'parent_entity_id' => $item->getID() > 0 ? (int) $item->fields['entities_id'] : null, 'parent_badges' => self::getParentValueBadges($item), 'dropdown_options' => self::getSelectableActorGroup(), + 'status_options' => $status_options, 'config_parent' => self::CONFIG_PARENT, 'escalade_takes_technician_group' => self::isTechnicianGroupHandledByEscalade(), 'params' => [ @@ -327,11 +369,11 @@ public static function getIcon(): string public static function getScreenTabs(): array { return [ - ['id' => 'ticket', 'label' => __('Ticket'), 'icon' => 'ti-ticket'], - ['id' => 'change', 'label' => __('Change'), 'icon' => 'ti-git-branch'], - ['id' => 'problem', 'label' => __('Problem'), 'icon' => 'ti-alert-circle'], + ['id' => 'ticket', 'label' => __('Ticket'), 'icon' => Ticket::getIcon()], + ['id' => 'change', 'label' => __('Change'), 'icon' => Change::getIcon()], + ['id' => 'problem', 'label' => __('Problem'), 'icon' => Problem::getIcon()], ['id' => 'task', 'label' => _n('Task', 'Tasks', 2), 'icon' => 'ti-checklist'], - ['id' => 'escalate', 'label' => __('Escalate', 'moreoptions'), 'icon' => 'ti-arrow-up'], + ['id' => 'escalate', 'label' => __('Escalate', 'moreoptions'), 'icon' => Escalation::getIcon(),], ]; } @@ -401,9 +443,21 @@ private static function getScreenSections(): array 'escalate' => [ [ 'title' => __('Escalate', 'moreoptions'), - 'icon' => 'ti-arrow-up', + 'icon' => Escalation::getIcon(), 'rows' => [ ['key' => 'escalate_is_active', 'kind' => 'yes_no', 'label' => __('Activate escalation', 'moreoptions')], + ['key' => 'escalate_remove_technician', 'kind' => 'yes_no', 'label' => __('Remove technician after escalation', 'moreoptions')], + ['key' => 'escalade_status_after_escalation_ticket', 'kind' => 'status', 'label' => __('Ticket status after escalation', 'moreoptions')], + ['key' => 'escalade_status_after_escalation_change', 'kind' => 'status', 'label' => __('Change status after escalation', 'moreoptions')], + ['key' => 'escalade_status_after_escalation_problem', 'kind' => 'status', 'label' => __('Problem status after escalation', 'moreoptions')], + ], + ], + [ + 'title' => __('Default options values', 'moreoptions'), + 'icon' => 'ti-settings', + 'rows' => [ + ['key' => 'escalade_assign_me_as_obsever_by_default', 'kind' => 'yes_no', 'label' => __('Assign me as observer after escalation', 'moreoptions')], + ['key' => 'escalade_is_private_by_default', 'kind' => 'yes_no', 'label' => __('Escalate event is private', 'moreoptions')], ], ], ], @@ -477,6 +531,11 @@ private static function getParentValueBadges(Entity $item): array $badges[$field] = Entity::inheritedValue(htmlescape($text), false, false); } + foreach (self::getStatusConfigFields() as $field => $itemtype) { + $text = self::getSelectableStatus($itemtype)[(int) ($parent_config->fields[$field] ?? 0)] ?? __('No change', 'moreoptions'); + $badges[$field] = Entity::inheritedValue(htmlescape($text), false, false); + } + return $badges; } @@ -577,6 +636,12 @@ public static function install(Migration $migration): void `assign_technician_from_task_change` tinyint NOT NULL DEFAULT '0', `assign_technician_from_task_problem` tinyint NOT NULL DEFAULT '0', `escalate_is_active` tinyint NOT NULL DEFAULT '0', + `escalate_remove_technician` tinyint NOT NULL DEFAULT '0', + `escalade_status_after_escalation_ticket` tinyint NOT NULL DEFAULT '0', + `escalade_status_after_escalation_change` tinyint NOT NULL DEFAULT '0', + `escalade_status_after_escalation_problem` tinyint NOT NULL DEFAULT '0', + `escalade_assign_me_as_obsever_by_default` tinyint NOT NULL DEFAULT '0', + `escalade_is_private_by_default` tinyint NOT NULL DEFAULT '0', PRIMARY KEY (`id`), KEY `entities_id` (`entities_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC; @@ -596,6 +661,12 @@ public static function install(Migration $migration): void 'assign_technician_from_task_change', 'assign_technician_from_task_problem', 'escalate_is_active', + 'escalate_remove_technician', + 'escalade_status_after_escalation_ticket', + 'escalade_status_after_escalation_change', + 'escalade_status_after_escalation_problem', + 'escalade_assign_me_as_obsever_by_default', + 'escalade_is_private_by_default', ] as $field ) { if (!$DB->fieldExists($table, $field)) { @@ -603,8 +674,25 @@ public static function install(Migration $migration): void } } + // Status fields added to an existing table: child entities inherit by default. + $new_status_fields = []; + foreach (array_keys(self::getStatusConfigFields()) as $field) { + if (!$DB->fieldExists($table, $field)) { + $migration->addField($table, $field, 'bool', ['value' => '0']); + $new_status_fields[] = $field; + } + } + $migration->executeMigration(); + if ($new_status_fields !== []) { + $DB->update( + $table, + array_fill_keys($new_status_fields, self::CONFIG_PARENT), + ['entities_id' => ['>', 0]], + ); + } + $entities = new Entity(); foreach ($entities->find() as $entity) { if (is_array($entity) && isset($entity['id'])) { diff --git a/src/Escalation.php b/src/Escalation.php index 534bc6b..7c9e546 100644 --- a/src/Escalation.php +++ b/src/Escalation.php @@ -72,7 +72,7 @@ public static function getTypeName($nb = 0): string public static function getIcon(): string { - return 'ti ti-arrow-up'; + return 'ti ti-escalator-up'; } /** @@ -275,6 +275,32 @@ public function post_addItem() 'type' => CommonITILActor::ASSIGN, '_plugin_moreoptions_escalade' => true, ]); + + if ((int) ($this->input['add_me_as_observer'] ?? 0) === 1) { + $this->addAuthorAsObserver($item); + } + } + + /** + * Adds the author of the escalation as an observer of the escalated item, unless they already are. + */ + private function addAuthorAsObserver(CommonITILObject $item): void + { + $user_link = getItemForItemtype($item->userlinkclass); + if (!$user_link instanceof CommonITILActor) { + return; + } + + $input = [ + $item->getForeignKeyField() => (int) $this->fields['items_id'], + 'users_id' => (int) $this->fields['users_id'], + 'type' => CommonITILActor::OBSERVER, + ]; + if (countElementsInTable($user_link::getTable(), $input) > 0) { + return; + } + + $user_link->add($input); } /** @@ -342,9 +368,16 @@ public static function showEscalationForm(CommonITILObject $item): void $groups_used[$key] = (int) $row['groups_id']; } + $config = Config::getConfig((int) $item->fields['entities_id']); + TemplateRenderer::getInstance()->display('@moreoptions/escalation_form.html.twig', [ 'item' => $item, 'groups_used' => $groups_used ?? [], + // Default values of the form options + 'config' => [ + 'assign_to_observer' => (int) ($config->fields['escalade_assign_me_as_obsever_by_default'] ?? 0) === 1, + 'is_private' => (int) ($config->fields['escalade_is_private_by_default'] ?? 0) === 1, + ], ]); } @@ -364,6 +397,14 @@ public static function escalate(CommonITILActor $group_link): void } self::keepOnlyAssignedGroup($group_link); + + $item = getItemForItemtype($group_link::$itemtype_1 ?? ''); + if (!$item instanceof CommonITILObject || !$item->getFromDB((int) $group_link->fields[$group_link::$items_id_1])) { + return; + } + + self::removeTechnician($item); + self::changeStatusAfterEscalation($item); } /** @@ -383,6 +424,50 @@ private static function keepOnlyAssignedGroup(CommonITILActor $group_link): void } } + /** + * Drop the technicians assigned to the escalated item, when the "remove technician" option + * is enabled for its entity. + */ + private static function removeTechnician(CommonITILObject $item): void + { + $config = Config::getConfig((int) $item->fields['entities_id']); + if ((int) ($config->fields['escalate_remove_technician'] ?? 0) !== 1) { + return; + } + + $user_link = getItemForItemtype($item->userlinkclass); + if (!$user_link instanceof CommonITILActor) { + return; + } + + $technician_links = $user_link->find([ + $item->getForeignKeyField() => $item->getID(), + 'type' => CommonITILActor::ASSIGN, + ]); + foreach ($technician_links as $technician_link) { + (new ($user_link::class)())->delete(['id' => $technician_link['id']]); + } + } + + /** + * Set the status configured for its entity on the escalated item, unless it already has it. + */ + private static function changeStatusAfterEscalation(CommonITILObject $item): void + { + // Reload: removing the actors (see self::removeTechnician()) may have changed the status. + if (!$item->getFromDB($item->getID())) { + return; + } + + $config = Config::getConfig((int) $item->fields['entities_id']); + $new_status = (int) ($config->fields['escalade_status_after_escalation_' . strtolower($item::class)] ?? 0); + if ($new_status === 0 || (int) $item->fields['status'] === $new_status) { + return; + } + + $item->update(['id' => $item->getID(), 'status' => $new_status]); + } + public static function install(Migration $migration): void { /** @var DBmysql $DB */ diff --git a/templates/config.html.twig b/templates/config.html.twig index 53ddcbd..7167967 100644 --- a/templates/config.html.twig +++ b/templates/config.html.twig @@ -80,7 +80,7 @@ aria-selected="{{ loop.first ? 'true' : 'false' }}" > - {{ tab.label }} +  {{ tab.label }} {% endfor %} @@ -120,7 +120,23 @@ {% endif %}
- {{ mo.render(row.field, options, current, parent_badges[row.field]|default(null), disabled, canedit, rand) }} + {% if row.kind == 'status' %} + {% if parent_badges[row.field] is defined %} +
{{ parent_badges[row.field]|raw }}
+ {% endif %} +
+ {{ call('Dropdown::showFromArray', [row.field, status_options[row.field], { + 'value': current, + 'width': '220px', + 'disabled': disabled or not canedit, + 'templateResult': 'templateItilStatus', + 'templateSelection': 'templateItilStatus', + 'display': false, + }])|raw }} +
+ {% else %} + {{ mo.render(row.field, options, current, parent_badges[row.field]|default(null), disabled, canedit, rand) }} + {% endif %}
diff --git a/templates/escalation_button.html.twig b/templates/escalation_button.html.twig index 4f625b2..ac3e8ee 100644 --- a/templates/escalation_button.html.twig +++ b/templates/escalation_button.html.twig @@ -53,7 +53,7 @@ button.type = 'button'; button.className = 'btn btn-sm btn-ghost-secondary moreoptions-escalate-btn ms-2 py-0 px-1'; button.title = {{ __('Escalate', 'moreoptions')|json_encode|raw }}; - button.innerHTML = '' + button.innerHTML = '' + '' + _.escape({{ __('Escalate', 'moreoptions')|json_encode|raw }}) + ''; button.addEventListener('click', function() { glpi_ajax_dialog({ diff --git a/templates/escalation_form.html.twig b/templates/escalation_form.html.twig index 8af422a..16ecff8 100644 --- a/templates/escalation_form.html.twig +++ b/templates/escalation_form.html.twig @@ -81,6 +81,7 @@ 'condition': {'is_assign': 1}, 'rand': rand, 'aria_label': 'Group'|itemtype_name, + 'used' : groups_used, } ) }} @@ -90,7 +91,7 @@ {% endset %} {{ fields.sliderField( 'add_me_as_observer', - 0, + config.assign_to_observer ? 1 : 0, observer_lbl, { 'full_width': true, @@ -105,7 +106,7 @@ {{ fields.sliderField( 'is_private', - 1, + config.is_private ? 1 : 0, private_label, { 'full_width': true, @@ -119,7 +120,7 @@
diff --git a/tests/Units/EscalationTest.php b/tests/Units/EscalationTest.php index 53ae250..f8ffb5b 100644 --- a/tests/Units/EscalationTest.php +++ b/tests/Units/EscalationTest.php @@ -37,15 +37,21 @@ use CommonITILActor; use CommonITILObject; use Group; +use ITILFollowup; +use Log; use GlpiPlugin\Moreoptions\Config; use GlpiPlugin\Moreoptions\Escalation; use GlpiPlugin\Moreoptions\Tests\MoreOptionsTestCase; use PHPUnit\Framework\Attributes\DataProvider; use Problem; use Session; +use Symfony\Component\DomCrawler\Crawler; use Ticket; +use User; use function Safe\json_decode; +use function Safe\ob_get_clean; +use function Safe\ob_start; class EscalationTest extends MoreOptionsTestCase { @@ -206,22 +212,367 @@ public function testEscalationToAlreadyAssignedGroupIsRefused(string $itemtype): ))); } + /** + * @return iterable, bool}> + */ + public static function removeTechnicianProvider(): iterable + { + foreach ([Ticket::class, Change::class, Problem::class] as $itemtype) { + yield $itemtype . ' with "Remove technician after escalation" enabled' => [$itemtype, true]; + yield $itemtype . ' with "Remove technician after escalation" disabled' => [$itemtype, false]; + } + } + + /** + * @param class-string $itemtype + */ + #[DataProvider('removeTechnicianProvider')] + public function testRemoveTechnicianAfterEscalation(string $itemtype, bool $remove_technician): void + { + $this->login(); + $entities_id = $this->getTestRootEntity(true); + $this->assertIsInt($entities_id); + $this->enableEscalation($entities_id, ['escalate_remove_technician' => (int) $remove_technician]); + + $item = $this->createItem($itemtype, [ + 'name' => 'Test escalation', + 'content' => 'Test content', + 'entities_id' => $entities_id, + ]); + $this->assertInstanceOf(CommonITILObject::class, $item); + + // Two technicians assigned to the item, plus a requester and an observer + $technicians = [$this->getUser('tech'), $this->getUser('normal')]; + foreach ($technicians as $technician) { + $this->addUserActor($item, $technician, CommonITILActor::ASSIGN); + } + + $requester = $this->getUser('post-only'); + $observer = $this->getUser('glpi'); + $this->addUserActor($item, $requester, CommonITILActor::REQUESTER); + $this->addUserActor($item, $observer, CommonITILActor::OBSERVER); + + $this->assertSame($this->getIdsOf($technicians), $this->getUserActorIds($item, CommonITILActor::ASSIGN)); + + $target_group = $this->createGroup($entities_id, 'Target group'); + $this->createItem(Escalation::class, [ + 'itemtype' => $item::class, + 'items_id' => $item->getID(), + 'groups_id' => $target_group->getID(), + ]); + + // The technicians are removed only when the option is enabled + $this->assertSame( + $remove_technician ? [] : $this->getIdsOf($technicians), + $this->getUserActorIds($item, CommonITILActor::ASSIGN), + ); + + // The other actors are always kept, and the target group is assigned in both cases + $this->assertContains($requester->getID(), $this->getUserActorIds($item, CommonITILActor::REQUESTER)); + $this->assertContains($observer->getID(), $this->getUserActorIds($item, CommonITILActor::OBSERVER)); + $this->assertSame([$target_group->getID()], $this->getAssignedGroupIds($item)); + } + + /** + * @return iterable, array, int>, bool, int|null}> + */ + public static function statusAfterEscalationProvider(): iterable + { + foreach ([Ticket::class, Change::class, Problem::class] as $itemtype) { + yield $itemtype . ' gets the configured status' => [ + $itemtype, + [$itemtype => CommonITILObject::WAITING], + false, + CommonITILObject::WAITING, + ]; + yield $itemtype . ' already having the configured status is not updated' => [ + $itemtype, + [$itemtype => CommonITILObject::WAITING], + true, + CommonITILObject::WAITING, + ]; + yield $itemtype . ' is not affected by the status configured for the other types' => [ + $itemtype, + array_fill_keys(array_diff([Ticket::class, Change::class, Problem::class], [$itemtype]), CommonITILObject::WAITING), + false, + null, + ]; + } + } + + /** + * @param class-string $itemtype + * @param array, int> $statuses Status to set after escalation, by itemtype + * @param int|null $expected_status Expected status after escalation, null if it must not be changed by the escalation + */ + #[DataProvider('statusAfterEscalationProvider')] + public function testStatusAfterEscalation(string $itemtype, array $statuses, bool $already_has_status, ?int $expected_status): void + { + $this->login(); + $entities_id = $this->getTestRootEntity(true); + $this->assertIsInt($entities_id); + + $options = []; + foreach ([Ticket::class, Change::class, Problem::class] as $configured_itemtype) { + $options['escalade_status_after_escalation_' . strtolower($configured_itemtype)] = $statuses[$configured_itemtype] ?? 0; + } + + $this->enableEscalation($entities_id, $options); + + $item = $this->createItem($itemtype, [ + 'name' => 'Test escalation', + 'content' => 'Test content', + 'entities_id' => $entities_id, + ]); + $this->assertInstanceOf(CommonITILObject::class, $item); + + if ($already_has_status) { + $this->updateItem($itemtype, $item->getID(), ['status' => $expected_status]); + } + + // Status of the item after the escalation when the plugin does not change it (the core + // may move a new item to "Processing (assigned)" when the target group is assigned). + $reference = $this->createItem($itemtype, [ + 'name' => 'Test escalation reference', + 'content' => 'Test content', + 'entities_id' => $entities_id, + ]); + $this->assertInstanceOf(CommonITILObject::class, $reference); + $this->createItem($reference->grouplinkclass, [ + $reference->getForeignKeyField() => $reference->getID(), + 'groups_id' => $this->createGroup($entities_id, 'Reference group')->getID(), + 'type' => CommonITILActor::ASSIGN, + ]); + $this->assertTrue($reference->getFromDB($reference->getID())); + + $nb_status_logs = $this->countStatusLogs($item); + + $this->createItem(Escalation::class, [ + 'itemtype' => $item::class, + 'items_id' => $item->getID(), + 'groups_id' => $this->createGroup($entities_id, 'Target group')->getID(), + ]); + + $this->assertTrue($item->getFromDB($item->getID())); + $this->assertSame( + $expected_status ?? (int) $reference->fields['status'], + (int) $item->fields['status'], + ); + + if ($already_has_status) { + // No useless update: the status history is left untouched + $this->assertSame($nb_status_logs, $this->countStatusLogs($item)); + } + } + + /** + * @return iterable, bool, bool}> + */ + public static function formDefaultsProvider(): iterable + { + foreach ([Ticket::class, Change::class, Problem::class] as $itemtype) { + yield $itemtype . ' with both options disabled' => [$itemtype, false, false]; + yield $itemtype . ' with "Assign me as observer by default" enabled' => [$itemtype, true, false]; + yield $itemtype . ' with "Private by default" enabled' => [$itemtype, false, true]; + yield $itemtype . ' with both options enabled' => [$itemtype, true, true]; + } + } + + /** + * The "Assign me as an observer" and "Private" switches of the escalation form are checked + * by default according to the entity configuration. + * + * @param class-string $itemtype + */ + #[DataProvider('formDefaultsProvider')] + public function testEscalationFormDefaults(string $itemtype, bool $observer_by_default, bool $private_by_default): void + { + $this->login(); + $entities_id = $this->getTestRootEntity(true); + $this->assertIsInt($entities_id); + $this->enableEscalation($entities_id, [ + 'escalade_assign_me_as_obsever_by_default' => (int) $observer_by_default, + 'escalade_is_private_by_default' => (int) $private_by_default, + ]); + + $item = $this->createItem($itemtype, [ + 'name' => 'Test escalation', + 'content' => 'Test content', + 'entities_id' => $entities_id, + ]); + $this->assertInstanceOf(CommonITILObject::class, $item); + + ob_start(); + try { + Escalation::showEscalationForm($item); + } finally { + $html = ob_get_clean(); + } + + $crawler = new Crawler($html); + + $observer_switch = $crawler->filter('input[type="checkbox"][name="add_me_as_observer"]'); + $this->assertCount(1, $observer_switch); + $this->assertSame($observer_by_default, $observer_switch->attr('checked') !== null); + + $private_switch = $crawler->filter('input[type="checkbox"][name="is_private"]'); + $this->assertCount(1, $private_switch); + $this->assertSame($private_by_default, $private_switch->attr('checked') !== null); + } + + /** + * @return iterable, bool, bool}> + */ + public static function addMeAsObserverProvider(): iterable + { + foreach ([Ticket::class, Change::class, Problem::class] as $itemtype) { + yield $itemtype . ' with "Assign me as an observer" checked' => [$itemtype, true, false]; + yield $itemtype . ' with "Assign me as an observer" unchecked' => [$itemtype, false, false]; + yield $itemtype . ' with "Assign me as an observer" checked, already observer' => [$itemtype, true, true]; + } + } + + /** + * @param class-string $itemtype + */ + #[DataProvider('addMeAsObserverProvider')] + public function testAddMeAsObserver(string $itemtype, bool $add_me_as_observer, bool $already_observer): void + { + $this->login(); + $entities_id = $this->getTestRootEntity(true); + $this->assertIsInt($entities_id); + $this->enableEscalation($entities_id); + + $item = $this->createItem($itemtype, [ + 'name' => 'Test escalation', + 'content' => 'Test content', + 'entities_id' => $entities_id, + ]); + $this->assertInstanceOf(CommonITILObject::class, $item); + + $me = $this->getUser(self::TU_USER); + if ($already_observer) { + $this->addUserActor($item, $me, CommonITILActor::OBSERVER); + } + + $this->createItem(Escalation::class, [ + 'itemtype' => $item::class, + 'items_id' => $item->getID(), + 'groups_id' => $this->createGroup($entities_id, 'Target group')->getID(), + 'add_me_as_observer' => (int) $add_me_as_observer, + ], ['add_me_as_observer']); + + // The author is an observer (only once) when asked, or when they already were + $this->assertSame( + $add_me_as_observer || $already_observer ? [$me->getID()] : [], + $this->getUserActorIds($item, CommonITILActor::OBSERVER), + ); + } + + /** + * @return iterable, bool}> + */ + public static function privateEscalationProvider(): iterable + { + foreach ([Ticket::class, Change::class, Problem::class] as $itemtype) { + yield $itemtype . ' with a private escalation' => [$itemtype, true]; + yield $itemtype . ' with a public escalation' => [$itemtype, false]; + } + } + + /** + * A private escalation is shown in the timeline only to the users allowed to see private + * followups. + * + * @param class-string $itemtype + */ + #[DataProvider('privateEscalationProvider')] + public function testPrivateEscalation(string $itemtype, bool $is_private): void + { + $this->login(); + $entities_id = $this->getTestRootEntity(true); + $this->assertIsInt($entities_id); + $this->enableEscalation($entities_id); + + $item = $this->createItem($itemtype, [ + 'name' => 'Test escalation', + 'content' => 'Test content', + 'entities_id' => $entities_id, + ]); + $this->assertInstanceOf(CommonITILObject::class, $item); + + $escalation = $this->createItem(Escalation::class, [ + 'itemtype' => $item::class, + 'items_id' => $item->getID(), + 'groups_id' => $this->createGroup($entities_id, 'Target group')->getID(), + 'is_private' => (int) $is_private, + ]); + $this->assertSame((int) $is_private, (int) $escalation->fields['is_private']); + + // Allowed to see private followups: the escalation is always shown, flagged as private + $this->assertNotEmpty(Session::haveRight('followup', ITILFollowup::SEEPRIVATE)); + $entries = $this->getEscalationTimelineEntries($item); + $this->assertCount(1, $entries); + $this->assertSame((int) $is_private, (int) $entries[0]['item']['is_private']); + + // Not allowed to see private followups (but still allowed to see the item, or the core + // returns an empty timeline): a private escalation is hidden + $_SESSION['glpiactiveprofile']['followup'] &= ~ITILFollowup::SEEPRIVATE; + $this->assertEmpty(Session::haveRight('followup', ITILFollowup::SEEPRIVATE)); + $this->assertCount($is_private ? 0 : 1, $this->getEscalationTimelineEntries($item)); + } + /** * Enable the escalation option for the given entity. + * + * @param array $options Other escalation options to set */ - private function enableEscalation(int $entities_id): void + private function enableEscalation(int $entities_id, array $options = []): void { + $fields = ['escalate_is_active' => 1] + $options; + $config = Config::getConfig($entities_id, false); if ($config->isNewItem()) { - $this->createTestConfig([ - 'entities_id' => $entities_id, - 'escalate_is_active' => 1, - ]); + $this->createTestConfig(['entities_id' => $entities_id] + $fields); } else { - $this->updateTestConfig($config, ['escalate_is_active' => 1]); + $this->updateTestConfig($config, $fields); } } + private function getUser(string $name): User + { + $user = new User(); + $this->assertTrue($user->getFromDBByCrit(['name' => $name])); + + return $user; + } + + private function addUserActor(CommonITILObject $item, User $user, int $type): void + { + $this->createItem($item->userlinkclass, [ + $item->getForeignKeyField() => $item->getID(), + 'users_id' => $user->getID(), + 'type' => $type, + ]); + } + + /** + * @return array + */ + private function getUserActorIds(CommonITILObject $item, int $type): array + { + $user_link = getItemForItemtype($item->userlinkclass); + $this->assertInstanceOf(CommonITILActor::class, $user_link); + + return array_map( + static fn(array $row): int => (int) $row['users_id'], + array_values($user_link->find([ + $item->getForeignKeyField() => $item->getID(), + 'type' => $type, + ], ['id ASC'])), + ); + } + private function createGroup(int $entities_id, string $name): Group { $group = $this->createItem(Group::class, [ @@ -253,11 +604,36 @@ private function getAssignedGroupIds(CommonITILObject $item): array } /** - * @param array $groups + * @return array}> + */ + private function getEscalationTimelineEntries(CommonITILObject $item): array + { + $this->assertTrue($item->getFromDB($item->getID())); + + return array_values(array_filter( + $item->getTimelineItems(), + static fn(array $entry): bool => $entry['type'] === Escalation::class, + )); + } + + /** + * Number of changes of the item status in its history. + */ + private function countStatusLogs(CommonITILObject $item): int + { + return countElementsInTable(Log::getTable(), [ + 'itemtype' => $item::class, + 'items_id' => $item->getID(), + 'id_search_option' => 12, + ]); + } + + /** + * @param array $items * @return array */ - private function getIdsOf(array $groups): array + private function getIdsOf(array $items): array { - return array_map(static fn(Group $group): int => $group->getID(), $groups); + return array_map(static fn(Group|User $item): int => $item->getID(), $items); } } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 3856728..308e3a6 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -32,7 +32,9 @@ */ require_once __DIR__ . '/../../../tests/bootstrap.php'; -require_once __DIR__ . '/../vendor/autoload.php'; +// Not the plugin `vendor/autoload.php`: it would load the dev tools dependencies (e.g. an older +// `twig/twig`) over the GLPI core ones. +require_once __DIR__ . '/MoreOptionsTestCase.php'; if (!Plugin::isPluginActive('moreoptions')) { throw new RuntimeException('Plugin moreoptions is not active in the test database');