Statistics::getJSChartTemplateWithData()   F
last analyzed

Complexity

Conditions 19
Paths 5760

Size

Total Lines 110
Code Lines 52

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 19
eloc 52
c 0
b 0
f 0
nc 5760
nop 8
dl 0
loc 110
rs 0.3499

How to fix   Long Method    Complexity    Many Parameters   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

1
<?php
2
/* For licensing terms, see /license.txt */
3
4
use Chamilo\CoreBundle\Entity\Course;
5
use Chamilo\CoreBundle\Entity\CourseRelUser;
6
use Chamilo\CoreBundle\Entity\MessageRelUser;
7
use Chamilo\CoreBundle\Entity\ResourceLink;
8
use Chamilo\CoreBundle\Entity\TrackEAccess;
9
use Chamilo\CoreBundle\Entity\User;
10
use Chamilo\CoreBundle\Entity\UserRelUser;
11
use Chamilo\CoreBundle\Enums\ActionIcon;
12
use Chamilo\CoreBundle\Framework\Container;
13
use Chamilo\CoreBundle\Helpers\ChamiloHelper;
14
use Doctrine\DBAL\ParameterType;
15
16
/**
17
 * This class provides some functions for statistics.
18
 */
19
class Statistics
20
{
21
    /**
22
     * Converts a number of bytes in a formatted string.
23
     *
24
     * @param int $size
25
     *
26
     * @return string Formatted file size or empty string if no match
27
     */
28
    public static function makeSizeString(int $size): string
29
    {
30
        if ($size < pow(2, 10)) {
31
            return $size." bytes";
32
        }
33
        if ($size >= pow(2, 10) && $size < pow(2, 20)) {
34
            return round($size / pow(2, 10), 0)." KB";
35
        }
36
        if ($size >= pow(2, 20) && $size < pow(2, 30)) {
37
            return round($size / pow(2, 20), 1)." MB";
38
        }
39
        if ($size > pow(2, 30)) {
40
            return round($size / pow(2, 30), 2)." GB";
41
        }
42
43
        return '';
44
    }
45
46
    /**
47
     * Count courses.
48
     *
49
     * @param string|null $categoryCode Code of a course category.
50
     *                                  Default: count all courses.
51
     * @param string|null $dateFrom dateFrom
52
     * @param string|null $dateUntil dateUntil
53
     *
54
     * @return int Number of courses counted
55
     * @throws \Doctrine\DBAL\Exception
56
     * @throws Exception
57
     */
58
    public static function countCourses(string $categoryCode = null, string $dateFrom = null, string $dateUntil = null): int
59
    {
60
        $courseTable = Database::get_main_table(TABLE_MAIN_COURSE);
61
        $accessUrlRelCourseTable = Database::get_main_table(TABLE_MAIN_ACCESS_URL_REL_COURSE);
62
        $accessUrlUtil = Container::getAccessUrlUtil();
63
64
        if ($accessUrlUtil->isMultiple()) {
65
            $accessUrl = $accessUrlUtil->getCurrent();
66
            $urlId = $accessUrl->getId();
67
            $sql = "SELECT COUNT(*) AS number
68
                    FROM $courseTable AS c, $accessUrlRelCourseTable AS u
69
                    WHERE u.c_id = c.id AND u.access_url_id = $urlId";
70
            if (isset($categoryCode)) {
71
                $sql .= " AND category_code = '".Database::escape_string($categoryCode)."'";
72
            }
73
        } else {
74
            $sql = "SELECT COUNT(*) AS number
75
                    FROM $courseTable AS c
76
                    WHERE 1 = 1";
77
            if (isset($categoryCode)) {
78
                $sql .= " WHERE c.category_code = '".Database::escape_string($categoryCode)."'";
79
            }
80
        }
81
82
        if (!empty($dateFrom)) {
83
            $dateFrom = api_get_utc_datetime("$dateFrom 00:00:00");
84
            $sql .= " AND c.creation_date >= '$dateFrom' ";
85
        }
86
        if (!empty($dateUntil)) {
87
            $dateUntil = api_get_utc_datetime("$dateUntil 23:59:59");
88
            $sql .= " AND c.creation_date <= '$dateUntil' ";
89
        }
90
91
        $res = Database::query($sql);
92
        $obj = Database::fetch_object($res);
93
94
        return $obj->number;
95
    }
96
97
    /**
98
     * Count courses by visibility.
99
     *
100
     * @param array|null  $visibility visibility (0 = closed, 1 = private, 2 = open, 3 = public) all courses
101
     * @param string|null $dateFrom dateFrom
102
     * @param string|null $dateUntil dateUntil
103
     *
104
     * @return int Number of courses counted
105
     * @throws \Doctrine\DBAL\Exception
106
     * @throws Exception
107
     */
108
    public static function countCoursesByVisibility(
109
        array $visibility = null,
110
        string $dateFrom = null,
111
        string $dateUntil = null
112
    ): int
113
    {
114
        $visibilityString = '';
115
        if (empty($visibility)) {
116
            return 0;
117
        } else {
118
            $auxArrayVisibility = [];
119
            if (!is_array($visibility)) {
120
                $visibility = [$visibility];
121
            }
122
            foreach ($visibility as $item) {
123
                $auxArrayVisibility[] = (int) $item;
124
            }
125
            $visibilityString = implode(',', $auxArrayVisibility);
126
        }
127
        $courseTable = Database::get_main_table(TABLE_MAIN_COURSE);
128
        $accessUrlRelCourseTable = Database::get_main_table(TABLE_MAIN_ACCESS_URL_REL_COURSE);
129
        $accessUrlUtil = Container::getAccessUrlUtil();
130
131
        if ($accessUrlUtil->isMultiple()) {
132
            $accessUrl = $accessUrlUtil->getCurrent();
133
            $urlId = $accessUrl->getId();
134
            $sql = "SELECT COUNT(*) AS number
135
                    FROM $courseTable AS c, $accessUrlRelCourseTable AS u
136
                    WHERE u.c_id = c.id AND u.access_url_id = $urlId";
137
        } else {
138
            $sql = "SELECT COUNT(*) AS number
139
                    FROM $courseTable AS c
140
                    WHERE 1 = 1";
141
        }
142
        $sql .= " AND visibility IN ($visibilityString) ";
143
        if (!empty($dateFrom)) {
144
            $dateFrom = api_get_utc_datetime("$dateFrom 00:00:00");
145
            $sql .= " AND c.creation_date >= '$dateFrom' ";
146
        }
147
        if (!empty($dateUntil)) {
148
            $dateUntil = api_get_utc_datetime("$dateUntil 23:59:59");
149
            $sql .= " AND c.creation_date <= '$dateUntil' ";
150
        }
151
        $res = Database::query($sql);
152
        $obj = Database::fetch_object($res);
153
154
        return $obj->number;
155
    }
156
157
    /**
158
     * Count users.
159
     *
160
     * @param int    $status user status (COURSEMANAGER or STUDENT) if not setted it'll count all users
161
     * @param string $categoryCode course category code. Default: count only users without filtering category
162
     * @param bool   $countInvisibleCourses Count invisible courses (todo)
163
     * @param bool   $onlyActive Count only active users (false to only return currently active users)
164
     *
165
     * @return int Number of users counted
166
     * @throws Exception
167
     */
168
    public static function countUsers(
169
        ?int $status = null,
170
        ?string $categoryCode = null,
171
        ?bool $countInvisibleCourses = true,
172
        ?bool $onlyActive = false
173
    ): int
174
    {
175
        // Database table definitions
176
        $course_user_table = Database::get_main_table(TABLE_MAIN_COURSE_USER);
177
        $course_table = Database::get_main_table(TABLE_MAIN_COURSE);
178
        $user_table = Database::get_main_table(TABLE_MAIN_USER);
179
        $access_url_rel_user_table = Database::get_main_table(TABLE_MAIN_ACCESS_URL_REL_USER);
180
        $tblCourseCategory = Database::get_main_table(TABLE_MAIN_CATEGORY);
181
        $tblCourseRelCategory = Database::get_main_table(TABLE_MAIN_COURSE_REL_CATEGORY);
182
183
        $conditions = [];
184
        $conditions[] = "u.active <> " . USER_SOFT_DELETED;
185
        if ($onlyActive) {
186
            $conditions[] = "u.active = 1";
187
        }
188
        if (isset($status)) {
189
            $conditions[] = "u.status = " . $status;
190
        }
191
192
        $where = implode(' AND ', $conditions);
193
194
        $accessUrlUtil = Container::getAccessUrlUtil();
195
196
        if ($accessUrlUtil->isMultiple()) {
197
            $accessUrl = $accessUrlUtil->getCurrent();
198
            $urlId = $accessUrl->getId();
199
            $sql = "SELECT COUNT(DISTINCT(u.id)) AS number
200
                FROM $user_table as u
201
                INNER JOIN $access_url_rel_user_table as url ON u.id = url.user_id
202
                WHERE $where AND url.access_url_id = $urlId";
203
204
            if (isset($categoryCode)) {
205
                $categoryCode = Database::escape_string($categoryCode);
206
                $sql = "SELECT COUNT(DISTINCT(cu.user_id)) AS number
207
                    FROM $course_user_table cu
208
                    INNER JOIN $course_table c ON c.id = cu.c_id
209
                    INNER JOIN $access_url_rel_user_table as url ON cu.user_id = url.user_id
210
                    INNER JOIN $tblCourseRelCategory crc ON crc.course_id = c.id
211
                    INNER JOIN $tblCourseCategory cc ON cc.id = crc.course_category_id
212
                    INNER JOIN $user_table u ON cu.user_id = u.id
213
                    WHERE $where AND url.access_url_id = $urlId AND cc.code = '$categoryCode'";
214
            }
215
        } else {
216
            $sql = "SELECT COUNT(DISTINCT(id)) AS number
217
                FROM $user_table u
218
                WHERE $where";
219
220
            if (isset($categoryCode)) {
221
                $categoryCode = Database::escape_string($categoryCode);
222
                $sql = "SELECT COUNT(DISTINCT(cu.user_id)) AS number
223
                    FROM $course_user_table cu
224
                    INNER JOIN $course_table c ON c.id = cu.c_id
225
                    INNER JOIN $tblCourseRelCategory crc ON crc.course_id = c.id
226
                    INNER JOIN $tblCourseCategory cc ON cc.id = crc.course_category_id
227
                    INNER JOIN $user_table u ON u.id = cu.user_id
228
                    WHERE $where AND cc.code = '$categoryCode'";
229
            }
230
        }
231
232
        $res = Database::query($sql);
233
        $obj = Database::fetch_object($res);
234
235
        return $obj->number;
236
    }
237
238
    /**
239
     * Get courses IDs from courses with some access_date between the two given dates
240
     * @param string $startDate
241
     * @param string $endDate
242
     *
243
     * @return array
244
     * @throws Exception
245
     */
246
    public static function getCoursesWithActivity(string $startDate, string $endDate): array
247
    {
248
        $access_url_rel_course_table = Database::get_main_table(TABLE_MAIN_ACCESS_URL_REL_COURSE);
249
        $table = Database::get_main_table(TABLE_STATISTIC_TRACK_E_LASTACCESS);
250
        $startDate = Database::escape_string($startDate);
251
        $endDate = Database::escape_string($endDate);
252
253
        $accessUrlUtil = Container::getAccessUrlUtil();
254
255
        if ($accessUrlUtil->isMultiple()) {
256
            $accessUrl = $accessUrlUtil->getCurrent();
257
            $urlId = $accessUrl->getId();
258
            $sql = "SELECT DISTINCT(t.c_id) FROM $table t , $access_url_rel_course_table a
259
                    WHERE
260
                        t.c_id = a.c_id AND
261
                        access_url_id = $urlId AND
262
                        access_date BETWEEN '$startDate' AND '$endDate'
263
                    ";
264
        } else {
265
            $sql = "SELECT DISTINCT(t.c_id) FROM $table t
266
                   access_date BETWEEN '$startDate' AND '$endDate' ";
267
        }
268
269
        $result = Database::query($sql);
270
271
        return Database::store_result($result);
272
    }
273
274
    /**
275
     * Count activities from track_e_default_table.
276
     *
277
     * @return int Number of activities counted
278
     * @throws Exception
279
     */
280
    public static function getNumberOfActivities(mixed $courseId = 0, ?int $sessionId = 0): int
281
    {
282
        // Database table definitions
283
        $track_e_default = Database::get_main_table(TABLE_STATISTIC_TRACK_E_DEFAULT);
284
        $table_user = Database::get_main_table(TABLE_MAIN_USER);
285
        $access_url_rel_user_table = Database::get_main_table(TABLE_MAIN_ACCESS_URL_REL_USER);
286
        $accessUrlUtil = Container::getAccessUrlUtil();
287
        if (is_array($courseId)) {
288
            // Usually when no param is given, we get an empty array from SortableTable
289
            $courseId = 0;
290
        }
291
292
        if ($accessUrlUtil->isMultiple()) {
293
            $accessUrl = $accessUrlUtil->getCurrent();
294
            $urlId = $accessUrl->getId();
295
            $sql = "SELECT count(default_id) AS total_number_of_items
296
                    FROM $track_e_default, $table_user user, $access_url_rel_user_table url
297
                    WHERE user.active <> ".USER_SOFT_DELETED." AND
298
                        default_user_id = user.id AND
299
                        user.id = url.user_id AND
300
                        access_url_id = $urlId";
301
        } else {
302
            $sql = "SELECT count(default_id) AS total_number_of_items
303
                    FROM $track_e_default, $table_user user
304
                    WHERE user.active <> ".USER_SOFT_DELETED." AND default_user_id = user.id ";
305
        }
306
307
        if (!empty($courseId)) {
308
            $courseId = (int) $courseId;
309
            $sql .= " AND c_id = $courseId";
310
            $sql .= api_get_session_condition($sessionId);
311
        }
312
313
        if (isset($_GET['keyword'])) {
314
            $keyword = Database::escape_string(trim($_GET['keyword']));
315
            $sql .= " AND (
316
                        user.username LIKE '%".$keyword."%' OR
317
                        default_event_type LIKE '%".$keyword."%' OR
318
                        default_value_type LIKE '%".$keyword."%' OR
319
                        default_value LIKE '%".$keyword."%') ";
320
        }
321
        $res = Database::query($sql);
322
        $obj = Database::fetch_object($res);
323
324
        return $obj->total_number_of_items;
325
    }
