From dce0493c56c02340f3b9c700783d8064f3c6be6c Mon Sep 17 00:00:00 2001 From: Lainow Date: Thu, 24 Sep 2026 12:17:35 +0200 Subject: [PATCH 1/8] Implement base of escalation process --- ajax/escalation_form.php | 50 +++ front/escalation.form.php | 54 ++++ hook.php | 3 + public/css/moreoptions.scss | 85 +++++ setup.php | 20 +- src/Config.php | 6 +- src/Controller.php | 16 +- src/Escalation.php | 411 ++++++++++++++++++++++++ templates/escalation_button.html.twig | 73 +++++ templates/escalation_form.html.twig | 127 ++++++++ templates/escalation_timeline.html.twig | 63 ++++ 11 files changed, 905 insertions(+), 3 deletions(-) create mode 100644 ajax/escalation_form.php create mode 100644 front/escalation.form.php create mode 100644 src/Escalation.php create mode 100644 templates/escalation_button.html.twig create mode 100644 templates/escalation_form.html.twig create mode 100644 templates/escalation_timeline.html.twig diff --git a/ajax/escalation_form.php b/ajax/escalation_form.php new file mode 100644 index 0000000..749c93b --- /dev/null +++ b/ajax/escalation_form.php @@ -0,0 +1,50 @@ +getFromDB((int) ($_GET['items_id'] ?? 0)) + || !$item->canAssign() + || !Escalation::isEnabledFor($item) +) { + throw new AccessDeniedHttpException(); +} + +Escalation::showEscalationForm($item); diff --git a/front/escalation.form.php b/front/escalation.form.php new file mode 100644 index 0000000..2f03067 --- /dev/null +++ b/front/escalation.form.php @@ -0,0 +1,54 @@ +getFromDB((int) ($_POST['items_id'] ?? 0)) + || !$item->canAssign() + || !Escalation::isEnabledFor($item) + ) { + throw new AccessDeniedHttpException(); + } + + (new Escalation())->add($_POST); +} + +Html::back(); diff --git a/hook.php b/hook.php index b721a23..676ccbb 100644 --- a/hook.php +++ b/hook.php @@ -34,12 +34,14 @@ declare(strict_types=1); use GlpiPlugin\Moreoptions\Config; +use GlpiPlugin\Moreoptions\Escalation; function plugin_moreoptions_install(): bool { $migration = new Migration(PLUGIN_MOREOPTIONS_VERSION); Config::install($migration); + Escalation::install($migration); $migration->executeMigration(); return true; } @@ -49,6 +51,7 @@ function plugin_moreoptions_uninstall(): bool $migration = new Migration(PLUGIN_MOREOPTIONS_VERSION); Config::uninstall($migration); + Escalation::uninstall($migration); return true; } diff --git a/public/css/moreoptions.scss b/public/css/moreoptions.scss index 276327f..646d9cd 100644 --- a/public/css/moreoptions.scss +++ b/public/css/moreoptions.scss @@ -111,3 +111,88 @@ } } } + +:root { + --mo-escalation-color: #ffdddd80; + + --mo-escalation-background: var(--mo-escalation-color); + --mo-escalation-border: color-mix(in srgb, var(--mo-escalation-color), #af2020 30%); +} + +// "Escalate" button, next to the "Assigned to" field (see escalation_button.html.twig). +.moreoptions-escalate-btn, +.moreoptions-escalate-btn:focus { + color: var(--mo-escalation-border) !important; + border-color: var(--mo-escalation-border) !important; +} + +.moreoptions-escalate-btn:hover { + background-color: var(--mo-escalation-border) !important; + border-color: var(--mo-escalation-border) !important; + color: white !important; +} + +// Escalation entries in the timeline (see Escalation::showInTimeline()): a single line, the same +// way GLPI core displays pending reason reminders (`.ITILReminder` in _timeline.scss). +.timeline-item.moreoptions-escalation { + width: 100%; + + .user-part { + display: none !important; + } + + .content-part { + flex-direction: row-reverse !important; + justify-content: flex-end; + margin-left: 75px; + margin-right: 83px; + + .timeline-content { + border: 0; + box-shadow: none; + margin-top: 0 !important; + flex-grow: 0 !important; + background: transparent; + + .card-body { + padding: 0; + display: flex; + flex-direction: row-reverse; + + .timeline-header { + margin-left: 1rem; + margin-top: 0; + margin-bottom: 0; + } + } + } + + .user_link { + display: none !important; + } + + .itil-date-creation { + margin-left: 1rem !important; + } + } + + // "Internal" icon, moved into the "Created: ... by ..." badge (see escalation_timeline.html.twig). + .moreoptions-escalation-separator { + opacity: 0.4; + } + + .moreoptions-escalation-group { + color: inherit; + font-weight: normal; + background-color: var(--mo-escalation-background); + border: 1px solid var(--mo-escalation-border); + + a { + color: inherit; + + &:hover { + text-decoration: underline; + } + } + } +} diff --git a/setup.php b/setup.php index bd1a412..666dccb 100644 --- a/setup.php +++ b/setup.php @@ -36,6 +36,7 @@ use Glpi\Plugin\Hooks; use GlpiPlugin\Moreoptions\Config; use GlpiPlugin\Moreoptions\Controller; +use GlpiPlugin\Moreoptions\Escalation; /** @phpstan-ignore theCodingMachineSafe.function (safe to assume this isn't already defined) */ define('PLUGIN_MOREOPTIONS_VERSION', '1.0.0-rc2'); @@ -81,10 +82,27 @@ function plugin_init_moreoptions(): void // Both hooks below are called by GLPI core with an array of parameters (not an item // instance), so they must be registered without an itemtype key: the callback filters // on $params['item'] itself. - $PLUGIN_HOOKS[Hooks::TIMELINE_ACTIONS]['moreoptions'] = Controller::showSolutionRequirementsWarning(...); + $PLUGIN_HOOKS[Hooks::TIMELINE_ACTIONS]['moreoptions'] = Controller::showTimelineActions(...); $PLUGIN_HOOKS[Hooks::POST_ITEM_FORM]['moreoptions'] = Controller::markMandatoryTaskFields(...); + $PLUGIN_HOOKS[Hooks::TIMELINE_ITEMS]['moreoptions'] = [ + Escalation::class, 'showInTimeline', + ]; + + // Group links added with `_plugin_moreoptions_escalade => true` are escalations. + $PLUGIN_HOOKS[Hooks::ITEM_ADD]['moreoptions'][Group_Ticket::class] = [ + Escalation::class, 'escalate', + ]; + + $PLUGIN_HOOKS[Hooks::ITEM_ADD]['moreoptions'][Change_Group::class] = [ + Escalation::class, 'escalate', + ]; + + $PLUGIN_HOOKS[Hooks::ITEM_ADD]['moreoptions'][Group_Problem::class] = [ + Escalation::class, 'escalate', + ]; + $PLUGIN_HOOKS[Hooks::PRE_ITEM_UPDATE]['moreoptions'][Ticket::class] = Controller::beforeCloseITILObject(...); $PLUGIN_HOOKS[Hooks::PRE_ITEM_UPDATE]['moreoptions'][Change::class] = Controller::beforeCloseITILObject(...); diff --git a/src/Config.php b/src/Config.php index 442eed7..458227c 100644 --- a/src/Config.php +++ b/src/Config.php @@ -402,7 +402,9 @@ private static function getScreenSections(): array [ 'title' => __('Escalate', 'moreoptions'), 'icon' => 'ti-arrow-up', - 'rows' => [], + 'rows' => [ + ['key' => 'escalate_is_active', 'kind' => 'yes_no', 'label' => __('Activate escalation', 'moreoptions')], + ], ], ], ]; @@ -574,6 +576,7 @@ public static function install(Migration $migration): void `assign_technician_from_task_ticket` tinyint NOT NULL DEFAULT '0', `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', PRIMARY KEY (`id`), KEY `entities_id` (`entities_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC; @@ -592,6 +595,7 @@ public static function install(Migration $migration): void 'assign_technician_from_task_ticket', 'assign_technician_from_task_change', 'assign_technician_from_task_problem', + 'escalate_is_active', ] as $field ) { if (!$DB->fieldExists($table, $field)) { diff --git a/src/Controller.php b/src/Controller.php index 664cd47..765379a 100644 --- a/src/Controller.php +++ b/src/Controller.php @@ -51,6 +51,7 @@ use CommonITILValidation; use Glpi\Application\View\TemplateRenderer; use GlpiPlugin\Moreoptions\Config; +use GlpiPlugin\Moreoptions\Escalation; use Group_Item; use Group_Problem; use Group_Ticket; @@ -489,7 +490,20 @@ public static function requireFieldsToClose(CommonDBTM $item, bool $is_solution } /** - * Hooked on {@link \Glpi\Plugin\Hooks::TIMELINE_ACTIONS}. Renders, into the ticket/change/ + * Hooked on {@link \Glpi\Plugin\Hooks::TIMELINE_ACTIONS}, which takes a single callback per + * plugin: renders everything MoreOptions adds to the ticket/change/problem timeline footer. + * + * @param array $params + */ + public static function showTimelineActions(array $params): void + { + self::showSolutionRequirementsWarning($params); + Escalation::showEscalateButton($params); + Escalation::showTimelineScripts($params); + } + + /** + * Called from {@see self::showTimelineActions()}. Renders, into the ticket/change/ * problem timeline footer, a script that mutes the "Add a solution" action, adds a lock icon * to it, and attaches a popover listing the missing fields, as soon as one of the fields * required to close the item (technician, group, category, location...) is missing. diff --git a/src/Escalation.php b/src/Escalation.php new file mode 100644 index 0000000..4924873 --- /dev/null +++ b/src/Escalation.php @@ -0,0 +1,411 @@ +> + */ + private static function getSupportedItemtypes(): array + { + return [Ticket::class, Change::class, Problem::class]; + } + + public static function isEnabledFor(CommonITILObject $item): bool + { + if (!in_array($item::class, self::getSupportedItemtypes(), true)) { + return false; + } + + $config = Config::getConfig((int) $item->fields['entities_id']); + + return (int) ($config->fields['escalate_is_active'] ?? 0) === 1; + } + + /** + * Hooked on {@link \Glpi\Plugin\Hooks::TIMELINE_ITEMS}. Adds the escalations of the item + * to its timeline. + * + * @param array $params Parameters passed by the hook (keys: item, timeline) + */ + public static function showInTimeline(array $params): void + { + if (!isset($params['item'], $params['timeline'])) { + return; + } + + $item = $params['item']; + if (!$item instanceof CommonITILObject || !self::isEnabledFor($item)) { + return; + } + + /** @var array $timeline */ + $timeline = &$params['timeline']; + + $escalations = (new self())->find([ + 'itemtype' => $item::class, + 'items_id' => $item->getID(), + ]); + + foreach ($escalations as $row) { + $timeline['MoreoptionsEscalation_' . $row['id']] = [ + 'type' => self::getType(), + 'class' => 'moreoptions-escalation', + 'item' => [ + 'id' => $row['id'], + 'content' => self::getTimelineContent($row), + 'is_content_safe' => true, + 'users_id' => $row['users_id'], + 'can_edit' => false, + 'timeline_position' => CommonITILObject::TIMELINE_LEFT, + 'date_creation' => $row['date_creation'], + 'date_mod' => $row['date_mod'], + 'is_private' => $row['is_private'], + ], + ]; + } + } + + /** + * The one-line summary shown in the timeline, in the manner of pending reason reminders: + * "Escalate from to ", or "Escalate to " when no + * group was assigned before the escalation. Each group links to its form. + * + * @param array $row + */ + private static function getTimelineContent(array $row): string + { + $can_view_groups = Group::canView(); + $badge = static function (int $groups_id) use ($can_view_groups): string { + $name = htmlescape(Dropdown::getDropdownName(Group::getTable(), $groups_id)); + if ($can_view_groups) { + $name = sprintf('%s', htmlescape(Group::getFormURLWithID($groups_id)), $name); + } + + return ' ' . $name . ''; + }; + + $target = $badge((int) $row['groups_id']); + $sources = array_map($badge, self::getSourceGroupIds($row)); + $text = $sources !== [] + ? sprintf(__s('Escalate from %1$s to %2$s', 'moreoptions'), implode(' ', $sources), $target) + : sprintf(__s('Escalate to %s', 'moreoptions'), $target); + + $content = '' + . '' + . $text; + + if (!empty($row['content'])) { + // Inline: the comment as plain text on a single line, cut with "..." when too long. + // Tooltip: the whole comment, with its formatting. + $excerpt = trim((string) preg_replace('/\s+/', ' ', RichText::getTextFromHtml($row['content'], false, true))); + if (mb_strlen($excerpt) > self::TIMELINE_EXCERPT_LENGTH) { + $excerpt = rtrim(mb_substr($excerpt, 0, self::TIMELINE_EXCERPT_LENGTH)) . '...'; + } + + $content .= sprintf( + '%s', + htmlescape(RichText::getSafeHtml($row['content'])), + ' (' . htmlescape($excerpt) . ')', + ); + } + + return $content . ''; + } + + /** + * @param array $row + * @return array + */ + private static function getSourceGroupIds(array $row): array + { + $groups_ids = json_decode((string) ($row['groups_ids_source'] ?? ''), true); + + return is_array($groups_ids) ? array_map('intval', $groups_ids) : []; + } + + /** + * The author is always the current user, and the source groups are the groups assigned to + * the item before the escalation (the target group excepted). + * + * @param array $input + * @return array|false + */ + public function prepareInputForAdd($input) + { + $item = getItemForItemtype($input['itemtype'] ?? ''); + if (!$item instanceof CommonITILObject || !$item->getFromDB((int) ($input['items_id'] ?? 0))) { + return false; + } + + $input['users_id'] = Session::getLoginUserID(); + + $groups_ids_source = []; + $group_link = getItemForItemtype($item->grouplinkclass); + if ($group_link instanceof CommonDBTM) { + foreach ( + $group_link->find([ + $item->getForeignKeyField() => $item->getID(), + 'type' => CommonITILActor::ASSIGN, + 'NOT' => ['groups_id' => (int) ($input['groups_id'] ?? 0)], + ]) as $assigned + ) { + $groups_ids_source[] = (int) $assigned['groups_id']; + } + } + $input['groups_ids_source'] = json_encode($groups_ids_source); + + return $input; + } + + /** + * Applies the escalation to the escalated item, through the group link hook (see + * self::escalate()): assigns the target group with `_plugin_moreoptions_escalade`, or, when it + * is already assigned, directly drops the other assigned groups. + */ + public function post_addItem() + { + $item = getItemForItemtype($this->fields['itemtype']); + if (!$item instanceof CommonITILObject) { + return; + } + + $group_link = getItemForItemtype($item->grouplinkclass); + if (!$group_link instanceof CommonITILActor) { + return; + } + + $criteria = [ + $item->getForeignKeyField() => (int) $this->fields['items_id'], + 'groups_id' => (int) $this->fields['groups_id'], + 'type' => CommonITILActor::ASSIGN, + ]; + + if ($group_link->getFromDBByCrit($criteria)) { + self::keepOnlyAssignedGroup($group_link); + return; + } + + $group_link->add($criteria + ['_plugin_moreoptions_escalade' => true]); + } + + /** + * Called from the {@link \Glpi\Plugin\Hooks::TIMELINE_ACTIONS} hook (see + * Controller::showTimelineActions()). Renders the script that adds a small "Escalate" button + * next to the "Assigned to" label, opening the escalation form in a modal. + * + * @param array $params + */ + public static function showEscalateButton(array $params): void + { + $item = $params['item'] ?? null; + if (!$item instanceof CommonITILObject || $item->isNewItem() || !$item->canAssign() || !self::isEnabledFor($item)) { + return; + } + + TemplateRenderer::getInstance()->display('@moreoptions/escalation_button.html.twig', [ + 'marker_id' => 'moreoptions-escalate-' . $item->getType() . '-' . $item->getID(), + 'itemtype' => $item->getType(), + 'items_id' => $item->getID(), + ]); + } + + /** + * Called from the {@link \Glpi\Plugin\Hooks::TIMELINE_ACTIONS} hook (see + * Controller::showTimelineActions()). Renders the script that moves the "internal" icon of + * escalation entries into their "Created: ... by ..." badge. + * + * @param array $params + */ + public static function showTimelineScripts(array $params): void + { + $item = $params['item'] ?? null; + if (!$item instanceof CommonITILObject || $item->isNewItem() || !self::isEnabledFor($item)) { + return; + } + + TemplateRenderer::getInstance()->display('@moreoptions/escalation_timeline.html.twig', [ + 'marker_id' => 'moreoptions-escalation-timeline-' . $item->getType() . '-' . $item->getID(), + ]); + } + + /** + * Renders the escalation form, loaded in the modal opened by the "Escalate" button (see + * ajax/escalation_form.php). + */ + public static function showEscalationForm(CommonITILObject $item): void + { + TemplateRenderer::getInstance()->display('@moreoptions/escalation_form.html.twig', [ + 'item' => $item, + ]); + } + + /** + * Hooked on {@link \Glpi\Plugin\Hooks::ITEM_ADD} for Group_Ticket, Change_Group and + * Group_Problem. Only group links added with `_plugin_moreoptions_escalade => true` in their + * input are escalations: the new group then replaces the previously assigned ones. + */ + public static function escalate(CommonITILActor $group_link): void + { + if ( + !is_array($group_link->input) + || !($group_link->input['_plugin_moreoptions_escalade'] ?? false) + || (int) $group_link->fields['type'] !== CommonITILActor::ASSIGN + ) { + return; + } + + self::keepOnlyAssignedGroup($group_link); + } + + /** + * Keep only the given group assigned to its item: drop the other assigned groups. + */ + private static function keepOnlyAssignedGroup(CommonITILActor $group_link): void + { + $items_id_field = $group_link::$items_id_1; + + $previous_links = $group_link->find([ + $items_id_field => (int) $group_link->fields[$items_id_field], + 'type' => CommonITILActor::ASSIGN, + 'NOT' => ['id' => $group_link->getID()], + ]); + foreach ($previous_links as $previous_link) { + (new ($group_link::class)())->delete(['id' => $previous_link['id']]); + } + } + + public static function install(Migration $migration): void + { + /** @var \DBmysql $DB */ + global $DB; + + $table = self::getTable(); + if (!$DB->tableExists($table)) { + $migration->displayMessage("Installing $table"); + $query = "CREATE TABLE IF NOT EXISTS `$table` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `itemtype` varchar(100) NOT NULL DEFAULT '', + `items_id` int unsigned NOT NULL DEFAULT '0', + `users_id` int unsigned NOT NULL DEFAULT '0', + `groups_ids_source` text, + `groups_id` int unsigned NOT NULL DEFAULT '0', + `content` longtext, + `date_creation` timestamp NULL DEFAULT NULL, + `date_mod` timestamp NULL DEFAULT NULL, + `is_private` tinyint NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + KEY `item` (`itemtype`, `items_id`), + KEY `users_id` (`users_id`), + KEY `groups_id` (`groups_id`), + KEY `date_creation` (`date_creation`), + KEY `date_mod` (`date_mod`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC; + "; + $DB->doQuery($query); + } + + // Source groups, formerly a single `groups_id_source`, are now a JSON list. + if (!$DB->fieldExists($table, 'groups_ids_source')) { + $migration->addField($table, 'groups_ids_source', 'text', ['after' => 'users_id']); + $migration->migrationOneTable($table); + + if ($DB->fieldExists($table, 'groups_id_source')) { + foreach ($DB->request(['FROM' => $table, 'WHERE' => ['groups_id_source' => ['>', 0]]]) as $row) { + $DB->update( + $table, + ['groups_ids_source' => json_encode([(int) $row['groups_id_source']])], + ['id' => $row['id']], + ); + } + } + } + if ($DB->fieldExists($table, 'groups_id_source')) { + $migration->dropKey($table, 'groups_id_source'); + $migration->dropField($table, 'groups_id_source'); + } + if (!$DB->fieldExists($table, 'is_private')) { + $migration->addField($table, 'is_private', 'bool', ['value' => '0']); + } + $migration->executeMigration(); + } + + public static function uninstall(Migration $migration): void + { + /** @var \DBmysql $DB */ + global $DB; + + $table = self::getTable(); + if ($DB->tableExists($table)) { + $DB->doQuery("DROP TABLE IF EXISTS `$table`"); + } + } +} diff --git a/templates/escalation_button.html.twig b/templates/escalation_button.html.twig new file mode 100644 index 0000000..4f625b2 --- /dev/null +++ b/templates/escalation_button.html.twig @@ -0,0 +1,73 @@ +{# + # ------------------------------------------------------------------------- + # 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 + # ------------------------------------------------------------------------- + #} + +{# + # Rendered through the core `Hooks::TIMELINE_ACTIONS` hook (see Escalation::showEscalateButton()). + # The "Assigned to" field is rendered by GLPI core, so this only drops an invisible anchor + + # script that adds a small "Escalate" button next to its label. The button opens the escalation + # form in a modal (see ajax/escalation_form.php). + #} + + diff --git a/templates/escalation_form.html.twig b/templates/escalation_form.html.twig new file mode 100644 index 0000000..8af422a --- /dev/null +++ b/templates/escalation_form.html.twig @@ -0,0 +1,127 @@ +{# + # ------------------------------------------------------------------------- + # 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 + # ------------------------------------------------------------------------- + #} + +{# + # Escalation form, loaded in the modal opened by the "Escalate" button next to the "Assigned to" + # field (see escalation_button.html.twig and ajax/escalation_form.php). + #} +{% import 'components/form/fields_macros.html.twig' as fields %} + +{% set rand = random() %} + +
+
+ + + + + +
+
+ {{ fields.textareaField( + 'content', + '', + '', + { + 'full_width': true, + 'no_label': true, + 'enable_richtext': true, + 'entities_id': item.fields['entities_id'], + 'rand': rand, + 'aria_label': __('Comment', 'moreoptions'), + } + ) }} +
+ +
+
+ {% set group_lbl %} + + {% endset %} + {{ fields.dropdownField( + 'Group', + 'groups_id', + 0, + group_lbl, + { + 'full_width': true, + 'icon_label': true, + 'required': true, + 'entity': item.fields['entities_id'], + 'condition': {'is_assign': 1}, + 'rand': rand, + 'aria_label': 'Group'|itemtype_name, + } + ) }} + + {% set observer_lbl %} + + {% endset %} + {{ fields.sliderField( + 'add_me_as_observer', + 0, + observer_lbl, + { + 'full_width': true, + 'icon_label': true, + 'rand': rand, + } + ) }} + + {% set private_label %} + + {% endset %} + + {{ fields.sliderField( + 'is_private', + 1, + private_label, + { + 'full_width': true, + 'icon_label': true, + 'rand': rand, + } + ) }} +
+
+
+ +
+ +
+
+
diff --git a/templates/escalation_timeline.html.twig b/templates/escalation_timeline.html.twig new file mode 100644 index 0000000..e9161c6 --- /dev/null +++ b/templates/escalation_timeline.html.twig @@ -0,0 +1,63 @@ +{# + # ------------------------------------------------------------------------- + # 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 + # ------------------------------------------------------------------------- + #} + +{# + # Rendered through the core `Hooks::TIMELINE_ACTIONS` hook (see Escalation::showTimelineScripts()). + # The header of timeline entries is rendered by GLPI core, so this only drops an invisible anchor + + # script that moves the "internal" icon of escalation entries into their "Created: ... by ..." + # badge, after a small separator. + #} + + From 7fce7cf5667b95c9b7af360ac9994629e6e482d2 Mon Sep 17 00:00:00 2001 From: Lainow Date: Thu, 24 Sep 2026 15:30:59 +0200 Subject: [PATCH 2/8] Fix used groups filter --- src/Escalation.php | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/Escalation.php b/src/Escalation.php index 4924873..b6d1477 100644 --- a/src/Escalation.php +++ b/src/Escalation.php @@ -303,8 +303,28 @@ public static function showTimelineScripts(array $params): void */ public static function showEscalationForm(CommonITILObject $item): void { + switch ($item::class) { + case Ticket::class: + $groups = new \Group_Ticket(); + break; + case Change::class: + $groups = new \Change_Group(); + break; + case Problem::class: + $groups = new \Group_Problem(); + break; + default: + return; + } + + $groups = $groups->find([strtolower($item::class) . 's_id' => $item->getID(), 'type' => CommonITILActor::ASSIGN]); + foreach ($groups as $key => $row) { + $groups_used[$key] = (int) $row['groups_id']; + } + TemplateRenderer::getInstance()->display('@moreoptions/escalation_form.html.twig', [ 'item' => $item, + 'groups_used' => $groups_used ?? [], ]); } From ad30e003cf58e6c0dacd8685296cdb7c2169f8d5 Mon Sep 17 00:00:00 2001 From: Lainow Date: Thu, 24 Sep 2026 15:48:45 +0200 Subject: [PATCH 3/8] Implement tests --- src/Escalation.php | 34 +++-- tests/Units/EscalationTest.php | 262 +++++++++++++++++++++++++++++++++ 2 files changed, 280 insertions(+), 16 deletions(-) create mode 100644 tests/Units/EscalationTest.php diff --git a/src/Escalation.php b/src/Escalation.php index b6d1477..7e57683 100644 --- a/src/Escalation.php +++ b/src/Escalation.php @@ -194,7 +194,7 @@ private static function getSourceGroupIds(array $row): array /** * The author is always the current user, and the source groups are the groups assigned to - * the item before the escalation (the target group excepted). + * the item before the escalation. Escalating to a group already assigned is refused. * * @param array $input * @return array|false @@ -215,12 +215,20 @@ public function prepareInputForAdd($input) $group_link->find([ $item->getForeignKeyField() => $item->getID(), 'type' => CommonITILActor::ASSIGN, - 'NOT' => ['groups_id' => (int) ($input['groups_id'] ?? 0)], ]) as $assigned ) { $groups_ids_source[] = (int) $assigned['groups_id']; } } + + if (in_array((int) ($input['groups_id'] ?? 0), $groups_ids_source, true)) { + Session::addMessageAfterRedirect( + __s('This group is already assigned.', 'moreoptions'), + false, + ERROR, + ); + return false; + } $input['groups_ids_source'] = json_encode($groups_ids_source); return $input; @@ -228,8 +236,8 @@ public function prepareInputForAdd($input) /** * Applies the escalation to the escalated item, through the group link hook (see - * self::escalate()): assigns the target group with `_plugin_moreoptions_escalade`, or, when it - * is already assigned, directly drops the other assigned groups. + * self::escalate()): assigns the target group with `_plugin_moreoptions_escalade`, which drops + * the other assigned groups. */ public function post_addItem() { @@ -243,18 +251,12 @@ public function post_addItem() return; } - $criteria = [ - $item->getForeignKeyField() => (int) $this->fields['items_id'], - 'groups_id' => (int) $this->fields['groups_id'], - 'type' => CommonITILActor::ASSIGN, - ]; - - if ($group_link->getFromDBByCrit($criteria)) { - self::keepOnlyAssignedGroup($group_link); - return; - } - - $group_link->add($criteria + ['_plugin_moreoptions_escalade' => true]); + $group_link->add([ + $item->getForeignKeyField() => (int) $this->fields['items_id'], + 'groups_id' => (int) $this->fields['groups_id'], + 'type' => CommonITILActor::ASSIGN, + '_plugin_moreoptions_escalade' => true, + ]); } /** diff --git a/tests/Units/EscalationTest.php b/tests/Units/EscalationTest.php new file mode 100644 index 0000000..7aba872 --- /dev/null +++ b/tests/Units/EscalationTest.php @@ -0,0 +1,262 @@ +, int}> + */ + public static function escalationProvider(): iterable + { + foreach ([Ticket::class, Change::class, Problem::class] as $itemtype) { + yield "$itemtype without group before escalation" => [$itemtype, 0]; + yield "$itemtype with one group before escalation" => [$itemtype, 1]; + yield "$itemtype with two groups before escalation" => [$itemtype, 2]; + } + } + + /** + * @param class-string $itemtype + */ + #[DataProvider('escalationProvider')] + public function testEscalation(string $itemtype, int $nb_source_groups): 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); + + // Groups assigned to the item before the escalation + $source_groups = []; + for ($i = 1; $i <= $nb_source_groups; $i++) { + $source_groups[] = $this->createGroup($entities_id, "Source group $i"); + } + foreach ($source_groups as $group) { + $this->createItem($item->grouplinkclass, [ + $item->getForeignKeyField() => $item->getID(), + 'groups_id' => $group->getID(), + 'type' => CommonITILActor::ASSIGN, + ]); + } + $this->assertSame($this->getIdsOf($source_groups), $this->getAssignedGroupIds($item)); + + // Escalate to a new group + $target_group = $this->createGroup($entities_id, 'Target group'); + $escalation = $this->createItem(Escalation::class, [ + 'itemtype' => $item::class, + 'items_id' => $item->getID(), + 'groups_id' => $target_group->getID(), + 'content' => 'Escalation comment', + ]); + + // The escalation keeps track of its author and of the previously assigned groups + $this->assertSame(Session::getLoginUserID(), (int) $escalation->fields['users_id']); + $this->assertSame( + $this->getIdsOf($source_groups), + json_decode($escalation->fields['groups_ids_source'], true), + ); + + // Only the target group remains assigned to the item + $this->assertSame([$target_group->getID()], $this->getAssignedGroupIds($item)); + + // The escalation is shown in the item timeline + $this->assertTrue($item->getFromDB($item->getID())); + /** @var array}> $timeline_entries */ + $timeline_entries = array_values(array_filter( + $item->getTimelineItems(), + static fn(array $entry): bool => $entry['type'] === Escalation::class, + )); + $this->assertCount(1, $timeline_entries); + + $entry = $timeline_entries[0]['item']; + $this->assertSame($escalation->getID(), (int) $entry['id']); + $this->assertSame(Session::getLoginUserID(), (int) $entry['users_id']); + $this->assertSame(CommonITILObject::TIMELINE_LEFT, $entry['timeline_position']); + + $content = $entry['content']; + $this->assertStringContainsString('Target group', $content); + $this->assertStringContainsString('Escalation comment', $content); + if ($nb_source_groups === 0) { + $this->assertStringContainsString('Escalate to', $content); + $this->assertStringNotContainsString('Escalate from', $content); + } else { + $this->assertStringContainsString('Escalate from', $content); + } + foreach ($source_groups as $group) { + $this->assertStringContainsString($group->fields['name'], $content); + } + } + + /** + * @return iterable}> + */ + public static function itemtypeProvider(): iterable + { + foreach ([Ticket::class, Change::class, Problem::class] as $itemtype) { + yield $itemtype => [$itemtype]; + } + } + + /** + * @param class-string $itemtype + */ + #[DataProvider('itemtypeProvider')] + public function testEscalationToAlreadyAssignedGroupIsRefused(string $itemtype): 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); + + $assigned_groups = [ + $this->createGroup($entities_id, 'Assigned group 1'), + $this->createGroup($entities_id, 'Assigned group 2'), + ]; + foreach ($assigned_groups as $group) { + $this->createItem($item->grouplinkclass, [ + $item->getForeignKeyField() => $item->getID(), + 'groups_id' => $group->getID(), + 'type' => CommonITILActor::ASSIGN, + ]); + } + + $escalation = new Escalation(); + $this->assertFalse($escalation->add([ + 'itemtype' => $item::class, + 'items_id' => $item->getID(), + 'groups_id' => $assigned_groups[0]->getID(), + ])); + $this->hasSessionMessages(ERROR, ['This group is already assigned, it cannot be escalated to.']); + + // Nothing changed: no escalation, and the assigned groups are kept + $this->assertSame(0, countElementsInTable(Escalation::getTable(), [ + 'itemtype' => $item::class, + 'items_id' => $item->getID(), + ])); + $this->assertSame($this->getIdsOf($assigned_groups), $this->getAssignedGroupIds($item)); + + // Nothing is added to the timeline + $this->assertTrue($item->getFromDB($item->getID())); + $this->assertSame([], array_values(array_filter( + $item->getTimelineItems(), + static fn(array $entry): bool => $entry['type'] === Escalation::class, + ))); + } + + /** + * Enable the escalation option for the given entity. + */ + private function enableEscalation(int $entities_id): void + { + $config = Config::getConfig($entities_id, false); + if ($config->isNewItem()) { + $this->createTestConfig([ + 'entities_id' => $entities_id, + 'escalate_is_active' => 1, + ]); + } else { + $this->updateTestConfig($config, ['escalate_is_active' => 1]); + } + } + + 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); + + $groups_ids = 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'])), + ); + + return $groups_ids; + } + + /** + * @param array $groups + * @return array + */ + private function getIdsOf(array $groups): array + { + return array_map(static fn(Group $group): int => $group->getID(), $groups); + } +} From aecdac2f7a586b90da207d1e7ee44a1f2041e18d Mon Sep 17 00:00:00 2001 From: Lainow Date: Fri, 25 Sep 2026 10:54:13 +0200 Subject: [PATCH 4/8] Fix lints --- tests/Units/EscalationTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Units/EscalationTest.php b/tests/Units/EscalationTest.php index 7aba872..5d7ca4e 100644 --- a/tests/Units/EscalationTest.php +++ b/tests/Units/EscalationTest.php @@ -186,7 +186,7 @@ public function testEscalationToAlreadyAssignedGroupIsRefused(string $itemtype): 'items_id' => $item->getID(), 'groups_id' => $assigned_groups[0]->getID(), ])); - $this->hasSessionMessages(ERROR, ['This group is already assigned, it cannot be escalated to.']); + $this->hasSessionMessages(ERROR, ['This group is already assigned.']); // Nothing changed: no escalation, and the assigned groups are kept $this->assertSame(0, countElementsInTable(Escalation::getTable(), [ From 1792fd29b5d9637d5d7d71e8c912c59c1c2bf3aa Mon Sep 17 00:00:00 2001 From: Lainow Date: Fri, 25 Sep 2026 11:03:08 +0200 Subject: [PATCH 5/8] Fix lints --- CHANGELOG.md | 1 + setup.php | 20 ++++++++------------ src/Escalation.php | 31 ++++++++++++++++++++++--------- tests/Units/EscalationTest.php | 15 ++++++++------- 4 files changed, 39 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3317e0..7f36e70 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ## Add - Add rector config +- Implementation of the basic concept of escalation ## [1.0.0-rc2] diff --git a/setup.php b/setup.php index 666dccb..c5e45c7 100644 --- a/setup.php +++ b/setup.php @@ -84,24 +84,20 @@ function plugin_init_moreoptions(): void // on $params['item'] itself. $PLUGIN_HOOKS[Hooks::TIMELINE_ACTIONS]['moreoptions'] = Controller::showTimelineActions(...); + $PLUGIN_HOOKS[Hooks::TIMELINE_ACTIONS]['moreoptions'] = Controller::showSolutionRequirementsWarning(...); + $PLUGIN_HOOKS[Hooks::POST_ITEM_FORM]['moreoptions'] = Controller::markMandatoryTaskFields(...); - $PLUGIN_HOOKS[Hooks::TIMELINE_ITEMS]['moreoptions'] = [ - Escalation::class, 'showInTimeline', - ]; + $PLUGIN_HOOKS[Hooks::TIMELINE_ITEMS]['moreoptions'] = Escalation::showInTimeline(...); // Group links added with `_plugin_moreoptions_escalade => true` are escalations. - $PLUGIN_HOOKS[Hooks::ITEM_ADD]['moreoptions'][Group_Ticket::class] = [ - Escalation::class, 'escalate', - ]; + $PLUGIN_HOOKS[Hooks::ITEM_ADD]['moreoptions'][Group_Ticket::class] = Escalation::escalate(...); - $PLUGIN_HOOKS[Hooks::ITEM_ADD]['moreoptions'][Change_Group::class] = [ - Escalation::class, 'escalate', - ]; + $PLUGIN_HOOKS[Hooks::ITEM_ADD]['moreoptions'][Change_Group::class] = Escalation::escalate(...); - $PLUGIN_HOOKS[Hooks::ITEM_ADD]['moreoptions'][Group_Problem::class] = [ - Escalation::class, 'escalate', - ]; + $PLUGIN_HOOKS[Hooks::ITEM_ADD]['moreoptions'][Group_Problem::class] = Escalation::escalate(...); + + $PLUGIN_HOOKS[Hooks::PRE_ITEM_UPDATE]['moreoptions'][Ticket::class] = Controller::beforeCloseITILObject(...); $PLUGIN_HOOKS[Hooks::PRE_ITEM_UPDATE]['moreoptions'][Ticket::class] = Controller::beforeCloseITILObject(...); diff --git a/src/Escalation.php b/src/Escalation.php index 7e57683..eb26e76 100644 --- a/src/Escalation.php +++ b/src/Escalation.php @@ -34,6 +34,10 @@ namespace GlpiPlugin\Moreoptions; +use Group_Ticket; +use Change_Group; +use Group_Problem; +use DBmysql; use Change; use CommonDBTM; use CommonITILActor; @@ -47,6 +51,10 @@ use Session; use Ticket; +use function Safe\json_decode; +use function Safe\json_encode; +use function Safe\preg_replace; + /** * An escalation of a ticket / change / problem, shown as its own entry in the * item timeline when the "Escalate" option is enabled for the item entity. @@ -54,6 +62,7 @@ class Escalation extends CommonDBTM { public $dohistory = true; + public static $rightname = 'ticket'; /** @@ -189,7 +198,7 @@ private static function getSourceGroupIds(array $row): array { $groups_ids = json_decode((string) ($row['groups_ids_source'] ?? ''), true); - return is_array($groups_ids) ? array_map('intval', $groups_ids) : []; + return is_array($groups_ids) ? array_map(intval(...), $groups_ids) : []; } /** @@ -229,6 +238,7 @@ public function prepareInputForAdd($input) ); return false; } + $input['groups_ids_source'] = json_encode($groups_ids_source); return $input; @@ -307,13 +317,13 @@ public static function showEscalationForm(CommonITILObject $item): void { switch ($item::class) { case Ticket::class: - $groups = new \Group_Ticket(); + $groups = new Group_Ticket(); break; case Change::class: - $groups = new \Change_Group(); + $groups = new Change_Group(); break; case Problem::class: - $groups = new \Group_Problem(); + $groups = new Group_Problem(); break; default: return; @@ -367,13 +377,13 @@ private static function keepOnlyAssignedGroup(CommonITILActor $group_link): void public static function install(Migration $migration): void { - /** @var \DBmysql $DB */ + /** @var DBmysql $DB */ global $DB; $table = self::getTable(); if (!$DB->tableExists($table)) { - $migration->displayMessage("Installing $table"); - $query = "CREATE TABLE IF NOT EXISTS `$table` ( + $migration->displayMessage('Installing ' . $table); + $query = "CREATE TABLE IF NOT EXISTS `{$table}` ( `id` int unsigned NOT NULL AUTO_INCREMENT, `itemtype` varchar(100) NOT NULL DEFAULT '', `items_id` int unsigned NOT NULL DEFAULT '0', @@ -410,24 +420,27 @@ public static function install(Migration $migration): void } } } + if ($DB->fieldExists($table, 'groups_id_source')) { $migration->dropKey($table, 'groups_id_source'); $migration->dropField($table, 'groups_id_source'); } + if (!$DB->fieldExists($table, 'is_private')) { $migration->addField($table, 'is_private', 'bool', ['value' => '0']); } + $migration->executeMigration(); } public static function uninstall(Migration $migration): void { - /** @var \DBmysql $DB */ + /** @var DBmysql $DB */ global $DB; $table = self::getTable(); if ($DB->tableExists($table)) { - $DB->doQuery("DROP TABLE IF EXISTS `$table`"); + $DB->doQuery(sprintf('DROP TABLE IF EXISTS `%s`', $table)); } } } diff --git a/tests/Units/EscalationTest.php b/tests/Units/EscalationTest.php index 5d7ca4e..53ae250 100644 --- a/tests/Units/EscalationTest.php +++ b/tests/Units/EscalationTest.php @@ -55,9 +55,9 @@ class EscalationTest extends MoreOptionsTestCase public static function escalationProvider(): iterable { foreach ([Ticket::class, Change::class, Problem::class] as $itemtype) { - yield "$itemtype without group before escalation" => [$itemtype, 0]; - yield "$itemtype with one group before escalation" => [$itemtype, 1]; - yield "$itemtype with two groups before escalation" => [$itemtype, 2]; + yield $itemtype . ' without group before escalation' => [$itemtype, 0]; + yield $itemtype . ' with one group before escalation' => [$itemtype, 1]; + yield $itemtype . ' with two groups before escalation' => [$itemtype, 2]; } } @@ -82,8 +82,9 @@ public function testEscalation(string $itemtype, int $nb_source_groups): void // Groups assigned to the item before the escalation $source_groups = []; for ($i = 1; $i <= $nb_source_groups; $i++) { - $source_groups[] = $this->createGroup($entities_id, "Source group $i"); + $source_groups[] = $this->createGroup($entities_id, 'Source group ' . $i); } + foreach ($source_groups as $group) { $this->createItem($item->grouplinkclass, [ $item->getForeignKeyField() => $item->getID(), @@ -91,6 +92,7 @@ public function testEscalation(string $itemtype, int $nb_source_groups): void 'type' => CommonITILActor::ASSIGN, ]); } + $this->assertSame($this->getIdsOf($source_groups), $this->getAssignedGroupIds($item)); // Escalate to a new group @@ -135,6 +137,7 @@ public function testEscalation(string $itemtype, int $nb_source_groups): void } else { $this->assertStringContainsString('Escalate from', $content); } + foreach ($source_groups as $group) { $this->assertStringContainsString($group->fields['name'], $content); } @@ -240,15 +243,13 @@ private function getAssignedGroupIds(CommonITILObject $item): array $group_link = getItemForItemtype($item->grouplinkclass); $this->assertInstanceOf(CommonITILActor::class, $group_link); - $groups_ids = array_map( + 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'])), ); - - return $groups_ids; } /** From 0e2f8a4edc3fa99977e06122cb9ea63812874733 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Langlois=20Ga=C3=ABtan?= <64356364+MyvTsv@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:27:15 +0200 Subject: [PATCH 6/8] Apply suggestion from @MyvTsv --- setup.php | 1 - 1 file changed, 1 deletion(-) diff --git a/setup.php b/setup.php index c5e45c7..96afaca 100644 --- a/setup.php +++ b/setup.php @@ -84,7 +84,6 @@ function plugin_init_moreoptions(): void // on $params['item'] itself. $PLUGIN_HOOKS[Hooks::TIMELINE_ACTIONS]['moreoptions'] = Controller::showTimelineActions(...); - $PLUGIN_HOOKS[Hooks::TIMELINE_ACTIONS]['moreoptions'] = Controller::showSolutionRequirementsWarning(...); $PLUGIN_HOOKS[Hooks::POST_ITEM_FORM]['moreoptions'] = Controller::markMandatoryTaskFields(...); From 40a89fd3fe4fce2e27fd76d4f62bb13bab3fd10c Mon Sep 17 00:00:00 2001 From: Samuel Launay <107540223+Lainow@users.noreply.github.com> Date: Fri, 25 Sep 2026 16:36:48 +0200 Subject: [PATCH 7/8] Update src/Escalation.php MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Langlois Gaƫtan <64356364+MyvTsv@users.noreply.github.com> --- src/Escalation.php | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/Escalation.php b/src/Escalation.php index eb26e76..647dece 100644 --- a/src/Escalation.php +++ b/src/Escalation.php @@ -118,11 +118,18 @@ public static function showInTimeline(array $params): void /** @var array $timeline */ $timeline = &$params['timeline']; - - $escalations = (new self())->find([ + $can_see_private = Session::haveRight('followup', ITILFollowup::SEEPRIVATE); + + $criterias = [ 'itemtype' => $item::class, 'items_id' => $item->getID(), - ]); + ]; + + if (!$can_see_private) { + $criterias['is_private'] = 0; + } + + $escalations = (new self())->find($criterias); foreach ($escalations as $row) { $timeline['MoreoptionsEscalation_' . $row['id']] = [ From 1929276b8fdbd320a7c9488a328058a7c1605b12 Mon Sep 17 00:00:00 2001 From: Lainow Date: Mon, 28 Sep 2026 09:34:00 +0200 Subject: [PATCH 8/8] Fix lints --- src/Escalation.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Escalation.php b/src/Escalation.php index 647dece..eb1290e 100644 --- a/src/Escalation.php +++ b/src/Escalation.php @@ -46,6 +46,7 @@ use Glpi\Application\View\TemplateRenderer; use Glpi\RichText\RichText; use Group; +use ITILFollowup; use Migration; use Problem; use Session; @@ -119,12 +120,12 @@ public static function showInTimeline(array $params): void /** @var array $timeline */ $timeline = &$params['timeline']; $can_see_private = Session::haveRight('followup', ITILFollowup::SEEPRIVATE); - + $criterias = [ 'itemtype' => $item::class, 'items_id' => $item->getID(), ]; - + if (!$can_see_private) { $criterias['is_private'] = 0; }