From 5dd454718ca792dea7ee3ac3bd304abb07a1246e Mon Sep 17 00:00:00 2001
From: Julien Durand <300495670+jdurand-teclib@users.noreply.github.com>
Date: Fri, 13 Nov 2026 12:19:11 +0100
Subject: [PATCH] chore: performance optimizations
---
CHANGELOG.md | 8 +++
inc/entity.class.php | 61 ++++++++++++++---
inc/ticket.class.php | 135 ++++++++++++++++++++++++++-----------
inc/ticketconfig.class.php | 9 +++
setup.php | 9 ++-
5 files changed, 173 insertions(+), 49 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 3746242..3d35fb1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
+## [Unreleased]
+
+### Fixed
+
+- Improve performance of the consumed details, the ticket Credit tab and the entity Credit tab, which no longer run SQL queries for each displayed row
+- Remove 2 SQL queries from every GLPI page by dropping the plugin's own activation check
+- Delete the default voucher options of a ticket when it is purged
+
## [1.15.8] - 2026-10-27
### Fixed
diff --git a/inc/entity.class.php b/inc/entity.class.php
index 0be59d8..cdd2d86 100644
--- a/inc/entity.class.php
+++ b/inc/entity.class.php
@@ -208,21 +208,30 @@ public static function showForItemtype(Entity $entity, $itemtype = 'Entity')
];
}
+ $vouchers = self::getAllForEntity($ID, $sqlfilter);
+ $consumed = self::getConsumedForCredits(array_keys($vouchers));
+ $type_links = [];
+
$entries = [];
- foreach (self::getAllForEntity($ID, $sqlfilter) as $data) {
+ foreach ($vouchers as $data) {
$quantity_sold = (int) $data['quantity'];
if (0 === $quantity_sold) {
$quantity_sold = __('Unlimited');
}
+ // The row already holds every field getById() would load.
$item = new self();
- $item = $item->getById($data['id']);
+ $item->fields = $data;
+ $consumed_for_credit = $consumed[$data['id']] ?? 0;
if (!empty($data['plugin_credit_types_id'])) {
- $type = new PluginCreditType();
- $type = $type->getById($data['plugin_credit_types_id']);
- if ($type) {
- $data['plugin_credit_types_id'] = $type->getLink();
+ $types_id = $data['plugin_credit_types_id'];
+ if (!array_key_exists($types_id, $type_links)) {
+ $type = PluginCreditType::getById((int) $types_id);
+ $type_links[$types_id] = $type ? $type->getLink() : null;
+ }
+ if ($type_links[$types_id] !== null) {
+ $data['plugin_credit_types_id'] = $type_links[$types_id];
}
} else {
$data['plugin_credit_types_id'] = '';
@@ -238,7 +247,7 @@ public static function showForItemtype(Entity $entity, $itemtype = 'Entity')
],
);
- $link = "" . PluginCreditEntity::getConsumedForCredit($data['id']) . "";
+ $link = "" . $consumed_for_credit . "";
$entries[] = array_merge($data, [
'name' => $item->getLink(),
@@ -246,7 +255,7 @@ public static function showForItemtype(Entity $entity, $itemtype = 'Entity')
'itemtype' => PluginCreditEntity::class,
'low_credit_alert' => $data['low_credit_alert'] == -1 ? __('Disabled') : $data['low_credit_alert'] . '%',
'quantity_consumed' => $modal . $link,
- 'quantity_remaining' => $data['quantity'] > 0 ? $data['quantity'] - PluginCreditEntity::getConsumedForCredit($data['id']) : 'Unlimited',
+ 'quantity_remaining' => $data['quantity'] > 0 ? $data['quantity'] - $consumed_for_credit : 'Unlimited',
'entities_id' => Entity::badgeCompletenameLink($entity),
]);
}
@@ -674,4 +683,40 @@ public static function getConsumedForCredit(int $credit_id)
return (int) $ticket_result['consumed_total'];
}
+
+ /**
+ * Get the total consumption of several credit vouchers in one query.
+ *
+ * @param int[] $credit_ids IDs of the credit vouchers
+ *
+ * @return array Total consumption by voucher ID, vouchers without consumption are absent
+ */
+ public static function getConsumedForCredits(array $credit_ids): array
+ {
+ /** @var DBmysql $DB */
+ global $DB;
+
+ if ($credit_ids === []) {
+ return [];
+ }
+
+ $iterator = $DB->request([
+ 'SELECT' => [
+ 'plugin_credit_entities_id',
+ 'SUM' => 'consumed AS consumed_total',
+ ],
+ 'FROM' => 'glpi_plugin_credit_tickets',
+ 'WHERE' => [
+ 'plugin_credit_entities_id' => $credit_ids,
+ ],
+ 'GROUPBY' => 'plugin_credit_entities_id',
+ ]);
+
+ $consumed = [];
+ foreach ($iterator as $row) {
+ $consumed[$row['plugin_credit_entities_id']] = (int) $row['consumed_total'];
+ }
+
+ return $consumed;
+ }
}
diff --git a/inc/ticket.class.php b/inc/ticket.class.php
index 722973a..7fc60d9 100644
--- a/inc/ticket.class.php
+++ b/inc/ticket.class.php
@@ -77,16 +77,11 @@ public static function getIcon()
public function getTabNameForItem(CommonGLPI $item, $withtemplate = 0)
{
- $nb = $item instanceof CommonDBTM ? self::countForItem($item) : 0;
- if ($item instanceof Ticket) {
- if ($_SESSION['glpishow_count_on_tabs']) {
- return self::createTabEntry(self::getTypeName($nb), $nb);
- } else {
- return self::getTypeName($nb);
- }
- } else {
- return self::getTypeName($nb);
+ if ($item instanceof Ticket && $_SESSION['glpishow_count_on_tabs']) {
+ $nb = self::countForItem($item);
+ return self::createTabEntry(self::getTypeName($nb), $nb);
}
+ return self::getTypeName(Session::getPluralNumber());
}
public static function displayTabContentForItem(CommonGLPI $item, $tabnum = 1, $withtemplate = 0)
@@ -232,9 +227,27 @@ public static function showForTicket(Ticket $ticket)
],
];
}
- foreach (self::getAllForTicket($ID) as $data) {
- $credit_entity = new PluginCreditEntity();
- $credit_entity->getFromDB($data['plugin_credit_entities_id']);
+ $consumptions = self::getAllForTicket($ID);
+
+ // Voucher names and user links are loaded once, not once per consumption.
+ $voucher_names = [];
+ $voucher_ids = array_unique(array_column($consumptions, 'plugin_credit_entities_id'));
+ if ($voucher_ids !== []) {
+ $vouchers = $DB->request([
+ 'SELECT' => ['id', 'name'],
+ 'FROM' => PluginCreditEntity::getTable(),
+ 'WHERE' => ['id' => $voucher_ids],
+ ]);
+ foreach ($vouchers as $voucher) {
+ $voucher_names[$voucher['id']] = $voucher['name'];
+ }
+ }
+ $showuserlink = Session::haveRight('user', READ);
+ $usernames = [];
+
+ foreach ($consumptions as $data) {
+ $users_id = $data['users_id'];
+ $usernames[$users_id] ??= $showuserlink ? getUserLink($users_id) : getUserName($users_id);
if (!empty($data['plugin_credit_types_id'])) {
$type = new PluginCreditType();
@@ -248,10 +261,12 @@ public static function showForTicket(Ticket $ticket)
$entries[] = array_merge($data, [
'id' => $data['id'],
- 'name' => $credit_entity->getName(),
+ 'name' => (string) ($voucher_names[$data['plugin_credit_entities_id']] ?? '') !== ''
+ ? $voucher_names[$data['plugin_credit_entities_id']]
+ : NOT_AVAILABLE,
'plugin_credit_types_id' => $data['plugin_credit_types_id'],
'date_creation' => $data['date_creation'],
- 'users_id' => Session::haveRight('user', READ) == true ? getUserLink($data["users_id"]) : getUserName($data["users_id"]),
+ 'users_id' => $usernames[$users_id],
'consumed' => $data['consumed'],
'itemtype' => PluginCreditTicket::class,
]);
@@ -271,10 +286,6 @@ public static function showForTicket(Ticket $ticket)
'entries' => $entries,
'massiveactionparams' => $massiveactionparams ?? [],
]);
-
- $Entity = new Entity();
- $Entity->getFromDB($ticket->fields['entities_id']);
- PluginCreditEntity::showForItemtype($Entity, 'Ticket');
}
/**
@@ -381,38 +392,86 @@ public static function displayVoucherInTicketProcessingForm($params)
*/
public static function displayConsumed($ID)
{
+ /** @var DBmysql $DB */
+ global $DB;
+
$consumed_credits = self::getConsumedForCreditEntity($ID);
$tickets_data = [];
if ($consumed_credits > 0) {
- foreach (self::getAllForCreditEntity($ID) as $data) {
+ // Tickets and categories come with the consumptions, in a single query.
+ $iterator = $DB->request([
+ 'SELECT' => [
+ 'glpi_tickets.*',
+ 'glpi_itilcategories.name AS credit_category_name',
+ 'glpi_itilcategories.completename AS credit_category_completename',
+ self::getTable() . '.date_creation AS credit_date_creation',
+ self::getTable() . '.users_id AS credit_users_id',
+ self::getTable() . '.consumed AS credit_consumed',
+ ],
+ 'FROM' => self::getTable(),
+ 'LEFT JOIN' => [
+ 'glpi_tickets' => [
+ 'ON' => [
+ 'glpi_tickets' => 'id',
+ self::getTable() => 'tickets_id',
+ ],
+ ],
+ 'glpi_itilcategories' => [
+ 'ON' => [
+ 'glpi_itilcategories' => 'id',
+ 'glpi_tickets' => 'itilcategories_id',
+ ],
+ ],
+ ],
+ 'WHERE' => [
+ self::getTable() . '.plugin_credit_entities_id' => $ID,
+ ],
+ 'ORDER' => [self::getTable() . '.id DESC'],
+ ]);
+
+ $showuserlink = Session::haveRight('user', READ);
+ $usernames = [];
+
+ foreach ($iterator as $data) {
$Ticket = new Ticket();
- $Ticket->getFromDB($data['tickets_id']);
+ $ticket_link = '';
+ $status = '';
+ $type = '';
+ // The ticket may have been purged since the consumption.
+ if ($data['id'] !== null) {
+ $Ticket->fields = $data;
+ $ticket_link = sprintf(
+ '%s',
+ htmlescape($Ticket->getLinkURL()),
+ htmlescape($Ticket->getNameID()),
+ );
+ $status = Ticket::getStatus($data['status']);
+ $type = Ticket::getTicketTypeName($data['type']);
+ }
- $itilcat = new ITILCategory();
$category = __('None');
- if ($itilcat->getFromDB($Ticket->fields['itilcategories_id'])) {
- $category = $itilcat->getName(['comments' => true]);
+ if ($data['credit_category_name'] !== null) {
+ $itilcat = new ITILCategory();
+ $itilcat->fields = [
+ 'id' => $data['itilcategories_id'],
+ 'name' => $data['credit_category_name'],
+ 'completename' => $data['credit_category_completename'],
+ ];
+ $category = $itilcat->getName();
}
- $showuserlink = Session::haveRight('user', READ) ? 1 : 0;
-
- $ticket_url = $Ticket->getLinkURL();
- $ticket_name = $Ticket->getNameID();
- $username = $showuserlink !== 0 ? getUserLink($data['users_id']) : getUserName($data['users_id']);
+ $users_id = $data['credit_users_id'];
+ $usernames[$users_id] ??= $showuserlink ? getUserLink($users_id) : getUserName($users_id);
$tickets_data[] = [
- 'ticket_link' => sprintf(
- '%s',
- htmlescape($ticket_url),
- htmlescape($ticket_name),
- ),
- 'status' => Ticket::getStatus($Ticket->fields['status']),
- 'type' => Ticket::getTicketTypeName($Ticket->fields['type']),
+ 'ticket_link' => $ticket_link,
+ 'status' => $status,
+ 'type' => $type,
'category' => $category,
- 'date_creation' => $data["date_creation"],
- 'username' => $username,
- 'consumed' => $data['consumed'],
+ 'date_creation' => $data['credit_date_creation'],
+ 'username' => $usernames[$users_id],
+ 'consumed' => $data['credit_consumed'],
];
}
}
diff --git a/inc/ticketconfig.class.php b/inc/ticketconfig.class.php
index 5662bca..3f4c002 100644
--- a/inc/ticketconfig.class.php
+++ b/inc/ticketconfig.class.php
@@ -161,6 +161,15 @@ public static function updateConfig(Ticket $ticket)
}
}
+ /**
+ * Remove the default voucher options of a purged ticket.
+ * Consumptions are kept: they still count against their voucher.
+ */
+ public static function cleanForTicket(Ticket $ticket): void
+ {
+ (new self())->deleteByCriteria(['tickets_id' => $ticket->getID()]);
+ }
+
public static function install(Migration $migration)
{
/** @var DBmysql $DB */
diff --git a/setup.php b/setup.php
index 77d2246..d7fc0f5 100644
--- a/setup.php
+++ b/setup.php
@@ -49,11 +49,14 @@ function plugin_init_credit()
/** @var array $PLUGIN_HOOKS */
global $PLUGIN_HOOKS;
- $plugin = new Plugin();
-
$PLUGIN_HOOKS['csrf_compliant']['credit'] = true;
- if (Session::getLoginUserID() && $plugin->isActivated('credit')) {
+ // Outside the session check: purges also run from cron and CLI.
+ $PLUGIN_HOOKS['item_purge']['credit'] = [
+ 'Ticket' => ['PluginCreditTicketConfig', 'cleanForTicket'],
+ ];
+
+ if (Session::getLoginUserID()) {
Plugin::registerClass(
'PluginCreditEntity',
[