326
327
    /**
328
     * Get activities data to display.
329
     *
330
     * @param int    $from
331
     * @param int    $numberOfItems
332
     * @param int    $column
333
     * @param string $direction
334
     * @param ?int   $courseId
335
     * @param ?int   $sessionId
336
     *
337
     * @return array
338
     * @throws Exception
339
     */
340
    public static function getActivitiesData(
341
        int $from,
342
        int $numberOfItems,
343
        int $column,
344
        string $direction,
345
        mixed $courseId = 0,
346
        ?int $sessionId = 0
347
    ): array
348
    {
349
        $track_e_default = Database::get_main_table(TABLE_STATISTIC_TRACK_E_DEFAULT);
350
        $table_user = Database::get_main_table(TABLE_MAIN_USER);
351
        $access_url_rel_user_table = Database::get_main_table(TABLE_MAIN_ACCESS_URL_REL_USER);
352
        $direction = strtoupper($direction);
353
        if (is_array($courseId)) {
354
            // Usually when no param is given, we get an empty array from SortableTable
355
            $courseId = 0;
356
        }
357
358
        if (!in_array($direction, ['ASC', 'DESC'])) {
359
            $direction = 'DESC';
360
        }
361
362
        $accessUrlUtil = Container::getAccessUrlUtil();
363
364
        if ($accessUrlUtil->isMultiple()) {
365
            $accessUrl = $accessUrlUtil->getCurrent();
366
            $urlId = $accessUrl->getId();
367
            $sql = "SELECT
368
                        default_event_type  as col0,
369
                        default_value_type    as col1,
370
                        default_value        as col2,
371
                        c_id         as col3,
372
                        session_id as col4,
373
                        user.username         as col5,
374
                        user.id         as col6,
375
                        default_date         as col7
376
                    FROM $track_e_default as track_default,
377
                    $table_user as user,
378
                    $access_url_rel_user_table as url
379
                    WHERE
380
                        user.active <> -1 AND
381
                        track_default.default_user_id = user.id AND
382
                        url.user_id = user.id AND
383
                        access_url_id= $urlId";
384
        } else {
385
            $sql = "SELECT
386
                       default_event_type  as col0,
387
                       default_value_type    as col1,
388
                       default_value        as col2,
389
                       c_id         as col3,
390
                       session_id as col4,
391
                       user.username         as col5,
392
                       user.id         as col6,
393
                       default_date         as col7
394
                   FROM $track_e_default track_default, $table_user user
395
                   WHERE user.active <> ".USER_SOFT_DELETED." AND track_default.default_user_id = user.id ";
396
        }
397
398
        if (!empty($_GET['keyword'])) {
399
            $keyword = Database::escape_string(trim($_GET['keyword']));
400
            $sql .= " AND (user.username LIKE '%".$keyword."%' OR
401
                        default_event_type LIKE '%".$keyword."%' OR
402
                        default_value_type LIKE '%".$keyword."%' OR
403
                        default_value LIKE '%".$keyword."%') ";
404
        }
405
406
        if (!empty($courseId)) {
407
            $courseId = (int) $courseId;
408
            $sql .= " AND c_id = $courseId";
409
            $sql .= api_get_session_condition($sessionId);
410
        }
411
412
        if (!empty($column)) {
413
            $sql .= " ORDER BY col$column $direction";
414
        } else {
415
            $sql .= " ORDER BY col7 DESC ";
416
        }
417
        $sql .= " LIMIT $from, $numberOfItems ";
418
419
        $res = Database::query($sql);
420
        $activities = [];
421
        while ($row = Database::fetch_row($res)) {
422
            if (false === strpos($row[1], '_object') &&
423
                false === strpos($row[1], '_array')
424
            ) {
425
                $row[2] = $row[2];
426
            } else {
427
                if (!empty($row[2])) {
428
                    $originalData = str_replace('\\', '', $row[2]);
429
                    $row[2] = UnserializeApi::unserialize('not_allowed_classes', $originalData);
430
                    if (is_array($row[2]) && !empty($row[2])) {
431
                        $row[2] = implode_with_key(', ', $row[2]);
432
                    } else {
433
                        $row[2] = $originalData;
434
                    }
435
                }
436
            }
437
438
            if (!empty($row['default_date'])) {
439
                $row['default_date'] = api_get_local_time($row['default_date']);
440
            } else {
441
                $row['default_date'] = '-';
442
            }
443
444
            if (!empty($row[7])) {
445
                $row[7] = api_get_local_time($row[7]);
446
            } else {
447
                $row[7] = '-';
448
            }
449
450
            if (!empty($row[5])) {
451
                // Course
452
                if (!empty($row[3])) {
453
                    $row[3] = Display::url(
454
                        $row[3],
455
                        api_get_path(WEB_CODE_PATH).'admin/course_edit.php?id='.$row[3]
456
                    );
457
                } else {
458
                    $row[3] = '-';
459
                }
460
461
                // session
462
                if (!empty($row[4])) {
463
                    $row[4] = Display::url(
464
                        $row[4],
465
                        api_get_path(WEB_CODE_PATH).'session/resume_session.php?id_session='.$row[4]
466
                    );
467
                } else {
468
                    $row[4] = '-';
469
                }
470
471
                // User id.
472
                $row[5] = Display::url(
473
                    $row[5],
474
                    api_get_path(WEB_AJAX_PATH).'user_manager.ajax.php?a=get_user_popup&user_id='.$row[6],
475
                    ['class' => 'ajax']
476
                );
477
478
                $row[6] = Tracking::get_ip_from_user_event(
479
                    $row[6],
480
                    $row[7],
481
                    true
482
                );
483
                if (empty($row[6])) {
484
                    $row[6] = get_lang('Unknown');
485
                }
486
            }
487
            $activities[] = $row;
488
        }
489
490
        return $activities;
491
    }
492
493
    /**
494
     * Show statistics.
495
     *
496
     * @param string $title      The title
497
     * @param array  $stats
498
     * @param ?bool   $showTotal
499
     * @param ?bool   $isFileSize
500
     *
501
     * @return string HTML table
502
     */
503
    public static function printStats(
504
        string $title,
505
        array $stats,
506
        ?bool $showTotal = true,
507
        ?bool $isFileSize = false,
508
        ?bool $barRelativeToMax = false
509
    ): string {
510
        $total = 0;
511
        $max = 0;
512
513
        $content = '<table class="table table-hover table-striped data_table stats_table" cellspacing="0" cellpadding="3" width="90%">
514
        <thead><tr><th colspan="'.($showTotal ? '4' : '3').'">'.$title.'</th></tr></thead><tbody>';
515
516
        $i = 0;
517
518
        foreach ($stats as $subtitle => $number) {
519
            $number = (float) $number;
520
            $total += $number;
521
            if ($number > $max) {
522
                $max = $number;
523
            }
524
        }
525
526
        foreach ($stats as $subtitle => $number) {
527
            $number = (float) $number;
528
529
            if (!$isFileSize) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $isFileSize of type boolean|null is loosely compared to false; this is ambiguous if the boolean can be false. You might want to explicitly use !== null instead.

If an expression can have both false, and null as possible values. It is generally a good practice to always use strict comparison to clearly distinguish between those two values.

$a = canBeFalseAndNull();

// Instead of
if ( ! $a) { }

// Better use one of the explicit versions:
if ($a !== null) { }
if ($a !== false) { }
if ($a !== null && $a !== false) { }
Loading history...
530
                $numberLabel = number_format($number, 0, ',', '.');
531
            } else {
532
                $numberLabel = self::makeSizeString($number);
533
            }
534
535
            $percentageRaw = ($total > 0) ? (100 * $number / $total) : 0.0;
536
            $percentageDisplay = ($total > 0) ? number_format($percentageRaw, 1, ',', '.') : '0';
537
538
            // Bar size: either relative to max (wanted for "1 vs 7") or relative to total (legacy behavior)
539
            $barPercent = $barRelativeToMax
540
                ? (($max > 0) ? (100 * $number / $max) : 0.0)
541
                : $percentageRaw;
542
543
            $content .= '<tr class="row_'.(0 == $i % 2 ? 'odd' : 'even').'">
544
            <td width="25%" style="vertical-align:top;">'.$subtitle.'</td>
545
            <td width="60%">'.Display::bar_progress($barPercent, false).'</td>
546
            <td width="5%" align="right" style="vertical-align:top;">'.$numberLabel.'</td>';
547
548
            if ($showTotal) {
549
                $content .= '<td width="5%" align="right"> '.$percentageDisplay.'%</td>';
550
            }
551
552
            $content .= '</tr>';
553
            $i++;
554
        }
555
556
        $content .= '</tbody></table>';
557
558
        return $content;
559
    }
560
561
    /**
562
     * Show some stats about the number of logins.
563
     *
564
     * @param string $type month, hour or day
565
     * @return string HTML block
566
     * @throws Exception
567
     */
568
    public static function printLoginStats(string $type): string
569
    {
570
        $table = Database::get_main_table(TABLE_STATISTIC_TRACK_E_LOGIN);
571
        $access_url_rel_user_table = Database::get_main_table(TABLE_MAIN_ACCESS_URL_REL_USER);
572
        $table_url = null;
573
        $where_url = null;
574
        $now = api_get_utc_datetime();
575
        $where_url_last = ' WHERE login_date > DATE_SUB("'.$now.'",INTERVAL 1 %s)';
576
        $accessUrlUtil = Container::getAccessUrlUtil();
577
578
        if ($accessUrlUtil->isMultiple()) {
579
            $accessUrl = $accessUrlUtil->getCurrent();
580
            $urlId = $accessUrl->getId();
581
            $table_url = ", $access_url_rel_user_table";
582
            $where_url = " WHERE login_user_id=user_id AND access_url_id = $urlId";
583
            $where_url_last = ' AND login_date > DATE_SUB("'.$now.'",INTERVAL 1 %s)';
584
        }
585
586
        $period = get_lang('Month');
587
        $periodCollection = api_get_months_long();
588
        $sql = "SELECT
589
                DATE_FORMAT( login_date, '%Y-%m' ) AS stat_date ,
590
                count( login_id ) AS number_of_logins
591
                FROM $table $table_url $where_url
592
                GROUP BY stat_date
593
                ORDER BY login_date DESC";
594
        $sql_last_x = null;
595
596
        switch ($type) {
597
            case 'hour':
598
                $period = get_lang('Hour');
599
                $sql = "SELECT
600
                          DATE_FORMAT( login_date, '%H') AS stat_date,
601
                          count( login_id ) AS number_of_logins
602
                        FROM $table $table_url $where_url
603
                        GROUP BY stat_date
604
                        ORDER BY stat_date ";
605
                $sql_last_x = "SELECT
606
                                DATE_FORMAT( login_date, '%H' ) AS stat_date,
607
                                count( login_id ) AS number_of_logins
608
                               FROM $table $table_url $where_url ".sprintf($where_url_last, 'DAY')."
609
                               GROUP BY stat_date
610
                               ORDER BY stat_date ";
611
                break;
612
            case 'day':
613
                $periodCollection = api_get_week_days_long();
614
                $period = get_lang('Day');
615
                $sql = "SELECT DATE_FORMAT( login_date, '%w' ) AS stat_date ,
616
                        count( login_id ) AS number_of_logins
617
                        FROM  $table $table_url $where_url
618
                        GROUP BY stat_date
619
                        ORDER BY DATE_FORMAT( login_date, '%w' ) ";
620
                $sql_last_x = "SELECT
621
                                DATE_FORMAT( login_date, '%w' ) AS stat_date,
622
                                count( login_id ) AS number_of_logins
623
                               FROM $table $table_url $where_url ".sprintf($where_url_last, 'WEEK')."
624
                               GROUP BY stat_date
625
                               ORDER BY DATE_FORMAT( login_date, '%w' ) ";
626
                break;
627
        }
628
629
        $content = '';
630
        if ($sql_last_x) {
631
            $res_last_x = Database::query($sql_last_x);
632
            $result_last_x = [];
633
            while ($obj = Database::fetch_object($res_last_x)) {
634
                $stat_date = ('day' === $type) ? $periodCollection[$obj->stat_date] : $obj->stat_date;
635
                $result_last_x[$stat_date] = $obj->number_of_logins;
636
            }
637
            $content .= self::printStats(get_lang('Last logins').' ('.$period.')', $result_last_x, true);
638
            flush(); //flush web request at this point to see something already while the full data set is loading
639
            $content .= '<br />';
640
        }
641
        $res = Database::query($sql);
642
        $result = [];
643
        while ($obj = Database::fetch_object($res)) {
644
            $stat_date = $obj->stat_date;
645
            switch ($type) {
646
                case 'month':
647
                    $stat_date = explode('-', $stat_date);
648
                    $stat_date[1] = $periodCollection[(int) $stat_date[1] - 1];
649
                    $stat_date = implode(' ', $stat_date);
650
                    break;
651
                case 'day':
652
                    $stat_date = $periodCollection[$stat_date];
653
                    break;
654
            }
655
            $result[$stat_date] = $obj->number_of_logins;
656
        }
657
        $content .= self::printStats(get_lang('All logins').' ('.$period.')', $result, true);
658
659
        return $content;
660
    }
