From 1ec00da5c296e1ee6d7a9c37927a010d322866f9 Mon Sep 17 00:00:00 2001 From: Lainow Date: Mon, 5 Oct 2026 13:44:50 +0200 Subject: [PATCH 1/3] Add escalate rules action --- CHANGELOG.md | 1 + front/escalation_rules.php | 65 +++ hook.php | 14 + public/css/moreoptions.scss | 22 + setup.php | 20 + src/EscalationRule.php | 451 ++++++++++++++++ templates/config.html.twig | 4 + templates/escalation_rules.html.twig | 104 ++++ templates/escalation_rules_banner.html.twig | 48 ++ tests/Units/EscalationRuleTest.php | 565 ++++++++++++++++++++ 10 files changed, 1294 insertions(+) create mode 100644 front/escalation_rules.php create mode 100644 src/EscalationRule.php create mode 100644 templates/escalation_rules.html.twig create mode 100644 templates/escalation_rules_banner.html.twig create mode 100644 tests/Units/EscalationRuleTest.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 9019c5b..77ced0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). - Add rector config - 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 ## [1.0.0-rc2] diff --git a/front/escalation_rules.php b/front/escalation_rules.php new file mode 100644 index 0000000..c4be943 --- /dev/null +++ b/front/escalation_rules.php @@ -0,0 +1,65 @@ + 0) { + Session::addMessageAfterRedirect(htmlescape(sprintf( + _n('%d rule action has been updated.', '%d rule actions have been updated.', $result['updated'], 'moreoptions'), + $result['updated'], + ))); + } + + if ($result['failed'] > 0) { + Session::addMessageAfterRedirect(htmlescape(sprintf( + _n('%d rule action cannot be updated.', '%d rule actions cannot be updated.', $result['failed'], 'moreoptions'), + $result['failed'], + )), false, ERROR); + } + + Html::back(); +} + +Html::header(__('Escalation in business rules', 'moreoptions'), '', 'admin', 'rule'); +EscalationRule::showRulesList(); +Html::footer(); diff --git a/hook.php b/hook.php index 0948759..548667f 100644 --- a/hook.php +++ b/hook.php @@ -35,6 +35,7 @@ use GlpiPlugin\Moreoptions\Config; use GlpiPlugin\Moreoptions\Escalation; +use GlpiPlugin\Moreoptions\EscalationRule; use GlpiPlugin\Moreoptions\Group_Link; function plugin_moreoptions_install(): bool @@ -54,7 +55,20 @@ function plugin_moreoptions_uninstall(): bool Config::uninstall($migration); Escalation::uninstall($migration); + EscalationRule::uninstall($migration); Group_Link::uninstall($migration); return true; } + +/** + * Adds the "Escalate to group" action to the ticket / change / problem rules (see the `use_rules` + * hook in setup.php). + * + * @param array $params + * @return array> + */ +function plugin_moreoptions_getRuleActions(array $params = []): array +{ + return EscalationRule::getRuleActions($params); +} diff --git a/public/css/moreoptions.scss b/public/css/moreoptions.scss index 107bc64..865bcb4 100644 --- a/public/css/moreoptions.scss +++ b/public/css/moreoptions.scss @@ -268,3 +268,25 @@ gap: 0.25rem; } } + +// "Review rules" page (see escalation_rules.html.twig): fixed column widths, so that every rule type +// table lines up the same way, and the field dropdown fills its column. +.moreoptions-escalation-rules { + table { + table-layout: fixed; + } + + // Name, Active, Field, Action type, Value + $column-widths: 30%, 8%, 27%, 12%, 23%; + @each $width in $column-widths { + th:nth-child(#{index($column-widths, $width)}) { + width: $width; + } + } + + // Active + th:nth-child(2), + td:nth-child(2) { + text-align: center; + } +} diff --git a/setup.php b/setup.php index dd06f8c..903b8c6 100644 --- a/setup.php +++ b/setup.php @@ -37,6 +37,7 @@ use GlpiPlugin\Moreoptions\Config; use GlpiPlugin\Moreoptions\Controller; use GlpiPlugin\Moreoptions\Escalation; +use GlpiPlugin\Moreoptions\EscalationRule; use GlpiPlugin\Moreoptions\Group_Link; /** @phpstan-ignore theCodingMachineSafe.function (safe to assume this isn't already defined) */ @@ -135,6 +136,25 @@ function plugin_init_moreoptions(): void $PLUGIN_HOOKS[Hooks::ITEM_ADD]['moreoptions'][ChangeTask::class] = Controller::assignTechnicianFromTask(...); $PLUGIN_HOOKS[Hooks::ITEM_ADD]['moreoptions'][ProblemTask::class] = Controller::assignTechnicianFromTask(...); + + // "Escalate to group" rule action (see EscalationRule). The PRE_ITEM_UPDATE hooks of tickets, + // changes and problems are already taken above: only one callback per itemtype is possible, so + // the existing one is called first, then the escalation one. + $PLUGIN_HOOKS[Hooks::USE_RULES]['moreoptions'] = EscalationRule::getRuleClasses(); + + foreach ([Ticket::class, Change::class, Problem::class] as $itemtype) { + $PLUGIN_HOOKS[Hooks::PRE_ITEM_ADD]['moreoptions'][$itemtype] = EscalationRule::dropFromUserInput(...); + + $PLUGIN_HOOKS[Hooks::ITEM_ADD]['moreoptions'][$itemtype] = EscalationRule::escalateAfterAdd(...); + + $pre_item_update = $PLUGIN_HOOKS[Hooks::PRE_ITEM_UPDATE]['moreoptions'][$itemtype]; + $PLUGIN_HOOKS[Hooks::PRE_ITEM_UPDATE]['moreoptions'][$itemtype] = static function (CommonDBTM $item) use ($pre_item_update): void { + $pre_item_update($item); + EscalationRule::dropFromUserInput($item); + }; + + $PLUGIN_HOOKS[Hooks::POST_PREPAREUPDATE]['moreoptions'][$itemtype] = EscalationRule::escalateBeforeUpdate(...); + } } /** diff --git a/src/EscalationRule.php b/src/EscalationRule.php new file mode 100644 index 0000000..e94ca48 --- /dev/null +++ b/src/EscalationRule.php @@ -0,0 +1,451 @@ +> + */ + public static function getRuleClasses(): array + { + return [RuleTicket::class, RuleChange::class, RuleProblem::class]; + } + + /** + * Called by the `getRuleActions` plugin hook (see hook.php). + * + * @param array $params + * @return array> + */ + public static function getRuleActions(array $params): array + { + if (!in_array($params['rule_itemtype'] ?? '', self::getRuleClasses(), true)) { + return []; + } + + return [ + self::ACTION_FIELD => [ + 'name' => __('Escalate to group', 'moreoptions'), + 'type' => 'dropdown', + 'table' => Group::getTable(), + 'condition' => ['is_assign' => 1], + 'force_actions' => ['assign'], + ], + ]; + } + + /** + * Hooked on {@link \Glpi\Plugin\Hooks::PRE_ITEM_ADD} / {@link \Glpi\Plugin\Hooks::PRE_ITEM_UPDATE} + * for tickets, changes and problems, which run before the rules. The escalation group may only + * come from the rules: drop it from the user input, otherwise anyone able to update an item could + * escalate it. + */ + public static function dropFromUserInput(CommonDBTM $item): void + { + if (is_array($item->input)) { + unset($item->input[self::ACTION_FIELD]); + } + } + + /** + * Hooked on {@link \Glpi\Plugin\Hooks::ITEM_ADD} for tickets, changes and problems: called once + * the actors of the new item are saved. + */ + public static function escalateAfterAdd(CommonDBTM $item): void + { + if ($item instanceof CommonITILObject) { + self::escalate($item); + } + } + + /** + * Hooked on {@link \Glpi\Plugin\Hooks::POST_PREPAREUPDATE} for tickets, changes and problems: + * called after the rules, on every update. ITEM_UPDATE would be too late (the actors sent by the + * form are saved after it, adding back the previous group and technicians) and is skipped when no + * column of the item changes (e.g. a rule matching on an actor change only). + * + * The item is escalated right away, then the input of the update is aligned on the escalation + * result, otherwise the update would undo it. + */ + public static function escalateBeforeUpdate(CommonDBTM $item): void + { + if (!$item instanceof CommonITILObject) { + return; + } + + $groups_id = self::escalate($item); + if ($groups_id === null || !is_array($item->input)) { + return; + } + + // Assigned groups: only the target one remains. + $item->input['_groups_id_assign'] = [$groups_id]; + unset($item->input['_additional_groups_assigns'], $item->input['_groups_id_assign_deleted']); + + // Assigned technicians: dropped by the escalation when the entity says so. + $config = Config::getConfig((int) $item->fields['entities_id']); + if ((int) ($config->fields['escalate_remove_technician'] ?? 0) === 1) { + $item->input['_users_id_assign'] = []; + unset( + $item->input['_additional_assigns'], + $item->input['_users_id_assign_deleted'], + $item->input['_users_id_assign_notif'], + ); + } + + // Status: may have been changed by the escalation. Also set on the fields, so that the update + // does not see (and log) it as a change. + $escalated = getItemForItemtype($item::class); + if ($escalated instanceof CommonITILObject && $escalated->getFromDB($item->getID())) { + $item->fields['status'] = $escalated->fields['status']; + if (array_key_exists('status', $item->input)) { + $item->input['status'] = $escalated->fields['status']; + } + } + } + + /** + * Escalates the item to the group set by an "Escalate to group" rule action, if any. + * + * When the item cannot be escalated (escalation not enabled for its entity, or see + * Escalation::getEscalationBlocker()), the reason goes to the GLPI events log: there is nobody to + * report it to when the rules run from the mail collector. + * + * @return int|null The group the item was escalated to, null when it was not + */ + private static function escalate(CommonITILObject $item): ?int + { + if (!is_array($item->input) || !isset($item->input[self::ACTION_FIELD])) { + return null; + } + + $groups_id = (int) $item->input[self::ACTION_FIELD]; + unset($item->input[self::ACTION_FIELD]); + + $blocker = Escalation::isEnabledFor($item) + ? Escalation::getEscalationBlocker($item, $groups_id) + : __('Escalation is not enabled for the entity of the item.', 'moreoptions'); + if ($blocker !== null) { + Event::log( + $item->getID(), + strtolower($item::class), + 3, + $item instanceof Ticket ? 'tracking' : 'maintain', + sprintf( + __('The "%1$s" rule action did not escalate the item to the group "%2$s": %3$s', 'moreoptions'), + __('Escalate to group', 'moreoptions'), + Dropdown::getDropdownName(Group::getTable(), $groups_id), + $blocker, + ), + ); + return null; + } + + $config = Config::getConfig((int) $item->fields['entities_id']); + + $escalation = new Escalation(); + $escalated = $escalation->add([ + 'itemtype' => $item::class, + 'items_id' => $item->getID(), + 'groups_id' => $groups_id, + 'content' => __('Escalated by a business rule.', 'moreoptions'), + 'is_private' => (int) ($config->fields['escalade_is_private_by_default'] ?? 0), + ]); + + return $escalated !== false ? $groups_id : null; + } + + /** + * Whether the current user can see the rules the "Review rules" page lists. + */ + public static function canManageRules(): bool + { + foreach (self::getRuleClasses() as $rule_class) { + if ($rule_class::canView()) { + return true; + } + } + + return false; + } + + /** + * Whether the "Review rules" page has a rule to switch to "Escalate to group": shows its link in + * the entity configuration (see escalation_rules_banner.html.twig). + */ + public static function hasRulesToReview(): bool + { + /** @var DBmysql $DB */ + global $DB; + + $criteria = self::getGroupAssignActionsCriteria([self::CORE_FIELD]); + if ($criteria === null) { + return false; + } + + $row = $DB->request(['COUNT' => 'cpt'] + $criteria)->current(); + + return (int) ($row['cpt'] ?? 0) > 0; + } + + /** + * The "Assign" actions on a technician group (core "Technician group" or "Escalate to group") + * of the rules the current user can see, sorted by rule type and ranking. + * + * @return array, rule_type: string, rule_link: string, is_active: bool, group: string, can_update: bool}> + */ + public static function getGroupAssignActions(): array + { + /** @var DBmysql $DB */ + global $DB; + + $criteria = self::getGroupAssignActionsCriteria([self::CORE_FIELD, self::ACTION_FIELD]); + if ($criteria === null) { + return []; + } + + $rules_table = Rule::getTable(); + $actions_table = RuleAction::getTable(); + + $iterator = $DB->request([ + 'SELECT' => [ + $actions_table . '.id', + $actions_table . '.field', + $actions_table . '.value', + $rules_table . '.id AS rules_id', + $rules_table . '.sub_type', + $rules_table . '.is_active', + ], + 'ORDER' => [$rules_table . '.sub_type', $rules_table . '.ranking', $actions_table . '.id'], + ] + $criteria); + + $actions = []; + foreach ($iterator as $row) { + $rule = getItemForItemtype($row['sub_type']); + if (!$rule instanceof Rule) { + continue; + } + + // Also loads the rule, for getLink(). + $can_update = $rule->can((int) $row['rules_id'], UPDATE); + + $actions[] = [ + 'id' => (int) $row['id'], + 'field' => (string) $row['field'], + 'rule_class' => $rule::class, + 'rule_type' => $rule->getTitle(), + 'rule_link' => $rule->getLink(), + 'is_active' => (int) $row['is_active'] === 1, + 'group' => Dropdown::getDropdownName(Group::getTable(), (int) $row['value']), + 'can_update' => $can_update, + ]; + } + + return $actions; + } + + /** + * The "Assign" actions on the given fields of the rules the current user can see, null when they + * cannot see any rule. + * + * @param array $fields + * @return array|null + */ + private static function getGroupAssignActionsCriteria(array $fields): ?array + { + $rule_classes = array_values(array_filter( + self::getRuleClasses(), + static fn(string $rule_class): bool => $rule_class::canView(), + )); + if ($rule_classes === []) { + return null; + } + + $rules_table = Rule::getTable(); + $actions_table = RuleAction::getTable(); + + return [ + 'FROM' => $actions_table, + 'INNER JOIN' => [ + $rules_table => [ + 'ON' => [ + $actions_table => 'rules_id', + $rules_table => 'id', + ], + ], + ], + 'WHERE' => [ + $actions_table . '.field' => $fields, + $actions_table . '.action_type' => 'assign', + $rules_table . '.sub_type' => $rule_classes, + ] + getEntitiesRestrictCriteria($rules_table, '', '', true), + ]; + } + + /** + * Applies the fields chosen on the "Review rules" page: switches each "Assign" technician group + * action whose field changed to "Escalate to group" or back to the core "Technician group". + * + * @param array $fields Rule action id => self::CORE_FIELD or self::ACTION_FIELD + * @return array{updated: int, failed: int} + */ + public static function switchActions(array $fields): array + { + $result = ['updated' => 0, 'failed' => 0]; + foreach ($fields as $ruleactions_id => $field) { + $action = new RuleAction(); + if (!$action->getFromDB((int) $ruleactions_id) || !in_array($field, [self::CORE_FIELD, self::ACTION_FIELD], true)) { + $result['failed']++; + continue; + } + + if ($action->fields['field'] === $field) { + continue; + } + + if (self::switchAction($action, $field)) { + $result['updated']++; + } else { + $result['failed']++; + } + } + + return $result; + } + + private static function switchAction(RuleAction $action, string $field): bool + { + if ( + $action->fields['action_type'] !== 'assign' + || !in_array($action->fields['field'], [self::CORE_FIELD, self::ACTION_FIELD], true) + ) { + return false; + } + + $rule = Rule::getRuleObjectByID((int) $action->fields['rules_id']); + if ( + $rule === null + || !in_array($rule::class, self::getRuleClasses(), true) + || !$rule->can((int) $action->fields['rules_id'], UPDATE) + || !$action->update(['id' => $action->getID(), 'field' => $field]) + ) { + return false; + } + + // As the core does when an action is added or removed (see RuleAction::post_addItem()). + $rule->update(['id' => $rule->getID(), 'date_mod' => $_SESSION['glpi_currenttime']]); + + return true; + } + + /** + * Renders the "Review rules" page (see front/escalation_rules.php). + */ + public static function showRulesList(): void + { + // One table per rule type, as the rules lists. + $actions = self::getGroupAssignActions(); + + $actions_by_type = []; + foreach ($actions as $action) { + $actions_by_type[$action['rule_class']] ??= ['title' => $action['rule_type'], 'actions' => []]; + $actions_by_type[$action['rule_class']]['actions'][] = $action; + } + + TemplateRenderer::getInstance()->display('@moreoptions/escalation_rules.html.twig', [ + 'actions_by_type' => $actions_by_type, + 'can_update_any' => in_array(true, array_column($actions, 'can_update'), true), + 'field_labels' => [ + self::CORE_FIELD => __('Technician group'), + self::ACTION_FIELD => __('Escalate to group', 'moreoptions'), + ], + ]); + } + + /** + * Once the plugin is uninstalled, the "Escalate to group" rule actions would be unknown, and + * their rules would silently stop assigning a group: switch them back to the core + * "Technician group". + */ + public static function uninstall(Migration $migration): void + { + /** @var DBmysql $DB */ + global $DB; + + $migration->displayMessage('Switching "Escalate to group" rule actions back to "Technician group"'); + $DB->update( + RuleAction::getTable(), + ['field' => self::CORE_FIELD], + ['field' => self::ACTION_FIELD], + ); + } +} diff --git a/templates/config.html.twig b/templates/config.html.twig index 7167967..3503755 100644 --- a/templates/config.html.twig +++ b/templates/config.html.twig @@ -143,6 +143,10 @@ {% endfor %} {% endfor %} + + {% if tab.id == 'escalate' %} + {{ include('@moreoptions/escalation_rules_banner.html.twig') }} + {% endif %} {% endfor %} diff --git a/templates/escalation_rules.html.twig b/templates/escalation_rules.html.twig new file mode 100644 index 0000000..1341d36 --- /dev/null +++ b/templates/escalation_rules.html.twig @@ -0,0 +1,104 @@ +{# + # ------------------------------------------------------------------------- + # MoreOptions 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 MoreOptions plugin team. + # @license MIT https://opensource.org/licenses/mit-license.php + # @link https://github.com/pluginsGLPI/moreoptions + # ------------------------------------------------------------------------- + #} + +
+ + + {% if actions_by_type is empty %} +
+ {{ __('No rule assigns a technician group.', 'moreoptions') }} +
+ {% endif %} + + {% for rule_class, rule_type in actions_by_type %} + {% set entries = [] %} + {% for action in rule_type.actions %} + {% set field %} + {% if action.can_update %} + {{ call('Dropdown::showFromArray', ['fields[' ~ action.id ~ ']', field_labels, { + 'value': action.field, + 'width': '100%', + 'display': false, + }])|raw }} + {% else %} + {{ field_labels[action.field] }} + {% endif %} + {% endset %} + + {% set is_active %} + + {% endset %} + + {% set entries = entries|merge([{ + 'name': action.rule_link, + 'is_active': is_active, + 'field': field, + 'action_type': __('Assign'), + 'value': action.group, + }]) %} + {% endfor %} + +
+ {{ include('components/datatable.html.twig', { + 'datatable_id': 'moreoptions_escalation_rules_' ~ loop.index, + 'table_class_style': 'table-hover table-vcenter card-table', + 'nofilter': true, + 'nosort': true, + 'super_header': rule_type.title, + 'columns': { + 'name': __('Name'), + 'is_active': __('Active'), + 'field': _n('Field', 'Fields', 1), + 'action_type': __('Action type'), + 'value': __('Value'), + }, + 'formatters': { + 'name': 'raw_html', + 'is_active': 'raw_html', + 'field': 'raw_html', + }, + 'entries': entries, + 'total_number': entries|length, + 'filtered_number': entries|length, + }, with_context = false) }} +
+ {% endfor %} + + {% if can_update_any %} +
+ +
+ {% endif %} +
diff --git a/templates/escalation_rules_banner.html.twig b/templates/escalation_rules_banner.html.twig new file mode 100644 index 0000000..57f507c --- /dev/null +++ b/templates/escalation_rules_banner.html.twig @@ -0,0 +1,48 @@ +{# + # ------------------------------------------------------------------------- + # MoreOptions 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 MoreOptions plugin team. + # @license MIT https://opensource.org/licenses/mit-license.php + # @link https://github.com/pluginsGLPI/moreoptions + # ------------------------------------------------------------------------- + #} + +{# + # Shown at the bottom of the "Escalate" tab of the entity configuration (see config.html.twig), when + # some rules assign a technician group: link to the page that switches them to "Escalate to group". + #} + +{% if call('GlpiPlugin\\Moreoptions\\EscalationRule::hasRulesToReview') %} +
+ +
+ {{ __('You have business rules that currently assign a technician to a ticket, change, or problem. You can update them here using the “Escalate to group” action.', 'moreoptions') }} +
+ + + {{ __('Check rules', 'moreoptions') }} + +
+{% endif %} diff --git a/tests/Units/EscalationRuleTest.php b/tests/Units/EscalationRuleTest.php new file mode 100644 index 0000000..28bc43a --- /dev/null +++ b/tests/Units/EscalationRuleTest.php @@ -0,0 +1,565 @@ +, class-string}> + */ + public static function itemtypeProvider(): iterable + { + yield Ticket::class => [Ticket::class, RuleTicket::class]; + yield Change::class => [Change::class, RuleChange::class]; + yield Problem::class => [Problem::class, RuleProblem::class]; + } + + /** + * @param class-string $itemtype + * @param class-string $rule_class + */ + #[DataProvider('itemtypeProvider')] + public function testActionIsAvailableInRules(string $itemtype, string $rule_class): void + { + $rule = getItemForItemtype($rule_class); + $this->assertInstanceOf(RuleCommonITILObject::class, $rule); + + $action = $rule->getAllActions()[EscalationRule::ACTION_FIELD] ?? null; + $this->assertIsArray($action); + $this->assertSame(['assign'], $action['force_actions'] ?? null); + $this->assertSame(Group::getTable(), $action['table'] ?? null); + } + + public function testActionIsNotAvailableInOtherRules(): void + { + $this->assertArrayNotHasKey(EscalationRule::ACTION_FIELD, (new RuleRight())->getAllActions()); + } + + /** + * @param class-string $itemtype + * @param class-string $rule_class + */ + #[DataProvider('itemtypeProvider')] + public function testEscalationOnAdd(string $itemtype, string $rule_class): void + { + $this->login(); + $entities_id = $this->getTestRootEntity(true); + $this->assertIsInt($entities_id); + $this->configureEscalation($entities_id, ['escalate_is_active' => 1]); + + $source_group = $this->createGroup($entities_id, 'Source group'); + $target_group = $this->createGroup($entities_id, 'Target group'); + $this->createEscalationRule($rule_class, RuleCommonITILObject::ONADD, $target_group); + + $item = $this->createItem($itemtype, [ + 'name' => 'Please escalate-me', + 'content' => 'Test content', + 'entities_id' => $entities_id, + '_groups_id_assign' => $source_group->getID(), + ]); + $this->assertInstanceOf(CommonITILObject::class, $item); + + // The target group replaced the group assigned on creation + $this->assertSame([$target_group->getID()], $this->getAssignedGroupIds($item)); + + $escalations = (new Escalation())->find(['itemtype' => $itemtype, 'items_id' => $item->getID()]); + $this->assertCount(1, $escalations); + $escalation = reset($escalations); + $this->assertSame($target_group->getID(), (int) $escalation['groups_id']); + $this->assertSame('[' . $source_group->getID() . ']', $escalation['groups_ids_source']); + } + + /** + * @param class-string $itemtype + * @param class-string $rule_class + */ + #[DataProvider('itemtypeProvider')] + public function testEscalationOnUpdate(string $itemtype, string $rule_class): void + { + $this->login(); + $entities_id = $this->getTestRootEntity(true); + $this->assertIsInt($entities_id); + $this->configureEscalation($entities_id, ['escalate_is_active' => 1]); + + $source_group = $this->createGroup($entities_id, 'Source group'); + $target_group = $this->createGroup($entities_id, 'Target group'); + $this->createEscalationRule($rule_class, RuleCommonITILObject::ONUPDATE, $target_group); + + $item = $this->createItem($itemtype, [ + 'name' => 'Test escalation', + 'content' => 'Test content', + 'entities_id' => $entities_id, + '_groups_id_assign' => $source_group->getID(), + ]); + $this->assertInstanceOf(CommonITILObject::class, $item); + $this->assertSame([$source_group->getID()], $this->getAssignedGroupIds($item)); + + $this->updateItem($itemtype, $item->getID(), ['name' => 'Please escalate-me']); + + $this->assertSame([$target_group->getID()], $this->getAssignedGroupIds($item)); + $this->assertSame(1, countElementsInTable(Escalation::getTable(), [ + 'itemtype' => $itemtype, + 'items_id' => $item->getID(), + ])); + } + + /** + * @param class-string $itemtype + * @param class-string $rule_class + */ + #[DataProvider('itemtypeProvider')] + public function testNoEscalationWhenDisabledForEntity(string $itemtype, string $rule_class): void + { + $this->login(); + $entities_id = $this->getTestRootEntity(true); + $this->assertIsInt($entities_id); + $this->configureEscalation($entities_id, ['escalate_is_active' => 0]); + + $source_group = $this->createGroup($entities_id, 'Source group'); + $target_group = $this->createGroup($entities_id, 'Target group'); + $this->createEscalationRule($rule_class, RuleCommonITILObject::ONADD, $target_group); + + $item = $this->createItem($itemtype, [ + 'name' => 'Please escalate-me', + 'content' => 'Test content', + 'entities_id' => $entities_id, + '_groups_id_assign' => $source_group->getID(), + ]); + $this->assertInstanceOf(CommonITILObject::class, $item); + + // Nothing happened... + $this->assertSame([$source_group->getID()], $this->getAssignedGroupIds($item)); + $this->assertSame(0, countElementsInTable(Escalation::getTable(), [ + 'itemtype' => $itemtype, + 'items_id' => $item->getID(), + ])); + + // ... and the events log says why + $events = (new Event())->find([ + 'type' => strtolower($itemtype), + 'items_id' => $item->getID(), + 'message' => ['LIKE', '%Escalation is not enabled for the entity of the item.%'], + ]); + $this->assertCount(1, $events); + $this->assertStringContainsString('Target group', reset($events)['message']); + } + + /** + * Saving the item form sends back its current actors and status: they must not undo the escalation. + * + * @param class-string $itemtype + * @param class-string $rule_class + */ + #[DataProvider('itemtypeProvider')] + public function testEscalationOnUpdateFromForm(string $itemtype, string $rule_class): void + { + $this->login(); + $entities_id = $this->getTestRootEntity(true); + $this->assertIsInt($entities_id); + + $status_field = 'escalade_status_after_escalation_' . strtolower($itemtype); + $this->configureEscalation($entities_id, [ + 'escalate_is_active' => 1, + 'escalate_remove_technician' => 1, + $status_field => CommonITILObject::WAITING, + ]); + + $source_group = $this->createGroup($entities_id, 'Source group'); + $target_group = $this->createGroup($entities_id, 'Target group'); + $technician_id = getItemByTypeName(User::class, 'tech', true); + $this->createEscalationRule($rule_class, RuleCommonITILObject::ONUPDATE, $target_group); + + $item = $this->createItem($itemtype, [ + 'name' => 'Test escalation', + 'content' => 'Test content', + 'entities_id' => $entities_id, + '_groups_id_assign' => $source_group->getID(), + '_users_id_assign' => $technician_id, + ]); + $this->assertInstanceOf(CommonITILObject::class, $item); + $this->assertTrue($item->getFromDB($item->getID())); + $status_before = (int) $item->fields['status']; + $this->assertNotSame(CommonITILObject::WAITING, $status_before); + + $this->assertTrue($item->update([ + 'id' => $item->getID(), + 'name' => 'Please escalate-me', + 'status' => $status_before, + '_actors' => [ + 'requester' => [], + 'observer' => [], + 'assign' => [ + ['itemtype' => Group::class, 'items_id' => $source_group->getID()], + ['itemtype' => User::class, 'items_id' => $technician_id, 'use_notification' => 1, 'alternative_email' => ''], + ], + ], + ])); + + $this->assertSame([$target_group->getID()], $this->getAssignedGroupIds($item)); + $this->assertSame([], $this->getAssignedUserIds($item)); + $this->assertTrue($item->getFromDB($item->getID())); + $this->assertSame(CommonITILObject::WAITING, (int) $item->fields['status']); + } + + /** + * A rule matching on an actor change only escalates the item, although none of its columns changes. + * + * @param class-string $itemtype + * @param class-string $rule_class + */ + #[DataProvider('itemtypeProvider')] + public function testEscalationOnActorChangeOnly(string $itemtype, string $rule_class): void + { + $this->login(); + $entities_id = $this->getTestRootEntity(true); + $this->assertIsInt($entities_id); + $this->configureEscalation($entities_id, ['escalate_is_active' => 1]); + + $target_group = $this->createGroup($entities_id, 'Target group'); + $technician_id = getItemByTypeName(User::class, 'tech', true); + $this->createRule( + (new RuleBuilder('Escalation on technician', $rule_class)) + ->setEntity(0) + ->setCondtion(RuleCommonITILObject::ONUPDATE) + ->addCriteria('_users_id_assign', Rule::PATTERN_IS, $technician_id) + ->addAction('assign', EscalationRule::ACTION_FIELD, $target_group->getID()), + ); + + $item = $this->createItem($itemtype, [ + 'name' => 'Test escalation', + 'content' => 'Test content', + 'entities_id' => $entities_id, + ]); + $this->assertInstanceOf(CommonITILObject::class, $item); + + $this->assertTrue($item->update([ + 'id' => $item->getID(), + '_actors' => [ + 'requester' => [], + 'observer' => [], + 'assign' => [ + ['itemtype' => User::class, 'items_id' => $technician_id, 'use_notification' => 1, 'alternative_email' => ''], + ], + ], + ])); + + $this->assertSame([$target_group->getID()], $this->getAssignedGroupIds($item)); + $this->assertSame(1, countElementsInTable(Escalation::getTable(), [ + 'itemtype' => $itemtype, + 'items_id' => $item->getID(), + ])); + } + + /** + * @param class-string $itemtype + * @param class-string $rule_class + */ + #[DataProvider('itemtypeProvider')] + public function testEscalationCannotComeFromUserInput(string $itemtype, string $rule_class): void + { + $this->login(); + $entities_id = $this->getTestRootEntity(true); + $this->assertIsInt($entities_id); + + $target_group = $this->createGroup($entities_id, 'Target group'); + + $item = $this->createItem($itemtype, [ + 'name' => 'Test escalation', + 'content' => 'Test content', + 'entities_id' => $entities_id, + EscalationRule::ACTION_FIELD => $target_group->getID(), + ], [EscalationRule::ACTION_FIELD]); + $this->assertInstanceOf(CommonITILObject::class, $item); + + $this->updateItem($itemtype, $item->getID(), [ + 'name' => 'Test escalation updated', + EscalationRule::ACTION_FIELD => $target_group->getID(), + ], [EscalationRule::ACTION_FIELD]); + + $this->assertSame([], $this->getAssignedGroupIds($item)); + $this->assertSame(0, countElementsInTable(Escalation::getTable(), [ + 'itemtype' => $itemtype, + 'items_id' => $item->getID(), + ])); + } + + public function testSwitchAction(): void + { + $this->login(); + $entities_id = $this->getTestRootEntity(true); + $this->assertIsInt($entities_id); + + $group = $this->createGroup($entities_id, 'Rule group'); + $rule = $this->createRule( + (new RuleBuilder('Technician group rule', RuleTicket::class)) + ->setEntity(0) + ->addCriteria('name', Rule::PATTERN_CONTAIN, 'anything') + ->addAction('assign', EscalationRule::CORE_FIELD, $group->getID()) + ->addAction('append', EscalationRule::CORE_FIELD, $group->getID()), + ); + + $assign = $this->getRuleAction($rule, 'assign'); + $append = $this->getRuleAction($rule, 'append'); + + // Only the "Assign" action is listed + $listed = array_column(EscalationRule::getGroupAssignActions(), 'field', 'id'); + $this->assertSame(EscalationRule::CORE_FIELD, $listed[$assign->getID()] ?? null); + $this->assertArrayNotHasKey($append->getID(), $listed); + + // Switch to "Escalate to group", then back + $this->assertSame(['updated' => 1, 'failed' => 0], EscalationRule::switchActions([$assign->getID() => EscalationRule::ACTION_FIELD])); + $this->assertTrue($assign->getFromDB($assign->getID())); + $this->assertSame(EscalationRule::ACTION_FIELD, $assign->fields['field']); + $this->assertSame((string) $group->getID(), (string) $assign->fields['value']); + + $this->assertSame(['updated' => 1, 'failed' => 0], EscalationRule::switchActions([$assign->getID() => EscalationRule::CORE_FIELD])); + $this->assertTrue($assign->getFromDB($assign->getID())); + $this->assertSame(EscalationRule::CORE_FIELD, $assign->fields['field']); + + // An "Add" action cannot be switched, nor can an action be switched to any other field + $this->assertSame(['updated' => 0, 'failed' => 1], EscalationRule::switchActions([$append->getID() => EscalationRule::ACTION_FIELD])); + $this->assertSame(['updated' => 0, 'failed' => 1], EscalationRule::switchActions([$assign->getID() => 'name'])); + $this->assertTrue($append->getFromDB($append->getID())); + $this->assertSame(EscalationRule::CORE_FIELD, $append->fields['field']); + $this->assertTrue($assign->getFromDB($assign->getID())); + $this->assertSame(EscalationRule::CORE_FIELD, $assign->fields['field']); + } + + public function testSwitchActions(): void + { + $this->login(); + $entities_id = $this->getTestRootEntity(true); + $this->assertIsInt($entities_id); + + $group = $this->createGroup($entities_id, 'Rule group'); + $actions = []; + foreach (['First', 'Second'] as $name) { + $rule = $this->createRule( + (new RuleBuilder($name . ' rule', RuleTicket::class)) + ->setEntity(0) + ->addCriteria('name', Rule::PATTERN_CONTAIN, 'anything') + ->addAction('assign', EscalationRule::CORE_FIELD, $group->getID()), + ); + $actions[] = $this->getRuleAction($rule, 'assign'); + } + + // Only the changed actions are counted, unknown ones fail + $result = EscalationRule::switchActions([ + $actions[0]->getID() => EscalationRule::ACTION_FIELD, + $actions[1]->getID() => EscalationRule::CORE_FIELD, + 0 => EscalationRule::ACTION_FIELD, + ]); + $this->assertSame(['updated' => 1, 'failed' => 1], $result); + + $this->assertTrue($actions[0]->getFromDB($actions[0]->getID())); + $this->assertSame(EscalationRule::ACTION_FIELD, $actions[0]->fields['field']); + $this->assertTrue($actions[1]->getFromDB($actions[1]->getID())); + $this->assertSame(EscalationRule::CORE_FIELD, $actions[1]->fields['field']); + } + + public function testUninstallSwitchesActionsBackToTechnicianGroup(): void + { + $this->login(); + $entities_id = $this->getTestRootEntity(true); + $this->assertIsInt($entities_id); + + $group = $this->createGroup($entities_id, 'Rule group'); + $rule = $this->createEscalationRule(RuleTicket::class, RuleCommonITILObject::ONADD, $group); + $action = $this->getRuleAction($rule, 'assign'); + $this->assertSame(EscalationRule::ACTION_FIELD, $action->fields['field']); + + EscalationRule::uninstall(new Migration(PLUGIN_MOREOPTIONS_VERSION)); + + $this->assertTrue($action->getFromDB($action->getID())); + $this->assertSame(EscalationRule::CORE_FIELD, $action->fields['field']); + $this->assertSame((string) $group->getID(), (string) $action->fields['value']); + $this->assertSame('assign', $action->fields['action_type']); + } + + public function testRulesPageAndBanner(): void + { + $this->login(); + $entities_id = $this->getTestRootEntity(true); + $this->assertIsInt($entities_id); + + $entity = getItemByTypeName(Entity::class, '_test_root_entity'); + $group = $this->createGroup($entities_id, 'Rule group'); + + // No rule assigns a technician group: no link to the rules page + $this->assertFalse(EscalationRule::hasRulesToReview()); + $this->assertCount(0, $this->getRulesPageLinks($entity)); + + $rule = $this->createRule( + (new RuleBuilder('Technician group rule', RuleTicket::class)) + ->setEntity(0) + ->addCriteria('name', Rule::PATTERN_CONTAIN, 'anything') + ->addAction('assign', EscalationRule::CORE_FIELD, $group->getID()), + ); + $action = $this->getRuleAction($rule, 'assign'); + $this->assertTrue(EscalationRule::hasRulesToReview()); + + // The rules page lists the action, with its field dropdown, in a single form with one save button + ob_start(); + EscalationRule::showRulesList(); + $crawler = new Crawler(ob_get_clean()); + + $this->assertCount(1, $crawler->filter('form')); + $this->assertCount(1, $crawler->filter('form button[name="update"]')); + $select = $crawler->filter('select[name="fields[' . $action->getID() . ']"]'); + $this->assertCount(1, $select); + $this->assertSame(EscalationRule::CORE_FIELD, $select->filter('option[selected]')->attr('value')); + $this->assertCount(1, $select->filter('option[value="' . EscalationRule::ACTION_FIELD . '"]')); + + // The entity configuration links to it + $this->assertCount(1, $this->getRulesPageLinks($entity)); + } + + private function getRulesPageLinks(Entity $entity): Crawler + { + ob_start(); + Config::showForEntity($entity); + + return (new Crawler(ob_get_clean()))->filter('a[href$="/plugins/moreoptions/front/escalation_rules.php"]'); + } + + /** + * @param array $fields + */ + private function configureEscalation(int $entities_id, array $fields): void + { + $config = Config::getConfig($entities_id, false); + if ($config->isNewItem()) { + $this->createTestConfig(['entities_id' => $entities_id] + $fields); + } else { + $this->updateTestConfig($config, $fields); + } + } + + /** + * @return array + */ + private function getAssignedUserIds(CommonITILObject $item): 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' => CommonITILActor::ASSIGN, + ], ['id ASC'])), + ); + } + + /** + * @param class-string $rule_class + */ + private function createEscalationRule(string $rule_class, int $condition, Group $group): Rule + { + $builder = (new RuleBuilder('Escalation rule', $rule_class)) + ->setEntity(0) + ->setCondtion($condition) + ->addCriteria('name', Rule::PATTERN_CONTAIN, 'escalate-me') + ->addAction('assign', EscalationRule::ACTION_FIELD, $group->getID()); + + return $this->createRule($builder); + } + + private function getRuleAction(Rule $rule, string $action_type): RuleAction + { + $action = new RuleAction(); + $this->assertTrue($action->getFromDBByCrit(['rules_id' => $rule->getID(), 'action_type' => $action_type])); + + return $action; + } + + private function createGroup(int $entities_id, string $name): Group + { + $group = $this->createItem(Group::class, [ + 'name' => $name, + 'entities_id' => $entities_id, + 'is_recursive' => 1, + 'is_assign' => 1, + ]); + $this->assertInstanceOf(Group::class, $group); + + return $group; + } + + /** + * @return array + */ + private function getAssignedGroupIds(CommonITILObject $item): array + { + $group_link = getItemForItemtype($item->grouplinkclass); + $this->assertInstanceOf(CommonITILActor::class, $group_link); + + return array_map( + static fn(array $row): int => (int) $row['groups_id'], + array_values($group_link->find([ + $item->getForeignKeyField() => $item->getID(), + 'type' => CommonITILActor::ASSIGN, + ], ['id ASC'])), + ); + } +} From 1cf5213be02021ea18c7f11bd345c79a4fefc29b Mon Sep 17 00:00:00 2001 From: Lainow Date: Mon, 5 Oct 2026 13:50:08 +0200 Subject: [PATCH 2/3] Fix changelog --- CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77ced0f..037dd48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,4 +24,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] - From 87a661ba8fd65eb9b8bb092a421087878d9a13f3 Mon Sep 17 00:00:00 2001 From: Lainow Date: Mon, 5 Oct 2026 14:52:12 +0200 Subject: [PATCH 3/3] Fix CI --- composer.json | 4 +--- psalm.xml | 1 + 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 3e53354..bdfda55 100644 --- a/composer.json +++ b/composer.json @@ -2,10 +2,8 @@ "require": { "php": ">=8.2" }, - "require-dev": { - "glpi-project/tools": "^0.7" - }, "config": { + "autoloader-suffix": "PluginMoreoptions", "optimize-autoloader": true, "platform": { "php": "8.2.99" diff --git a/psalm.xml b/psalm.xml index cad0abc..351df00 100644 --- a/psalm.xml +++ b/psalm.xml @@ -1,6 +1,7 @@