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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
60 changes: 58 additions & 2 deletions src/Escalation.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<int>|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
{
Expand Down Expand Up @@ -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<int>
* @return list<int>
*/
private static function getAssignedGroupIds(CommonITILObject $item): array
{
Expand Down Expand Up @@ -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,
Expand Down
21 changes: 20 additions & 1 deletion src/Group_Link.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<int> $groups_ids
* @return list<int> 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;
Expand Down
12 changes: 10 additions & 2 deletions templates/escalation_form.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -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 %}
<div class="col-12">
<div class="alert alert-warning mb-3" role="alert">
<i class="ti ti-alert-triangle me-1"></i>
{{ __('No group in the next level of the escalation tree.', 'moreoptions') }}
</div>
</div>
{% endif %}

{% set observer_lbl %}
<i class="ti ti-eye me-1" title="{{ __('Assign me as an observer', 'moreoptions') }}"
Expand Down Expand Up @@ -119,7 +127,7 @@
</div>

<div class="d-flex justify-content-end mt-3">
<button class="btn btn-primary" type="submit" name="add">
<button class="btn btn-primary" type="submit" name="add" {{ no_allowed_group ? 'disabled' }}>
<i class="ti ti-escalator-up"></i>
<span>{{ __('Escalate', 'moreoptions') }}</span>
</button>
Expand Down
164 changes: 164 additions & 0 deletions tests/Units/EscalationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<CommonITILObject> $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.
*
Expand Down
Loading