661
662
    /**
663
     * Print the number of recent logins.
664
     *
665
     * @param ?bool  $distinct        whether to only give distinct users stats, or *all* logins
666
     * @param ?int   $sessionDuration Number of minutes a session must have lasted at a minimum to be taken into account
667
     * @param ?array $periods         List of number of days we want to query (default: [1, 7, 31] for last 1 day, last 7 days, last 31 days)
668
     *
669
     * @throws Exception
670
     *
671
     * @return string HTML table
672
     */
673
    public static function printRecentLoginStats(?bool $distinct = false, ?int $sessionDuration = 0, ?array $periods = []): string
674
    {
675
        $table = Database::get_main_table(TABLE_STATISTIC_TRACK_E_LOGIN);
676
        $access_url_rel_user_table = Database::get_main_table(TABLE_MAIN_ACCESS_URL_REL_USER);
677
        $table_url = '';
678
        $where_url = '';
679
        $accessUrlUtil = Container::getAccessUrlUtil();
680
681
        if ($accessUrlUtil->isMultiple()) {
682
            $accessUrl = $accessUrlUtil->getCurrent();
683
            $urlId = $accessUrl->getId();
684
            $table_url = ", $access_url_rel_user_table";
685
            $where_url = " AND login_user_id=user_id AND access_url_id = $urlId";
686
        }
687
688
        $now = api_get_utc_datetime();
689
        $field = 'login_id';
690
        if ($distinct) {
691
            $field = 'DISTINCT(login_user_id)';
692
        }
693
694
        if (empty($periods)) {
695
            $periods = [1, 7, 31];
696
        }
697
        $sqlList = [];
698
699
        $sessionDuration = (int) $sessionDuration * 60; // convert from minutes to seconds
700
        foreach ($periods as $day) {
701
            $date = new DateTime($now);
702
            $startDate = $date->format('Y-m-d').' 00:00:00';
703
            $endDate = $date->format('Y-m-d').' 23:59:59';
704
705
            if ($day > 1) {
706
                $startDate = $date->sub(new DateInterval('P'.$day.'D'));
707
                $startDate = $startDate->format('Y-m-d').' 00:00:00';
708
            }
709
710
            $localDate = api_get_local_time($startDate, null, null, false, false);
711
            $localEndDate = api_get_local_time($endDate, null, null, false, false);
712
713
            $label = sprintf(get_lang('Last %s days'), $day);
714
            if (1 == $day) {
715
                $label = get_lang('Today');
716
            }
717
            $label .= " <span class=\"muted right\" style=\"float: right; margin-right: 5px;\">[$localDate - $localEndDate]</span>";
718
            $sql = "SELECT count($field) AS number
719
                    FROM $table $table_url
720
                    WHERE ";
721
            if (0 == $sessionDuration) {
722
                $sql .= " logout_date != login_date AND ";
723
            } else {
724
                $sql .= " UNIX_TIMESTAMP(logout_date) - UNIX_TIMESTAMP(login_date) > $sessionDuration AND ";
725
            }
726
            $sql .= "login_date BETWEEN '$startDate' AND '$endDate'
727
                        $where_url";
728
            $sqlList[$label] = $sql;
729
        }
730
731
        $sql = "SELECT count($field) AS number
732
                FROM $table $table_url ";
733
        if (0 == $sessionDuration) {
734
            $sql .= " WHERE logout_date != login_date $where_url";
735
        } else {
736
            $sql .= " WHERE UNIX_TIMESTAMP(logout_date) - UNIX_TIMESTAMP(login_date) > $sessionDuration $where_url";
737
        }
738
        $sqlList[get_lang('Total')] = $sql;
739
        $totalLogin = [];
740
        foreach ($sqlList as $label => $query) {
741
            $res = Database::query($query);
742
            $obj = Database::fetch_object($res);
743
            $totalLogin[$label] = $obj->number;
744
        }
745
746
        if ($distinct) {
747
            $content = self::printStats(get_lang('Distinct users logins'), $totalLogin, false, false, true);
748
        } else {
749
            $content = self::printStats(get_lang('Logins'), $totalLogin, false, false, true);
750
        }
751
752
        return $content;
753
    }
754
755
    /**
756
     * Get the number of recent logins.
757
     *
758
     * @param ?bool $distinct            Whether to only give distinct users stats, or *all* logins
759
     * @param ?int  $sessionDuration     Number of minutes a session must have lasted at a minimum to be taken into account
760
     * @param ?bool $completeMissingDays Whether to fill the daily gaps (if any) when getting a list of logins
761
     *
762
     * @throws Exception
763
     *
764
     * @return array
765
     */
766
    public static function getRecentLoginStats(?bool $distinct = false, ?int $sessionDuration = 0, ?bool $completeMissingDays = true): array
767
    {
768
        $table = Database::get_main_table(TABLE_STATISTIC_TRACK_E_LOGIN);
769
        $access_url_rel_user_table = Database::get_main_table(TABLE_MAIN_ACCESS_URL_REL_USER);
770
        $table_url = '';
771
        $where_url = '';
772
        $accessUrlUtil = Container::getAccessUrlUtil();
773
774
        if ($accessUrlUtil->isMultiple()) {
775
            $accessUrl = $accessUrlUtil->getCurrent();
776
            $urlId = $accessUrl->getId();
777
            $table_url = ", $access_url_rel_user_table";
778
            $where_url = " AND login_user_id=user_id AND access_url_id = $urlId";
779
        }
780
781
        $now = api_get_utc_datetime();
782
        $date = new DateTime($now);
783
        $date->sub(new DateInterval('P31D'));
784
        $newDate = $date->format('Y-m-d h:i:s');
785
        $totalLogin = self::buildDatesArray($newDate, $now, true);
786
787
        $field = 'login_id';
788
        if ($distinct) {
789
            $field = 'DISTINCT(login_user_id)';
790
        }
791
        $sessionDuration = (int) $sessionDuration * 60; //Convert from minutes to seconds
792
793
        $sql = "SELECT count($field) AS number, date(login_date) as login_date
794
                FROM $table $table_url
795
                WHERE ";
796
        if (0 == $sessionDuration) {
797
            $sql .= " logout_date != login_date AND ";
798
        } else {
799
            $sql .= " UNIX_TIMESTAMP(logout_date) - UNIX_TIMESTAMP(login_date) > $sessionDuration AND ";
800
        }
801
        $sql .= " login_date >= '$newDate' $where_url
802
                GROUP BY date(login_date)";
803
804
        $res = Database::query($sql);
805
        while ($row = Database::fetch_assoc($res)) {
806
            $monthAndDay = substr($row['login_date'], 5, 5);
807
            $totalLogin[$monthAndDay] = $row['number'];
808
        }
809
810
        return $totalLogin;
811
    }
812
813
    /**
814
     * Get course tools usage statistics for the whole platform (by URL if multi-url).
815
     * @throws Exception
816
     */
817
    public static function getToolsStats(): array
818
    {
819
        $table = Database::get_main_table(TABLE_STATISTIC_TRACK_E_ACCESS);
820
        $access_url_rel_course_table = Database::get_main_table(TABLE_MAIN_ACCESS_URL_REL_COURSE);
821
822
        $tools = [
823
            'announcement',
824
            'assignment',
825
            'calendar_event',
826
            'chat',
827
            'course_description',
828
            'document',
829
            'dropbox',
830
            'group',
831
            'learnpath',
832
            'link',
833
            'quiz',
834
            'student_publication',
835
            'user',
836
            'forum',
837
        ];
838
        $tool_names = [];
839
        foreach ($tools as $tool) {
840
            $tool_names[$tool] = get_lang(ucfirst($tool), '');
841
        }
842
        $accessUrlUtil = Container::getAccessUrlUtil();
843
844
        if ($accessUrlUtil->isMultiple()) {
845
            $accessUrl = $accessUrlUtil->getCurrent();
846
            $urlId = $accessUrl->getId();
847
            $sql = "SELECT access_tool, count( access_id ) AS number_of_logins
848
                    FROM $table t , $access_url_rel_course_table a
849
                    WHERE
850
                        access_tool IN ('".implode("','", $tools)."') AND
851
                        t.c_id = a.c_id AND
852
                        access_url_id = $urlId
853
                        GROUP BY access_tool
854
                    ";
855
        } else {
856
            $sql = "SELECT access_tool, count( access_id ) AS number_of_logins
857
                    FROM $table
858
                    WHERE access_tool IN ('".implode("','", $tools)."')
859
                    GROUP BY access_tool ";
860
        }
861
862
        $res = Database::query($sql);
863
        $result = [];
864
        while ($obj = Database::fetch_object($res)) {
865
            $result[$tool_names[$obj->access_tool]] = $obj->number_of_logins;
866
        }
867
868
        return $result;
869
    }
870
871
    /**
872
     * Show some stats about the accesses to the different course tools.
873
     *
874
     * @param array $result If defined, this serves as data. Otherwise, will get the data from getToolsStats()
875
     *
876
     * @return string HTML table
877
     * @throws Exception
878
     */
879
    public static function printToolStats($result = null): string
880
    {
881
        if (empty($result)) {
882
            $result = self::getToolsStats();
883
        }
884
885
        return self::printStats(get_lang('Tools access'), $result, true);
886
    }
887
888
    /**
889
     * Returns some stats about the number of courses per language.
890
     * @throws Exception
891
     */
892
    public static function printCourseByLanguageStats(): array
893
    {
894
        $table = Database::get_main_table(TABLE_MAIN_COURSE);
895
        $access_url_rel_course_table = Database::get_main_table(TABLE_MAIN_ACCESS_URL_REL_COURSE);
896
        $accessUrlUtil = Container::getAccessUrlUtil();
897
898
        if ($accessUrlUtil->isMultiple()) {
899
            $accessUrl = $accessUrlUtil->getCurrent();
900
            $urlId = $accessUrl->getId();
901
            $sql = "SELECT course_language, count( c.code ) AS number_of_courses
902
                    FROM $table as c, $access_url_rel_course_table as u
903
                    WHERE u.c_id = c.id AND access_url_id = $urlId
904
                    GROUP BY course_language
905
                    ORDER BY number_of_courses DESC";
906
        } else {
907
            $sql = "SELECT course_language, count( code ) AS number_of_courses
908
                   FROM $table GROUP BY course_language
909
                   ORDER BY number_of_courses DESC";
910
        }
911
        $res = Database::query($sql);
912
        $result = [];
913
        while ($obj = Database::fetch_object($res)) {
914
            $result[$obj->course_language] = $obj->number_of_courses;
915
        }
916
917
        return $result;
918
    }
919
920
    /**
921
     * Shows the number of users having their picture uploaded in Dokeos.
922
     * @throws Exception
923
     */
924
    public static function printUserPicturesStats(): string
925
    {
926
        $user_table = Database::get_main_table(TABLE_MAIN_USER);
927
        $access_url_rel_user_table = Database::get_main_table(TABLE_MAIN_ACCESS_URL_REL_USER);
928
        $url_condition = null;
929
        $url_condition2 = null;
930
        $table = null;
931
        $accessUrlUtil = Container::getAccessUrlUtil();
932
933
        if ($accessUrlUtil->isMultiple()) {
934
            $accessUrl = $accessUrlUtil->getCurrent();
935
            $urlId = $accessUrl->getId();
936
            $url_condition = ", $access_url_rel_user_table as url WHERE url.user_id=u.id AND access_url_id='".$urlId."'";
937
            $url_condition2 = " AND url.user_id=u.id AND access_url_id = $urlId";
938
            $table = ", $access_url_rel_user_table as url ";
939
        }
940
        $sql = "SELECT COUNT(*) AS n FROM $user_table as u ".$url_condition;
941
        $res = Database::query($sql);
942
        $count1 = Database::fetch_object($res);
943
        $sql = "SELECT COUNT(*) AS n FROM $user_table as u $table
944
               WHERE LENGTH(picture_uri) > 0 $url_condition2";
945
946
        $sql .= !str_contains($sql, 'WHERE') ? ' WHERE u.active <> '.USER_SOFT_DELETED : ' AND u.active <> '.USER_SOFT_DELETED;
947
948
        $res = Database::query($sql);
949
        $count2 = Database::fetch_object($res);
950
        // #users without picture
951
        $result[get_lang('No')] = $count1->n - $count2->n;
0 ignored issues
show
Comprehensibility Best Practice introduced by
$result was never initialized. Although not strictly required by PHP, it is generally a good practice to add $result = array(); before regardless.
Loading history...
952
        $result[get_lang('Yes')] = $count2->n; // #users with picture
953
954
        return self::printStats(get_lang('Number of users').' ('.get_lang('Picture').')', $result, true);
955
    }
