From 5a3ea44890e7fae4a9f375d7128d49c7c9bfe8d7 Mon Sep 17 00:00:00 2001 From: Christian Beeznest Date: Wed, 13 Aug 2025 13:03:32 -0500 Subject: [PATCH] Learnpath: Implement tool home page (LP list), new interface - refs #4763 --- assets/css/app.scss | 4 + assets/vue/components/StudentViewButton.vue | 32 +- assets/vue/components/lp/LpCardItem.vue | 138 ++++++ .../vue/components/lp/LpCategorySection.vue | 183 ++++++++ assets/vue/components/lp/LpLayout.vue | 5 + assets/vue/components/lp/LpRowItem.vue | 174 ++++++++ assets/vue/router/index.js | 2 + assets/vue/router/lp.js | 10 + assets/vue/services/lpService.js | 100 +++++ assets/vue/views/lp/LpList.vue | 407 ++++++++++++++++++ public/main/lp/lp_add.php | 25 +- public/main/lp/lp_add_category.php | 2 +- public/main/lp/lp_controller.php | 170 ++++---- public/main/upload/index.php | 2 +- public/main/upload/upload_ppt.php | 2 +- .../Controller/Api/LpReorderController.php | 38 ++ .../State/LpCollectionStateProvider.php | 76 ++++ src/CoreBundle/Tool/LearningPath.php | 2 +- src/CourseBundle/Entity/CLp.php | 70 +++ src/CourseBundle/Entity/CLpCategory.php | 49 ++- src/CourseBundle/Repository/CLpRepository.php | 99 ++++- 21 files changed, 1487 insertions(+), 103 deletions(-) create mode 100644 assets/vue/components/lp/LpCardItem.vue create mode 100644 assets/vue/components/lp/LpCategorySection.vue create mode 100644 assets/vue/components/lp/LpLayout.vue create mode 100644 assets/vue/components/lp/LpRowItem.vue create mode 100644 assets/vue/router/lp.js create mode 100644 assets/vue/services/lpService.js create mode 100644 assets/vue/views/lp/LpList.vue create mode 100644 src/CoreBundle/Controller/Api/LpReorderController.php create mode 100644 src/CoreBundle/State/LpCollectionStateProvider.php diff --git a/assets/css/app.scss b/assets/css/app.scss index 5d6286f08b5..8fa55ff6cb3 100644 --- a/assets/css/app.scss +++ b/assets/css/app.scss @@ -908,6 +908,10 @@ img.course-tool__icon { } } +.ghosting { opacity: .6; } +.chosen { outline: 2px solid #ddd; } +.dragging { outline: 2px solid var(--support-5, #f60); } + @import "~@fancyapps/fancybox/dist/jquery.fancybox.css"; @import "~timepicker/jquery.timepicker.min.css"; @import "~qtip2/dist/jquery.qtip.min.css"; diff --git a/assets/vue/components/StudentViewButton.vue b/assets/vue/components/StudentViewButton.vue index 7a6626e514e..222142175b8 100644 --- a/assets/vue/components/StudentViewButton.vue +++ b/assets/vue/components/StudentViewButton.vue @@ -28,24 +28,30 @@ const securityStore = useSecurityStore() const { isCoach } = useUserSessionSubscription() const isStudentView = computed({ - async set() { - const studentView = await permissionService.toogleStudentView() + async set(v) { + try { + const resp = await permissionService.toogleStudentView() + const mode = (typeof resp === "string" ? resp : resp?.data || "").toString().toLowerCase() + const desired = mode.includes("student") - platformConfigStore.studentView = studentView - - emit("change", studentView) + platformConfigStore.studentView = desired + emit("change", desired) + } catch (e) { + console.warn("[SVB] toggle failed", e) + const desired = !platformConfigStore.isStudentViewActive + platformConfigStore.studentView = desired + emit("change", desired) + } }, get() { return platformConfigStore.isStudentViewActive }, }) -const showButton = computed(() => { - return ( - securityStore.isAuthenticated && - cidReqStore.course && - (securityStore.isCourseAdmin || securityStore.isAdmin || isCoach.value) && - "true" === platformConfigStore.getSetting("course.student_view_enabled") - ) -}) +const showButton = computed(() => + securityStore.isAuthenticated && + cidReqStore.course && + (securityStore.isCourseAdmin || securityStore.isAdmin || isCoach.value) && + platformConfigStore.getSetting("course.student_view_enabled") === "true" +) diff --git a/assets/vue/components/lp/LpCardItem.vue b/assets/vue/components/lp/LpCardItem.vue new file mode 100644 index 00000000000..50dddf288ba --- /dev/null +++ b/assets/vue/components/lp/LpCardItem.vue @@ -0,0 +1,138 @@ + + + diff --git a/assets/vue/components/lp/LpCategorySection.vue b/assets/vue/components/lp/LpCategorySection.vue new file mode 100644 index 00000000000..c4f9fa53b8d --- /dev/null +++ b/assets/vue/components/lp/LpCategorySection.vue @@ -0,0 +1,183 @@ + + + diff --git a/assets/vue/components/lp/LpLayout.vue b/assets/vue/components/lp/LpLayout.vue new file mode 100644 index 00000000000..20cac927a7e --- /dev/null +++ b/assets/vue/components/lp/LpLayout.vue @@ -0,0 +1,5 @@ + diff --git a/assets/vue/components/lp/LpRowItem.vue b/assets/vue/components/lp/LpRowItem.vue new file mode 100644 index 00000000000..b0a5a457cef --- /dev/null +++ b/assets/vue/components/lp/LpRowItem.vue @@ -0,0 +1,174 @@ + + + diff --git a/assets/vue/router/index.js b/assets/vue/router/index.js index 67db987645e..c64a3288857 100644 --- a/assets/vue/router/index.js +++ b/assets/vue/router/index.js @@ -23,6 +23,7 @@ import assignments from "./assignments" import links from "./links" import glossary from "./glossary" import attendance from "./attendance" +import lpRoutes from "./lp" import catalogue from "./catalogue" import { useSecurityStore } from "../store/securityStore" import MyCourseList from "../views/user/courses/List.vue" @@ -236,6 +237,7 @@ const router = createRouter({ links, glossary, attendance, + lpRoutes, accountRoutes, personalFileRoutes, messageRoutes, diff --git a/assets/vue/router/lp.js b/assets/vue/router/lp.js new file mode 100644 index 00000000000..0a0dc2a8798 --- /dev/null +++ b/assets/vue/router/lp.js @@ -0,0 +1,10 @@ +export default { + path: "/resources/lp/:node(\\d+)", + meta: { requiresAuth: true, showBreadcrumb: true }, + name: "lp", + component: () => import("../components/lp/LpLayout.vue"), + redirect: { name: "LpList" }, + children: [ + { name: "LpList", path: "", component: () => import("../views/lp/LpList.vue") }, + ], +} diff --git a/assets/vue/services/lpService.js b/assets/vue/services/lpService.js new file mode 100644 index 00000000000..5fcb654e44d --- /dev/null +++ b/assets/vue/services/lpService.js @@ -0,0 +1,100 @@ +import { ENTRYPOINT } from "../config/entrypoint" +import axios from "axios" + +/** Lists learning paths filtered by course/session/title. */ +const getLearningPaths = async (params) => { + const response = await axios.get(`${ENTRYPOINT}learning_paths/`, { params }) + return response.data +} + +/** Fetches a learning path by ID (iid). */ +const getLearningPath = async (lpId) => { + const response = await axios.get(`${ENTRYPOINT}learning_paths/${lpId}/`) + return response.data +} + +/** Builds legacy VIEW URL (old student/teacher mode). */ +const buildLegacyViewUrl = (lpId, { cid, sid, isStudentView = "true" } = {}) => { + if (!lpId) { + console.warn("[buildLegacyViewUrl] called with empty lpId!", { lpId, cid, sid }) + console.trace() + } + const qs = new URLSearchParams({ action: "view", cid, sid, isStudentView }) + if (lpId) qs.set("lp_id", lpId) + return `/main/lp/lp_controller.php?${qs.toString()}` +} + +/** + * Builds a generic legacy controller URL (lp_controller.php) for any action. + * + * Supported signatures: + * buildLegacyActionUrl(lpId, "report", { cid, sid, node, params }) + * buildLegacyActionUrl("add_lp", { cid, sid, node, params }) // without lpId + */ +const buildLegacyActionUrl = (arg1, arg2, arg3 = {}) => { + let lpId, action, opts + if (typeof arg2 === "string") { + lpId = arg1 + action = arg2 + opts = arg3 + } else { + lpId = undefined + action = arg1 + opts = arg2 || {} + } + + const { cid, sid, node, gid = 0, gradebook = 0, origin = "", params = {} } = opts + + const search = new URLSearchParams() + search.set("action", action) + + if (cid !== undefined && cid !== null && String(cid) !== "" && Number(cid) !== 0) { + search.set("cid", cid) + } + if (lpId !== undefined && lpId !== null && String(lpId) !== "" && Number(lpId) !== 0) { + search.set("lp_id", lpId) + } + // include sid even if it is 0 + if (sid !== undefined && sid !== null) { + search.set("sid", Number.isNaN(Number(sid)) ? String(sid) : Number(sid)) + } + search.set("gid", Number(gid)) + search.set("gradebook", Number(gradebook)) + search.set("origin", origin) + if (node !== undefined && node !== null) search.set("node", node) + + Object.entries(params).forEach(([k, v]) => { + if (v !== undefined && v !== null) search.set(k, String(v)) + }) + + return `/main/lp/lp_controller.php?${search.toString()}` +} + +/** Navigates immediately to a legacy controller action. */ +const goLegacyAction = (lpId, action, opts = {}) => { + const url = + typeof action === "string" + ? (opts.absoluteUrl ?? false) + ? action // allow passing a direct absolute URL + : (opts.urlOverride ?? null) || + buildLegacyActionUrl(lpId, action, opts) + : "" + + window.location.href = url +} + +/** Lists LP categories for a course (empty included). */ +const getLpCategories = async (params) => { + // API Platform resource for CLpCategory (GET collection) + const response = await axios.get(`${ENTRYPOINT}learning_path_categories/`, { params }) + return response.data +} + +export default { + getLearningPaths, + getLearningPath, + buildLegacyViewUrl, + buildLegacyActionUrl, + goLegacyAction, + getLpCategories, +} diff --git a/assets/vue/views/lp/LpList.vue b/assets/vue/views/lp/LpList.vue new file mode 100644 index 00000000000..d506620c763 --- /dev/null +++ b/assets/vue/views/lp/LpList.vue @@ -0,0 +1,407 @@ + + + diff --git a/public/main/lp/lp_add.php b/public/main/lp/lp_add.php index dac5e52424d..851a8fc0fd3 100644 --- a/public/main/lp/lp_add.php +++ b/public/main/lp/lp_add.php @@ -36,12 +36,25 @@ function activate_end_date() { /* Constants and variables */ -$is_allowed_to_edit = api_is_allowed_to_edit(null, true); -$isStudentView = $_REQUEST['isStudentView'] ?? null; -$lpId = $_REQUEST['lp_id'] ?? null; - -if ((!$is_allowed_to_edit) || $isStudentView) { - header('location:lp_controller.php?action=view&lp_id='.$lpId.'&'.api_get_cidreq()); +$is_allowed_to_edit = api_is_allowed_to_create_course(); +$isStudentView = filter_var($_REQUEST['isStudentView'] ?? 'false', FILTER_VALIDATE_BOOLEAN); +$lpId = (int)($_REQUEST['lp_id'] ?? 0); + +if (!$is_allowed_to_edit || $isStudentView) { + $course = api_get_course_entity(api_get_course_int_id()); + $nodeId = method_exists($course,'getResourceNode') && $course->getResourceNode() + ? (int)$course->getResourceNode()->getId() + : 0; + + $cid = (int)($_REQUEST['cid'] ?? api_get_course_int_id()); + $sid = (int)($_REQUEST['sid'] ?? api_get_session_id()); + $qs = ['cid' => $cid, 'isStudentView' => 'true']; + if ($sid > 0) $qs['sid'] = $sid; + if (isset($_REQUEST['gid'])) $qs['gid'] = (int)$_REQUEST['gid']; + if (isset($_REQUEST['gradebook'])) $qs['gradebook'] = (int)$_REQUEST['gradebook']; + + $listUrl = api_get_path(WEB_PATH).'resources/lp/'.$nodeId.'?'.http_build_query($qs); + header('Location: '.$listUrl); exit; } diff --git a/public/main/lp/lp_add_category.php b/public/main/lp/lp_add_category.php index 7e366454e1c..d7cf6de6b5a 100644 --- a/public/main/lp/lp_add_category.php +++ b/public/main/lp/lp_add_category.php @@ -10,7 +10,7 @@ $this_section = SECTION_COURSES; api_protect_course_script(); -$is_allowed_to_edit = api_is_allowed_to_edit(null, true); +$is_allowed_to_edit = api_is_allowed_to_create_course(); if (!$is_allowed_to_edit) { header('location:lp_controller.php?action=list&'.api_get_cidreq()); diff --git a/public/main/lp/lp_controller.php b/public/main/lp/lp_controller.php index 6baf988addd..563413e0ca5 100644 --- a/public/main/lp/lp_controller.php +++ b/public/main/lp/lp_controller.php @@ -23,7 +23,7 @@ require_once __DIR__.'/../inc/global.inc.php'; api_protect_course_script(true); - +$oLP = null; $debug = false; $current_course_tool = TOOL_LEARNPATH; $lpItemId = isset($_REQUEST['id']) ? (int) $_REQUEST['id'] : 0; @@ -35,6 +35,27 @@ $courseInfo = api_get_course_info_by_id($courseId); $course = api_get_course_entity($courseId); $userId = api_get_user_id(); + +$nodeId = 0; +if (isset($course) && method_exists($course, 'getResourceNode') && $course->getResourceNode()) { + $nodeId = (int) $course->getResourceNode()->getId(); +} +$qs = [ + 'cid' => (int) $courseId, +]; +if (!empty($sessionId)) { + $qs['sid'] = $sessionId; +} +if (isset($_GET['gid'])) { + $qs['gid'] = (int) $_GET['gid']; +} +if (isset($_GET['gradebook'])) { + $qs['gradebook'] = (int) $_GET['gradebook']; +} +if (isset($_GET['isStudentView'])) { + $qs['isStudentView'] = Security::remove_XSS($_GET['isStudentView']); +} +$listUrl = api_get_path(WEB_PATH).'resources/lp/'.$nodeId.'?'.http_build_query($qs); $glossaryExtraTools = api_get_setting('show_glossary_in_extra_tools'); $showGlossary = in_array($glossaryExtraTools, ['true', 'lp', 'exercise_and_lp']); if ($showGlossary) { @@ -52,7 +73,7 @@ } $ajax_url = api_get_path(WEB_AJAX_PATH).'lp.ajax.php?lp_id='.$lpId.'&'.api_get_cidreq(); -$listUrl = api_get_self().'?action=list&'.api_get_cidreq(); + $lpfound = false; $myrefresh = 0; $myrefresh_id = 0; @@ -152,28 +173,27 @@ error_log('Entered lp_controller.php -+- (action: '.$action.')'); } -$eventLpId = $lpId; -if (empty($lpId)) { - if (isset($oLP)) { - $eventLpId = $oLP->get_id(); - } -} +$__returnTo = $_GET['returnTo'] ?? ''; +$__listUrlForSpa = $listUrl; // el de resources/lp/... +$goList = static function () use ($__listUrlForSpa, $__returnTo) { + header('Location: '.$__listUrlForSpa); + exit; +}; +if ($action === '' || $action === 'list') { + $goList(); +} +if (in_array($action, ['view','content'], true) && (empty($lpId) || !$lp_found || !is_object($oLP))) { + $goList(); +} +$eventLpId = $lpId ?: (($lp_found && is_object($oLP)) ? $oLP->get_id() : 0); $lp_detail_id = 0; $attemptId = 0; -switch ($action) { - case '': - case 'list': - $eventLpId = 0; - break; - case 'view': - case 'content': - $lp_detail_id = $oLP->get_current_item_id(); - $attemptId = $oLP->getCurrentAttempt(); - break; - default: - $lp_detail_id = (!empty($_REQUEST['id']) ? (int) $_REQUEST['id'] : 0); - break; +if ($lp_found && is_object($oLP) && in_array($action, ['view','content'], true)) { + $lp_detail_id = $oLP->get_current_item_id(); + $attemptId = $oLP->getCurrentAttempt(); +} else { + $lp_detail_id = !empty($_REQUEST['id']) ? (int)$_REQUEST['id'] : 0; } $logInfo = [ @@ -207,7 +227,7 @@ case 'recalculate': if (!isset($oLP) || !$lp_found) { Display::addFlash(Display::return_message(get_lang('No learning path found'), 'error')); - header("Location: $listUrl"); + $goList(); exit; } @@ -215,7 +235,7 @@ if (0 === $userId) { Display::addFlash(Display::return_message(get_lang('User ID not provided'), 'error')); - header("Location: $listUrl"); + $goList(); exit; } @@ -250,7 +270,7 @@ } if (!$lp_found) { // Check if the learnpath ID was defined, otherwise send back to list - require 'lp_list.php'; + $goList(); } else { require 'lp_add_author.php'; } @@ -293,7 +313,7 @@ ); } Display::addFlash(Display::return_message(get_lang('Message Sent'))); - header('Location: '.$listUrl); + $goList(); exit; break; case 'add_item': @@ -414,7 +434,7 @@ } if (!$lp_found) { // Check if the learnpath ID was defined, otherwise send back to list - require 'lp_list.php'; + $goList(); } else { Session::write('refresh', 1); @@ -472,7 +492,7 @@ if (isset($_REQUEST['id'])) { learnpath::moveUpCategory((int) $_REQUEST['id']); } - require 'lp_list.php'; + $goList(); break; case 'move_down_category': if (!$is_allowed_to_edit) { @@ -481,7 +501,7 @@ if (isset($_REQUEST['id'])) { learnpath::moveDownCategory((int) $_REQUEST['id']); } - require 'lp_list.php'; + $goList(); break; case 'delete_lp_category': if (!$is_allowed_to_edit) { @@ -493,7 +513,7 @@ Display::addFlash(Display::return_message(get_lang('Deleted'))); } } - require 'lp_list.php'; + $goList(); break; case 'add_lp': if (!$is_allowed_to_edit) { @@ -506,7 +526,7 @@ api_not_allowed(true); } if (!$lp_found) { - require 'lp_list.php'; + $goList(); } else { Session::write('refresh', 1); require 'lp_admin_view.php'; @@ -519,10 +539,10 @@ api_not_allowed(true); } if (!$lp_found) { - require 'lp_list.php'; + $goList(); } else { $oLP->set_autolaunch($lpId, $_GET['status']); - require 'lp_list.php'; + $goList(); exit; } } @@ -532,7 +552,7 @@ api_not_allowed(true); } if (!$lp_found) { - require 'lp_list.php'; + $goList(); } else { Session::write('refresh', 1); $url = api_get_self().'?action=add_item&type=step&lp_id='.intval($oLP->lp_id).'&'.api_get_cidreq(); @@ -593,7 +613,7 @@ api_not_allowed(true); } if (!$lp_found) { - require 'lp_list.php'; + $goList(); } else { if (isset($_POST['submit_button'])) { // Updating the lp.modified_on @@ -624,7 +644,7 @@ } if (!$lp_found) { - require 'lp_list.php'; + $goList(); } else { Session::write('refresh', 1); if (isset($_POST['submit_button'])) { @@ -659,7 +679,7 @@ api_not_allowed(true); } if (!$lp_found) { - require 'lp_list.php'; + $goList(); } else { Session::write('refresh', 1); require 'lp_view_item.php'; @@ -673,7 +693,7 @@ require 'lp_upload.php'; // Reinit current working directory as many functions in upload change it. chdir($cwdir); - require 'lp_list.php'; + $goList(); break; case 'copy': if (!$is_allowed_to_edit) { @@ -686,11 +706,11 @@ } if (!$lp_found) { - require 'lp_list.php'; + $goList(); } else { $oLP->copy(); } - require 'lp_list.php'; + $goList(); break; case 'export': if (!$is_allowed_to_edit) { @@ -701,7 +721,7 @@ api_not_allowed(true); } if (!$lp_found) { - require 'lp_list.php'; + $goList(); } else { ScormExport::export($oLP); exit(); @@ -721,12 +741,12 @@ } if (!$lp_found) { - require 'lp_list.php'; + $goList(); } else { $selectedItems = isset($_GET['items']) ? explode(',', $_GET['items']) : []; $result = ScormExport::exportToPdf($lpId, $courseInfo, $selectedItems); if (!$result) { - require 'lp_list.php'; + $goList(); } exit; } @@ -735,30 +755,30 @@ $allowExport = ('true' === api_get_setting('lp.allow_lp_chamilo_export')); if (api_is_allowed_to_edit() && $allowExport) { if (!$lp_found) { - require 'lp_list.php'; + $goList(); } else { $result = $oLP->exportToCourseBuildFormat($lpId); if (!$result) { - require 'lp_list.php'; + $goList(); } exit; } } - require 'lp_list.php'; + $goList(); break; case 'delete': if (!$is_allowed_to_edit) { api_not_allowed(true); } if (!$lp_found) { - require 'lp_list.php'; + $goList(); } else { Session::write('refresh', 1); $oLP->delete(null, $lpId, 'remove'); SkillModel::deleteSkillsFromItem($lpId, ITEM_TYPE_LEARNPATH); Display::addFlash(Display::return_message(get_lang('Deleted'))); Session::erase('oLP'); - require 'lp_list.php'; + $goList(); } break; case 'toggle_category_visibility': @@ -768,7 +788,7 @@ learnpath::toggleCategoryVisibility($_REQUEST['id'], $_REQUEST['new_status']); Display::addFlash(Display::return_message(get_lang('Update successful'))); - header('Location: '.$listUrl); + $goList(); exit; break; @@ -782,7 +802,7 @@ learnpath::toggleVisibility($_REQUEST['lp_id'], $_REQUEST['new_status']); Display::addFlash(Display::return_message(get_lang('Update successful'))); } - header('Location: '.$listUrl); + $goList(); exit; break; @@ -793,7 +813,7 @@ learnpath::toggleCategoryPublish($_REQUEST['id'], $_REQUEST['new_status']); Display::addFlash(Display::return_message(get_lang('Update successful'))); - header('Location: '.$listUrl); + $goList(); exit; break; @@ -806,7 +826,7 @@ learnpath::togglePublish($_REQUEST['lp_id'], $_REQUEST['new_status']); Display::addFlash(Display::return_message(get_lang('Update successful'))); } - header('Location: '.$listUrl); + $goList(); exit; break; @@ -819,7 +839,7 @@ learnpath::move($_REQUEST['lp_id'], 'up'); Display::addFlash(Display::return_message(get_lang('Update successful'))); } - header('Location: '.$listUrl); + $goList(); exit; break; @@ -832,7 +852,7 @@ learnpath::move($_REQUEST['lp_id'], 'down'); Display::addFlash(Display::return_message(get_lang('Update successful'))); } - header('Location: '.$listUrl); + $goList(); exit; break; @@ -842,7 +862,7 @@ } if (!$lp_found) { - require 'lp_list.php'; + $goList(); } else { Session::write('refresh', 1); require 'lp_edit.php'; @@ -855,7 +875,7 @@ api_not_allowed(true); } if (!$lp_found) { - require 'lp_list.php'; + $goList(); } else { Session::write('refresh', 1); if (!empty($_REQUEST['parent_item_id'])) { @@ -872,7 +892,7 @@ api_not_allowed(true); } if (!$lp_found) { - require 'lp_list.php'; + $goList(); } else { if (!empty($_REQUEST['id'])) { $oLP->delete_item($_REQUEST['id']); @@ -884,7 +904,7 @@ break; case 'restart': if (!$lp_found) { - require 'lp_list.php'; + $goList(); } else { $oLP->restart(); require 'lp_view.php'; @@ -892,7 +912,7 @@ break; case 'last': if (!$lp_found) { - require 'lp_list.php'; + $goList(); } else { $oLP->last(); require 'lp_view.php'; @@ -900,7 +920,7 @@ break; case 'first': if (!$lp_found) { - require 'lp_list.php'; + $goList(); } else { $oLP->first(); require 'lp_view.php'; @@ -908,7 +928,7 @@ break; case 'next': if (!$lp_found) { - require 'lp_list.php'; + $goList(); } else { $oLP->next(); require 'lp_view.php'; @@ -916,7 +936,7 @@ break; case 'previous': if (!$lp_found) { - require 'lp_list.php'; + $goList(); } else { $oLP->previous(); require 'lp_view.php'; @@ -924,7 +944,7 @@ break; case 'content': if (!$lp_found) { - require 'lp_list.php'; + $goList(); } else { $oLP->save_last(); $oLP->set_current_item($_GET['item_id']); @@ -934,7 +954,7 @@ break; case 'view': if (!$lp_found) { - require 'lp_list.php'; + $goList(); } else { if (!empty($_REQUEST['item_id'])) { $oLP->set_current_item($_REQUEST['item_id']); @@ -944,7 +964,7 @@ break; case 'save': if (!$lp_found) { - require 'lp_list.php'; + $goList(); } else { $oLP->save_item(); require 'lp_save.php'; @@ -952,7 +972,7 @@ break; case 'stats': if (!$lp_found) { - require 'lp_list.php'; + $goList(); } else { $oLP->save_current(); $oLP->save_last(); @@ -968,7 +988,7 @@ Session::write('refresh', 1); $oLP->save_last(); } - require 'lp_list.php'; + $goList(); break; case 'mode': // Switch between fullscreen and embedded mode. @@ -992,7 +1012,7 @@ } } - header('Location: '.$listUrl); + $goList(); exit; break; @@ -1002,7 +1022,7 @@ $oLP->update_default_scorm_commit(); Display::addFlash(Display::return_message(get_lang('Updated'))); } - header('Location: '.$listUrl); + $goList(); exit; break; @@ -1012,7 +1032,7 @@ $oLP->switch_attempt_mode(); Display::addFlash(Display::return_message(get_lang('Updated'))); } - header('Location: '.$listUrl); + $goList(); exit; break; @@ -1022,13 +1042,13 @@ $oLP->update_scorm_debug(); Display::addFlash(Display::return_message(get_lang('Updated'))); } - header('Location: '.$listUrl); + $goList(); exit; break; case 'return_to_course_homepage': if (!$lp_found) { - require 'lp_list.php'; + $goList(); } else { $oLP->save_current(); $oLP->save_last(); @@ -1063,7 +1083,7 @@ break; case 'impress': if (!$lp_found) { - require 'lp_list.php'; + $goList(); } else { if (!empty($_REQUEST['item_id'])) { $oLP->set_current_item($_REQUEST['item_id']); @@ -1092,12 +1112,12 @@ } if (!$lp_found) { - require 'lp_list.php'; + $goList(); } Session::write('refresh', 1); $oLP->set_seriousgame_mode(); - require 'lp_list.php'; + $goList(); break; case 'report': require 'lp_report.php'; @@ -1163,7 +1183,7 @@ ]); break; default: - require 'lp_list.php'; + $goList(); break; } diff --git a/public/main/upload/index.php b/public/main/upload/index.php index 01bd3e3c6b1..f3f6580237e 100644 --- a/public/main/upload/index.php +++ b/public/main/upload/index.php @@ -36,7 +36,7 @@ function check_unzip() { } "; -$is_allowed_to_edit = api_is_allowed_to_edit(null, true); +$is_allowed_to_edit = api_is_allowed_to_create_course(); if (!$is_allowed_to_edit) { api_not_allowed(true); } diff --git a/public/main/upload/upload_ppt.php b/public/main/upload/upload_ppt.php index fd08d328e13..36520abf1c4 100644 --- a/public/main/upload/upload_ppt.php +++ b/public/main/upload/upload_ppt.php @@ -60,7 +60,7 @@ Event::event_access_tool(TOOL_UPLOAD); // check access permissions (edit permission is needed to add a document or a LP) -$is_allowed_to_edit = api_is_allowed_to_edit(); +$is_allowed_to_edit = api_is_allowed_to_create_course(); if (!$is_allowed_to_edit) { api_not_allowed(true); diff --git a/src/CoreBundle/Controller/Api/LpReorderController.php b/src/CoreBundle/Controller/Api/LpReorderController.php new file mode 100644 index 00000000000..ff3d4c964b4 --- /dev/null +++ b/src/CoreBundle/Controller/Api/LpReorderController.php @@ -0,0 +1,38 @@ +reorder($request); + } + + public function reorder(Request $request): JsonResponse + { + $data = json_decode($request->getContent() ?: '[]', true); + $courseId = isset($data['courseId']) ? (int) $data['courseId'] : null; + $sid = array_key_exists('sid', $data) ? ($data['sid'] !== null ? (int)$data['sid'] : null) : null; + $order = $data['order'] ?? $data['ids'] ?? null; + $categoryId = array_key_exists('categoryId', $data) ? ($data['categoryId'] !== null ? (int)$data['categoryId'] : null) : null; + + if (!$courseId || !\is_array($order)) { + return new JsonResponse(['error' => 'Invalid payload'], 400); + } + + $this->lpRepo->reorderByIds($courseId, $sid, array_map('intval', $order), $categoryId); + + return new JsonResponse(null, 204); + } +} diff --git a/src/CoreBundle/State/LpCollectionStateProvider.php b/src/CoreBundle/State/LpCollectionStateProvider.php new file mode 100644 index 00000000000..5f8f78109ee --- /dev/null +++ b/src/CoreBundle/State/LpCollectionStateProvider.php @@ -0,0 +1,76 @@ +getClass() === CLp::class && $op->getName() === 'get_lp_collection_with_progress'; + } + + public function provide(Operation $operation, array $uriVariables = [], array $context = []): object|array|null + { + $f = $context['filters'] ?? []; + $parentNodeId = (int)($f['resourceNode.parent'] ?? 0); + if ($parentNodeId <= 0) { + return []; + } + + $course = $this->em->createQuery( + 'SELECT c + FROM ' . Course::class . ' c + JOIN c.resourceNode rn + WHERE rn.id = :nid' + ) + ->setParameter('nid', $parentNodeId) + ->getOneOrNullResult(); + + if (!$course) { + return []; + } + + $sid = isset($f['sid']) ? (int)$f['sid'] : null; + $title = $f['title'] ?? null; + + $session = $sid ? $this->em->getReference(CoreSession::class, $sid) : null; + + $lps = $this->lpRepo->findAllByCourse($course, $session, $title) + ->getQuery() + ->getResult(); + + if (!$lps) { + return []; + } + + $user = $this->security->getUser(); + if ($user instanceof User) { + $progress = $this->lpRepo->lastProgressForUser($lps, $user, $session); + foreach ($lps as $lp) { + $lp->setProgress($progress[(int)$lp->getIid()] ?? 0); + } + } + + return $lps; + } +} diff --git a/src/CoreBundle/Tool/LearningPath.php b/src/CoreBundle/Tool/LearningPath.php index 9327a4c33d1..a893eef48d5 100644 --- a/src/CoreBundle/Tool/LearningPath.php +++ b/src/CoreBundle/Tool/LearningPath.php @@ -28,7 +28,7 @@ public function getCategory(): string public function getLink(): string { - return '/main/lp/lp_controller.php'; + return '/resources/lp/:nodeId/'; } public function getIcon(): string diff --git a/src/CourseBundle/Entity/CLp.php b/src/CourseBundle/Entity/CLp.php index dbeb639e9b5..47408594d1e 100644 --- a/src/CourseBundle/Entity/CLp.php +++ b/src/CourseBundle/Entity/CLp.php @@ -6,10 +6,19 @@ namespace Chamilo\CourseBundle\Entity; +use ApiPlatform\Doctrine\Orm\Filter\SearchFilter; +use ApiPlatform\Metadata\ApiFilter; +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Metadata\Post; +use Chamilo\CoreBundle\Controller\Api\LpReorderController; use Chamilo\CoreBundle\Entity\AbstractResource; use Chamilo\CoreBundle\Entity\Asset; use Chamilo\CoreBundle\Entity\ResourceInterface; use Chamilo\CoreBundle\Entity\ResourceShowCourseResourcesInSessionInterface; +use Chamilo\CoreBundle\Filter\SidFilter; +use Chamilo\CoreBundle\State\LpCollectionStateProvider; use Chamilo\CourseBundle\Repository\CLpRepository; use DateTime; use Doctrine\Common\Collections\ArrayCollection; @@ -17,12 +26,53 @@ use Doctrine\ORM\Mapping as ORM; use Gedmo\Mapping\Annotation as Gedmo; use Stringable; +use Symfony\Component\Serializer\Annotation\Groups; +use Symfony\Component\Serializer\Annotation\MaxDepth; +use Symfony\Component\Serializer\Annotation\SerializedName; use Symfony\Component\Uid\Uuid; use Symfony\Component\Validator\Constraints as Assert; /** * Course learning paths (LPs). */ +#[ApiResource( + shortName: 'LearningPaths', + operations: [ + new GetCollection( + openapiContext: [ + 'summary' => 'List learning paths filtered by resourceNode.parent (course) and sid', + 'parameters' => [ + ['name'=>'resourceNode.parent','in'=>'query','required'=>true,'schema'=>['type'=>'integer']], + ['name'=>'sid','in'=>'query','required'=>false,'schema'=>['type'=>'integer']], + ['name'=>'title','in'=>'query','required'=>false,'schema'=>['type'=>'string']], + ], + ], + name: 'get_lp_collection_with_progress', + provider: LpCollectionStateProvider::class, + ), + new Get(security: "is_granted('ROLE_USER')"), + new Post( + uriTemplate: '/learning_paths/reorder', + status: 204, + controller: LpReorderController::class, + security: "is_granted('ROLE_TEACHER') or is_granted('ROLE_ADMIN')", + read: false, + deserialize: false, + name: 'lp_reorder' + ), + ], + normalizationContext: [ + 'groups' => ['lp:read','resource_node:read','resource_link:read'], + 'enable_max_depth' => true, + ], + denormalizationContext: ['groups' => ['lp:write']], + paginationEnabled: true, +)] +#[ApiFilter(SearchFilter::class, properties: [ + 'title' => 'partial', + 'resourceNode.parent' => 'exact', +])] +#[ApiFilter(filterClass: SidFilter::class)] #[ORM\Table(name: 'c_lp')] #[ORM\Entity(repositoryClass: CLpRepository::class)] class CLp extends AbstractResource implements ResourceInterface, ResourceShowCourseResourcesInSessionInterface, Stringable @@ -34,6 +84,7 @@ class CLp extends AbstractResource implements ResourceInterface, ResourceShowCou #[ORM\Column(name: 'iid', type: 'integer')] #[ORM\Id] #[ORM\GeneratedValue] + #[Groups(['lp:read'])] protected ?int $iid = null; #[Assert\NotBlank] @@ -42,12 +93,14 @@ class CLp extends AbstractResource implements ResourceInterface, ResourceShowCou #[Assert\NotBlank] #[ORM\Column(name: 'title', type: 'string', length: 255, nullable: false)] + #[Groups(['lp:read', 'lp:write'])] protected string $title; #[ORM\Column(name: 'ref', type: 'text', nullable: true)] protected ?string $ref = null; #[ORM\Column(name: 'description', type: 'text', nullable: true)] + #[Groups(['lp:read', 'lp:write'])] protected ?string $description; #[ORM\Column(name: 'path', type: 'text', nullable: false)] @@ -105,6 +158,8 @@ class CLp extends AbstractResource implements ResourceInterface, ResourceShowCou #[ORM\ManyToOne(targetEntity: CLpCategory::class, inversedBy: 'lps')] #[ORM\JoinColumn(name: 'category_id', referencedColumnName: 'iid')] + #[Groups(['lp:read'])] + #[MaxDepth(1)] protected ?CLpCategory $category = null; #[ORM\Column(name: 'max_attempts', type: 'integer', nullable: false)] @@ -122,9 +177,11 @@ class CLp extends AbstractResource implements ResourceInterface, ResourceShowCou protected DateTime $modifiedOn; #[ORM\Column(name: 'published_on', type: 'datetime', nullable: true)] + #[Groups(['lp:read'])] protected ?DateTime $publishedOn; #[ORM\Column(name: 'expired_on', type: 'datetime', nullable: true)] + #[Groups(['lp:read'])] protected ?DateTime $expiredOn = null; #[ORM\Column(name: 'accumulate_scorm_time', type: 'integer', nullable: false, options: ['default' => 1])] @@ -161,6 +218,10 @@ class CLp extends AbstractResource implements ResourceInterface, ResourceShowCou #[ORM\Column(name: 'auto_forward_video', type: 'boolean', options: ['default' => 0])] protected bool $autoForwardVideo = false; + #[Groups(['lp:read'])] + #[SerializedName('progress')] + private ?int $progress = null; + public function __construct() { $now = new DateTime(); @@ -662,6 +723,15 @@ public function setAutoForwardVideo(bool $autoForwardVideo): self return $this; } + public function getProgress(): int + { + return ($this->progress ?? 0); + } + public function setProgress(?int $progress): void + { + $this->progress = $progress; + } + public function getResourceIdentifier(): int|Uuid { return $this->getIid(); diff --git a/src/CourseBundle/Entity/CLpCategory.php b/src/CourseBundle/Entity/CLpCategory.php index 9ff32eae66a..c8e3ae85c01 100644 --- a/src/CourseBundle/Entity/CLpCategory.php +++ b/src/CourseBundle/Entity/CLpCategory.php @@ -17,10 +17,49 @@ use Doctrine\ORM\Mapping as ORM; use Stringable; use Symfony\Component\Validator\Constraints as Assert; - -/** - * Learning paths categories. - */ +use ApiPlatform\Metadata\ApiResource; +use ApiPlatform\Metadata\Get; +use ApiPlatform\Metadata\GetCollection; +use ApiPlatform\Doctrine\Orm\Filter\SearchFilter; +use ApiPlatform\Metadata\ApiFilter; +use Symfony\Component\Serializer\Annotation\Groups; + +#[ApiResource( + shortName: 'LearningPathCategories', + operations: [ + new GetCollection( + openapiContext: [ + 'summary' => 'List LP categories by course (resourceNode.parent) or sid', + 'parameters' => [ + [ + 'name' => 'resourceNode.parent', + 'in' => 'query', + 'required' => true, + 'description' => 'Parent ResourceNode (course node id)', + 'schema' => ['type' => 'integer'], + ], + [ + 'name' => 'sid', + 'in' => 'query', + 'required' => false, + 'description' => 'Session id (SidFilter si aplica)', + 'schema' => ['type' => 'integer'], + ], + ], + ], + ), + new Get(security: "is_granted('ROLE_USER')"), + ], + normalizationContext: [ + 'groups' => ['lp_category:read', 'resource_node:read', 'resource_link:read'], + 'enable_max_depth' => true, + ], + paginationEnabled: false, +)] +#[ApiFilter(SearchFilter::class, properties: [ + 'resourceNode.parent' => 'exact', + 'title' => 'partial', +])] #[ORM\Table(name: 'c_lp_category')] #[ORM\Entity(repositoryClass: CLpCategoryRepository::class)] class CLpCategory extends AbstractResource implements ResourceInterface, ResourceShowCourseResourcesInSessionInterface, Stringable @@ -28,10 +67,12 @@ class CLpCategory extends AbstractResource implements ResourceInterface, Resourc #[ORM\Column(name: 'iid', type: 'integer')] #[ORM\Id] #[ORM\GeneratedValue] + #[Groups(['lp_category:read','lp:read'])] protected ?int $iid = null; #[Assert\NotBlank] #[ORM\Column(name: 'title', type: 'text')] + #[Groups(['lp_category:read','lp:read'])] protected string $title; /** diff --git a/src/CourseBundle/Repository/CLpRepository.php b/src/CourseBundle/Repository/CLpRepository.php index c68c6f693af..95e5e20077f 100644 --- a/src/CourseBundle/Repository/CLpRepository.php +++ b/src/CourseBundle/Repository/CLpRepository.php @@ -9,11 +9,13 @@ use Chamilo\CoreBundle\Entity\Course; use Chamilo\CoreBundle\Entity\ResourceInterface; use Chamilo\CoreBundle\Entity\Session; +use Chamilo\CoreBundle\Entity\User; use Chamilo\CoreBundle\Repository\ResourceRepository; use Chamilo\CoreBundle\Repository\ResourceWithLinkInterface; use Chamilo\CourseBundle\Entity\CForum; use Chamilo\CourseBundle\Entity\CLp; use Chamilo\CourseBundle\Entity\CLpItem; +use Chamilo\CourseBundle\Entity\CLpView; use Doctrine\ORM\QueryBuilder; use Doctrine\Persistence\ManagerRegistry; use Exception; @@ -68,7 +70,7 @@ public function findAllByCourse( bool $onlyPublished = true, ?int $categoryId = null ): QueryBuilder { - $qb = $this->getResourcesByCourse($course, $session); + $qb = $this->getResourcesByCourse($course, $session, null, null, true, true); /*if ($onlyPublished) { $this->addDateFilterQueryBuilder(new DateTime(), $qb); @@ -137,4 +139,99 @@ public function getLpSessionId(int $lpId): ?int return null; } + + public function lastProgressForUser(iterable $lps, User $user, ?Session $session): array + { + $lpIds = []; + foreach ($lps as $lp) { + $id = (int) $lp->getIid(); + if ($id > 0) { $lpIds[] = $id; } + } + if (!$lpIds) { + return []; + } + + $em = $this->getEntityManager(); + $qb = $em->createQueryBuilder(); + $sub = $session + ? 'SELECT MAX(v2.iid) FROM ' . CLpView::class . ' v2 + WHERE v2.user = :user AND v2.session = :session AND v2.lp = v.lp' + : 'SELECT MAX(v2.iid) FROM ' . CLpView::class . ' v2 + WHERE v2.user = :user AND v2.session IS NULL AND v2.lp = v.lp'; + + $qb->select('IDENTITY(v.lp) AS lp_id', 'COALESCE(v.progress, 0) AS progress') + ->from(CLpView::class, 'v') + ->where($qb->expr()->in('v.lp', ':lpIds')) + ->andWhere('v.user = :user') + ->andWhere($session ? 'v.session = :session' : 'v.session IS NULL') + ->andWhere('v.iid = ('.$sub.')') + ->setParameter('lpIds', $lpIds) + ->setParameter('user', $user); + + if ($session) { + $qb->setParameter('session', $session); + } + + $rows = $qb->getQuery()->getArrayResult(); + + $map = array_fill_keys($lpIds, 0); + foreach ($rows as $r) { + $map[(int)$r['lp_id']] = (int)$r['progress']; + } + + return $map; + } + + public function reorderByIds(int $courseId, ?int $sessionId, array $orderedLpIds, ?int $categoryId = null): void + { + if (!$orderedLpIds) return; + + $em = $this->getEntityManager(); + $course = $em->getReference(Course::class, $courseId); + $session = $sessionId ? $em->getReference(Session::class, $sessionId) : null; + + $qb = $this->createQueryBuilder('lp') + ->addSelect('rn', 'rl') + ->join('lp.resourceNode', 'rn') + ->join('rn.resourceLinks', 'rl') + ->where('lp.iid IN (:ids)') + ->andWhere('rl.course = :course')->setParameter('course', $course) + ->setParameter('ids', $orderedLpIds); + + if ($session) { + $qb->andWhere('rl.session = :sid')->setParameter('sid', $session); + } else { + $qb->andWhere('rl.session IS NULL'); + } + + if ($categoryId !== null) { + $qb->andWhere('lp.category = :cat')->setParameter('cat', $categoryId); + } else { + $qb->andWhere('lp.category IS NULL'); + } + + /** @var CLp[] $lps */ + $lps = $qb->getQuery()->getResult(); + $linksByLpId = []; + $positions = []; + foreach ($lps as $lp) { + $link = $lp->getResourceNode()->getResourceLinkByContext($course, $session); + if (!$link) continue; + $linksByLpId[(int)$lp->getIid()] = $link; + $positions[] = (int)$link->getDisplayOrder(); + } + if (!$linksByLpId) return; + + sort($positions); + $start = $positions[0]; + + $pos = $start; + foreach ($orderedLpIds as $lpId) { + if (!isset($linksByLpId[$lpId])) continue; + $linksByLpId[$lpId]->setDisplayOrder($pos); + $pos++; + } + + $em->flush(); + } }