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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions src/Glpi/Dashboard/Filters/AbstractFilter.php
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,22 @@ protected static function getSearchOptionID(string $table, string $name, string
return array_search($name . "-" . $tableToSearch, $sort);
}

/**
* Build a SQL alias unique to this filter, for use in a JOIN this filter
* adds to a query. Several filters can be active on the same query at
* once (e.g. two different group filters); a hardcoded/shared alias
* would make their JOIN/WHERE keys collide, and Provider::getFiltersCriteria()
* would silently drop one filter's conditions when merging them.
*
* @param string $prefix short prefix describing what's joined (e.g. 'gl' for a group link table)
*
* @return string e.g. "gl_group_tech" for GroupTechFilter with prefix 'gl'
*/
protected static function uniqueAlias(string $prefix): string
{
return $prefix . '_' . static::getId();
}

/**
* @return list<int>
*/
Expand Down
22 changes: 12 additions & 10 deletions src/Glpi/Dashboard/Filters/AbstractGroupFilter.php
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ public static function canBeApplied(string $table): bool
* @param mixed $value
* @return int[]
*/
private static function resolveGroupIds($value): array
public static function resolveGroupIds($value): array
{
$values = is_array($value) ? $value : [$value];
// Expand "mygroups" to the current user's groups and normalize to unique positive IDs
Expand Down Expand Up @@ -122,36 +122,38 @@ public static function getCriteria(string $table, $value): array
$grouplink = $main_item->grouplinkclass;
$gl_table = $grouplink::getTable();
$fk = $main_item::getForeignKeyField();
$alias = self::uniqueAlias('gl');

$criteria["JOIN"] = [
"$gl_table as gl" => [
"$gl_table as $alias" => [
'ON' => [
'gl' => $fk,
$alias => $fk,
$table => 'id',
],
],
];
$criteria["WHERE"] = [
"gl.type" => static::getGroupType(),
"gl.groups_id" => $groups_ids_value,
"$alias.type" => static::getGroupType(),
"$alias.groups_id" => $groups_ids_value,
];
} else {
$group_item_table = Group_Item::getTable();
$alias = self::uniqueAlias('gi');
$criteria['JOIN'] = [
$group_item_table => [
"$group_item_table as $alias" => [
'ON' => [
$group_item_table => 'items_id',
$alias => 'items_id',
$table => 'id', [
'AND' => [
$group_item_table . '.itemtype' => getItemtypeForTable($table),
"$alias.itemtype" => getItemtypeForTable($table),
],
],
],
],
];
$criteria["WHERE"] = [
$group_item_table . ".type" => static::getGroupType(),
$group_item_table . '.groups_id' => $groups_ids_value,
"$alias.type" => static::getGroupType(),
"$alias.groups_id" => $groups_ids_value,
];
}

Expand Down
4 changes: 3 additions & 1 deletion src/Glpi/Dashboard/Filters/AssignedITILUserFilterTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,8 +51,10 @@ trait AssignedITILUserFilterTrait
*
* @return array<string, array<string, mixed>>
*/
protected static function getAssignedITILUserCriteria(string $table, int $users_id, string $alias = 'ul_assigned'): array
protected static function getAssignedITILUserCriteria(string $table, int $users_id, ?string $alias = null): array
{
$alias ??= self::uniqueAlias('ul');

$main_item = match ($table) {
Ticket::getTable() => new Ticket(),
Change::getTable() => new Change(),
Expand Down
9 changes: 9 additions & 0 deletions src/Glpi/Dashboard/Grid.php
Original file line number Diff line number Diff line change
Expand Up @@ -1656,6 +1656,15 @@ public function getAllDasboardCards($force = false): array
'filters' => Filter::getAppliableFilters(\Computer::getTable()),
];

$cards["report_ticket_by_group_and_status"] = [
'widgettype' => ['hBars', 'stackedHBars'],
'itemtype' => "\\Ticket",
'group' => __('Assistance'),
'label' => __("Number of opened and solved tickets by group"),
'provider' => "Glpi\\Dashboard\\Provider::ticketsByGroupAndStatus",
'filters' => Filter::getAppliableFilters(Ticket::getTable()),
];

$cards["RemindersList"] = [
'widgettype' => ["articleList"],
'label' => sprintf(__('List of %s'), Reminder::getTypeName(Session::getPluralNumber())),
Expand Down
121 changes: 121 additions & 0 deletions src/Glpi/Dashboard/Provider.php
Original file line number Diff line number Diff line change
Expand Up @@ -1270,6 +1270,104 @@ public static function ticketsByCategoryAndEntity(array $params = []): array
];
}

/**
* count number of opened and closed tickets grouped by their assigned group
* @param array<string, mixed> $params
* @return array<string, mixed>
*/
public static function ticketsByGroupAndStatus(array $params = []): array
{
$DB = DBConnection::getReadConnection();

$default_params = [
'label' => "",
'icon' => Ticket::getIcon(),
'apply_filters' => [],
];
$params = array_merge($default_params, $params);

$ticket_table = Ticket::getTable();
$group_ticket_table = Group_Ticket::getTable();
$group_table = Group::getTable();

$opened_statuses = implode(',', [Ticket::INCOMING, Ticket::ASSIGNED, Ticket::PLANNED, Ticket::WAITING]);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
$opened_statuses = implode(',', [Ticket::INCOMING, Ticket::ASSIGNED, Ticket::PLANNED, Ticket::WAITING]);
$opened_statuses = implode(',', [Ticket::getNotSolvedStatusArray()]);

I think you forget Ticket::APPROVAL (unless it is missing on purpose?), if that is the case you can use getNotSolvedStatusArray() so you are sure to not miss anything ;)

$closed_statuses = implode(',', [Ticket::SOLVED, Ticket::CLOSED]);

// Restrict our own grouping to the groups selected by the "technician
// group" filter, not just which tickets are included (see
// extractActorFilterIds() doc for why this is needed).
$filtered_group_ids = self::extractActorFilterIds(
GroupTechFilter::class,
'gl_' . GroupTechFilter::getId() . '.groups_id',
$ticket_table,
$params['apply_filters']
);

Profiler::getInstance()->start(__METHOD__ . ' build SQL criteria');
$criteria = array_merge_recursive(
[
'SELECT' => [
"$group_table.name AS group_name",
new QueryExpression("COUNT(DISTINCT CASE WHEN $ticket_table.status IN ($opened_statuses) THEN $ticket_table.id END) AS opened"),
new QueryExpression("COUNT(DISTINCT CASE WHEN $ticket_table.status IN ($closed_statuses) THEN $ticket_table.id END) AS closed"),
],
'FROM' => $ticket_table,
'INNER JOIN' => [
$group_ticket_table => [
'ON' => [
$group_ticket_table => 'tickets_id',
$ticket_table => 'id',
[
'AND' => [
"$group_ticket_table.type" => Group_Ticket::ASSIGN,
],
],
],
],
$group_table => [
'ON' => [
$group_table => 'id',
$group_ticket_table => 'groups_id',
],
],
],
'WHERE' => [

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we need something to keep only the groups requested by the filters?

If I filter for "Group A", all groups still shows:

Image

"$ticket_table.is_deleted" => 0,
] + ($filtered_group_ids !== null ? ["$group_table.id" => $filtered_group_ids] : [])
+ getEntitiesRestrictCriteria($ticket_table),
'GROUPBY' => "$group_table.id",
Comment thread
Herafia marked this conversation as resolved.
'ORDERBY' => "$group_table.name",
],
Ticket::getCriteriaFromProfile(),
self::getFiltersCriteria($ticket_table, $params['apply_filters'])
);
$iterator = $DB->request($criteria);
Profiler::getInstance()->stop(__METHOD__ . ' build SQL criteria');

$data = [
'labels' => [],
'series' => [
['name' => __('Opened'), 'data' => []],
['name' => __('Closed'), 'data' => []],
Comment on lines +1350 to +1351

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure how to fix it but if you only have one type of tickets, the numbers overlap and become unreadable:

Image

],
];
foreach ($iterator as $result) {
$data['labels'][] = $result['group_name'];
$data['series'][0]['data'][] = (int) $result['opened'];
$data['series'][1]['data'][] = (int) $result['closed'];
}

if (count($data['labels']) === 0) {
$data['nodata'] = true;
}

return [
'data' => $data,
'label' => $params['label'],
'icon' => $params['icon'],
];
}

/**
* Get a list of article for an compatible item (with date,name,text fields)
*
Expand Down Expand Up @@ -2213,6 +2311,29 @@ final public static function getSearchFiltersCriteria(string $table = "", array
return ['criteria' => $s_criteria];
}

/**
* Extracts an actor filter's resolved ids (e.g. selected group ids) so a
* provider can restrict its own GROUP BY, not just gate ticket inclusion.
* Also unsets the filter from $apply_filters to avoid a redundant join later.
*
* @param array<string, mixed> $apply_filters
*
* @return int[]|null
*/
private static function extractActorFilterIds(
string $filter_class,
string $where_key,
string $table,
array &$apply_filters
): ?array {
$value = $apply_filters[$filter_class::getId()] ?? null;
unset($apply_filters[$filter_class::getId()]);

$ids = $value !== null ? ($filter_class::getCriteria($table, $value)['WHERE'][$where_key] ?? null) : null;

return $ids === null ? null : (array) $ids;
}

/**
* @param string $table
* @param array $apply_filters
Expand Down
6 changes: 5 additions & 1 deletion src/Glpi/Dashboard/Widget.php
Original file line number Diff line number Diff line change
Expand Up @@ -1301,9 +1301,13 @@ private static function getBarsGraph(
'color': (param) => palette[param.dataIndex % palette.length]
}
}
// Hide labels with a value of zero to avoid overlapping values
serie['label'] = {
...serie['label'],
'formatter': (param) => param.data.value == 0 ? '' : param.data.value
'formatter': (param) => {
const raw_value = (param.data !== null && typeof param.data === 'object') ? param.data.value : param.data;
return raw_value == 0 ? '' : raw_value;
}
};
});
if ({{ horizontal ? 'true' : 'false' }}) {
Expand Down
68 changes: 68 additions & 0 deletions tests/functional/Glpi/Dashboard/ProviderTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1131,4 +1131,72 @@ public function testTicketsByCategoryAndEntity()
$this->assertSame(1, $series_by_name[$category2->fields['completename']][$entity_index], '1 ticket expected for category 2');
$this->assertGreaterThanOrEqual(1, $series_by_name[__('None')][$entity_index], 'at least 1 uncategorized ticket expected');
}

public function testTicketsByGroupAndStatus()
{
$this->login();
$self = getItemByTypeName(User::class, TU_USER);

$group1 = $this->createItem(\Group::class, [
'name' => 'test dashboard group 1 for tickets by status',
]);
$group2 = $this->createItem(\Group::class, [
'name' => 'test dashboard group 2 for tickets by status',
]);

$this->createItem(\Group_User::class, [
'groups_id' => $group1->getID(),
'users_id' => $self->getID(),
]);
$this->createItem(\Group_User::class, [
'groups_id' => $group2->getID(),
'users_id' => $self->getID(),
]);

\Session::loadGroups();

$this->attachTicketToGroup('group1 opened 1', \Ticket::ASSIGNED, $group1);
$this->attachTicketToGroup('group2 opened', \Ticket::ASSIGNED, $group2);
$this->attachTicketToGroup('group1 opened 2', \Ticket::INCOMING, $group1);
$this->attachTicketToGroup('group1 closed', \Ticket::CLOSED, $group1);
$this->attachTicketToGroup('group2 closed/solved 1', \Ticket::SOLVED, $group2);
$this->attachTicketToGroup('group2 closed 2', \Ticket::CLOSED, $group2);

$result = Provider::ticketsByGroupAndStatus();
$this->assertArrayHasKey('data', $result);
$this->assertArrayHasKey('label', $result);
$this->assertArrayHasKey('icon', $result);

$this->assertArrayHasKey('labels', $result['data']);
$this->assertArrayHasKey('series', $result['data']);
$this->assertCount(2, $result['data']['series']);

$group1_index = array_search($group1->fields['name'], $result['data']['labels'], true);
$group2_index = array_search($group2->fields['name'], $result['data']['labels'], true);
$this->assertNotFalse($group1_index, 'group1 must be included in report labels');
$this->assertNotFalse($group2_index, 'group2 must be included in report labels');
$this->assertNotSame($group1_index, $group2_index, 'each group must have its own row');

$this->assertSame(2, $result['data']['series'][0]['data'][$group1_index], 'group1: 2 opened tickets expected');
$this->assertSame(1, $result['data']['series'][1]['data'][$group1_index], 'group1: 1 closed ticket expected');
$this->assertSame(1, $result['data']['series'][0]['data'][$group2_index], 'group2: 1 opened ticket expected');
$this->assertSame(2, $result['data']['series'][1]['data'][$group2_index], 'group2: 2 closed tickets expected');
}

private function attachTicketToGroup(string $name, int $status, \Group $group): \Ticket
{
$ticket = $this->createItem(\Ticket::class, [
'name' => "test dashboard $name",
'content' => 'blablabla',
'status' => $status,
'entities_id' => $this->getTestRootEntity(true),
]);
$this->createItem(\Group_Ticket::class, [
'tickets_id' => $ticket->getID(),
'groups_id' => $group->getID(),
'type' => \Group_Ticket::ASSIGN,
]);

return $ticket;
}
}
14 changes: 7 additions & 7 deletions tests/functional/Glpi/Dashboard/UserAssignedFilterTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -74,21 +74,21 @@ public function testGetCriteria(string $table, string $ul_table, string $fk): vo
$criteria = UserAssignedFilter::getCriteria($table, '42');
$this->assertSame(
[
"$ul_table as ul_assigned" => [
"$ul_table as ul_user_assigned" => [
'ON' => [
'ul_assigned' => $fk,
$table => 'id',
'ul_user_assigned' => $fk,
$table => 'id',
],
],
],
$criteria['JOIN']
);
$this->assertSame(CommonITILActor::ASSIGN, $criteria['WHERE']['ul_assigned.type']);
$this->assertSame(42, $criteria['WHERE']['ul_assigned.users_id']);
$this->assertSame(CommonITILActor::ASSIGN, $criteria['WHERE']['ul_user_assigned.type']);
$this->assertSame(42, $criteria['WHERE']['ul_user_assigned.users_id']);

$myself = UserAssignedFilter::getCriteria($table, 'myself');
$this->assertSame(CommonITILActor::ASSIGN, $myself['WHERE']['ul_assigned.type']);
$this->assertSame($_SESSION['glpiID'], $myself['WHERE']['ul_assigned.users_id']);
$this->assertSame(CommonITILActor::ASSIGN, $myself['WHERE']['ul_user_assigned.type']);
$this->assertSame($_SESSION['glpiID'], $myself['WHERE']['ul_user_assigned.users_id']);

$this->assertSame([], UserAssignedFilter::getCriteria($table, ''));
$this->assertSame([], UserAssignedFilter::getCriteria($table, '0'));
Expand Down
Loading