956
957
    /**
958
     * Print important activities report page
959
     */
960
    public static function printActivitiesStats(): string
961
    {
962
        $keyword = isset($_GET['keyword']) ? (string) $_GET['keyword'] : '';
963
        $keyword = Security::remove_XSS($keyword);
964
965
        $content = '';
966
        $content .= '
967
    <style>
968
        .ch-activities-wrap { margin-top: 10px; }
969
        .ch-activities-header { display:flex; align-items:flex-end; justify-content:space-between; gap:12px; flex-wrap:wrap; }
970
        .ch-activities-title { margin:0; }
971
        .ch-activities-help { margin:6px 0 0; color:#5f6b7a; font-size:13px; }
972
        .ch-activities-actions { margin:10px 0 16px; }
973
        .ch-chip-filter { width: 280px; max-width: 100%; padding: 8px 10px; border: 1px solid #d6dde6; border-radius: 8px; }
974
        .ch-groups { margin-top: 14px; }
975
        .ch-group { margin: 14px 0; }
976
        .ch-group-title { margin: 0 0 8px; font-size: 14px; font-weight: 600; color:#2b3645; }
977
        .ch-chip-row { display:flex; flex-wrap:wrap; gap:8px; }
978
        .ch-chip {
979
            display:inline-flex; align-items:center; gap:8px;
980
            padding: 6px 10px;
981
            border-radius: 999px;
982
            border: 1px solid #d6dde6;
983
            background: #f7f9fc;
984
            color: #1f2d3d;
985
            text-decoration: none;
986
            font-size: 13px;
987
            line-height: 1;
988
        }
989
        .ch-chip:hover { background:#eef3fb; border-color:#c8d3e1; text-decoration:none; }
990
        .ch-chip-code { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 12px; color:#51606f; }
991
        .ch-empty { color:#5f6b7a; font-size:13px; }
992
        .ch-divider { border-top: 1px solid #e6edf5; margin: 14px 0; }
993
    </style>
994
    ';
995
996
        $content .= '<div class="ch-activities-wrap">';
997
998
        $content .= '
999
        <div class="ch-activities-header">
1000
            <div>
1001
                <h4 class="ch-activities-title">'.get_lang('Important activities').'</h4>
1002
                <div class="ch-activities-help">
1003
                    <!-- This report lists tracked event records. Select an event type below or search by keyword. -->
1004
                    '.get_lang('Search').' / '.get_lang('Event type').'
1005
                </div>
1006
            </div>
1007
        </div>
1008
    ';
1009
        $form = new FormValidator(
1010
            'search_simple',
1011
            'get',
1012
            api_get_path(WEB_CODE_PATH).'admin/statistics/index.php',
1013
            '',
1014
            ['style' => 'display:inline-block']
1015
        );
1016
1017
        $renderer = &$form->defaultRenderer();
1018
        $renderer->setCustomElementTemplate('<span>{element}</span> ');
1019
1020
        $form->addHidden('report', 'activities');
1021
        $form->addHidden('activities_direction', 'DESC');
1022
        $form->addHidden('activities_column', '4');
1023
1024
        $form->addElement('text', 'keyword', get_lang('Keyword'));
1025
        $form->addButtonSearch(get_lang('Search'), 'submit');
1026
1027
        $content .= '<div class="ch-activities-actions">'.$form->returnForm().'</div>';
1028
        if (!empty($keyword)) {
1029
            $table = new SortableTable(
1030
                'activities',
1031
                ['Statistics', 'getNumberOfActivities'],
1032
                ['Statistics', 'getActivitiesData'],
1033
                7,
1034
                50,
1035
                'DESC'
1036
            );
1037
1038
            $parameters = [];
1039
            $parameters['report'] = 'activities';
1040
            $parameters['keyword'] = $keyword;
1041
1042
            $table->set_additional_parameters($parameters);
1043
            $table->set_header(0, get_lang('Event type'));
1044
            $table->set_header(1, get_lang('Data type'));
1045
            $table->set_header(2, get_lang('Value'));
1046
            $table->set_header(3, get_lang('Course'));
1047
            $table->set_header(4, get_lang('Session'));
1048
            $table->set_header(5, get_lang('Username'));
1049
            $table->set_header(6, get_lang('IP address'));
1050
            $table->set_header(7, get_lang('Date'));
1051
1052
            $content .= $table->return_table();
1053
            $content .= '<div class="ch-divider"></div>';
1054
        }
1055
1056
        $prefix = 'LOG_';
1057
        $userDefinedConstants = get_defined_constants(true)['user'] ?? [];
1058
        $filteredConstants = array_filter(
1059
            $userDefinedConstants,
1060
            static function ($constantName) use ($prefix) {
1061
                return strpos((string) $constantName, $prefix) === 0;
1062
            },
1063
            ARRAY_FILTER_USE_KEY
1064
        );
1065
1066
        $eventTypes = [];
1067
        foreach (array_keys($filteredConstants) as $constantName) {
1068
            if ($constantName === 'LOG_WS') {
1069
                // Expand WS events based on Rest constants.
1070
                $constantValue = (string) constant($constantName);
1071
                if (class_exists('Rest')) {
1072
                    try {
1073
                        $reflection = new ReflectionClass('Rest');
1074
                        foreach ($reflection->getConstants() as $name => $value) {
1075
                            $eventTypes[] = $constantValue.(string) $value;
1076
                        }
1077
                    } catch (\Throwable $e) {
1078
                        // Ignore reflection issues.
1079
                    }
1080
                }
1081
                continue;
1082
            }
1083
            if (substr($constantName, -3) === '_ID') {
1084
                continue;
1085
            }
1086
1087
            $eventTypes[] = (string) constant($constantName);
1088
        }
1089
1090
        $eventTypes = array_values(array_unique(array_filter($eventTypes)));
1091
        sort($eventTypes);
1092
1093
        // Group event types by prefix to make it easier to scan.
1094
        $groupLabels = [
1095
            'course'    => 'Course',
1096
            'session'   => 'Session',
1097
            'user'      => 'User',
1098
            'soc'       => 'Social',
1099
            'msg'       => 'Message',
1100
            'message'   => 'Message',
1101
            'wiki'      => 'Wiki',
1102
            'resource'  => 'Resource',
1103
            'ws'        => 'Webservice',
1104
            'default'   => 'Other',
1105
        ];
1106
1107
        $groups = [];
1108
        foreach ($eventTypes as $evt) {
1109
            $evt = trim($evt);
1110
            if ($evt === '') {
1111
                continue;
1112
            }
1113
1114
            $first = explode('_', $evt, 2)[0] ?? 'default';
1115
            $key = $groupLabels[$first] ?? $groupLabels['default'];
1116
1117
            if (!isset($groups[$key])) {
1118
                $groups[$key] = [];
1119
            }
1120
            $groups[$key][] = $evt;
1121
        }
1122
1123
        // Chips section: click = open the report with keyword.
1124
        $linkBase = api_get_self().'?report=activities&activities_direction=DESC&activities_column=7&keyword=';
1125
1126
        $content .= '
1127
        <div class="ch-activities-header" style="margin-top:8px;">
1128
            <div>
1129
                <h4 class="ch-activities-title">'.get_lang('Event type').'</h4>
1130
                <div class="ch-activities-help">
1131
                    '.get_lang('Click an event type to filter results.').'
1132
                </div>
1133
            </div>
1134
            <input id="chChipFilter" class="ch-chip-filter" type="text" placeholder="Filter event types...">
1135
        </div>
1136
    ';
1137
1138
        if (empty($groups)) {
1139
            $content .= '<div class="ch-empty">No event types found.</div>';
1140
        } else {
1141
            $content .= '<div class="ch-groups" id="chChipGroups">';
1142
            $preferredOrder = ['Course', 'Session', 'User', 'Social', 'Message', 'Resource', 'Wiki', 'Webservice', 'Other'];
1143
            foreach ($preferredOrder as $label) {
1144
                if (empty($groups[$label])) {
1145
                    continue;
1146
                }
1147
1148
                $content .= '<div class="ch-group">';
1149
                $content .= '<div class="ch-group-title">'.htmlspecialchars($label, ENT_QUOTES, 'UTF-8').' ('.count($groups[$label]).')</div>';
1150
                $content .= '<div class="ch-chip-row">';
1151
1152
                foreach ($groups[$label] as $evt) {
1153
                    $evtEsc = htmlspecialchars($evt, ENT_QUOTES, 'UTF-8');
1154
                    $human = ucwords(str_replace('_', ' ', $evt));
1155
                    $humanEsc = htmlspecialchars($human, ENT_QUOTES, 'UTF-8');
1156
1157
                    $content .= '<a class="ch-chip" data-chip-text="'.$evtEsc.' '.$humanEsc.'" href="'.$linkBase.$evtEsc.'" title="'.$evtEsc.'">'
1158
                        .$humanEsc.' <span class="ch-chip-code">'.$evtEsc.'</span></a>';
1159
                }
1160
1161
                $content .= '</div></div>';
1162
            }
1163
1164
            $content .= '</div>';
1165
            $content .= '
1166
        <script>
1167
            (function () {
1168
                var input = document.getElementById("chChipFilter");
1169
                var container = document.getElementById("chChipGroups");
1170
                if (!input || !container) { return; }
1171
1172
                input.addEventListener("input", function () {
1173
                    var q = (input.value || "").toLowerCase().trim();
1174
                    var chips = container.querySelectorAll(".ch-chip");
1175
1176
                    chips.forEach(function (chip) {
1177
                        var t = (chip.getAttribute("data-chip-text") || "").toLowerCase();
1178
                        chip.style.display = (!q || t.indexOf(q) !== -1) ? "" : "none";
1179
                    });
1180
                    var groups = container.querySelectorAll(".ch-group");
1181
                    groups.forEach(function (g) {
1182
                        var visible = g.querySelectorAll(".ch-chip:not([style*=none])").length > 0;
1183
                        g.style.display = visible ? "" : "none";
1184
                    });
1185
                });
1186
            })();
1187
        </script>
1188
        ';
1189
        }
1190
1191
        $content .= '</div>';
1192
1193
        return $content;
1194
    }
1195
1196
    /**
1197
     * Shows statistics about the time of last visit to each course.
1198
     * @throws Exception
1199
     */
1200
    public static function printCourseLastVisit(): string
1201
    {
1202
        $access_url_rel_course_table = Database::get_main_table(TABLE_MAIN_ACCESS_URL_REL_COURSE);
1203
        $columns[0] = 'c_id';
0 ignored issues
show
Comprehensibility Best Practice introduced by
$columns was never initialized. Although not strictly required by PHP, it is generally a good practice to add $columns = array(); before regardless.
Loading history...
1204
        $columns[1] = 'access_date';
1205
        $sql_order[SORT_ASC] = 'ASC';
0 ignored issues
show
Comprehensibility Best Practice introduced by
$sql_order was never initialized. Although not strictly required by PHP, it is generally a good practice to add $sql_order = array(); before regardless.
Loading history...
1206
        $sql_order[SORT_DESC] = 'DESC';
1207
        $per_page = isset($_GET['per_page']) ? intval($_GET['per_page']) : 10;
1208
        $page_nr = isset($_GET['page_nr']) ? intval($_GET['page_nr']) : 1;
1209
        $column = isset($_GET['column']) ? intval($_GET['column']) : 0;
1210
        $direction = isset($_GET['direction']) ? $_GET['direction'] : SORT_ASC;
1211
1212
        if (!in_array($direction, [SORT_ASC, SORT_DESC])) {
1213
            $direction = SORT_ASC;
1214
        }
1215
        $form = new FormValidator('courselastvisit', 'get');
1216
        $form->addElement('hidden', 'report', 'courselastvisit');
1217
        $form->addText('date_diff', get_lang('days'), true);
1218
        $form->addRule('date_diff', 'InvalidNumber', 'numeric');
1219
        $form->addButtonSearch(get_lang('Search'), 'submit');
1220
        if (!isset($_GET['date_diff'])) {
1221
            $defaults['date_diff'] = 60;
0 ignored issues
show
Comprehensibility Best Practice introduced by
$defaults was never initialized. Although not strictly required by PHP, it is generally a good practice to add $defaults = array(); before regardless.
Loading history...
1222
        } else {
1223
            $defaults['date_diff'] = Security::remove_XSS($_GET['date_diff']);
1224
        }
1225
        $form->setDefaults($defaults);
1226
        $content = $form->returnForm();
1227
1228
        $values = $form->exportValues();
1229
        $date_diff = $values['date_diff'];
1230
        $table = Database::get_main_table(TABLE_STATISTIC_TRACK_E_LASTACCESS);
1231
        $accessUrlUtil = Container::getAccessUrlUtil();
1232
1233
        if ($accessUrlUtil->isMultiple()) {
1234
            $accessUrl = $accessUrlUtil->getCurrent();
1235
            $urlId = $accessUrl->getId();
1236
            $sql = "SELECT * FROM $table t , $access_url_rel_course_table a
1237
                   WHERE
1238
                        c_id = a.c_id AND
1239
                        access_url_id = $urlId
1240
                   GROUP BY c_id
1241
                   HAVING c_id <> ''
1242
                   AND DATEDIFF( '".api_get_utc_datetime()."' , access_date ) <= ".$date_diff;
1243
        } else {
1244
            $sql = "SELECT * FROM $table t
1245
                   GROUP BY c_id
1246
                   HAVING c_id <> ''
1247
                   AND DATEDIFF( '".api_get_utc_datetime()."' , access_date ) <= ".$date_diff;
1248
        }
1249
        $sql .= ' ORDER BY `'.$columns[$column].'` '.$sql_order[$direction];
1250
        $from = ($page_nr - 1) * $per_page;
1251
        $sql .= ' LIMIT '.$from.','.$per_page;
1252
1253
        $content .= '<p>'.get_lang('Latest access').' &gt;= '.$date_diff.' '.get_lang('days').'</p>';
1254
        $res = Database::query($sql);
1255
        if (Database::num_rows($res) > 0) {
1256
            $courses = [];
1257
            while ($obj = Database::fetch_object($res)) {
1258
                $courseInfo = api_get_course_info_by_id($obj->c_id);
1259
                $course = [];
1260
                $course[] = '<a href="'.api_get_path(WEB_COURSE_PATH).$courseInfo['code'].'">'.$courseInfo['code'].' <a>';
1261
                // Allow sort by date hiding the numerical date
1262
                $course[] = '<span style="display:none;">'.$obj->access_date.'</span>'.api_convert_and_format_date($obj->access_date);
1263
                $courses[] = $course;
1264
            }
1265
            $parameters['date_diff'] = $date_diff;
0 ignored issues
show
Comprehensibility Best Practice introduced by
$parameters was never initialized. Although not strictly required by PHP, it is generally a good practice to add $parameters = array(); before regardless.
Loading history...
1266
            $parameters['report'] = 'courselastvisit';
1267
            $table_header[] = [get_lang("Course code"), true];
0 ignored issues
show
Comprehensibility Best Practice introduced by
$table_header was never initialized. Although not strictly required by PHP, it is generally a good practice to add $table_header = array(); before regardless.
Loading history...
1268
            $table_header[] = [get_lang("Latest access"), true];
1269
1270
            ob_start();
1271
            Display:: display_sortable_table(
1272
                $table_header,
1273
                $courses,
1274
                ['column' => $column, 'direction' => $direction],
1275
                [],
1276
                $parameters
1277
            );
1278
            $content .= ob_get_contents();
1279
            ob_end_clean();
1280
        } else {
1281
            $content = get_lang('No search results');
1282
        }
1283
1284
        return $content;
1285
    }
1286
1287
    /**
1288
     * Displays the statistics of the messages sent and received by each user in the social network.
1289
     *
1290
     * @param string $messageType Type of message: 'sent' or 'received'
1291
     *
1292
     * @return array Message list
1293
     */
1294
    public static function getMessages(string $messageType): array
1295
    {
1296
        $messageTable = Database::get_main_table(TABLE_MESSAGE);
1297
        $messageRelUserTable = Database::get_main_table(TABLE_MESSAGE_REL_USER);
1298
        $userTable = Database::get_main_table(TABLE_MAIN_USER);
1299
        $accessUrlRelUserTable = Database::get_main_table(TABLE_MAIN_ACCESS_URL_REL_USER);
1300
1301
        switch ($messageType) {
1302
            case 'sent':
1303
                $field = 'm.user_sender_id';
1304
                $joinCondition = "m.id = mru.message_id AND mru.receiver_type = " . MessageRelUser::TYPE_SENDER;
1305
                break;
1306
            case 'received':
1307
                $field = 'mru.user_id';
1308
                $joinCondition = "m.id = mru.message_id AND mru.receiver_type = " . MessageRelUser::TYPE_TO;
1309
                break;
1310
        }
1311
1312
        $accessUrlUtil = Container::getAccessUrlUtil();
1313
1314
        if ($accessUrlUtil->isMultiple()) {
1315
            $accessUrl = $accessUrlUtil->getCurrent();
1316
            $urlId = $accessUrl->getId();
1317
            $sql = "SELECT u.lastname, u.firstname, u.username, COUNT(DISTINCT m.id) AS count_message
1318
            FROM $messageTable m
1319
            INNER JOIN $messageRelUserTable mru ON $joinCondition
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $joinCondition does not seem to be defined for all execution paths leading up to this point.
Loading history...
1320
            INNER JOIN $userTable u ON $field = u.id
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $field does not seem to be defined for all execution paths leading up to this point.
Loading history...
1321
            INNER JOIN $accessUrlRelUserTable url ON u.id = url.user_id
1322
            WHERE url.access_url_id = $urlId
1323
            AND u.active <> " . USER_SOFT_DELETED . "
1324
            GROUP BY $field
1325
            ORDER BY count_message DESC";
1326
        } else {
1327
            $sql = "SELECT u.lastname, u.firstname, u.username, COUNT(DISTINCT m.id) AS count_message
1328
            FROM $messageTable m
1329
            INNER JOIN $messageRelUserTable mru ON $joinCondition
1330
            INNER JOIN $userTable u ON $field = u.id
1331
            WHERE u.active <> " . USER_SOFT_DELETED . "
1332
            GROUP BY $field
1333
            ORDER BY count_message DESC";
1334
        }
1335
        $res = Database::query($sql);
1336
        $messages_sent = [];
1337
        while ($messages = Database::fetch_array($res)) {
1338
            if (empty($messages['username'])) {
1339
                $messages['username'] = get_lang('Unknown');
1340
            }
1341
            $users = api_get_person_name(
1342
                    $messages['firstname'],
1343
                    $messages['lastname']
1344
                ) . '<br />(' . $messages['username'] . ')';
1345
            $messages_sent[$users] = $messages['count_message'];
1346
        }
1347
1348
        return $messages_sent;
1349
    }
1350
1351
    /**
1352
     * Count the number of friends for each social network users.
1353
     * @throws Exception
1354
     */
1355
    public static function getFriends(): array
1356
    {
1357
        $user_friend_table = Database::get_main_table(TABLE_MAIN_USER_REL_USER);
1358
        $user_table = Database::get_main_table(TABLE_MAIN_USER);
1359
        $access_url_rel_user_table = Database::get_main_table(TABLE_MAIN_ACCESS_URL_REL_USER);
1360
1361
        $accessUrlUtil = Container::getAccessUrlUtil();
1362
1363
        if ($accessUrlUtil->isMultiple()) {
1364
            $accessUrl = $accessUrlUtil->getCurrent();
1365
            $urlId = $accessUrl->getId();
1366
            $sql = "SELECT lastname, firstname, username, COUNT(friend_user_id) AS count_friend
1367
                    FROM $access_url_rel_user_table as url, $user_friend_table uf
1368
                    LEFT JOIN $user_table u
1369
                    ON (uf.user_id = u.id) AND u.active <> ".USER_SOFT_DELETED."
1370
                    WHERE
1371
                        uf.relation_type <> '".UserRelUser::USER_RELATION_TYPE_RRHH."' AND
1372
                        uf.user_id = url.user_id AND
1373
                        access_url_id = $urlId
1374
                    GROUP BY uf.user_id
1375
                    ORDER BY count_friend DESC ";
1376
        } else {
1377
            $sql = "SELECT lastname, firstname, username, COUNT(friend_user_id) AS count_friend
1378
                    FROM $user_friend_table uf
1379
                    LEFT JOIN $user_table u
1380
                    ON (uf.user_id = u.id) AND u.active <> ".USER_SOFT_DELETED."
1381
                    WHERE uf.relation_type <> '".UserRelUser::USER_RELATION_TYPE_RRHH."'
1382
                    GROUP BY uf.user_id
1383
                    ORDER BY count_friend DESC ";
1384
        }
1385
        $res = Database::query($sql);
1386
        $list_friends = [];
1387
        while ($friends = Database::fetch_array($res)) {
1388
            $users = api_get_person_name($friends['firstname'], $friends['lastname']).'<br />('.$friends['username'].')';
1389
            $list_friends[$users] = $friends['count_friend'];
1390
        }
1391
1392
        return $list_friends;
1393
    }
1394
1395
    /**
1396
     * Returns the number of users that didn't log in for a certain period of time.
1397
     * @throws Exception
1398
     */
1399
    public static function printUsersNotLoggedInStats(): string
1400
    {
1401
        $totalLogin = [];
1402
        $table = Database::get_main_table(TABLE_STATISTIC_TRACK_E_LOGIN);
1403
        $access_url_rel_user_table = Database::get_main_table(TABLE_MAIN_ACCESS_URL_REL_USER);
1404
        $total = self::countUsers();
1405
        $accessUrlUtil = Container::getAccessUrlUtil();
1406
1407
        if ($accessUrlUtil->isMultiple()) {
1408
            $accessUrl = $accessUrlUtil->getCurrent();
1409
            $urlId = $accessUrl->getId();
1410
            $table_url = ", $access_url_rel_user_table";
1411
            $where_url = " AND login_user_id=user_id AND access_url_id = $urlId";
1412
        } else {
1413
            $table_url = '';
1414
            $where_url = '';
1415
        }
1416
        $now = api_get_utc_datetime();
1417
        $sql[get_lang('This day')] =
0 ignored issues
show
Comprehensibility Best Practice introduced by
$sql was never initialized. Although not strictly required by PHP, it is generally a good practice to add $sql = array(); before regardless.
Loading history...
1418
            "SELECT count(distinct(login_user_id)) AS number ".
1419
            " FROM $table $table_url ".
1420
            " WHERE DATE_ADD(login_date, INTERVAL 1 DAY) >= '$now' $where_url";
1421
        $sql[get_lang('In the last 7 days')] =
1422
            "SELECT count(distinct(login_user_id)) AS number ".
1423
            " FROM $table $table_url ".
1424
            " WHERE DATE_ADD(login_date, INTERVAL 7 DAY) >= '$now' $where_url";
1425
        $sql[get_lang('In the last 31 days')] =
1426
            "SELECT count(distinct(login_user_id)) AS number ".
1427
            " FROM $table $table_url ".
1428
            " WHERE DATE_ADD(login_date, INTERVAL 31 DAY) >= '$now' $where_url";
1429
        $sql[sprintf(get_lang('Last %d months'), 6)] =
1430
            "SELECT count(distinct(login_user_id)) AS number ".
1431
            " FROM $table $table_url ".
1432
            " WHERE DATE_ADD(login_date, INTERVAL 6 MONTH) >= '$now' $where_url";
1433
        $sql[get_lang('Never connected')] =
1434
            "SELECT count(distinct(login_user_id)) AS number ".
1435
            " FROM $table $table_url WHERE 1=1 $where_url";
1436
        foreach ($sql as $index => $query) {
1437
            $res = Database::query($query);
1438
            $obj = Database::fetch_object($res);
1439
            $r = $total - $obj->number;
1440
            $totalLogin[$index] = $r < 0 ? 0 : $r;
1441
        }
1442
1443
        return self::printStats(
1444
            get_lang('Not logged in for some time'),
1445
            $totalLogin,
1446
            false
1447
        );
1448
    }
1449
1450
    /**
1451
     * Returns an array with indexes as the 'yyyy-mm-dd' format of each date
1452
     * within the provided range (including limits). Dates are assumed to be
1453
     * given in UTC.
1454
     *
1455
     * @param string $startDate  Start date, in Y-m-d or Y-m-d h:i:s format
1456
     * @param string $endDate    End date, in Y-m-d or Y-m-d h:i:s format
1457
     * @param ?bool   $removeYear Whether to remove the year in the results (for easier reading)
1458
     *
1459
     * @return array|bool False on error in the params, array of [date1 => 0, date2 => 0, ...] otherwise
1460
     */
1461
    public static function buildDatesArray(string $startDate, string $endDate, ?bool $removeYear = false): mixed
1462
    {
1463
        if (strlen($startDate) > 10) {
1464
            $startDate = substr($startDate, 0, 10);
1465
        }
1466
        if (strlen($endDate) > 10) {
1467
            $endDate = substr($endDate, 0, 10);
1468
        }
1469
        if (!preg_match('/\d\d\d\d-\d\d-\d\d/', $startDate)) {
1470
            return false;
1471
        }
1472
        if (!preg_match('/\d\d\d\d-\d\d-\d\d/', $startDate)) {
1473
            return false;
1474
        }
1475
        $startTimestamp = strtotime($startDate);
1476
        $endTimestamp = strtotime($endDate);
1477
        $list = [];
1478
        for ($time = $startTimestamp; $time < $endTimestamp; $time += 86400) {
1479
            $datetime = api_get_utc_datetime($time);
1480
            if ($removeYear) {
1481
                $datetime = substr($datetime, 5, 5);
1482
            } else {
1483
                $dateTime = substr($datetime, 0, 10);
1484
            }
1485
            $list[$datetime] = 0;
1486
        }
1487
1488
        return $list;
1489
    }
1490
1491
    /**
1492
     * Builds a Chart.js chart from an Ajax endpoint that returns JSON chart data.
1493
     *
1494
     * Expected JSON format:
1495
     * {
1496
     *   "labels": [...],
1497
     *   "datasets": [...]
1498
     * }
1499
     */
1500
    public static function getJSChartTemplate(
1501
        string $url,
1502
        string $type,
1503
        string $options = '',
1504
        string $canvasId = 'canvas',
1505
        bool $fullSize = false,
1506
        string $onClickHandler = '',
1507
        string $afterInitJs = '',
1508
        array $dimensions = []
1509
    ): string {
1510
        $urlJs = json_encode($url, JSON_UNESCAPED_SLASHES);
1511
        $typeJs = json_encode($type, JSON_UNESCAPED_SLASHES);
1512
        $canvasIdJs = json_encode($canvasId, JSON_UNESCAPED_SLASHES);
1513
1514
        $typeLower = strtolower(trim($type));
1515
        $isCircular = in_array($typeLower, ['pie', 'doughnut', 'polararea'], true);
1516
1517
        // For circular charts, default to a centered medium size (60%) unless overridden.
1518
        $circularScale = isset($dimensions['circular_scale']) ? (float) $dimensions['circular_scale'] : 0.5;
1519
        if ($circularScale <= 0) {
1520
            $circularScale = 0.5;
1521
        }
1522
        if ($circularScale > 1) {
1523
            $circularScale = 1.0;
1524
        }
1525
1526
        // Build options:
1527
        // - For non-circular charts, when fullSize=true, disable aspect ratio so it fills the wrapper.
1528
        // - For circular charts, keep aspect ratio to avoid massive charts on tall wrappers.
1529
        $options = trim($options);
1530
        if ($isCircular) {
1531
            $baseOptions = 'responsive: true, maintainAspectRatio: true,';
1532
        } else {
1533
            $baseOptions = $fullSize
1534
                ? 'responsive: true, maintainAspectRatio: false,'
1535
                : 'responsive: true,';
1536
        }
1537
1538
        $finalOptions = trim($baseOptions.' '.$options);
1539
        $finalOptions = rtrim($finalOptions, ", \n\r\t");
1540
        $optionsJs = '{'.$finalOptions.'}';
1541
1542
        $w = isset($dimensions['width']) ? (int) $dimensions['width'] : 0;
1543
        $h = isset($dimensions['height']) ? (int) $dimensions['height'] : 0;
1544
1545
        $applyDimensionsJs = '';
1546
        if ($w > 0 || $h > 0) {
1547
            $applyDimensionsJs .= '
1548
            var el = document.getElementById(canvasId);
1549
            if (el) {'.($w > 0 ? ' el.width = '.$w.';' : '').($h > 0 ? ' el.height = '.$h.';' : '').'}
1550
        ';
1551
        }
1552
1553
        // Apply a default centered medium size for circular charts (unless explicit width/height provided).
1554
        $applyCircularSizingJs = '';
1555
        if ($isCircular && $w <= 0 && $h <= 0 && $circularScale < 1.0) {
1556
            $percent = (int) round($circularScale * 100);
1557
            $applyCircularSizingJs = '
1558
            // Center and scale down circular charts by default.
1559
            ctxEl.style.display = "block";
1560
            ctxEl.style.marginLeft = "auto";
1561
            ctxEl.style.marginRight = "auto";
1562
            ctxEl.style.width = "'.$percent.'%";
1563
            ctxEl.style.maxWidth = "'.$percent.'%";
1564
            ctxEl.style.height = "auto";
1565
        ';
1566
        } elseif ($isCircular) {
1567
            // Always center circular charts even if scale is 100% or dimensions were provided.
1568
            $applyCircularSizingJs = '
1569
            ctxEl.style.display = "block";
1570
            ctxEl.style.marginLeft = "auto";
1571
            ctxEl.style.marginRight = "auto";
1572
        ';
1573
        }
1574
1575
        $bindOnClickJs = '';
1576
        if (!empty(trim($onClickHandler))) {
1577
            // "chart" variable is available in the handler
1578
            $bindOnClickJs = '
1579
            options.onClick = function(evt) {
1580
                '.$onClickHandler.'
1581
            };
1582
        ';
1583
        }
1584
1585
        $afterInitJs = trim($afterInitJs);
1586
        $afterInitBlock = $afterInitJs !== '' ? $afterInitJs : '';
1587
1588
        return <<<JS
1589
        <script>
1590
            $(function () {
1591
                var url = $urlJs;
1592
                var canvasId = $canvasIdJs;
1593
                var ctxEl = document.getElementById(canvasId);
1594
1595
                if (!ctxEl) {
1596
                    // Canvas not found: nothing to render.
1597
                    return;
1598
                }
1599
1600
                $applyCircularSizingJs
1601
                $applyDimensionsJs
1602
1603
                var ctx = ctxEl.getContext('2d');
1604
                var chart = null;
1605
1606
                $.ajax({
1607
                    url: url,
1608
                    dataType: "json"
1609
                }).done(function (payload) {
1610
                    var data = payload;
1611
1612
                    // If backend returns a JSON string, parse it.
1613
                    if (typeof data === "string") {
1614
                        try { data = JSON.parse(data); } catch (e) { data = null; }
1615
                    }
1616
1617
                    if (!data || !data.labels || !data.datasets) {
1618
                        // Invalid dataset: do not crash the page.
1619
                        return;
1620
                    }
1621
1622
                    var options = $optionsJs;
1623
1624
                    $bindOnClickJs
1625
1626
                    chart = new Chart(ctx, {
1627
                        type: $typeJs,
1628
                        data: data,
1629
                        options: options
1630
                    });
1631
1632
                    $afterInitBlock
1633
                });
1634
            });
1635
        </script>
1636
        JS;
1637
    }
1638
1639
    /**
1640
     * Builds a Chart.js chart from a PHP array (no Ajax call).
1641
     */
1642
    public static function getJSChartTemplateWithData(
1643
        array $chartData,
1644
        string $type,
1645
        string $options = '',
1646
        string $canvasId = 'canvas',
1647
        bool $fullSize = false,
1648
        string $onClickHandler = '',
1649
        string $afterInitJs = '',
1650
        array $dimensions = []
1651
    ): string {
1652
        $dataJs = json_encode($chartData, JSON_UNESCAPED_SLASHES);
1653
        $typeJs = json_encode($type, JSON_UNESCAPED_SLASHES);
1654
        $canvasIdJs = json_encode($canvasId, JSON_UNESCAPED_SLASHES);
1655
1656
        $typeLower = strtolower(trim($type));
1657
        $isCircular = in_array($typeLower, ['pie', 'doughnut', 'polararea'], true);
1658
1659
        // For circular charts, default to a centered medium size (60%) unless overridden.
1660
        $circularScale = isset($dimensions['circular_scale']) ? (float) $dimensions['circular_scale'] : 0.5;
1661
        if ($circularScale <= 0) {
1662
            $circularScale = 0.5;
1663
        }
1664
        if ($circularScale > 1) {
1665
            $circularScale = 1.0;
1666
        }
1667
1668
        $options = trim($options);
1669
        if ($isCircular) {
1670
            $baseOptions = 'responsive: true, maintainAspectRatio: true,';
1671
        } else {
1672
            $baseOptions = $fullSize
1673
                ? 'responsive: true, maintainAspectRatio: false,'
1674
                : 'responsive: true,';
1675
        }
1676
1677
        $finalOptions = trim($baseOptions.' '.$options);
1678
        $finalOptions = rtrim($finalOptions, ", \n\r\t");
1679
        $optionsJs = '{'.$finalOptions.'}';
1680
1681
        $w = isset($dimensions['width']) ? (int) $dimensions['width'] : 0;
1682
        $h = isset($dimensions['height']) ? (int) $dimensions['height'] : 0;
1683
1684
        $applyDimensionsJs = '';
1685
        if ($w > 0 || $h > 0) {
1686
            $applyDimensionsJs .= '
1687
            var el = document.getElementById(canvasId);
1688
            if (el) {'.($w > 0 ? ' el.width = '.$w.';' : '').($h > 0 ? ' el.height = '.$h.';' : '').'}
1689
        ';
1690
        }
1691
1692
        $applyCircularSizingJs = '';
1693
        if ($isCircular && $w <= 0 && $h <= 0 && $circularScale < 1.0) {
1694
            $percent = (int) round($circularScale * 100);
1695
            $applyCircularSizingJs = '
1696
            // Center and scale down circular charts by default.
1697
            ctxEl.style.display = "block";
1698
            ctxEl.style.marginLeft = "auto";
1699
            ctxEl.style.marginRight = "auto";
1700
            ctxEl.style.width = "'.$percent.'%";
1701
            ctxEl.style.maxWidth = "'.$percent.'%";
1702
            ctxEl.style.height = "auto";
1703
        ';
1704
        } elseif ($isCircular) {
1705
            $applyCircularSizingJs = '
1706
            ctxEl.style.display = "block";
1707
            ctxEl.style.marginLeft = "auto";
1708
            ctxEl.style.marginRight = "auto";
1709
        ';
1710
        }
1711
1712
        $bindOnClickJs = '';
1713
        if (!empty(trim($onClickHandler))) {
1714
            $bindOnClickJs = '
1715
            options.onClick = function(evt) {
1716
                '.$onClickHandler.'
1717
            };
1718
        ';
1719
        }
1720
1721
        $afterInitJs = trim($afterInitJs);
1722
        $afterInitBlock = $afterInitJs !== '' ? $afterInitJs : '';
1723
1724
        return <<<JS
1725
            <script>
1726
                $(function () {
1727
                    var canvasId = $canvasIdJs;
1728
                    var ctxEl = document.getElementById(canvasId);
1729
1730
                    if (!ctxEl) {
1731
                        return;
1732
                    }
1733
1734
                    $applyCircularSizingJs
1735
                    $applyDimensionsJs
1736
1737
                    var ctx = ctxEl.getContext('2d');
1738
                    var chart = null;
1739
1740
                    var data = $dataJs;
1741
                    var options = $optionsJs;
1742
1743
                    $bindOnClickJs
1744
1745
                    chart = new Chart(ctx, {
1746
                        type: $typeJs,
1747
                        data: data,
1748
                        options: options
1749
                    });
1750
1751
                    $afterInitBlock
1752
                });
1753
            </script>
1754
            JS;
1755
    }
1756
1757
    public static function buildJsChartData(array $all, string $chartName): array
1758
    {
1759
        $list = [];
1760
        $palette = ChamiloHelper::getColorPalette(true, true);
1761
        foreach ($all as $tick => $tock) {
1762
            $list['labels'][] = $tick;
1763
        }
1764
1765
        $list['datasets'][0]['label'] = $chartName;
1766
        $list['datasets'][0]['borderColor'] = 'rgba(255,255,255,1)';
1767
1768
        $i = 0;
1769
        foreach ($all as $tick => $tock) {
1770
            $j = $i % count($palette);
1771
            $list['datasets'][0]['data'][] = $tock;
1772
            $list['datasets'][0]['backgroundColor'][] = $palette[$j];
1773
            $i++;
1774
        }
1775
1776
        $scoreDisplay = ScoreDisplay::instance();
1777
        $table = new HTML_Table(['class' => 'data_table stats_table']);
1778
        $headers = [
1779
            get_lang('Name'),
1780
            get_lang('Count'),
1781
            get_lang('Percentage'),
1782
        ];
1783
        $row = 0;
1784
        $column = 0;
1785
        foreach ($headers as $header) {
1786
            $table->setHeaderContents($row, $column, $header);
1787
            $column++;
1788
        }
1789
1790
        $total = 0;
1791
        foreach ($all as $name => $value) {
1792
            $total += $value;
1793
        }
1794
        $row++;
1795
        foreach ($all as $name => $value) {
1796
            $table->setCellContents($row, 0, $name);
1797
            $table->setCellContents($row, 1, $value);
1798
            $table->setCellContents($row, 2, $scoreDisplay->display_score([$value, $total], SCORE_PERCENT));
1799
            $row++;
1800
        }
1801
        $table = Display::page_subheader2($chartName).$table->toHtml();
1802
1803
        return ['chart' => $list, 'table' => $table];
1804
    }
1805
1806
    /**
1807
     * Display the Logins By Date report and allow export its result to XLS.
1808
     */
1809
    public static function printLoginsByDate(): mixed
1810
    {
1811
        if (isset($_GET['export']) && 'xls' === $_GET['export']) {
1812
            $result = self::getLoginsByDate($_GET['start'], $_GET['end']);
1813
            $data = [[get_lang('Username'), get_lang('First name'), get_lang('Last name'), get_lang('Total time')]];
1814
1815
            foreach ($result as $i => $item) {
1816
                $data[] = [
1817
                    $item['username'],
1818
                    $item['firstname'],
1819
                    $item['lastname'],
1820
                    api_time_to_hms($item['time_count']),
1821
                ];
1822
            }
1823
1824
            Export::arrayToXls($data);
1825
            exit;
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
1826
        }
1827
1828
        $content = Display::page_header(get_lang('Logins by date'));
1829
1830
        $actions = '';
1831
        $form = new FormValidator('frm_logins_by_date', 'get');
1832
        $form->addDateRangePicker(
1833
            'daterange',
1834
            get_lang('Date range'),
1835
            true,
1836
            ['format' => 'YYYY-MM-DD', 'timePicker' => 'false', 'validate_format' => 'Y-m-d']
1837
        );
1838
        $form->addHidden('report', 'logins_by_date');
1839
        $form->addButtonFilter(get_lang('Search'));
1840
1841
        if ($form->validate()) {
1842
            $values = $form->exportValues();
1843
1844
            $result = self::getLoginsByDate($values['daterange_start'], $values['daterange_end']);
1845
1846
            if (!empty($result)) {
1847
                $actions = Display::url(
1848
                    Display::getMdiIcon(ActionIcon::EXPORT_SPREADSHEET, 'ch-tool-icon', null, ICON_SIZE_MEDIUM, get_lang('Export to XLS')),
1849
                    api_get_self().'?'.http_build_query(
1850
                        [
1851
                            'report' => 'logins_by_date',
1852
                            'export' => 'xls',
1853
                            'start' => Security::remove_XSS($values['daterange_start']),
1854
                            'end' => Security::remove_XSS($values['daterange_end']),
1855
                        ]
1856
                    )
1857
                );
1858
            }
1859
1860
            $table = new HTML_Table(['class' => 'data_table stats_table']);
1861
            $table->setHeaderContents(0, 0, get_lang('Username'));
1862
            $table->setHeaderContents(0, 1, get_lang('First name'));
1863
            $table->setHeaderContents(0, 2, get_lang('Last name'));
1864
            $table->setHeaderContents(0, 3, get_lang('Total time'));
1865
1866
            foreach ($result as $i => $item) {
1867
                $table->setCellContents($i + 1, 0, $item['username']);
1868
                $table->setCellContents($i + 1, 1, $item['firstname']);
1869
                $table->setCellContents($i + 1, 2, $item['lastname']);
1870
                $table->setCellContents($i + 1, 3, api_time_to_hms($item['time_count']));
1871
            }
1872
1873
            $table->setColAttributes(0, ['class' => 'text-center']);
1874
            $table->setColAttributes(3, ['class' => 'text-center']);
1875
            $content = $table->toHtml();
1876
        }
1877
1878
        $content .= $form->returnForm();
1879
1880
        if (!empty($actions)) {
1881
            $content .= Display::toolbarAction('logins_by_date_toolbar', [$actions]);
1882
        }
1883
1884
        return $content;
1885
    }
1886
1887
    /**
1888
     * Return HTML table for the student boss role, for the given user ID
1889
     * @param int $bossId
1890
     * @return string
1891
     */
1892
    public static function getBossTable(int $bossId): string
1893
    {
1894
        $students = UserManager::getUsersFollowedByStudentBoss(
1895
            $bossId,
1896
            0,
1897
            false,
1898
            false,
1899
            false,
1900
            null,
1901
            null,
1902
            null,
1903
            null,
1904
            1
1905
        );
1906
1907
        if (!empty($students)) {
1908
            $table = new HTML_Table(['class' => 'table table-responsive', 'id' => 'table_'.$bossId]);
1909
            $headers = [
1910
                get_lang('Name'),
1911
            ];
1912
            $row = 0;
1913
            $column = 0;
1914
            foreach ($headers as $header) {
1915
                $table->setHeaderContents($row, $column, $header);
1916
                $column++;
1917
            }
1918
            $row++;
1919
            foreach ($students as $student) {
1920
                $column = 0;
1921
                $content = api_get_person_name($student['firstname'], $student['lastname']);
1922
                $content = '<div style="width: 200px; overflow-wrap: break-word;">'.$content.'</div>';
1923
                $table->setCellContents(
1924
                    $row,
1925
                    $column++,
1926
                    $content
1927
                );
1928
                $row++;
1929
            }
1930
1931
            return $table->toHtml();
1932
        }
1933
1934
        return '<table id="table_'.$bossId.'"></table>';
1935
    }
1936
1937
    /**
1938
     * @param string $startDate
1939
     * @param string $endDate
1940
     *
1941
     * @return array
1942
     * @throws Exception
1943
     */
1944
    public static function getLoginsByDate(string $startDate, string $endDate): array
1945
    {
1946
        $startDate = api_get_utc_datetime("$startDate 00:00:00");
1947
        $endDate = api_get_utc_datetime("$endDate 23:59:59");
1948
1949
        if (empty($startDate) || empty($endDate)) {
1950
            return [];
1951
        }
1952
1953
        $tblUser = Database::get_main_table(TABLE_MAIN_USER);
1954
        $tblLogin = Database::get_main_table(TABLE_STATISTIC_TRACK_E_LOGIN);
1955
        $urlJoin = '';
1956
        $urlWhere = '';
1957
1958
        $accessUrlUtil = Container::getAccessUrlUtil();
1959
1960
        if ($accessUrlUtil->isMultiple()) {
1961
            $accessUrl = $accessUrlUtil->getCurrent();
1962
            $urlId = $accessUrl->getId();
1963
            $tblUrlUser = Database::get_main_table(TABLE_MAIN_ACCESS_URL_REL_USER);
1964
1965
            $urlJoin = "INNER JOIN $tblUrlUser au ON u.id = au.user_id";
1966
            $urlWhere = "AND au.access_url_id = $urlId";
1967
        }
1968
1969
        $sql = "SELECT u.id,
1970
                    u.firstname,
1971
                    u.lastname,
1972
                    u.username,
1973
                    SUM(TIMESTAMPDIFF(SECOND, l.login_date, l.logout_date)) AS time_count
1974
                FROM $tblUser u
1975
                INNER JOIN $tblLogin l
1976
                ON u.id = l.login_user_id
1977
                $urlJoin
1978
                WHERE u.active <> ".USER_SOFT_DELETED." AND l.login_date BETWEEN '$startDate' AND '$endDate'
1979
                $urlWhere
1980
                GROUP BY u.id";
1981
1982
        $stmt = Database::query($sql);
1983
1984
        return Database::store_result($stmt, 'ASSOC');
1985
    }
1986
1987
    /**
1988
     * Gets the number of new users registered between two dates.
1989
     * @throws Exception
1990
     */
1991
    public static function getNewUserRegistrations(string $startDate, string $endDate): array
1992
    {
1993
        $sql = "SELECT DATE_FORMAT(created_at, '%Y-%m-%d') as reg_date, COUNT(*) as user_count
1994
            FROM user
1995
            WHERE created_at BETWEEN '$startDate' AND '$endDate'
1996
            GROUP BY reg_date";
1997
1998
        $result = Database::query($sql);
1999
        $data = [];
2000
        while ($row = Database::fetch_array($result)) {
2001
            $userCount = is_numeric($row['user_count']) ? (int) $row['user_count'] : 0;
2002
            $data[] = ['date' => $row['reg_date'], 'count' => $userCount];
2003
        }
2004
2005
        return $data;
2006
    }
2007
2008
    /**
2009
     * Gets the number of users registered by creator (creator_id) between two dates.
2010
     * @throws Exception
2011
     */
2012
    public static function getUserRegistrationsByCreator(string $startDate, string $endDate): array
2013
    {
2014
        $sql = "SELECT u.creator_id, COUNT(u.id) as user_count, c.firstname, c.lastname
2015
                FROM user u
2016
                LEFT JOIN user c ON u.creator_id = c.id
2017
                WHERE u.created_at BETWEEN '$startDate' AND '$endDate'
2018
                AND u.creator_id IS NOT NULL
2019
                GROUP BY u.creator_id";
2020
2021
        $result = Database::query($sql);
2022
        $data = [];
2023
        while ($row = Database::fetch_array($result)) {
2024
            $userCount = is_numeric($row['user_count']) ? (int) $row['user_count'] : 0;
2025
            $name = trim($row['firstname'] . ' ' . $row['lastname']);
2026
            if (!empty($name)) {
2027
                $data[] = [
2028
                    'name' => $name,
2029
                    'count' => $userCount
2030
                ];
2031
            }
2032
        }
2033
2034
        return $data;
2035
    }
2036
2037
    /**
2038
     * Initializes an array with dates between two given dates, setting each date's value to 0.
2039
     * @throws Exception
2040
     */
2041
    public static function initializeDateRangeArray(string $startDate, string $endDate): array
2042
    {
2043
        $dateRangeArray = [];
2044
        $currentDate = new DateTime($startDate);
2045
        $endDate = new DateTime($endDate);
2046
2047
        // Loop through the date range and initialize each date with 0
2048
        while ($currentDate <= $endDate) {
2049
            $formattedDate = $currentDate->format('Y-m-d');
2050
            $dateRangeArray[$formattedDate] = 0;
2051
            $currentDate->modify('+1 day');
2052
        }
2053
2054
        return $dateRangeArray;
2055
    }
2056
2057
    /**
2058
     * Checks if the difference between two dates is more than one month.
2059
     * @throws Exception
2060
     */
2061
    public static function isMoreThanAMonth(string $dateStart, string $dateEnd): bool
2062
    {
2063
        $startDate = new DateTime($dateStart);
2064
        $endDate = new DateTime($dateEnd);
2065
2066
        $diff = $startDate->diff($endDate);
2067
2068
        if ($diff->y >= 1) {
2069
            return true;
2070
        }
2071
2072
        if ($diff->m > 1) {
2073
            return true;
2074
        }
2075
2076
        if ($diff->m == 1) {
2077
            return $diff->d > 0;
2078
        }
2079
2080
        return false;
2081
    }
2082
2083
    /**
2084
     * Groups registration data by month.
2085
     * @throws Exception
2086
     */
2087
    public static function groupByMonth(array $registrations): array
2088
    {
2089
        $groupedData = [];
2090
2091
        foreach ($registrations as $registration) {
2092
            $monthYear = (new DateTime($registration['date']))->format('Y-m');
2093
            if (isset($groupedData[$monthYear])) {
2094
                $groupedData[$monthYear] += $registration['count'];
2095
            } else {
2096
                $groupedData[$monthYear] = $registration['count'];
2097
            }
2098
        }
2099
2100
        return $groupedData;
2101
    }
2102
2103
    /**
2104
     * Retrieves the available tools using the repository.
2105
     */
2106
    public static function getAvailableTools(): array
2107
    {
2108
        $em = Database::getManager();
2109
        $repo = $em->getRepository(ResourceLink::class);
2110
2111
        return $repo->getAvailableTools();
2112
    }
2113
2114
    /**
2115
     * Generates a report of tool usage based on the provided tool IDs.
2116
     */
2117
    public static function getToolUsageReportByTools(array $toolIds): array
2118
    {
2119
        $em = Database::getManager();
2120
        $repo = $em->getRepository(ResourceLink::class);
2121
2122
        return $repo->getToolUsageReportByTools($toolIds);
2123
    }
2124
2125
    /**
2126
     * Return de number of certificates generated.
2127
     * This function is resource intensive.
2128
     * @throws \Doctrine\DBAL\Exception
2129
     * @throws Exception
2130
     */
2131
    public static function countCertificatesByQuarter(string $dateFrom = null, string $dateUntil = null): int
2132
    {
2133
        $tableGradebookCertificate = Database::get_main_table(TABLE_MAIN_GRADEBOOK_CERTIFICATE);
2134
        $condition = "";
2135
        if (!empty($dateFrom) && !empty($dateUntil)) {
2136
            $dateFrom = api_get_utc_datetime("$dateFrom 00:00:00");
2137
            $dateUntil = api_get_utc_datetime("$dateUntil 23:59:59");
2138
            $condition = "WHERE (created_at BETWEEN '$dateFrom' AND '$dateUntil')";
2139
        } elseif (!empty($dateFrom)) {
2140
            $dateFrom = api_get_utc_datetime("$dateFrom 00:00:00");
2141
            $condition = "WHERE created_at >= '$dateFrom'";
2142
        } elseif (!empty($dateUntil)) {
2143
            $dateUntil = api_get_utc_datetime("$dateUntil 23:59:59");
2144
            $condition = "WHERE created_at <= '$dateUntil'";
2145
        }
2146
        $sql = "
2147
            SELECT count(*) AS count
2148
            FROM $tableGradebookCertificate
2149
            $condition
2150
        ";
2151
        $response = Database::query($sql);
2152
        $obj = Database::fetch_object($response);
2153
        return $obj->count;
2154
    }
2155
2156
    /**
2157
     * Get the number of logins by dates.
2158
     * This function is resource intensive.
2159
     * @throws Exception
2160
     */
2161
    public static function getSessionsByDuration(string $dateFrom, string $dateUntil): array
2162
    {
2163
        $results = [
2164
            '0' => 0,
2165
            '5' => 0,
2166
            '10' => 0,
2167
            '15' => 0,
2168
            '30' => 0,
2169
            '60' => 0,
2170
        ];
2171
        if (!empty($dateFrom) && !empty($dateUntil)) {
2172
            $table = Database::get_main_table(TABLE_STATISTIC_TRACK_E_LOGIN);
2173
            $accessUrlRelUserTable = Database::get_main_table(TABLE_MAIN_ACCESS_URL_REL_USER);
2174
            $urlId = api_get_current_access_url_id();
2175
            $tableUrl = '';
2176
            $whereUrl = '';
2177
            $dateFrom = api_get_utc_datetime("$dateFrom 00:00:00");
2178
            $dateUntil = api_get_utc_datetime("$dateUntil 23:59:59");
2179
            $accessUrlUtil = Container::getAccessUrlUtil();
2180
2181
            if ($accessUrlUtil->isMultiple()) {
2182
                $accessUrl = $accessUrlUtil->getCurrent();
2183
                $urlId = $accessUrl->getId();
2184
                $tableUrl = ", $accessUrlRelUserTable";
2185
                $whereUrl = " AND login_user_id = user_id AND access_url_id = $urlId";
2186
            }
2187
            $sql = "SELECT login_id, TIMESTAMPDIFF(SECOND, login_date, logout_date) AS duration
2188
            FROM $table $tableUrl
2189
            WHERE login_date >= '$dateFrom'
2190
            AND logout_date <= '$dateUntil'
2191
            $whereUrl
2192
            ";
2193
            $res = Database::query($sql);
2194
            while ($session = Database::fetch_array($res)) {
2195
                if ($session['duration'] > 3600) {
2196
                    $results['60']++;
2197
                } elseif ($session['duration'] > 1800) {
2198
                    $results['30']++;
2199
                } elseif ($session['duration'] > 900) {
2200
                    $results['15']++;
2201
                } elseif ($session['duration'] > 600) {
2202
                    $results['10']++;
2203
                } elseif ($session['duration'] > 300) {
2204
                    $results['5']++;
2205
                } else {
2206
                    $results['0']++;
2207
                }
2208
            }
2209
        }
2210
        return $results;
2211
    }
2212
2213
    /**
2214
     * Returns the number of user subscriptions grouped by day.
2215
     */
2216
    public static function getSubscriptionsByDay(string $startDate, string $endDate): array
2217
    {
2218
        $conn = Database::getManager()->getConnection();
2219
        $sql = "
2220
        SELECT DATE(default_date) AS date, COUNT(default_id) AS count
2221
        FROM track_e_default
2222
        WHERE default_event_type = :eventType
2223
        AND default_date BETWEEN :start AND :end
2224
        GROUP BY DATE(default_date)
2225
        ORDER BY DATE(default_date)
2226
    ";
2227
2228
        return $conn->executeQuery($sql, [
2229
            'eventType' => 'user_subscribed',
2230
            'start' => $startDate.' 00:00:00',
2231
            'end' => $endDate.' 23:59:59',
2232
        ])->fetchAllAssociative();
2233
    }
2234
2235
    /**
2236
     * Returns the number of user unsubscriptions grouped by day.
2237
     */
2238
    public static function getUnsubscriptionsByDay(string $startDate, string $endDate): array
2239
    {
2240
        $conn = Database::getManager()->getConnection();
2241
        $sql = "
2242
        SELECT DATE(default_date) AS date, COUNT(default_id) AS count
2243
        FROM track_e_default
2244
        WHERE default_event_type IN (:eventType1, :eventType2)
2245
        AND default_date BETWEEN :start AND :end
2246
        GROUP BY DATE(default_date)
2247
        ORDER BY DATE(default_date)
2248
    ";
2249
2250
        return $conn->executeQuery($sql, [
2251
            'eventType1' => 'user_unsubscribed',
2252
            'eventType2' => 'session_user_deleted',
2253
            'start' => $startDate.' 00:00:00',
2254
            'end' => $endDate.' 23:59:59',
2255
        ], [
2256
            'eventType1' => ParameterType::STRING,
2257
            'eventType2' => ParameterType::STRING,
2258
        ])->fetchAllAssociative();
2259
    }
2260
2261
    /**
2262
     * Users with activity in this course but not officially enrolled (optionally in a session).
2263
     */
2264
    public static function getNonRegisteredActiveUsersInCourse(int $courseId, int $sessionId = 0): array
2265
    {
2266
        $em = Database::getManager();
2267
        $conn = $em->getConnection();
2268
2269
        if ($sessionId > 0) {
2270
            // When working inside a session
2271
            $sql = '
2272
            SELECT
2273
                u.id AS id,
2274
                u.firstname AS firstname,
2275
                u.lastname AS lastname,
2276
                u.email AS email,
2277
                MAX(t.access_date) AS lastAccess
2278
            FROM track_e_access t
2279
            INNER JOIN user u ON u.id = t.access_user_id
2280
            LEFT JOIN session_rel_course_rel_user scru
2281
                ON scru.user_id = u.id
2282
                AND scru.c_id = :courseId
2283
                AND scru.session_id = :sessionId
2284
            WHERE
2285
                t.c_id = :courseId
2286
                AND t.session_id = :sessionId
2287
                AND scru.id IS NULL
2288
            GROUP BY
2289
                u.id, u.firstname, u.lastname, u.email
2290
            ORDER BY
2291
                lastAccess DESC
2292
        ';
2293
        } else {
2294
            // When not in session (regular course access)
2295
            $sql = '
2296
            SELECT
2297
                u.id AS id,
2298
                u.firstname AS firstname,
2299
                u.lastname AS lastname,
2300
                u.email AS email,
2301
                MAX(t.access_date) AS lastAccess
2302
            FROM track_e_access t
2303
            INNER JOIN user u ON u.id = t.access_user_id
2304
            LEFT JOIN course_rel_user cu
2305
                ON cu.user_id = u.id
2306
                AND cu.c_id = :courseId
2307
            WHERE
2308
                t.c_id = :courseId
2309
                AND cu.id IS NULL
2310
            GROUP BY
2311
                u.id, u.firstname, u.lastname, u.email
2312
            ORDER BY
2313
                lastAccess DESC
2314
        ';
2315
        }
2316
2317
        // Execute SQL safely
2318
        $stmt = $conn->prepare($sql);
2319
        $stmt->bindValue('courseId', $courseId);
2320
        if ($sessionId > 0) {
2321
            $stmt->bindValue('sessionId', $sessionId);
2322
        }
2323
2324
        $rows = $stmt->executeQuery()->fetchAllAssociative();
2325
2326
        // Format date results nicely
2327
        foreach ($rows as &$r) {
2328
            $r['lastAccess'] = !empty($r['lastAccess'])
2329
                ? (new \DateTime($r['lastAccess']))->format('Y-m-d H:i:s')
2330
                : '';
2331
        }
2332
2333
        return $rows;
2334
    }
2335
2336
    public static function statistics_render_menu(array $tools): string
2337
    {
2338
        if (empty($tools)) {
2339
            return '';
2340
        }
2341
2342
        $baseUrl = api_get_self();
2343
        $current = $_GET;
2344
        $cols = min(count($tools), 5);
2345
2346
        $parseQuery = static function (string $query): array {
2347
            $decoded = html_entity_decode($query, ENT_QUOTES);
2348
            $params = [];
2349
            parse_str($decoded, $params);
2350
2351
            return is_array($params) ? $params : [];
2352
        };
2353
2354
        $isActiveItem = static function (array $params) use ($current): bool {
2355
            if (empty($params)) {
2356
                return false;
2357
            }
2358
2359
            foreach ($params as $k => $v) {
2360
                if (!array_key_exists($k, $current)) {
2361
                    return false;
2362
                }
2363
                if ((string) $current[$k] !== (string) $v) {
2364
                    return false;
2365
                }
2366
            }
2367
2368
            return true;
2369
        };
2370
2371
        $buildUrl = static function (array $params) use ($baseUrl): string {
2372
            if (empty($params)) {
2373
                return $baseUrl;
2374
            }
2375
2376
            $query = http_build_query($params, '', '&', PHP_QUERY_RFC3986);
2377
2378
            return str_contains($baseUrl, '?')
2379
                ? ($baseUrl . '&' . $query)
2380
                : ($baseUrl . '?' . $query);
2381
        };
2382
2383
        $activeInfo = null;
2384
        $out = '<nav class="w-full">';
2385
        static $cssPrinted = false;
2386
        if (!$cssPrinted) {
2387
            $cssPrinted = true;
2388
2389
            $out .= '<style>
2390
            .stats-menu-wrap{ overflow-x:auto; padding-bottom:2px; }
2391
            .stats-menu-grid{
2392
              --stats-cols: 5;
2393
              display:grid;
2394
              gap:1rem;
2395
              align-items:start;
2396
              grid-template-columns: repeat(1, minmax(0, 1fr));
2397
            }
2398
            .p-inputtext, .p-select { width: auto !important; }
2399
            @media (min-width:640px){
2400
              .stats-menu-grid{ grid-template-columns: repeat(2, minmax(0, 1fr)); }
2401
            }
2402
            @media (min-width:768px){
2403
              .stats-menu-grid{ grid-template-columns: repeat(3, minmax(0, 1fr)); }
2404
            }
2405
            @media (min-width:1024px){
2406
              .stats-menu-grid{ grid-template-columns: repeat(var(--stats-cols), minmax(240px, 1fr)); }
2407
            }
2408
            </style>';
2409
        }
2410
2411
        $out .= '<div class="stats-menu-wrap">';
2412
        $out .= '<div class="stats-menu-grid" style="--stats-cols:' . (int) $cols . '">';
2413
2414
        foreach ($tools as $section => $items) {
2415
            $section = (string) $section;
2416
2417
            $sectionHasActive = false;
2418
            foreach ($items as $key => $_label) {
2419
                $params = $parseQuery((string) $key);
2420
                if ($isActiveItem($params)) {
2421
                    $sectionHasActive = true;
2422
                    break;
2423
                }
2424
            }
2425
2426
            $sectionCardClass = $sectionHasActive
2427
                ? 'border-primary/30 ring-1 ring-primary/20'
2428
                : 'border-surface-200';
2429
2430
            $dotClass = $sectionHasActive ? 'bg-primary' : 'bg-surface-300';
2431
2432
            $out .= '<section class="self-start h-fit min-w-0 rounded-2xl border ' . $sectionCardClass . ' bg-surface-50 p-4 shadow-sm">';
2433
            $out .= '  <h3 class="flex items-center gap-2 text-sm font-semibold text-surface-900">
2434
                <span class="h-2 w-2 rounded-full ' . $dotClass . '"></span>
2435
                ' . htmlspecialchars($section, ENT_QUOTES) . '
2436
            </h3>';
2437
2438
            $out .= '  <ul class="mt-3 space-y-1">';
2439
2440
            foreach ($items as $key => $label) {
2441
                $params = $parseQuery((string) $key);
2442
                $url = $buildUrl($params);
2443
                $active = $isActiveItem($params);
2444
2445
                if ($active) {
2446
                    $activeInfo = ['section' => $section, 'label' => (string) $label];
2447
                }
2448
2449
                $aClass = $active
2450
                    ? 'bg-primary/10 text-primary ring-1 ring-primary/25'
2451
                    : 'text-surface-700 hover:bg-surface-100 hover:text-surface-900';
2452
2453
                $itemDotClass = $active ? 'bg-primary' : 'bg-surface-300 group-hover:bg-primary/60';
2454
2455
                $out .= '<li>
2456
                <a href="' . htmlspecialchars($url, ENT_QUOTES) . '"
2457
                   ' . ($active ? 'aria-current="page"' : '') . '
2458
                   class="group flex items-start justify-between gap-3 rounded-xl px-3 py-2 text-sm font-medium transition
2459
                          focus:outline-none focus:ring-2 focus:ring-primary/30 ' . $aClass . '">
2460
                  <span class="flex items-start gap-2 min-w-0">
2461
                    <span class="mt-2 h-1.5 w-1.5 shrink-0 rounded-full ' . $itemDotClass . '"></span>
2462
                    <span class="leading-5 break-words">' . htmlspecialchars((string) $label, ENT_QUOTES) . '</span>
2463
                  </span>';
2464
2465
                if ($active) {
2466
                    $out .= '<span class="inline-flex items-center rounded-full bg-primary/15 px-2 py-0.5 text-xs font-semibold text-primary">'
2467
                        . htmlspecialchars(get_lang('Active'), ENT_QUOTES) .
2468
                        '</span>';
2469
                }
2470
2471
                $out .= '</a></li>';
2472
            }
2473
2474
            $out .= '  </ul>';
2475
            $out .= '</section>';
2476
        }
2477
2478
        $out .= '</div></div>';
2479
2480
        if (!empty($activeInfo)) {
2481
            $out .= '<div class="mt-4 flex flex-wrap items-center gap-2 text-sm text-surface-700">
2482
            <span class="font-semibold">' . htmlspecialchars(get_lang('You are here'), ENT_QUOTES) . ':</span>
2483
            <span class="inline-flex items-center rounded-full bg-primary/10 px-3 py-1 text-xs font-semibold text-primary">'
2484
                . htmlspecialchars($activeInfo['section'], ENT_QUOTES) . ' · ' . htmlspecialchars($activeInfo['label'], ENT_QUOTES) .
2485
                '</span>
2486
        </div>';
2487
        }
2488
2489
        $out .= '</nav>';
2490
        $out .= '<div class="my-6 h-px w-full bg-surface-200"></div>';
2491
2492
        return $out;
2493
    }
2494
}
2495