diff --git a/assets/vue/components/Breadcrumb.vue b/assets/vue/components/Breadcrumb.vue
index 97f9fd51a6..a91308b82f 100644
--- a/assets/vue/components/Breadcrumb.vue
+++ b/assets/vue/components/Breadcrumb.vue
@@ -26,7 +26,7 @@
diff --git a/assets/vue/components/resource_links/ShowLinks.vue b/assets/vue/components/resource_links/ShowLinks.vue
index 3c195224bb..b0110dc665 100644
--- a/assets/vue/components/resource_links/ShowLinks.vue
+++ b/assets/vue/components/resource_links/ShowLinks.vue
@@ -6,7 +6,16 @@
>
- {{ $t("Course") }}: {{ link.course.resourceNode.title }}
+
+ {{ $t("Course") }}: {{ link.course.resourceNode.title }}
+
+ {{ $t("Course") }}: {{ link.course.resourceNode.title }}
import { RESOURCE_LINK_DRAFT, RESOURCE_LINK_PUBLISHED } from "../../constants/entity/resourcelink"
import { useI18n } from "vue-i18n"
+import BaseAppLink from "../basecomponents/BaseAppLink.vue"
const { t } = useI18n()
@@ -77,8 +87,17 @@ defineProps({
required: false,
default: false,
},
+ clickableCourse: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
})
+const courseId = (course) => {
+ return course['@id'] ? course['@id'].split('/').pop() : null;
+}
+
const visibilityOptions = [
{ value: RESOURCE_LINK_PUBLISHED, label: t("Published") },
{ value: RESOURCE_LINK_DRAFT, label: t("Draft") },
diff --git a/assets/vue/composables/fileUtils.js b/assets/vue/composables/fileUtils.js
index 3709dee2fe..0e0f87381b 100644
--- a/assets/vue/composables/fileUtils.js
+++ b/assets/vue/composables/fileUtils.js
@@ -1,4 +1,8 @@
export function useFileUtils() {
+ const isFile = (fileData) => {
+ return fileData.resourceNode && fileData.resourceNode.firstResourceFile
+ }
+
const isImage = (fileData) => {
return isFile(fileData) && fileData.resourceNode.firstResourceFile.image
}
@@ -21,8 +25,9 @@ export function useFileUtils() {
return mimeType.split("/")[1].toLowerCase() === "html"
}
- const isFile = (fileData) => {
- return fileData.resourceNode && fileData.resourceNode.firstResourceFile
+ const isPreviewable = (fileData) => {
+ const mimeType = fileData.resourceNode.firstResourceFile.mimeType.toLowerCase()
+ return isImage(fileData) || isVideo(fileData) || isAudio(fileData) || isHtml(fileData) || mimeType === "application/pdf"
}
return {
@@ -31,5 +36,6 @@ export function useFileUtils() {
isVideo,
isAudio,
isHtml,
+ isPreviewable,
}
}
diff --git a/assets/vue/router/admin.js b/assets/vue/router/admin.js
index 8d4f0f29d6..eaaf86b494 100644
--- a/assets/vue/router/admin.js
+++ b/assets/vue/router/admin.js
@@ -1,19 +1,19 @@
export default {
path: '/admin',
name: 'admin',
- meta: { requiresAuth: true },
+ meta: { requiresAuth: true, showBreadcrumb: true },
component: () => import('../components/admin/AdminLayout.vue'),
children: [
{
path: '',
name: 'AdminIndex',
- meta: { requiresAdmin: true, requiresSessionAdmin: true },
+ meta: { requiresAdmin: true, requiresSessionAdmin: true, showBreadcrumb: false },
component: () => import('../views/admin/AdminIndex.vue'),
},
{
name: 'AdminConfigurationColors',
path: 'configuration/colors',
- meta: { requiresAdmin: true, requiresSessionAdmin: true },
+ meta: { requiresAdmin: true, requiresSessionAdmin: true, showBreadcrumb: true },
component: () => import('../views/admin/AdminConfigureColors.vue'),
}
],
diff --git a/assets/vue/views/course/CatalogueCourses.vue b/assets/vue/views/course/CatalogueCourses.vue
index 9b3236019a..6d609e90a4 100644
--- a/assets/vue/views/course/CatalogueCourses.vue
+++ b/assets/vue/views/course/CatalogueCourses.vue
@@ -152,16 +152,39 @@
>
+
+
+
+
+
@@ -180,10 +203,13 @@ import DataTable from "primevue/datatable"
import Column from "primevue/column"
import Rating from "primevue/rating"
import { usePlatformConfig } from "../../store/platformConfig"
+import { useSecurityStore } from "../../store/securityStore"
+const securityStore = useSecurityStore()
const status = ref(null)
const courses = ref([])
const filters = ref(null)
+const currentUserId = securityStore.user.id
const platformConfigStore = usePlatformConfig()
const showCourseDuration = "true" === platformConfigStore.getSetting("course.show_course_duration")
@@ -257,6 +283,10 @@ const newRating = function (courseId, value) {
})
}
+const isUserInCourse = (course) => {
+ return course.users.some((user) => user.user.id === currentUserId)
+}
+
const clearFilter = function () {
initFilters()
}
diff --git a/public/main/admin/statistics/index.php b/public/main/admin/statistics/index.php
index c7ba145aa5..debecc73f5 100644
--- a/public/main/admin/statistics/index.php
+++ b/public/main/admin/statistics/index.php
@@ -23,7 +23,7 @@ $validated = false;
if (
in_array(
$report,
- ['recentlogins', 'tools', 'courses', 'coursebylanguage', 'users', 'users_active', 'session_by_date']
+ ['recentlogins', 'tools', 'courses', 'coursebylanguage', 'users', 'users_active', 'session_by_date', 'new_user_registrations']
)
) {
$htmlHeadXtra[] = api_get_build_js('libs/chartjs/chart.js');
@@ -349,6 +349,7 @@ $tools = [
'report=zombies' => get_lang('Zombies'),
'report=users_active' => get_lang('Users statistics'),
'report=users_online' => get_lang('Users online'),
+ 'report=new_user_registrations' => get_lang('New users registrations'),
],
get_lang('System') => [
'report=activities' => get_lang('ImportantActivities'),
@@ -1445,6 +1446,143 @@ switch ($report) {
';
break;
+ case 'new_user_registrations':
+ $form = new FormValidator('new_user_registrations', 'get', api_get_self());
+ $form->addDateRangePicker('daterange', get_lang('Date range'), true, [
+ 'format' => 'YYYY-MM-DD',
+ 'timePicker' => 'false',
+ 'validate_format' => 'Y-m-d'
+ ]);
+ $form->addHidden('report', 'new_user_registrations');
+ $form->addButtonSearch(get_lang('Search'));
+
+ $validated = $form->validate() || isset($_REQUEST['daterange']);
+ $chartContent = '';
+ $chartCreatorContent = '';
+ $textChart = '';
+ if ($validated) {
+ $values = $form->getSubmitValues();
+ $dateStart = Security::remove_XSS($values['daterange_start']);
+ $dateEnd = Security::remove_XSS($values['daterange_end']);
+
+ $all = Statistics::initializeDateRangeArray($dateStart, $dateEnd);
+ $registrations = Statistics::getNewUserRegistrations($dateStart, $dateEnd);
+
+ if (empty($registrations)) {
+ $content .= '' . get_lang('No data available for the selected date range') . '
';
+ } else {
+ if (Statistics::isMoreThanAMonth($dateStart, $dateEnd)) {
+ $textChart = get_lang('User registrations by month');
+ $all = Statistics::groupByMonth($registrations);
+ $chartData = Statistics::buildJsChartData($all, get_lang('User Registrations by Month'));
+
+ // Allow clicks only when showing by month
+ $onClickHandler = '
+ var activePoints = chart.getElementsAtEventForMode(evt, "nearest", { intersect: true }, false);
+ if (activePoints.length > 0) {
+ var firstPoint = activePoints[0];
+ var label = chart.data.labels[firstPoint.index];
+ var yearMonth = label.split("-");
+ var year = yearMonth[0];
+ var month = yearMonth[1];
+ $.ajax({
+ url: "/main/inc/ajax/statistics.ajax.php?a=get_user_registration_by_day",
+ type: "POST",
+ data: { year: year, month: month },
+ success: function(response) {
+ var dailyData = JSON.parse(response);
+ chart.data.labels = dailyData.labels;
+ 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 days');
+ foreach ($registrations as $registration) {
+ $date = $registration['date'];
+ if (isset($all[$date])) {
+ $all[$date] += $registration['count'];
+ }
+ }
+ $chartData = Statistics::buildJsChartData($all, $textChart);
+ $onClickHandler = '';
+ }
+
+ $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 }
+ }',
+ '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();
+ }
+ });
+ });
+ '
+ );
+
+ $chartContent .= '';
+ $chartContent .= '';
+
+ $creators = Statistics::getUserRegistrationsByCreator($dateStart, $dateEnd);
+ if (!empty($creators)) {
+ $chartCreatorContent = '
';
+ $creatorLabels = [];
+ $creatorData = [];
+ foreach ($creators as $creator) {
+ $creatorLabels[] = $creator['name'];
+ $creatorData[] = $creator['count'];
+ }
+
+ $htmlHeadXtra[] = Statistics::getJSChartTemplateWithData(
+ ['labels' => $creatorLabels, 'datasets' => [['label' => get_lang('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 }
+ }',
+ 'user_registration_by_creator_chart',
+ false,
+ '',
+ '',
+ ['width' => 700, 'height' => 700]
+ );
+
+ $chartCreatorContent .= '';
+ }
+ }
+ }
+
+ $content .= $form->returnForm();
+ $content .= $chartContent;
+ $content .= $chartCreatorContent;
+
+ break;
case 'users':
$content .= '';
$content .= '
';
diff --git a/public/main/exercise/recalculate_all.php b/public/main/exercise/recalculate_all.php
index 89f5e0a63a..adcc5f11e7 100644
--- a/public/main/exercise/recalculate_all.php
+++ b/public/main/exercise/recalculate_all.php
@@ -26,7 +26,7 @@ if (!$is_allowedToEdit) {
$result = ExerciseLib::get_exam_results_data(
0,
0,
- 1,
+ null,
'asc',
$exerciseId,
'',
diff --git a/public/main/inc/ajax/statistics.ajax.php b/public/main/inc/ajax/statistics.ajax.php
index 062457e0e7..d6b5300f80 100644
--- a/public/main/inc/ajax/statistics.ajax.php
+++ b/public/main/inc/ajax/statistics.ajax.php
@@ -20,6 +20,37 @@ $order = isset($_REQUEST['sord']) && in_array($_REQUEST['sord'], ['asc', 'desc']
$table = '';
switch ($action) {
+ case 'get_user_registration_by_month':
+ $dateStart = Security::remove_XSS($_POST['date_start']);
+ $dateEnd = Security::remove_XSS($_POST['date_end']);
+
+ $registrations = Statistics::getNewUserRegistrations($dateStart, $dateEnd);
+ $all = Statistics::groupByMonth($registrations);
+ $labels = [];
+ $data = [];
+ foreach ($all as $month => $count) {
+ $labels[] = $month;
+ $data[] = $count;
+ }
+
+ echo json_encode(['labels' => $labels, 'data' => $data]);
+ exit;
+ case 'get_user_registration_by_day':
+ $year = intval($_POST['year']);
+ $month = intval($_POST['month']);
+
+ $startDate = "$year-$month-01";
+ $endDate = date("Y-m-t", strtotime($startDate));
+ $dailyData = Statistics::getNewUserRegistrations($startDate, $endDate);
+ $labels = [];
+ $data = [];
+ foreach ($dailyData as $registration) {
+ $labels[] = $registration['date'];
+ $data[] = $registration['count'];
+ }
+
+ echo json_encode(['labels' => $labels, 'data' => $data]);
+ exit;
case 'add_student_to_boss':
$studentId = isset($_GET['student_id']) ? (int) $_GET['student_id'] : 0;
$bossId = isset($_GET['boss_id']) ? (int) $_GET['boss_id'] : 0;
diff --git a/public/main/inc/lib/statistics.lib.php b/public/main/inc/lib/statistics.lib.php
index 7128904cca..b0e1a4b025 100644
--- a/public/main/inc/lib/statistics.lib.php
+++ b/public/main/inc/lib/statistics.lib.php
@@ -1248,31 +1248,75 @@ class Statistics
return $chartCode;
}
- public static function getJSChartTemplateWithData($data, $type = 'pie', $options = '', $elementId = 'canvas', $responsive = true)
- {
+ public static function getJSChartTemplateWithData(
+ $data,
+ $type = 'pie',
+ $options = '',
+ $elementId = 'canvas',
+ $responsive = true,
+ $onClickHandler = '',
+ $extraButtonHandler = '',
+ $canvasDimensions = ['width' => 420, 'height' => 420]
+ ): string {
$data = json_encode($data);
$responsiveValue = $responsive ? 'true' : 'false';
+ $indexAxisOption = '';
+ if ($type === 'bar') {
+ $indexAxisOption = 'indexAxis: "y",';
+ }
+
+ $onClickScript = '';
+ if (!empty($onClickHandler)) {
+ $onClickScript = '
+ onClick: function(evt) {
+ '.$onClickHandler.'
+ },
+ ';
+ }
+
+ $canvasSize = '';
+ if ($responsiveValue === 'false') {
+ $canvasSize = '
+ ctx.canvas.width = '.$canvasDimensions['width'].';
+ ctx.canvas.height = '.$canvasDimensions['height'].';
+ ';
+ }
+
$chartCode = '
';
@@ -1499,4 +1543,115 @@ class Statistics
return Database::store_result($stmt, 'ASSOC');
}
+
+ /**
+ * Gets the number of new users registered between two dates.
+ */
+ public static function getNewUserRegistrations(string $startDate, string $endDate): array
+ {
+ $sql = "SELECT DATE_FORMAT(registration_date, '%Y-%m-%d') as reg_date, COUNT(*) as user_count
+ FROM user
+ WHERE registration_date BETWEEN '$startDate' AND '$endDate'
+ GROUP BY reg_date";
+
+ $result = Database::query($sql);
+ $data = [];
+ while ($row = Database::fetch_array($result)) {
+ $userCount = is_numeric($row['user_count']) ? (int) $row['user_count'] : 0;
+ $data[] = ['date' => $row['reg_date'], 'count' => $userCount];
+ }
+
+ return $data;
+ }
+
+ /**
+ * Gets the number of users registered by creator (creator_id) between two dates.
+ */
+ public static function getUserRegistrationsByCreator(string $startDate, string $endDate): array
+ {
+ $sql = "SELECT u.creator_id, COUNT(u.id) as user_count, c.firstname, c.lastname
+ FROM user u
+ LEFT JOIN user c ON u.creator_id = c.id
+ WHERE u.registration_date BETWEEN '$startDate' AND '$endDate'
+ AND u.creator_id IS NOT NULL
+ GROUP BY u.creator_id";
+
+ $result = Database::query($sql);
+ $data = [];
+ while ($row = Database::fetch_array($result)) {
+ $userCount = is_numeric($row['user_count']) ? (int) $row['user_count'] : 0;
+ $name = trim($row['firstname'] . ' ' . $row['lastname']);
+ if (!empty($name)) {
+ $data[] = [
+ 'name' => $name,
+ 'count' => $userCount
+ ];
+ }
+ }
+
+ return $data;
+ }
+
+ /**
+ * Initializes an array with dates between two given dates, setting each date's value to 0.
+ */
+ public static function initializeDateRangeArray(string $startDate, string $endDate): array
+ {
+ $dateRangeArray = [];
+ $currentDate = new DateTime($startDate);
+ $endDate = new DateTime($endDate);
+
+ // Loop through the date range and initialize each date with 0
+ while ($currentDate <= $endDate) {
+ $formattedDate = $currentDate->format('Y-m-d');
+ $dateRangeArray[$formattedDate] = 0;
+ $currentDate->modify('+1 day');
+ }
+
+ return $dateRangeArray;
+ }
+
+ /**
+ * Checks if the difference between two dates is more than one month.
+ */
+ public static function isMoreThanAMonth(string $dateStart, string $dateEnd): bool
+ {
+ $startDate = new DateTime($dateStart);
+ $endDate = new DateTime($dateEnd);
+
+ $diff = $startDate->diff($endDate);
+
+ if ($diff->y >= 1) {
+ return true;
+ }
+
+ if ($diff->m > 1) {
+ return true;
+ }
+
+ if ($diff->m == 1) {
+ return $diff->d > 0;
+ }
+
+ return false;
+ }
+
+ /**
+ * Groups registration data by month.
+ */
+ public static function groupByMonth(array $registrations): array
+ {
+ $groupedData = [];
+
+ foreach ($registrations as $registration) {
+ $monthYear = (new DateTime($registration['date']))->format('Y-m');
+ if (isset($groupedData[$monthYear])) {
+ $groupedData[$monthYear] += $registration['count'];
+ } else {
+ $groupedData[$monthYear] = $registration['count'];
+ }
+ }
+
+ return $groupedData;
+ }
}
diff --git a/public/main/lp/learnpath.class.php b/public/main/lp/learnpath.class.php
index 1abc7a0e42..b829935ab0 100644
--- a/public/main/lp/learnpath.class.php
+++ b/public/main/lp/learnpath.class.php
@@ -4,6 +4,7 @@
use Chamilo\CoreBundle\Entity\Course;
use Chamilo\CoreBundle\Entity\ResourceLink;
+use Chamilo\CoreBundle\Entity\TrackEExercise;
use Chamilo\CoreBundle\Entity\User;
use Chamilo\CoreBundle\Entity\Session as SessionEntity;
use Chamilo\CoreBundle\ServiceHelper\ThemeHelper;
@@ -8796,4 +8797,65 @@ class learnpath
return $document ? $repo->getResourceFileContent($document) : '';
}
+
+ /**
+ * Recalculates the results for all exercises associated with the learning path (LP) for the given user.
+ */
+ public function recalculateResultsForLp(int $userId): void
+ {
+ $em = Database::getManager();
+ $lpItemRepo = $em->getRepository(CLpItem::class);
+ $lpItems = $lpItemRepo->findBy(['lp' => $this->lp_id]);
+
+ if (empty($lpItems)) {
+ Display::addFlash(Display::return_message(get_lang('No items found'), 'error'));
+ return;
+ }
+
+ $lpItemIds = array_map(fn($item) => $item->getIid(), $lpItems);
+ $lpItemViewRepo = $em->getRepository(CLpItemView::class);
+ $lpItemViews = $lpItemViewRepo->createQueryBuilder('v')
+ ->where('v.item IN (:lpItemIds)')
+ ->setParameter('lpItemIds', $lpItemIds)
+ ->getQuery()
+ ->getResult();
+
+ if (empty($lpItemViews)) {
+ Display::addFlash(Display::return_message(get_lang('No item views found'), 'error'));
+ return;
+ }
+
+ $lpViewIds = array_map(fn($view) => $view->getIid(), $lpItemViews);
+ $trackEExerciseRepo = $em->getRepository(TrackEExercise::class);
+ $trackExercises = $trackEExerciseRepo->createQueryBuilder('te')
+ ->where('te.origLpId = :lpId')
+ ->andWhere('te.origLpItemId IN (:lpItemIds)')
+ ->andWhere('te.origLpItemViewId IN (:lpViewIds)')
+ ->andWhere('te.user = :userId')
+ ->setParameter('lpId', $this->lp_id)
+ ->setParameter('lpItemIds', $lpItemIds)
+ ->setParameter('lpViewIds', $lpViewIds)
+ ->setParameter('userId', $userId)
+ ->getQuery()
+ ->getResult();
+
+ if (empty($trackExercises)) {
+ Display::addFlash(Display::return_message(get_lang('No exercise attempts found'), 'error'));
+ return;
+ }
+
+ foreach ($trackExercises as $trackExercise) {
+ $exeId = $trackExercise->getExeId();
+ $exerciseId = $trackExercise->getQuiz()->getIid();
+ $courseId = $trackExercise->getCourse()->getId();
+
+ $result = ExerciseLib::recalculateResult($exeId, $userId, $exerciseId, $courseId);
+
+ if ($result) {
+ Display::addFlash(Display::return_message(get_lang('Results recalculated'), 'success'));
+ } else {
+ Display::addFlash(Display::return_message(get_lang('Error recalculating results'), 'error'));
+ }
+ }
+ }
}
diff --git a/public/main/lp/lp_controller.php b/public/main/lp/lp_controller.php
index a6b3a0be7e..c1e80fa683 100644
--- a/public/main/lp/lp_controller.php
+++ b/public/main/lp/lp_controller.php
@@ -203,6 +203,26 @@ if (isset($_POST['title'])) {
$redirectTo = '';
switch ($action) {
+ case 'recalculate':
+ if (!isset($oLP) || !$lp_found) {
+ Display::addFlash(Display::return_message(get_lang('NoLpFound'), 'error'));
+ header("Location: $listUrl");
+ exit;
+ }
+
+ $userId = isset($_GET['user_id']) ? (int) $_GET['user_id'] : 0;
+
+ if (0 === $userId) {
+ Display::addFlash(Display::return_message(get_lang('NoUserIdProvided'), 'error'));
+ header("Location: $listUrl");
+ exit;
+ }
+
+ $oLP->recalculateResultsForLp($userId);
+
+ $url = api_get_self().'?action=report&lp_id='.$lpId.'&'.api_get_cidreq();
+ header("Location: $url");
+ exit;
case 'author_view':
$teachers = [];
$field = new ExtraField('user');
diff --git a/public/main/lp/lp_report.php b/public/main/lp/lp_report.php
index d25885782c..df2e518403 100644
--- a/public/main/lp/lp_report.php
+++ b/public/main/lp/lp_report.php
@@ -359,6 +359,12 @@ if (!empty($users)) {
['data-id' => $userId, 'data-username' => $userInfo['username'], 'class' => 'delete_attempt']
);
+ $actions .= Display::url(
+ Display::getMdiIcon('file-document-refresh', 'ch-tool-icon', null, 32, get_lang('Recalculate result')),
+ api_get_path(WEB_CODE_PATH) . 'lp/lp_controller.php?'.api_get_cidreq().'&action=recalculate&user_id='.$userId.'&lp_id='.$lpId,
+ ['title' => get_lang('Recalculate result')]
+ );
+
$row[] = $actions;
$row['username'] = $userInfo['username'];
$userList[] = $row;
diff --git a/src/CourseBundle/Entity/CLpView.php b/src/CourseBundle/Entity/CLpView.php
index af10a20fa1..83b4b90d65 100644
--- a/src/CourseBundle/Entity/CLpView.php
+++ b/src/CourseBundle/Entity/CLpView.php
@@ -51,6 +51,11 @@ class CLpView
#[ORM\Column(name: 'progress', type: 'integer', nullable: true)]
protected ?int $progress = null;
+ public function getIid(): ?int
+ {
+ return $this->iid;
+ }
+
public function setViewCount(int $viewCount): self
{
$this->viewCount = $viewCount;