diff --git a/CHANGELOG.md b/CHANGELOG.md index afde6fa..b8ec0ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Implementation of the basic concept of escalation - Add the "Escalate to group" action to the ticket, change and problem business rules, and a page to switch the rules "Technician group" actions to it - Add the escalation hierarchy between groups: graph editor in the "Escalation" tab of the groups, with basic links (limited to their entity) and inherited links (replicated in the child entities). Changing it requires the right to update the GLPI configuration +- Filter the groups of the "Escalate" form with the escalation tree ## [1.0.0-rc2] diff --git a/src/Escalation.php b/src/Escalation.php index 64a9a17..f582bad 100644 --- a/src/Escalation.php +++ b/src/Escalation.php @@ -153,9 +153,51 @@ private static function getEscalationsOf(CommonITILObject $item, string $order = return (new self())->find($criterias, $order); } + /** + * Whether the group filter of the entity of the item applies to the current user: the option is + * active, and the active profile of the user is not allowed to bypass it. + */ + public static function isGroupFilterApplied(CommonITILObject $item): bool + { + $config = Config::getConfig((int) $item->fields['entities_id']); + if ((int) ($config->fields['escalate_group_filter_is_active'] ?? 0) !== 1) { + return false; + } + + $bypass_profiles = Config::decodeProfiles($config->fields['escalate_group_filter_bypass_profiles'] ?? null); + + return !in_array((int) ($_SESSION['glpiactiveprofile']['id'] ?? 0), $bypass_profiles, true); + } + + /** + * Groups the item can be escalated to when the group filter applies (see + * self::isGroupFilterApplied()): the groups of the next level of the escalation tree (see + * Group_Link::getNextLevelGroupsOf()) from the groups assigned to the item. + * + * Null, any group being allowed, when the filter does not apply, or when no group is assigned + * to the item: there is no level to start from. + * + * @return list|null Ids of the groups + */ + public static function getGroupFilter(CommonITILObject $item): ?array + { + if (!self::isGroupFilterApplied($item)) { + return null; + + } + + $assigned_groups = self::getAssignedGroupIds($item); + if ($assigned_groups === []) { + return null; + } + + return Group_Link::getNextLevelGroupsOf($assigned_groups, (int) $item->fields['entities_id']); + } + /** * Why the item cannot be escalated to the given group, or null when it can: the group must - * exist, be assignable, be visible from the item entity and not be already assigned to the item. + * exist, be assignable, be visible from the item entity, not be already assigned to the item + * and be allowed by the group filter (see self::getGroupFilter()). */ public static function getEscalationBlocker(CommonITILObject $item, int $groups_id): ?string { @@ -184,11 +226,16 @@ public static function getEscalationBlocker(CommonITILObject $item, int $groups_ return __('This group is already assigned.', 'moreoptions'); } + $allowed_groups = self::getGroupFilter($item); + if ($allowed_groups !== null && !in_array($groups_id, $allowed_groups, true)) { + return __('This group is not in the next level of the escalation tree.', 'moreoptions'); + } + return null; } /** - * @return array + * @return list */ private static function getAssignedGroupIds(CommonITILObject $item): array { @@ -456,9 +503,18 @@ public static function showEscalationForm(CommonITILObject $item): void $config = Config::getConfig((int) $item->fields['entities_id']); + $group_condition = ['is_assign' => 1]; + $allowed_groups = self::getGroupFilter($item); + if ($allowed_groups !== null) { + // No group in the next level: the "0" id matches none + $group_condition['id'] = $allowed_groups !== [] ? $allowed_groups : [0]; + } + TemplateRenderer::getInstance()->display('@moreoptions/escalation_form.html.twig', [ 'item' => $item, 'groups_used' => $groups_used ?? [], + 'group_condition' => $group_condition, + 'no_allowed_group' => $allowed_groups === [], // Default values of the form options 'config' => [ 'assign_to_observer' => (int) ($config->fields['escalade_assign_me_as_obsever_by_default'] ?? 0) === 1, diff --git a/src/Group_Link.php b/src/Group_Link.php index 4107fc3..9172dd0 100644 --- a/src/Group_Link.php +++ b/src/Group_Link.php @@ -223,7 +223,26 @@ public static function getLinksForEntity(int $entities_id): array */ public static function getNextLevelGroups(int $groups_id, int $entities_id): array { - $destinations = EscalationGraph::fromLinks([], self::getLinksForEntity($entities_id))->getChildren($groups_id); + return self::getNextLevelGroupsOf([$groups_id], $entities_id); + } + + /** + * Groups of the next level of the escalation, from any of the given groups in the given entity + * (see getNextLevelGroups()). + * + * @param list $groups_ids + * @return list Ids of the groups, in ascending order + */ + public static function getNextLevelGroupsOf(array $groups_ids, int $entities_id): array + { + $graph = EscalationGraph::fromLinks([], self::getLinksForEntity($entities_id)); + + $destinations = []; + foreach ($groups_ids as $groups_id) { + array_push($destinations, ...$graph->getChildren($groups_id)); + } + + $destinations = array_values(array_unique($destinations)); sort($destinations); return $destinations; diff --git a/templates/escalation_form.html.twig b/templates/escalation_form.html.twig index 16ecff8..65aa917 100644 --- a/templates/escalation_form.html.twig +++ b/templates/escalation_form.html.twig @@ -78,12 +78,20 @@ 'icon_label': true, 'required': true, 'entity': item.fields['entities_id'], - 'condition': {'is_assign': 1}, + 'condition': group_condition, 'rand': rand, 'aria_label': 'Group'|itemtype_name, 'used' : groups_used, } ) }} + {% if no_allowed_group %} +
+ +
+ {% endif %} {% set observer_lbl %}
- diff --git a/tests/Units/EscalationTest.php b/tests/Units/EscalationTest.php index 9b23f14..e7068fd 100644 --- a/tests/Units/EscalationTest.php +++ b/tests/Units/EscalationTest.php @@ -42,9 +42,13 @@ use Log; use GlpiPlugin\Moreoptions\Config; use GlpiPlugin\Moreoptions\Escalation; +use GlpiPlugin\Moreoptions\EscalationTree\EscalationLink; +use GlpiPlugin\Moreoptions\Group_Link; +use GlpiPlugin\Moreoptions\LinkStrategy\LinkStrategyEnum; use GlpiPlugin\Moreoptions\Tests\MoreOptionsTestCase; use PHPUnit\Framework\Attributes\DataProvider; use Problem; +use Profile; use Session; use Symfony\Component\DomCrawler\Crawler; use Ticket; @@ -585,6 +589,166 @@ public function testEscalationBlocker(string $itemtype): void $this->hasSessionMessages(ERROR, ['This group is not visible from the entity of the item.']); } + /** + * With the group filter active, the item can only be escalated to the groups of the next level + * of the escalation tree, from the groups assigned to it. + * + * @param class-string $itemtype + */ + #[DataProvider('itemtypeProvider')] + public function testGroupFilter(string $itemtype): void + { + $this->login(); + $entities_id = $this->getTestRootEntity(true); + $this->assertIsInt($entities_id); + $this->enableEscalation($entities_id, ['escalate_group_filter_is_active' => 1]); + + // A -> B, A -> C, B -> D + $a = $this->createGroup($entities_id, 'Level 1'); + $b = $this->createGroup($entities_id, 'Level 2 - B'); + $c = $this->createGroup($entities_id, 'Level 2 - C'); + $d = $this->createGroup($entities_id, 'Level 3'); + $this->createItems(Group_Link::class, [ + ['entities_id' => $entities_id] + (new EscalationLink($a->getID(), $b->getID(), LinkStrategyEnum::BASIC))->toRow(), + ['entities_id' => $entities_id] + (new EscalationLink($a->getID(), $c->getID(), LinkStrategyEnum::BASIC))->toRow(), + ['entities_id' => $entities_id] + (new EscalationLink($b->getID(), $d->getID(), LinkStrategyEnum::BASIC))->toRow(), + ]); + + $item = $this->createItem($itemtype, [ + 'name' => 'Test escalation', + 'content' => 'Test content', + 'entities_id' => $entities_id, + ]); + + // No group assigned: no level to start from, any group is allowed + $this->assertNull(Escalation::getGroupFilter($item)); + + $this->createItem($item->grouplinkclass, [ + $item->getForeignKeyField() => $item->getID(), + 'groups_id' => $a->getID(), + 'type' => CommonITILActor::ASSIGN, + ]); + + $this->assertSame([$b->getID(), $c->getID()], Escalation::getGroupFilter($item)); + $this->assertNull(Escalation::getEscalationBlocker($item, $b->getID())); + $this->assertSame( + 'This group is not in the next level of the escalation tree.', + Escalation::getEscalationBlocker($item, $d->getID()), + ); + + // The escalation to a group out of the next level is refused + $this->assertFalse((new Escalation())->add([ + 'itemtype' => $item::class, + 'items_id' => $item->getID(), + 'groups_id' => $d->getID(), + ])); + $this->hasSessionMessages(ERROR, ['This group is not in the next level of the escalation tree.']); + $this->assertSame([$a->getID()], $this->getAssignedGroupIds($item)); + + // Escalate to B: the next level is now D + $this->createItem(Escalation::class, [ + 'itemtype' => $item::class, + 'items_id' => $item->getID(), + 'groups_id' => $b->getID(), + ]); + $this->assertSame([$d->getID()], Escalation::getGroupFilter($item)); + + $this->assertTrue(Escalation::isGroupFilterApplied($item)); + + // A profile allowed to bypass the filter can escalate to any group + $config = Config::getConfig($entities_id, false); + $this->updateItem(Config::class, $config->getID(), [ + 'escalate_group_filter_bypass_profiles' => [(string) $_SESSION['glpiactiveprofile']['id']], + ], ['escalate_group_filter_bypass_profiles']); + $this->assertFalse(Escalation::isGroupFilterApplied($item)); + $this->assertNull(Escalation::getGroupFilter($item)); + $this->assertNull(Escalation::getEscalationBlocker($item, $c->getID())); + + // Another profile does not bypass it + $this->updateItem(Config::class, $config->getID(), [ + 'escalate_group_filter_bypass_profiles' => [(string) getItemByTypeName(Profile::class, 'Technician', true)], + ], ['escalate_group_filter_bypass_profiles']); + $this->assertSame([$d->getID()], Escalation::getGroupFilter($item)); + + // The filter not active: any group is allowed + $this->updateItem(Config::class, $config->getID(), ['escalate_group_filter_is_active' => 0]); + $this->assertFalse(Escalation::isGroupFilterApplied($item)); + $this->assertNull(Escalation::getGroupFilter($item)); + } + + /** + * With several groups assigned, the groups allowed by the filter are the union of their next + * levels, without duplicates. + */ + public function testGroupFilterWithSeveralAssignedGroups(): void + { + $this->login(); + $entities_id = $this->getTestRootEntity(true); + $this->assertIsInt($entities_id); + $this->enableEscalation($entities_id, ['escalate_group_filter_is_active' => 1]); + + // A -> C, A -> D, B -> D, B -> E + $a = $this->createGroup($entities_id, 'Source A'); + $b = $this->createGroup($entities_id, 'Source B'); + $c = $this->createGroup($entities_id, 'Target C'); + $d = $this->createGroup($entities_id, 'Target D'); + $e = $this->createGroup($entities_id, 'Target E'); + $this->createItems(Group_Link::class, [ + ['entities_id' => $entities_id] + (new EscalationLink($a->getID(), $c->getID(), LinkStrategyEnum::BASIC))->toRow(), + ['entities_id' => $entities_id] + (new EscalationLink($a->getID(), $d->getID(), LinkStrategyEnum::BASIC))->toRow(), + ['entities_id' => $entities_id] + (new EscalationLink($b->getID(), $d->getID(), LinkStrategyEnum::BASIC))->toRow(), + ['entities_id' => $entities_id] + (new EscalationLink($b->getID(), $e->getID(), LinkStrategyEnum::BASIC))->toRow(), + ]); + + $item = $this->createItem(Ticket::class, [ + 'name' => 'Test escalation', + 'content' => 'Test content', + 'entities_id' => $entities_id, + '_groups_id_assign' => [$a->getID(), $b->getID()], + ]); + + $this->assertSame([$c->getID(), $d->getID(), $e->getID()], Escalation::getGroupFilter($item)); + } + + /** + * With the group filter active, the escalation form only offers the groups of the next level, + * and cannot be submitted when there is none. + */ + public function testEscalationFormWithoutNextLevel(): void + { + $this->login(); + $entities_id = $this->getTestRootEntity(true); + $this->assertIsInt($entities_id); + $this->enableEscalation($entities_id, ['escalate_group_filter_is_active' => 1]); + + $item = $this->createItem(Ticket::class, [ + 'name' => 'Test escalation', + 'content' => 'Test content', + 'entities_id' => $entities_id, + '_groups_id_assign' => [$this->createGroup($entities_id, 'Last level')->getID()], + ]); + $this->assertInstanceOf(Ticket::class, $item); + + ob_start(); + Escalation::showEscalationForm($item); + $crawler = new Crawler(ob_get_clean()); + + $this->assertStringContainsString( + 'No group in the next level of the escalation tree.', + $crawler->filter('.alert-warning')->text(), + ); + $this->assertNotNull($crawler->filter('button[type="submit"][name="add"]')->attr('disabled')); + + // Without the filter, the form can be submitted + $this->enableEscalation($entities_id, ['escalate_group_filter_is_active' => 0]); + ob_start(); + Escalation::showEscalationForm($item); + $crawler = new Crawler(ob_get_clean()); + + $this->assertCount(0, $crawler->filter('.alert-warning')); + $this->assertNull($crawler->filter('button[type="submit"][name="add"]')->attr('disabled')); + } + /** * Enable the escalation option for the given entity. *