Merge remote-tracking branch 'origin/master'

pull/5817/head
Angel Fernando Quiroz Campos 2 months ago
commit 35abf4e9ff
No known key found for this signature in database
GPG Key ID: B284841AE3E562CD
  1. 18
      assets/vue/components/Breadcrumb.vue
  2. 1
      assets/vue/components/ccalendarevent/CCalendarEventInfo.vue
  3. 19
      assets/vue/components/documents/DocumentEntry.vue
  4. 21
      assets/vue/components/resource_links/ShowLinks.vue
  5. 10
      assets/vue/composables/fileUtils.js
  6. 6
      assets/vue/router/admin.js
  7. 34
      assets/vue/views/course/CatalogueCourses.vue
  8. 140
      public/main/admin/statistics/index.php
  9. 2
      public/main/exercise/recalculate_all.php
  10. 31
      public/main/inc/ajax/statistics.ajax.php
  11. 167
      public/main/inc/lib/statistics.lib.php
  12. 62
      public/main/lp/learnpath.class.php
  13. 20
      public/main/lp/lp_controller.php
  14. 6
      public/main/lp/lp_report.php
  15. 5
      src/CourseBundle/Entity/CLpView.php

@ -26,7 +26,7 @@
<script setup> <script setup>
import { ref, watchEffect } from "vue" import { ref, watchEffect } from "vue"
import { useRoute } from "vue-router" import { useRoute, useRouter } from "vue-router"
import { useI18n } from "vue-i18n" import { useI18n } from "vue-i18n"
import Breadcrumb from "primevue/breadcrumb" import Breadcrumb from "primevue/breadcrumb"
import { useCidReqStore } from "../store/cidReq" import { useCidReqStore } from "../store/cidReq"
@ -37,6 +37,7 @@ const legacyItems = ref(window.breadcrumb)
const cidReqStore = useCidReqStore() const cidReqStore = useCidReqStore()
const route = useRoute() const route = useRoute()
const router = useRouter()
const { t } = useI18n() const { t } = useI18n()
const { course, session } = storeToRefs(cidReqStore) const { course, session } = storeToRefs(cidReqStore)
@ -62,6 +63,21 @@ watchEffect(() => {
itemList.value = [] itemList.value = []
if (route.fullPath.startsWith("/admin")) {
const parts = route.path.split("/").filter(Boolean)
parts.forEach((part, index) => {
const path = `/${parts.slice(0, index + 1).join("/")}`
const matchedRoute = router.getRoutes().find(r => r.path === path)
if (matchedRoute) {
const label = matchedRoute.meta?.breadcrumb || t(part.charAt(0).toUpperCase() + part.slice(1))
itemList.value.push({
label: t(label),
route: { path },
})
}
})
}
if (route.name && route.name.includes("Page")) { if (route.name && route.name.includes("Page")) {
itemList.value.push({ itemList.value.push({
label: t("Pages"), label: t("Pages"),

@ -25,6 +25,7 @@
v-else v-else
:item="event" :item="event"
:show-status="false" :show-status="false"
:clickable-course="true"
/> />
<CalendarRemindersInfo :event="event" /> <CalendarRemindersInfo :event="event" />

@ -1,6 +1,7 @@
<template> <template>
<div v-if="isFile"> <div v-if="isFile">
<BaseAppLink <BaseAppLink
v-if="isPreviewable"
:data-type="dataType" :data-type="dataType"
:url="data.contentUrl" :url="data.contentUrl"
class="flex align-center" class="flex align-center"
@ -12,6 +13,18 @@
/> />
{{ data.title }} {{ data.title }}
</BaseAppLink> </BaseAppLink>
<BaseAppLink
v-else
:data-type="dataType"
:url="data.contentUrl"
class="flex align-center"
>
<ResourceIcon
:resource-data="data"
class="mr-2"
/>
{{ data.title }}
</BaseAppLink>
</div> </div>
<div v-else> <div v-else>
<BaseAppLink <BaseAppLink
@ -60,7 +73,7 @@ const dataType = computed(() => {
return "video" return "video"
} }
if (isAudio(props.data)) { if (isAudio(props.data)) {
return "video" return "audio"
} }
return "iframe" return "iframe"
@ -69,4 +82,8 @@ const dataType = computed(() => {
const isFile = computed(() => { const isFile = computed(() => {
return props.data && utilsIsFile(props.data) return props.data && utilsIsFile(props.data)
}) })
const isPreviewable = computed(() => {
return useFileUtils().isPreviewable(props.data)
})
</script> </script>

@ -6,7 +6,16 @@
> >
<div v-if="link.course" :class="{ 'text-right text-body-2': editStatus }"> <div v-if="link.course" :class="{ 'text-right text-body-2': editStatus }">
<span class="mdi mdi-book"></span> <span class="mdi mdi-book"></span>
{{ $t("Course") }}: {{ link.course.resourceNode.title }} <BaseAppLink
v-if="clickableCourse"
:to="{
name: 'CourseHome',
params: { id: courseId(link.course) }
}"
>
{{ $t("Course") }}: {{ link.course.resourceNode.title }}
</BaseAppLink>
<span v-else>{{ $t("Course") }}: {{ link.course.resourceNode.title }}</span>
</div> </div>
<div <div
@ -56,6 +65,7 @@
<script setup> <script setup>
import { RESOURCE_LINK_DRAFT, RESOURCE_LINK_PUBLISHED } from "../../constants/entity/resourcelink" import { RESOURCE_LINK_DRAFT, RESOURCE_LINK_PUBLISHED } from "../../constants/entity/resourcelink"
import { useI18n } from "vue-i18n" import { useI18n } from "vue-i18n"
import BaseAppLink from "../basecomponents/BaseAppLink.vue"
const { t } = useI18n() const { t } = useI18n()
@ -77,8 +87,17 @@ defineProps({
required: false, required: false,
default: false, default: false,
}, },
clickableCourse: {
type: Boolean,
required: false,
default: false,
},
}) })
const courseId = (course) => {
return course['@id'] ? course['@id'].split('/').pop() : null;
}
const visibilityOptions = [ const visibilityOptions = [
{ value: RESOURCE_LINK_PUBLISHED, label: t("Published") }, { value: RESOURCE_LINK_PUBLISHED, label: t("Published") },
{ value: RESOURCE_LINK_DRAFT, label: t("Draft") }, { value: RESOURCE_LINK_DRAFT, label: t("Draft") },

@ -1,4 +1,8 @@
export function useFileUtils() { export function useFileUtils() {
const isFile = (fileData) => {
return fileData.resourceNode && fileData.resourceNode.firstResourceFile
}
const isImage = (fileData) => { const isImage = (fileData) => {
return isFile(fileData) && fileData.resourceNode.firstResourceFile.image return isFile(fileData) && fileData.resourceNode.firstResourceFile.image
} }
@ -21,8 +25,9 @@ export function useFileUtils() {
return mimeType.split("/")[1].toLowerCase() === "html" return mimeType.split("/")[1].toLowerCase() === "html"
} }
const isFile = (fileData) => { const isPreviewable = (fileData) => {
return fileData.resourceNode && fileData.resourceNode.firstResourceFile const mimeType = fileData.resourceNode.firstResourceFile.mimeType.toLowerCase()
return isImage(fileData) || isVideo(fileData) || isAudio(fileData) || isHtml(fileData) || mimeType === "application/pdf"
} }
return { return {
@ -31,5 +36,6 @@ export function useFileUtils() {
isVideo, isVideo,
isAudio, isAudio,
isHtml, isHtml,
isPreviewable,
} }
} }

@ -1,19 +1,19 @@
export default { export default {
path: '/admin', path: '/admin',
name: 'admin', name: 'admin',
meta: { requiresAuth: true }, meta: { requiresAuth: true, showBreadcrumb: true },
component: () => import('../components/admin/AdminLayout.vue'), component: () => import('../components/admin/AdminLayout.vue'),
children: [ children: [
{ {
path: '', path: '',
name: 'AdminIndex', name: 'AdminIndex',
meta: { requiresAdmin: true, requiresSessionAdmin: true }, meta: { requiresAdmin: true, requiresSessionAdmin: true, showBreadcrumb: false },
component: () => import('../views/admin/AdminIndex.vue'), component: () => import('../views/admin/AdminIndex.vue'),
}, },
{ {
name: 'AdminConfigurationColors', name: 'AdminConfigurationColors',
path: 'configuration/colors', path: 'configuration/colors',
meta: { requiresAdmin: true, requiresSessionAdmin: true }, meta: { requiresAdmin: true, requiresSessionAdmin: true, showBreadcrumb: true },
component: () => import('../views/admin/AdminConfigureColors.vue'), component: () => import('../views/admin/AdminConfigureColors.vue'),
} }
], ],

@ -152,16 +152,39 @@
> >
<template #body="{ data }"> <template #body="{ data }">
<router-link <router-link
v-slot="{ navigate }" v-if="data.visibility === 3"
:to="{ name: 'CourseHome', params: { id: data.id } }" :to="{ name: 'CourseHome', params: { id: data.id } }"
> >
<Button <Button
:label="$t('Go to the course')" :label="$t('Go to the course')"
class="btn btn--primary text-white" class="btn btn--primary text-white"
icon="pi pi-external-link" icon="pi pi-external-link"
@click="navigate"
/> />
</router-link> </router-link>
<router-link
v-else-if="data.visibility === 2 && isUserInCourse(data)"
:to="{ name: 'CourseHome', params: { id: data.id } }"
>
<Button
:label="$t('Go to the course')"
class="btn btn--primary text-white"
icon="pi pi-external-link"
/>
</router-link>
<Button
v-else-if="data.visibility === 2 && !isUserInCourse(data)"
:label="$t('Not subscribed')"
class="btn btn--primary text-white"
icon="pi pi-times"
disabled
/>
<Button
v-else
:label="$t('Private course')"
class="btn btn--primary text-white"
icon="pi pi-lock"
disabled
/>
</template> </template>
</Column> </Column>
<template #footer> <template #footer>
@ -180,10 +203,13 @@ import DataTable from "primevue/datatable"
import Column from "primevue/column" import Column from "primevue/column"
import Rating from "primevue/rating" import Rating from "primevue/rating"
import { usePlatformConfig } from "../../store/platformConfig" import { usePlatformConfig } from "../../store/platformConfig"
import { useSecurityStore } from "../../store/securityStore"
const securityStore = useSecurityStore()
const status = ref(null) const status = ref(null)
const courses = ref([]) const courses = ref([])
const filters = ref(null) const filters = ref(null)
const currentUserId = securityStore.user.id
const platformConfigStore = usePlatformConfig() const platformConfigStore = usePlatformConfig()
const showCourseDuration = "true" === platformConfigStore.getSetting("course.show_course_duration") 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 () { const clearFilter = function () {
initFilters() initFilters()
} }

@ -23,7 +23,7 @@ $validated = false;
if ( if (
in_array( in_array(
$report, $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'); $htmlHeadXtra[] = api_get_build_js('libs/chartjs/chart.js');
@ -349,6 +349,7 @@ $tools = [
'report=zombies' => get_lang('Zombies'), 'report=zombies' => get_lang('Zombies'),
'report=users_active' => get_lang('Users statistics'), 'report=users_active' => get_lang('Users statistics'),
'report=users_online' => get_lang('Users online'), 'report=users_online' => get_lang('Users online'),
'report=new_user_registrations' => get_lang('New users registrations'),
], ],
get_lang('System') => [ get_lang('System') => [
'report=activities' => get_lang('ImportantActivities'), 'report=activities' => get_lang('ImportantActivities'),
@ -1445,6 +1446,143 @@ switch ($report) {
</div>'; </div>';
break; 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 .= '<div class="alert alert-info">' . get_lang('No data available for the selected date range') . '</div>';
} 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 .= '<canvas id="user_registration_chart"></canvas>';
$chartContent .= '<button id="backButton" style="display:none;" class="btn btn--info">'.get_lang('Back to Months').'</button>';
$creators = Statistics::getUserRegistrationsByCreator($dateStart, $dateEnd);
if (!empty($creators)) {
$chartCreatorContent = '<hr />';
$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 .= '<canvas id="user_registration_by_creator_chart"></canvas>';
}
}
}
$content .= $form->returnForm();
$content .= $chartContent;
$content .= $chartCreatorContent;
break;
case 'users': case 'users':
$content .= '<div class="grid grid-cols-3 gap-4">'; $content .= '<div class="grid grid-cols-3 gap-4">';
$content .= '<div><canvas id="canvas1" class="mb-5"></canvas></div>'; $content .= '<div><canvas id="canvas1" class="mb-5"></canvas></div>';

@ -26,7 +26,7 @@ if (!$is_allowedToEdit) {
$result = ExerciseLib::get_exam_results_data( $result = ExerciseLib::get_exam_results_data(
0, 0,
0, 0,
1, null,
'asc', 'asc',
$exerciseId, $exerciseId,
'', '',

@ -20,6 +20,37 @@ $order = isset($_REQUEST['sord']) && in_array($_REQUEST['sord'], ['asc', 'desc']
$table = ''; $table = '';
switch ($action) { 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': case 'add_student_to_boss':
$studentId = isset($_GET['student_id']) ? (int) $_GET['student_id'] : 0; $studentId = isset($_GET['student_id']) ? (int) $_GET['student_id'] : 0;
$bossId = isset($_GET['boss_id']) ? (int) $_GET['boss_id'] : 0; $bossId = isset($_GET['boss_id']) ? (int) $_GET['boss_id'] : 0;

@ -1248,31 +1248,75 @@ class Statistics
return $chartCode; 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); $data = json_encode($data);
$responsiveValue = $responsive ? 'true' : 'false'; $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 = ' $chartCode = '
<script> <script>
$(function() { $(function() {
Chart.defaults.responsive = '.$responsiveValue.'; Chart.defaults.responsive = '.$responsiveValue.';
var ctx = document.getElementById("'.$elementId.'").getContext("2d"); var ctx = document.getElementById("'.$elementId.'").getContext("2d");
ctx.canvas.width = 420; '.$canvasSize.'
ctx.canvas.height = 420;
var chart = new Chart(ctx, { var chart = new Chart(ctx, {
type: "'.$type.'", type: "'.$type.'",
data: '.$data.', data: '.$data.',
options: { options: {
plugins: { plugins: {
'.$options.' '.$options.',
datalabels: {
anchor: "end",
align: "left",
formatter: function(value) {
return value;
},
color: "#000"
},
}, },
cutout: "25%" '.$indexAxisOption.'
scales: {
x: { beginAtZero: true },
y: { barPercentage: 0.5 }
},
'.$onClickScript.'
} }
}); });
var title = chart.options.plugins.title.text; var title = chart.options.plugins.title.text;
$("#'.$elementId.'_title").html(title); $("#'.$elementId.'_title").html(title);
$("#'.$elementId.'_table").html(chart.data.datasets[0].data); $("#'.$elementId.'_table").html(chart.data.datasets[0].data);
'.$extraButtonHandler.'
}); });
</script>'; </script>';
@ -1499,4 +1543,115 @@ class Statistics
return Database::store_result($stmt, 'ASSOC'); 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;
}
} }

@ -4,6 +4,7 @@
use Chamilo\CoreBundle\Entity\Course; use Chamilo\CoreBundle\Entity\Course;
use Chamilo\CoreBundle\Entity\ResourceLink; use Chamilo\CoreBundle\Entity\ResourceLink;
use Chamilo\CoreBundle\Entity\TrackEExercise;
use Chamilo\CoreBundle\Entity\User; use Chamilo\CoreBundle\Entity\User;
use Chamilo\CoreBundle\Entity\Session as SessionEntity; use Chamilo\CoreBundle\Entity\Session as SessionEntity;
use Chamilo\CoreBundle\ServiceHelper\ThemeHelper; use Chamilo\CoreBundle\ServiceHelper\ThemeHelper;
@ -8796,4 +8797,65 @@ class learnpath
return $document ? $repo->getResourceFileContent($document) : ''; 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'));
}
}
}
} }

@ -203,6 +203,26 @@ if (isset($_POST['title'])) {
$redirectTo = ''; $redirectTo = '';
switch ($action) { 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': case 'author_view':
$teachers = []; $teachers = [];
$field = new ExtraField('user'); $field = new ExtraField('user');

@ -359,6 +359,12 @@ if (!empty($users)) {
['data-id' => $userId, 'data-username' => $userInfo['username'], 'class' => 'delete_attempt'] ['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[] = $actions;
$row['username'] = $userInfo['username']; $row['username'] = $userInfo['username'];
$userList[] = $row; $userList[] = $row;

@ -51,6 +51,11 @@ class CLpView
#[ORM\Column(name: 'progress', type: 'integer', nullable: true)] #[ORM\Column(name: 'progress', type: 'integer', nullable: true)]
protected ?int $progress = null; protected ?int $progress = null;
public function getIid(): ?int
{
return $this->iid;
}
public function setViewCount(int $viewCount): self public function setViewCount(int $viewCount): self
{ {
$this->viewCount = $viewCount; $this->viewCount = $viewCount;

Loading…
Cancel
Save