From cbbcff5ad78dbcb6cc495f846a87f318478db991 Mon Sep 17 00:00:00 2001 From: Christian Beeznest Date: Tue, 10 Feb 2026 13:04:34 -0500 Subject: [PATCH] Admin: Improvements to admin stats - refs #7505 --- public/main/admin/statistics/index.php | 510 +++++++++++++----- public/main/inc/lib/statistics.lib.php | 137 +++-- public/main/inc/lib/usermanager.lib.php | 113 ++-- .../inc/lib/zombie/zombie_report.class.php | 41 +- 4 files changed, 583 insertions(+), 218 deletions(-) diff --git a/public/main/admin/statistics/index.php b/public/main/admin/statistics/index.php index e08db94b90c..86c20e7d35a 100644 --- a/public/main/admin/statistics/index.php +++ b/public/main/admin/statistics/index.php @@ -20,12 +20,28 @@ $sessionDuration = isset($_GET['session_duration']) ? (int) $_GET['session_duration'] : ''; $validated = false; +$statusId = 0; if ( -in_array( - $report, - ['recentlogins', 'tools', 'courses', 'coursebylanguage', 'users', 'users_active', 'session_by_date', 'new_user_registrations'] -) + in_array( + $report, + ['recentlogins', 'tools', 'courses', 'coursebylanguage', 'users', 'users_active', 'session_by_date', 'new_user_registrations'] + ) ) { + $htmlHeadXtra[] = ''; $htmlHeadXtra[] = api_get_build_js('libs/chartjs/chart.js'); //$htmlHeadXtra[] = api_get_asset('chartjs-plugin-labels/build/chartjs-plugin-labels.min.js'); // Prepare variables for the JS charts @@ -96,7 +112,7 @@ } '; - $reportOptions = ' + $reportOptions = ' legend: { position: "left" }, @@ -332,29 +348,47 @@ } '; - $validated = $form->validate() || isset($_REQUEST['range']); + $validated = $form->validate() + || isset($_REQUEST['range']) + || isset($_REQUEST['range_start']) + || isset($_REQUEST['range_end']); + if ($validated) { $values = $form->getSubmitValues(); $urlBase = api_get_path(WEB_CODE_PATH).'inc/ajax/statistics.ajax.php?'; - $dateStart = null; - $dateEnd = null; - - if (isset($values['range_start'])) { - $dateStart = Security::remove_XSS($values['range_start']); - } - if (isset($values['range_end'])) { - $dateEnd = Security::remove_XSS($values['range_end']); + $rangeRaw = (string) ($_REQUEST['range'] ?? ($values['range'] ?? '')); + if (!empty($rangeRaw)) { + $form->setDefaults(['range' => Security::remove_XSS($rangeRaw)]); } - if (isset($_REQUEST['range_start'])) { - $dateStart = Security::remove_XSS($_REQUEST['range_start']); + $statusId = (int) ($_REQUEST['status_id'] ?? ($values['status_id'] ?? 0)); + if (!empty($statusId)) { + $form->setDefaults(['status_id' => $statusId]); } - if (isset($_REQUEST['range_end'])) { - $dateEnd = Security::remove_XSS($_REQUEST['range_end']); + $dateStart = (string) ($_REQUEST['range_start'] ?? ($values['range_start'] ?? '')); + $dateEnd = (string) ($_REQUEST['range_end'] ?? ($values['range_end'] ?? '')); + + $dateStart = Security::remove_XSS($dateStart); + $dateEnd = Security::remove_XSS($dateEnd); + $isValidStart = false; + $isValidEnd = false; + + if (!empty($dateStart)) { + $dt = DateTime::createFromFormat('Y-m-d', $dateStart); + $isValidStart = $dt && $dt->format('Y-m-d') === $dateStart; + } + if (!empty($dateEnd)) { + $dt = DateTime::createFromFormat('Y-m-d', $dateEnd); + $isValidEnd = $dt && $dt->format('Y-m-d') === $dateEnd; } - $statusId = (int) ($_REQUEST['status_id'] ?? 0); + if (!$isValidStart) { + $dateStart = ''; + } + if (!$isValidEnd) { + $dateEnd = ''; + } $conditions = "&date_start=$dateStart&date_end=$dateEnd&status=$statusId"; @@ -614,15 +648,19 @@ break; case 'session_by_date': $sessions = []; + $values = []; if ($validated) { $values = $form->getSubmitValues(); - $dateStart = $values['range_start']; - $dateEnd = $values['range_end']; + $dateStart = (string) ($_REQUEST['range_start'] ?? ($values['range_start'] ?? '')); + $dateEnd = (string) ($_REQUEST['range_end'] ?? ($values['range_end'] ?? '')); + $statusId = (int) ($_REQUEST['status_id'] ?? ($values['status_id'] ?? 0)); + $dateStart = Security::remove_XSS($dateStart); + $dateEnd = Security::remove_XSS($dateEnd); $first = DateTime::createFromFormat('Y-m-d', $dateStart); $second = DateTime::createFromFormat('Y-m-d', $dateEnd); $numberOfWeeks = 0; - if ($first) { - $numberOfWeeks = floor($first->diff($second)->days / 7); + if ($first && $second) { + $numberOfWeeks = (int) floor($first->diff($second)->days / 7); } $statusCondition = ''; @@ -820,12 +858,20 @@ $link = ''; if ($validated) { - $url = api_get_self().'?report=session_by_date&action=export'; - if (!empty($values)) { - foreach ($values as $index => $value) { - $url .= '&'.$index.'='.$value; + $exportParams = [ + 'report' => 'session_by_date', + 'action' => 'export', + 'range_start' => (string) ($_REQUEST['range_start'] ?? ($values['range_start'] ?? '')), + 'range_end' => (string) ($_REQUEST['range_end'] ?? ($values['range_end'] ?? '')), + 'status_id' => (string) ((int) ($_REQUEST['status_id'] ?? ($values['status_id'] ?? 0))), + ]; + foreach ($exportParams as $k => $v) { + if ($v === '' || $v === '0') { + unset($exportParams[$k]); } } + + $url = api_get_self().'?'.http_build_query($exportParams); $link = Display::url( Display::getMdiIcon(ActionIcon::EXPORT_SPREADSHEET, 'ch-tool-icon').' '.get_lang('Export to XLS'), $url, @@ -837,6 +883,63 @@ break; case 'user_session': + $htmlHeadXtra[] = << + #user_session input[name="range"]{ + min-width: 320px; + } + @media (max-width: 768px){ + #user_session input[name="range"]{ + width: 100%; + min-width: 0; + } + } + + HTML; + + $labelLastWeek = addslashes(get_lang('Last week')); + $labelNextWeek = addslashes(get_lang('Next week')); + + $htmlHeadXtra[] = << + (function () { + "use strict"; + function patchUserSessionRanges() { + var \$input = $('input[name="range"]'); + if (!\$input.length) return; + + var drp = \$input.data('daterangepicker'); + if (!drp) return; + + var lastWeekLabel = "{$labelLastWeek}"; + var nextWeekLabel = "{$labelNextWeek}"; + + drp.ranges = drp.ranges || {}; + + // Add "Last week" + drp.ranges[lastWeekLabel] = [ + moment().subtract(1,'week').startOf('week'), + moment().subtract(1,'week').endOf('week') + ]; + + if (drp.ranges[nextWeekLabel]) { + delete drp.ranges[nextWeekLabel]; + } + + \$input.on('show.daterangepicker', function () { + var drp2 = \$input.data('daterangepicker'); + if (!drp2) return; + drp2.ranges = drp.ranges; + }); + } + + $(function () { + patchUserSessionRanges(); + setTimeout(patchUserSessionRanges, 150); + }); + })(); + + JS; $form = new FormValidator('user_session', 'get'); $form->addDateRangePicker('range', get_lang('Date range'), true); $form->addHidden('report', 'user_session'); @@ -1058,6 +1161,18 @@ function resizeGrid() { $values = $form->getSubmitValues(); $toolIds = $values['tool_ids']; $reportData = Statistics::getToolUsageReportByTools($toolIds); + usort($reportData, static function (array $a, array $b): int { + $c1 = (int) ($a['resource_count'] ?? 0); + $c2 = (int) ($b['resource_count'] ?? 0); + + if ($c1 === $c2) { + $d1 = (string) ($a['last_updated'] ?? ''); + $d2 = (string) ($b['last_updated'] ?? ''); + return strcmp($d2, $d1); + } + + return $c2 <=> $c1; + }); $table = new HTML_Table(['class' => 'table table-hover table-striped data_table stats_table']); $headers = [ @@ -1077,7 +1192,7 @@ function resizeGrid() { foreach ($reportData as $data) { $linkHtml = $data['link'] !== '-' ? sprintf( - '%s', + '%s', $data['link'], htmlspecialchars($data['tool_name']) ) @@ -1104,6 +1219,12 @@ function resizeGrid() { case 'users_active': $content = ''; if ($validated) { + $htmlHeadXtra[] = ''; + $startDate = $values['daterange_start']; $endDate = $values['daterange_end']; @@ -1129,9 +1250,32 @@ function resizeGrid() { $conditions = []; $extraConditions = ''; - if (!empty($startDate) && !empty($endDate)) { - // $extraConditions is already cleaned inside the function getUserListExtraConditions - $extraConditions .= " AND created_at BETWEEN '$startDate' AND '$endDate' "; + $userTable = Database::get_main_table(TABLE_MAIN_USER); + $dateColumn = ''; + try { + $candidates = ['created_at', 'registration_date']; + foreach ($candidates as $candidate) { + $q = Database::query("SHOW COLUMNS FROM $userTable LIKE '$candidate'"); + if ($q && Database::num_rows($q) > 0) { + $dateColumn = $candidate; + break; + } + } + } catch (\Throwable $e) { + $dateColumn = ''; + } + + if (!empty($startDate) && !empty($endDate) && !empty($dateColumn)) { + $startDay = Security::remove_XSS((string) $startDate); + $endDay = Security::remove_XSS((string) $endDate); + $dtStart = DateTime::createFromFormat('Y-m-d', $startDay); + $dtEnd = DateTime::createFromFormat('Y-m-d', $endDay); + + if ($dtStart && $dtEnd && $dtStart->format('Y-m-d') === $startDay && $dtEnd->format('Y-m-d') === $endDay) { + $startUtc = api_get_utc_datetime($startDay.' 00:00:00'); + $endUtc = api_get_utc_datetime($endDay.' 23:59:59'); + $extraConditions .= " AND $dateColumn BETWEEN '$startUtc' AND '$endUtc' "; + } } $totalCount = UserManager::getUserListExtraConditions( @@ -1699,7 +1843,7 @@ function resizeGrid() { $content = $header.$extraTables.$graph.$content; } - $content = $form->returnForm().$content; + $content = '
'.$form->returnForm().$content.'
'; break; case 'users_online': @@ -1731,19 +1875,30 @@ function resizeGrid() { } $now = api_get_local_time(); + $tones = [ + 3 => ['border' => 'border-danger/20', 'bg' => 'bg-danger/10', 'iconBg' => 'bg-danger/20', 'text' => 'text-danger'], + 5 => ['border' => 'border-warning/20', 'bg' => 'bg-warning/10', 'iconBg' => 'bg-warning/20', 'text' => 'text-warning'], + 30 => ['border' => 'border-info/20', 'bg' => 'bg-info/10', 'iconBg' => 'bg-info/20', 'text' => 'text-info'], + 120=> ['border' => 'border-success/20', 'bg' => 'bg-success/10', 'iconBg' => 'bg-success/20', 'text' => 'text-success'], + ]; - $renderCard = static function (string $label, int $value, string $iconClass): string { + $renderCard = static function (string $label, int $value, string $iconClass, array $tone): string { $labelEsc = htmlspecialchars($label, ENT_QUOTES, 'UTF-8'); $valueEsc = (int) $value; + $border = $tone['border'] ?? 'border-gray-25'; + $bg = $tone['bg'] ?? 'bg-white'; + $iconBg = $tone['iconBg'] ?? 'bg-gray-20'; + $text = $tone['text'] ?? 'text-gray-90'; + return ' -
+
-
+
-
'.$labelEsc.'
+
'.$labelEsc.'
'.$valueEsc.'
@@ -1754,49 +1909,52 @@ function resizeGrid() { $cardsOnline = ''; $cardsTest = ''; - // Use distinct icons per interval (optional, but looks nicer). $icons = [ 3 => 'fa fa-bolt', - 5 => 'fa fa-thermometer-4', - 30 => 'fa fa-thermometer-2', - 120 => 'fa fa-clock-o', + 5 => 'fa fa-exclamation-triangle', + 30 => 'fa fa-info-circle', + 120 => 'fa fa-check-circle', ]; foreach ($intervals as $minutes) { $minutes = (int) $minutes; - $suffix = ' ('.$minutes.'′)'; + $suffix = " ({$minutes}')"; + + $tone = $tones[$minutes] ?? []; $cardsOnline .= $renderCard( get_lang('Users online').$suffix, $onlineCounts[$minutes] ?? 0, - $icons[$minutes] ?? 'fa fa-user' + $icons[$minutes] ?? 'fa fa-user', + $tone ); $cardsTest .= $renderCard( get_lang('Users active in a test').$suffix, $testCounts[$minutes] ?? 0, - $icons[$minutes] ?? 'fa fa-pencil' + $icons[$minutes] ?? 'fa fa-pencil', + $tone ); } $content = ' -
-
-

'.get_lang('Users online').'

-
'.htmlspecialchars((string) $now, ENT_QUOTES, "UTF-8").'
-
+
+
+

'.get_lang('Users online').'

+
'.htmlspecialchars((string) $now, ENT_QUOTES, "UTF-8").'
+
-
- '.$cardsOnline.' -
+
+ '.$cardsOnline.' +
-

'.get_lang('Users active in a test').'

+

'.get_lang('Users active in a test').'

-
- '.$cardsTest.' +
+ '.$cardsTest.' +
-
- '; + '; break; case 'new_user_registrations': $form = new FormValidator('new_user_registrations', 'get', api_get_self()); @@ -1812,6 +1970,26 @@ function resizeGrid() { $chartContent = ''; $chartCreatorContent = ''; $textChart = ''; + $htmlHeadXtra[] = ' + + '; + if ($validated) { $values = $form->getSubmitValues(); $dateStart = Security::remove_XSS($values['daterange_start']); @@ -1847,11 +2025,11 @@ function resizeGrid() { chart.data.datasets[0].data = dailyData.data; chart.data.datasets[0].label = "User Registrations for " + year + "-" + month; chart.update(); - $("#backButton").show(); } }); - }'; + } + '; } else { $textChart = get_lang('User registrations by day'); foreach ($registrations as $registration) { @@ -1864,42 +2042,54 @@ function resizeGrid() { $onClickHandler = ''; } + $options = ' + responsive: true, + maintainAspectRatio: false, + animation: false, + plugins: { + title: { text: "'.$textChart.'", display: true } + }, + scales: { + x: { beginAtZero: true }, + y: { barPercentage: 0.4, categoryPercentage: 0.5, barThickness: 10, maxBarThickness: 15 } + }, + layout: { + padding: { left: 10, right: 10, top: 10, bottom: 10 } + } + '; $htmlHeadXtra[] = Statistics::getJSChartTemplateWithData( $chartData['chart'], 'bar', - 'title: { text: "'.$textChart.'", display: true }, - scales: { - x: { beginAtZero: true }, - y: { barPercentage: 0.4, categoryPercentage: 0.5, barThickness: 10, maxBarThickness: 15 } - }, - layout: { - padding: { left: 10, right: 10, top: 10, bottom: 10 } - }', + $options, 'user_registration_chart', true, $onClickHandler, ' - $("#backButton").click(function() { - $.ajax({ - url: "/main/inc/ajax/statistics.ajax.php?a=get_user_registration_by_month", - type: "POST", - data: { date_start: "'.$dateStart.'", date_end: "'.$dateEnd.'" }, - success: function(response) { - var monthlyData = JSON.parse(response); - chart.data.labels = monthlyData.labels; - chart.data.datasets[0].data = monthlyData.data; - chart.data.datasets[0].label = "'.get_lang('User registrations by month').'"; - chart.update(); - $("#backButton").hide(); - } - }); - }); - ' + $("#backButton").click(function() { + $.ajax({ + url: "/main/inc/ajax/statistics.ajax.php?a=get_user_registration_by_month", + type: "POST", + data: { date_start: "'.$dateStart.'", date_end: "'.$dateEnd.'" }, + success: function(response) { + var monthlyData = JSON.parse(response); + chart.data.labels = monthlyData.labels; + chart.data.datasets[0].data = monthlyData.data; + chart.data.datasets[0].label = "'.get_lang('User registrations by month').'"; + chart.update(); + $("#backButton").hide(); + } + }); + }); + ', + ['width' => 900, 'height' => 360] ); + $chartContent .= '
'; $chartContent .= ''; + $chartContent .= '
'; + $chartContent .= '
'; $chartContent .= ''; - + $chartContent .= '
'; $creators = Statistics::getUserRegistrationsByCreator($dateStart, $dateEnd); if (!empty($creators)) { $chartCreatorContent = '
'; @@ -1910,31 +2100,42 @@ function resizeGrid() { $creatorData[] = $creator['count']; } + $creatorOptions = ' + responsive: true, + maintainAspectRatio: false, + animation: false, + plugins: { + title: { text: "'.get_lang('User registrations by creator').'", display: true }, + legend: { position: "top" } + }, + layout: { + padding: { left: 10, right: 10, top: 10, bottom: 10 } + } + '; + $htmlHeadXtra[] = Statistics::getJSChartTemplateWithData( ['labels' => $creatorLabels, 'datasets' => [['label' => get_lang('User registrations by creator'), 'data' => $creatorData]]], 'pie', - 'title: { text: "'.get_lang('User registrations by creator').'", display: true }, - legend: { position: "top" }, - layout: { - padding: { left: 10, right: 10, top: 10, bottom: 10 } - }', + $creatorOptions, 'user_registration_by_creator_chart', - false, + true, '', '', - ['width' => 700, 'height' => 700] + ['width' => 700, 'height' => 520] ); + $chartCreatorContent .= '
'; $chartCreatorContent .= ''; + $chartCreatorContent .= '
'; + } } } - } - $content .= $form->returnForm(); - $content .= $chartContent; - $content .= $chartCreatorContent; + $content .= $form->returnForm(); + $content .= $chartContent; + $content .= $chartCreatorContent; - break; + break; case 'users': $content .= '
'; $content .= '
'; @@ -2004,12 +2205,34 @@ function resizeGrid() { $content .= Statistics::printUserPicturesStats(); break; case 'no_login_users': + $totalUsers = Statistics::countUsers(null, null, true, false); + $content .= Display::page_subheader2(get_lang('Number of users').': '.(int) $totalUsers); $content .= Statistics::printUsersNotLoggedInStats(); break; case 'zombies': - $content .= ZombieReport::create(['report' => 'zombies'])->display(true); + $htmlHeadXtra[] = <<<'HTML' + + HTML; + $content .= '
'.ZombieReport::create(['report' => 'zombies'])->display(true).'
'; break; case 'activities': + $htmlHeadXtra[] = << + (function () { + "use strict"; + $(document).on("click", "a.js-user-details-link", function (e) { + e.stopImmediatePropagation(); + }); + })(); + + JS; $content .= Statistics::printActivitiesStats(); break; case 'messagesent': @@ -2026,7 +2249,18 @@ function resizeGrid() { $content .= Statistics::printStats(get_lang('Contacts count'), $friends, false); break; case 'logins_by_date': - $content .= Statistics::printLoginsByDate(); + $htmlHeadXtra[] = ''; + $content .= '
'.Statistics::printLoginsByDate().'
'; break; case 'quarterly_report': global $htmlHeadXtra; @@ -2082,11 +2316,11 @@ function resizeGrid() { $ajaxEndpointJs = json_encode($ajaxEndpoint, JSON_UNESCAPED_SLASHES); $loadingHtml = ' -
- '.$waitIcon.' - Loading report… -
- '; +
+ '.$waitIcon.' + Loading report… +
+ '; $loadingHtmlJs = json_encode($loadingHtml, JSON_UNESCAPED_SLASHES); $htmlHeadXtra[] = << -

'.get_lang('Show').': '.get_lang('All').'

-
- -
- '; +
+
+
+

'.get_lang('Quarterly report').'

+ + '.get_lang('Show').': '.get_lang('All').' + +
+
+ '; $content .= '
'; foreach ($cards as $card) { @@ -2189,31 +2421,31 @@ class="inline-flex items-center rounded-md bg-blue-600 px-3 py-2 text-sm font-se $action = $card['action']; $target = $card['target']; $content .= ' -
- - '; +
+ +
+
+ '; } $content .= '
'; break; diff --git a/public/main/inc/lib/statistics.lib.php b/public/main/inc/lib/statistics.lib.php index 0fce1447da1..2f5d02ecdc7 100644 --- a/public/main/inc/lib/statistics.lib.php +++ b/public/main/inc/lib/statistics.lib.php @@ -468,11 +468,18 @@ public static function getActivitiesData( $row[4] = '-'; } - // User id. + // User details (plain link - avoid legacy "send message" popups) + $userId = (int) ($row[6] ?? 0); + $userLabel = htmlspecialchars((string) ($row[5] ?? ''), ENT_QUOTES, 'UTF-8'); + $row[5] = Display::url( - $row[5], - api_get_path(WEB_AJAX_PATH).'user_manager.ajax.php?a=get_user_popup&user_id='.$row[6], - ['class' => 'ajax'] + $userLabel, + api_get_path(WEB_CODE_PATH).'admin/user_information.php?user_id='.$userId, + [ + 'class' => 'js-user-details-link text-primary underline hover:text-primary/80', + 'target' => '_self', + 'rel' => 'noopener', + ] ); $row[6] = Tracking::get_ip_from_user_event( @@ -520,6 +527,10 @@ public static function printStats( } } + if (false === $showTotal) { + $barRelativeToMax = true; + } + $colspan = $showTotal ? 4 : 3; $css = ''; @@ -546,7 +557,7 @@ public static function printStats( font-size: 12px; color: #5f6b7a; white-space: nowrap; - min-width: 48px; + min-width: 52px; text-align: right; } .ch-stats-cols th{ vertical-align: middle; } @@ -579,13 +590,13 @@ public static function printStats( : self::makeSizeString((int) $number); $percentageRaw = ($total > 0) ? (100 * $number / $total) : 0.0; - $percentageDisplay = ($total > 0) ? number_format($percentageRaw, 1, ',', '.') : '0'; + $percentageDisplay = ($total > 0) ? number_format($percentageRaw, 1, ',', '.') : '0,0'; $barPercent = $barRelativeToMax ? (($max > 0) ? (100 * $number / $max) : 0.0) : $percentageRaw; - $barPercent = max(0.0, min(100.0, $barPercent)); + $barPercent = max(0.0, min(100.0, (float) $barPercent)); $barHtml = '
@@ -899,25 +910,26 @@ public static function getToolsStats(): array if ($accessUrlUtil->isMultiple()) { $accessUrl = $accessUrlUtil->getCurrent(); $urlId = $accessUrl->getId(); - $sql = "SELECT access_tool, count( access_id ) AS number_of_logins - FROM $table t , $access_url_rel_course_table a - WHERE - access_tool IN ('".implode("','", $tools)."') AND - t.c_id = a.c_id AND - access_url_id = $urlId - GROUP BY access_tool - "; + $sql = "SELECT access_tool, count(access_id) AS number_of_logins + FROM $table t, $access_url_rel_course_table a + WHERE + access_tool IN ('".implode("','", $tools)."') AND + t.c_id = a.c_id AND + access_url_id = $urlId + GROUP BY access_tool + ORDER BY number_of_logins DESC"; } else { - $sql = "SELECT access_tool, count( access_id ) AS number_of_logins - FROM $table - WHERE access_tool IN ('".implode("','", $tools)."') - GROUP BY access_tool "; + $sql = "SELECT access_tool, count(access_id) AS number_of_logins + FROM $table + WHERE access_tool IN ('".implode("','", $tools)."') + GROUP BY access_tool + ORDER BY number_of_logins DESC"; } $res = Database::query($sql); $result = []; while ($obj = Database::fetch_object($res)) { - $result[$tool_names[$obj->access_tool]] = $obj->number_of_logins; + $result[$tool_names[$obj->access_tool]] = (int) $obj->number_of_logins; } return $result; @@ -973,38 +985,78 @@ public static function printCourseByLanguageStats(): array } /** - * Shows the number of users having their picture uploaded in Dokeos. + * Shows the number of users having their picture uploaded. + * Compatible with different schemas (picture, picture_uri, picture_resource_node_id). * @throws Exception */ public static function printUserPicturesStats(): string { - $user_table = Database::get_main_table(TABLE_MAIN_USER); - $access_url_rel_user_table = Database::get_main_table(TABLE_MAIN_ACCESS_URL_REL_USER); - $url_condition = null; - $url_condition2 = null; - $table = null; + $userTable = Database::get_main_table(TABLE_MAIN_USER); + $accessUrlRelUserTable = Database::get_main_table(TABLE_MAIN_ACCESS_URL_REL_USER); + $accessUrlUtil = Container::getAccessUrlUtil(); + $cols = []; + try { + $conn = Database::getManager()->getConnection(); + $sm = method_exists($conn, 'createSchemaManager') ? $conn->createSchemaManager() : $conn->getSchemaManager(); + $cols = $sm->listTableColumns($userTable); + } catch (\Throwable $e) { + $cols = []; + } + + $hasPicture = isset($cols['picture']); + $hasPictureUri = isset($cols['picture_uri']); + $hasPictureNodeId = isset($cols['picture_resource_node_id']); + + $joins = ''; + $where = 'WHERE u.active <> '.USER_SOFT_DELETED; if ($accessUrlUtil->isMultiple()) { $accessUrl = $accessUrlUtil->getCurrent(); - $urlId = $accessUrl->getId(); - $url_condition = ", $access_url_rel_user_table as url WHERE url.user_id=u.id AND access_url_id='".$urlId."'"; - $url_condition2 = " AND url.user_id=u.id AND access_url_id = $urlId"; - $table = ", $access_url_rel_user_table as url "; + $urlId = (int) $accessUrl->getId(); + $joins .= " INNER JOIN $accessUrlRelUserTable url + ON url.user_id = u.id AND url.access_url_id = $urlId "; } - $sql = "SELECT COUNT(*) AS n FROM $user_table as u ".$url_condition; + + // Total users (within current URL if multi-url). + $sql = "SELECT COUNT(*) AS n FROM $userTable u $joins $where"; $res = Database::query($sql); - $count1 = Database::fetch_object($res); - $sql = "SELECT COUNT(*) AS n FROM $user_table as u $table - WHERE LENGTH(picture_uri) > 0 $url_condition2"; + $totalObj = Database::fetch_object($res); + $totalUsers = (int) ($totalObj->n ?? 0); - $sql .= !str_contains($sql, 'WHERE') ? ' WHERE u.active <> '.USER_SOFT_DELETED : ' AND u.active <> '.USER_SOFT_DELETED; + // Users with a picture (build conditions depending on available columns). + $pictureWhereParts = []; - $res = Database::query($sql); - $count2 = Database::fetch_object($res); - // #users without picture - $result[get_lang('No')] = $count1->n - $count2->n; - $result[get_lang('Yes')] = $count2->n; // #users with picture + if ($hasPictureUri) { + $pictureWhereParts[] = "(u.picture_uri IS NOT NULL AND u.picture_uri <> '')"; + } + + if ($hasPicture) { + // Exclude common "empty" values. + $pictureWhereParts[] = "(u.picture IS NOT NULL AND u.picture <> '' AND u.picture <> '0' AND u.picture <> 'unknown.jpg' AND u.picture <> 'unknown.png')"; + } + + if ($hasPictureNodeId) { + $pictureWhereParts[] = "(u.picture_resource_node_id IS NOT NULL AND u.picture_resource_node_id <> 0)"; + } + + $withPicture = 0; + if (!empty($pictureWhereParts)) { + $pictureWhere = implode(' OR ', $pictureWhereParts); + + $sql = "SELECT COUNT(*) AS n + FROM $userTable u + $joins + $where + AND ($pictureWhere)"; + $res = Database::query($sql); + $obj = Database::fetch_object($res); + $withPicture = (int) ($obj->n ?? 0); + } + + $result = []; + $result[get_lang('No')] = max(0, $totalUsers - $withPicture); + $result[get_lang('Yes')] = max(0, $withPicture); return self::printStats(get_lang('Number of users').' ('.get_lang('Picture').')', $result, false); } @@ -1722,7 +1774,7 @@ public static function getJSChartTemplateWithData( $options = trim($options); if ($isCircular) { - $baseOptions = 'responsive: true, maintainAspectRatio: true,'; + $baseOptions = 'responsive: true, maintainAspectRatio: false, devicePixelRatio: Math.min(2, window.devicePixelRatio || 1),'; } else { $baseOptions = $fullSize ? 'responsive: true, maintainAspectRatio: false,' @@ -1803,6 +1855,9 @@ public static function getJSChartTemplateWithData( options: options }); + setTimeout(function(){ if (chart) chart.resize(); }, 80); + setTimeout(function(){ if (chart) chart.resize(); }, 280); + $afterInitBlock }); diff --git a/public/main/inc/lib/usermanager.lib.php b/public/main/inc/lib/usermanager.lib.php index 6cee11832cf..4ce21476e7e 100644 --- a/public/main/inc/lib/usermanager.lib.php +++ b/public/main/inc/lib/usermanager.lib.php @@ -4,6 +4,7 @@ use Chamilo\CoreBundle\Entity\ExtraField as EntityExtraField; use Chamilo\CoreBundle\Entity\ExtraFieldValues as EntityExtraFieldValues; use Chamilo\CoreBundle\Entity\GradebookCategory; +use Chamilo\CoreBundle\Entity\GradebookCertificate; use Chamilo\CoreBundle\Entity\Session as SessionEntity; use Chamilo\CoreBundle\Entity\SessionRelCourse; use Chamilo\CoreBundle\Entity\User; @@ -6362,7 +6363,7 @@ public static function countUsersWhoFinishedCourses() if (!empty($gradebook)) { $finished = 0; Database::getManager()->persist($gradebook); - $certificateRepo = $entityManager->getRepository(\Chamilo\CoreBundle\Entity\GradebookCertificate::class); + $certificateRepo = $entityManager->getRepository(GradebookCertificate::class); $finished = $certificateRepo->getCertificateByUserId($gradebook->getId(), $row['user_id']); if (!empty($finished)) { $courses[$row['code']]['finished']++; @@ -6384,46 +6385,84 @@ public static function countUsersWhoFinishedCourses() public static function countUsersWhoFinishedCoursesInSessions() { $coursesInSessions = []; - $currentAccessUrlId = api_get_current_access_url_id(); - $sql = "SELECT course.code, srcru.session_id, srcru.user_id, session.title - FROM session_rel_course_rel_user srcru - JOIN course ON srcru.c_id = course.id - JOIN access_url_rel_session aurs on srcru.session_id = aurs.session_id - JOIN session ON srcru.session_id = session.id - WHERE aurs.access_url_id = $currentAccessUrlId - ORDER BY course.code, session.title - "; + $currentAccessUrlId = (int) api_get_current_access_url_id(); + + $sql = "SELECT + course.id AS cid, + course.code, + srcru.session_id, + srcru.user_id, + session.title + FROM session_rel_course_rel_user srcru + INNER JOIN course ON srcru.c_id = course.id + INNER JOIN access_url_rel_session aurs ON srcru.session_id = aurs.session_id + INNER JOIN session ON srcru.session_id = session.id + WHERE aurs.access_url_id = $currentAccessUrlId + ORDER BY course.code, session.title"; + $res = Database::query($sql); - if (Database::num_rows($res) > 0) { - while ($row = Database::fetch_array($res)) { - $index = $row['code'].' ('.$row['title'].')'; - if (!isset($coursesInSessions[$index])) { - $coursesInSessions[$index] = [ - 'subscribed' => 0, - 'finished' => 0, - ]; - } - $coursesInSessions[$index]['subscribed']++; - $entityManager = Database::getManager(); - $repository = $entityManager->getRepository(GradebookCategory::class); - /** @var GradebookCategory $gradebook */ - $gradebook = $repository->findOneBy( - [ - 'course' => $row['cid'], - 'sessionId' => $row['session_id'], - ] - ); - if (!empty($gradebook)) { - $finished = 0; - Database::getManager()->persist($gradebook); - $certificateRepo = $entityManager->getRepository(\Chamilo\CoreBundle\Entity\GradebookCertificate::class); - $finished = $certificateRepo->getCertificateByUserId($gradebook->getId(), $row['user_id']); - if (!empty($finished)) { - $coursesInSessions[$index]['finished']++; - } + if (false === $res || 0 === Database::num_rows($res)) { + return $coursesInSessions; + } + + $entityManager = Database::getManager(); + $gradebookRepo = $entityManager->getRepository(GradebookCategory::class); + $gbMeta = $entityManager->getClassMetadata(GradebookCategory::class); + $certificateRepo = $entityManager->getRepository(GradebookCertificate::class); + $gradebookCache = []; + + while ($row = Database::fetch_array($res)) { + $courseId = (int) ($row['cid'] ?? 0); + $sessionId = (int) ($row['session_id'] ?? 0); + $userId = (int) ($row['user_id'] ?? 0); + + if ($courseId <= 0 || $sessionId <= 0 || $userId <= 0) { + continue; + } + + $index = $row['code'].' ('.$row['title'].')'; + + if (!isset($coursesInSessions[$index])) { + $coursesInSessions[$index] = [ + 'subscribed' => 0, + 'finished' => 0, + ]; + } + + $coursesInSessions[$index]['subscribed']++; + $cacheKey = $courseId.':'.$sessionId; + if (!array_key_exists($cacheKey, $gradebookCache)) { + $criteria = [ + 'course' => $courseId, + ]; + + if ($gbMeta->hasAssociation('session')) { + $criteria['session'] = $entityManager->getReference( + SessionEntity::class, + $sessionId + ); + } elseif ($gbMeta->hasField('session')) { + $criteria['session'] = $sessionId; + } elseif ($gbMeta->hasField('session_id')) { + $criteria['session_id'] = $sessionId; + } elseif ($gbMeta->hasField('sid')) { + $criteria['sid'] = $sessionId; } + + $gradebookCache[$cacheKey] = $gradebookRepo->findOneBy($criteria) ?: null; + } + + $gradebook = $gradebookCache[$cacheKey]; + if (null === $gradebook) { + continue; + } + + $certificate = $certificateRepo->getCertificateByUserId($gradebook->getId(), $userId); + if (!empty($certificate)) { + $coursesInSessions[$index]['finished']++; } } + return $coursesInSessions; } diff --git a/public/main/inc/lib/zombie/zombie_report.class.php b/public/main/inc/lib/zombie/zombie_report.class.php index 77cc0ea5b46..d61192405e0 100644 --- a/public/main/inc/lib/zombie/zombie_report.class.php +++ b/public/main/inc/lib/zombie/zombie_report.class.php @@ -108,6 +108,45 @@ public function display_parameters($return = false) { $form = $this->get_parameters_form(); $result = $form->returnForm(); + $iso = $this->get_ceiling('Y-m-d'); + + $result .= " + + "; if ($return) { return $result; @@ -126,7 +165,7 @@ public function is_valid() public function get_ceiling($format = null) { $result = $this->request->get('ceiling'); - $result = $result ? $result : ZombieManager::last_year(); + $result = $result ? $result : strtotime('today'); $result = is_array($result) && 1 == count($result) ? reset($result) : $result; $result = is_array($result) ? mktime(0, 0, 0, $result['F'], $result['d'], $result['Y']) : $result;