diff --git a/assets/vue/App.vue b/assets/vue/App.vue index 843b0ee342c..ecad47a570c 100644 --- a/assets/vue/App.vue +++ b/assets/vue/App.vue @@ -233,6 +233,43 @@ const isEmbeddedContext = computed(() => { return isPickerContext.value || isIframeContext.value || isDialogContext.value }) +const isTruthyQueryValue = (value) => { + return ["1", "true", "yes", "on"].includes(String(value || "").toLowerCase()) +} + +const isLearnpathEmbeddedRoute = computed(() => { + const qp = queryParams.value + const origin = String(qp.get("origin") || "").toLowerCase() + const lpAction = String(qp.get("action") || "").toLowerCase() + const hasLpId = qp.has("lp_id") + + // LP player/runtime screens are rendered inside the learning path player. + // They must use EmptyLayout to avoid duplicated Chamilo header/sidebar. + if ( + hasLpId && + ("view" === lpAction || isTruthyQueryValue(qp.get("embedded")) || isTruthyQueryValue(qp.get("isStudentView"))) + ) { + return true + } + + if ("learnpath" !== origin) { + return false + } + + // Authoring screens launched from the LP add-item screen must keep the full + // course layout. This is used by Exercise, Forum and Survey creation flows. + if (isTruthyQueryValue(qp.get("returnToLp"))) { + return false + } + + return ( + qp.has("lp_init") || + qp.has("learnpath_id") || + qp.has("learnpath_item_id") || + qp.has("learnpath_item_view_id") + ) +}) + const layout = computed(() => { if (showAccessUrlChosserLayout.value && !hideGlobalUi.value) { return AccessUrlChooserLayout @@ -250,12 +287,7 @@ const layout = computed(() => { return EmptyLayout } - const lpAction = String(qp.get("action") || "").toLowerCase() - const isLearningPathPlayerContext = - qp.has("lp_id") && - ("view" === lpAction || "1" === qp.get("embedded") || "true" === String(qp.get("isStudentView") || "")) - - if (isLearningPathPlayerContext) { + if (isLearnpathEmbeddedRoute.value) { return EmptyLayout } diff --git a/assets/vue/components/basecomponents/BaseCheckbox.vue b/assets/vue/components/basecomponents/BaseCheckbox.vue index 2d9d0bf5b73..532e33498db 100644 --- a/assets/vue/components/basecomponents/BaseCheckbox.vue +++ b/assets/vue/components/basecomponents/BaseCheckbox.vue @@ -3,13 +3,14 @@ @@ -40,5 +41,9 @@ defineProps({ type: [String, Number, Object], default: undefined, }, + disabled: { + type: Boolean, + default: false, + }, }) diff --git a/assets/vue/components/basecomponents/ChamiloIcons.js b/assets/vue/components/basecomponents/ChamiloIcons.js index 8767d055a85..2f4a32e4b43 100644 --- a/assets/vue/components/basecomponents/ChamiloIcons.js +++ b/assets/vue/components/basecomponents/ChamiloIcons.js @@ -16,6 +16,7 @@ export const chamiloIconToClass = { "back": "mdi mdi-arrow-left-bold-box", "bug-check": "", "bug-outline": "", + "broom": "mdi mdi-broom", "catalogue-add": "mdi mdi-book-plus", "catalogue-remove": "mdi mdi-book-minus-outline", "calendar-plus": "mdi mdi-calendar-plus", @@ -112,8 +113,10 @@ export const chamiloIconToClass = { "reply": "mdi mdi-reply", "reply-all": "mdi mdi-reply-all", "restart": "mdi mdi-restart", - "autolunch": "mdi mdi-rocket-launch", - "autolunch-off": "mdi mdi-rocket-launch-outline", + "rocket-launch": "mdi mdi-rocket-launch", + "rocket-launch-outline": "mdi mdi-rocket-launch-outline", + "autolaunch": "mdi mdi-rocket-launch", + "autolaunch-off": "mdi mdi-rocket-launch-outline", "select-all": "mdi mdi-select-group", "send": "mdi mdi-send", "sent": "mdi mdi-send-check", @@ -160,6 +163,7 @@ export const chamiloIconToClass = { "account-cancel": "mdi mdi-account-cancel", "zip-pack": "mdi mdi-archive-arrow-down", "zip-unpack": "mdi mdi-archive-arrow-up", + "clean-all": "mdi mdi-broom", "clear-all": "mdi mdi-broom", "qrcode": "mdi mdi-qrcode", "minus": "mdi mdi-minus", diff --git a/assets/vue/components/course/CourseTool.vue b/assets/vue/components/course/CourseTool.vue index 55caf652d24..0443969f796 100644 --- a/assets/vue/components/course/CourseTool.vue +++ b/assets/vue/components/course/CourseTool.vue @@ -3,8 +3,8 @@ {{ $t(tool.tool.titleToShow) }} @@ -81,7 +81,7 @@ import { useCidReqStore } from "../../store/cidReq" const platformConfigStore = usePlatformConfig() const cidReqStore = useCidReqStore() -const { session } = storeToRefs(cidReqStore) +const { course, session } = storeToRefs(cidReqStore) const { getSetting } = storeToRefs(platformConfigStore) const isSorting = inject("isSorting") @@ -127,6 +127,65 @@ const titleCustomClass = computed(() => { } return "" }) + +function getCourseResourceNodeId() { + return Number(cidReqStore.course?.resourceNode?.id) || Number(course.value?.resourceNode?.id) || 0 +} + +const isExerciseTool = computed(() => { + const values = [ + props.tool?.title, + props.tool?.titleToShow, + props.tool?.name, + props.tool?.tool?.title, + props.tool?.tool?.titleToShow, + props.tool?.tool?.name, + props.tool?.url, + ] + .filter(Boolean) + .join(" ") + .toLowerCase() + + return ( + values.includes("quiz") || + values.includes("exercise/exercise.php") || + values.includes("/main/exercise/exercise.php") || + values.includes("tests") || + values.includes("exercises") + ) +}) + +const exerciseToolRoute = computed(() => { + if (!isExerciseTool.value) { + return null + } + + const nodeId = getCourseResourceNodeId() + const courseId = Number(course.value?.id) || Number(cidReqStore.course?.id) || 0 + + if (nodeId <= 0 || courseId <= 0) { + return null + } + + return { + name: "ExerciseList", + params: { node: nodeId }, + query: { + cid: courseId, + sid: Number(session.value?.id) || 0, + gid: 0, + }, + } +}) + +const resolvedToolTo = computed(() => { + return exerciseToolRoute.value || props.tool.to || null +}) + +const resolvedToolUrl = computed(() => { + return exerciseToolRoute.value ? null : props.tool.url || null +}) + const isVisible = computed(() => { if (typeof props.tool.visibility === "boolean") { return props.tool.visibility diff --git a/assets/vue/layouts/MyCourses.vue b/assets/vue/layouts/MyCourses.vue index 2c81f52f44c..412c012d78c 100644 --- a/assets/vue/layouts/MyCourses.vue +++ b/assets/vue/layouts/MyCourses.vue @@ -11,7 +11,7 @@ v-if="showPendingExerciseAttemptsLink" :label="t('Pending tests')" icon="quiz" - to-url="/main/exercise/pending.php" + :route="{ name: 'ExercisePendingAttempts' }" type="primary" /> diff --git a/assets/vue/router/exercise.js b/assets/vue/router/exercise.js new file mode 100644 index 00000000000..a467988d5e1 --- /dev/null +++ b/assets/vue/router/exercise.js @@ -0,0 +1,163 @@ +export default { + path: "/resources/exercise/:node/", + meta: { + requiresAuth: true, + showBreadcrumb: true, + tool: "exercise", + breadcrumb: "Exercises", + }, + name: "exercise", + component: () => import("../components/layout/SimpleRouterViewLayout.vue"), + redirect: { name: "ExerciseList" }, + children: [ + { + name: "ExerciseList", + path: "", + meta: { breadcrumb: "Exercises" }, + component: () => import("../views/exercise/ExerciseListView.vue"), + }, + { + name: "ExerciseCreate", + path: "create", + meta: { breadcrumb: "Create exercise" }, + component: () => import("../views/exercise/ExerciseConfigurationView.vue"), + }, + { + name: "ExerciseCategories", + path: "categories", + meta: { breadcrumb: "Exercise categories" }, + component: () => import("../views/exercise/ExerciseCategoryManagerView.vue"), + props: { categoryType: "exercise" }, + }, + { + name: "ExerciseQuestionCategories", + path: "question-categories", + meta: { breadcrumb: "Question categories" }, + component: () => import("../views/exercise/ExerciseCategoryManagerView.vue"), + props: { categoryType: "question" }, + }, + { + name: "ExerciseImportAiken", + path: "import/aiken", + meta: { breadcrumb: "Import Aiken quiz" }, + component: () => import("../views/exercise/ExerciseQuestionImportView.vue"), + props: { importType: "aiken" }, + }, + { + name: "ExerciseImportExcel", + path: "import/excel", + meta: { breadcrumb: "Import quiz from Excel" }, + component: () => import("../views/exercise/ExerciseQuestionImportView.vue"), + props: { importType: "excel" }, + }, + { + name: "ExerciseImportQti2", + path: "import/qti2", + meta: { breadcrumb: "Import exercises QTI2" }, + component: () => import("../views/exercise/ExerciseQuestionImportView.vue"), + props: { importType: "qti2" }, + }, + { + name: "ExerciseAiAikenGenerator", + path: "aiken-generator", + meta: { breadcrumb: "AI Aiken generator" }, + component: () => import("../views/exercise/ExerciseAiAikenGeneratorView.vue"), + }, + { + name: "ExerciseOverview", + path: ":exerciseId/overview", + meta: { breadcrumb: "Exercise overview" }, + component: () => import("../views/exercise/ExerciseOverviewView.vue"), + }, + { + name: "ExerciseEdit", + path: ":exerciseId/edit", + meta: { breadcrumb: "Edit exercise" }, + component: () => import("../views/exercise/ExerciseConfigurationView.vue"), + }, + { + name: "ExercisePlayer", + path: ":exerciseId/player", + meta: { breadcrumb: "Exercise player" }, + component: () => import("../views/exercise/ExercisePlayerView.vue"), + }, + { + name: "ExerciseResult", + path: ":exerciseId/result/:attemptId", + meta: { breadcrumb: "Exercise result" }, + component: () => import("../views/exercise/ExerciseResultView.vue"), + }, + { + name: "ExerciseReport", + path: ":exerciseId/report", + meta: { breadcrumb: "Learner score" }, + component: () => import("../views/exercise/ExerciseReportView.vue"), + }, + { + name: "ExerciseQuestionStats", + path: ":exerciseId/question-stats", + meta: { breadcrumb: "Question stats" }, + component: () => import("../views/exercise/ExerciseQuestionStatsView.vue"), + }, + { + name: "ExerciseReportByQuestion", + path: ":exerciseId/report-by-question", + meta: { breadcrumb: "Report by question" }, + component: () => import("../views/exercise/ExerciseReportByQuestionView.vue"), + }, + { + name: "ExerciseLiveResults", + path: ":exerciseId/live-results", + meta: { breadcrumb: "Live results" }, + component: () => import("../views/exercise/ExerciseLiveResultsView.vue"), + }, + { + name: "ExerciseGlobalQuestionSelector", + path: "questions/create", + meta: { breadcrumb: "Add a question" }, + component: () => import("../views/exercise/ExerciseGlobalQuestionSelectorView.vue"), + }, + { + name: "ExerciseGlobalQuestionCreate", + path: "questions/create/:questionType", + meta: { breadcrumb: "Create question" }, + component: () => import("../views/exercise/ExerciseQuestionEditorView.vue"), + }, + { + name: "ExerciseQuestionPool", + path: "questions/recycle", + meta: { breadcrumb: "Recycle existing questions" }, + component: () => import("../views/exercise/ExerciseQuestionBankView.vue"), + }, + { + name: "ExerciseGlobalQuestionEdit", + path: "questions/:questionId/edit", + meta: { breadcrumb: "Edit question" }, + component: () => import("../views/exercise/ExerciseQuestionEditorView.vue"), + }, + { + name: "ExerciseQuestions", + path: ":exerciseId/questions", + meta: { breadcrumb: "Questions" }, + component: () => import("../views/exercise/ExerciseQuestionSelectorView.vue"), + }, + { + name: "ExerciseQuestionBank", + path: ":exerciseId/question-bank", + meta: { breadcrumb: "Recycle existing questions" }, + component: () => import("../views/exercise/ExerciseQuestionBankView.vue"), + }, + { + name: "ExerciseQuestionCreate", + path: ":exerciseId/questions/create/:questionType", + meta: { breadcrumb: "Create question" }, + component: () => import("../views/exercise/ExerciseQuestionEditorView.vue"), + }, + { + name: "ExerciseQuestionEdit", + path: ":exerciseId/questions/:questionId/edit", + meta: { breadcrumb: "Edit question" }, + component: () => import("../views/exercise/ExerciseQuestionEditorView.vue"), + }, + ], +} diff --git a/assets/vue/router/index.js b/assets/vue/router/index.js index 307133e8b81..bf8377ef31a 100644 --- a/assets/vue/router/index.js +++ b/assets/vue/router/index.js @@ -26,6 +26,7 @@ import assignments from "./assignments" import links from "./links" import forum from "./forum" import survey from "./survey" +import exercise from "./exercise" import courseDescription from "./courseDescription" import notebook from "./notebook" import portfolio from "./portfolio" @@ -294,6 +295,11 @@ async function courseHomeBeforeEnter(to) { const courseSettingsStore = useCourseSettings() const sid = sessionId ? `&sid=${sessionId}` : "" + const courseContextQuery = { + cid: courseId, + sid: sessionId || 0, + gid: 0, + } // Document auto-launch const documentAutoLaunch = parseInt(courseSettingsStore.getSetting("enable_document_auto_launch"), 10) || 0 @@ -307,20 +313,27 @@ async function courseHomeBeforeEnter(to) { // Exercise auto-launch const exerciseAutoLaunch = parseInt(courseSettingsStore.getSetting("enable_exercise_auto_launch"), 10) || 0 + const exerciseResourceNodeId = cidReqStore.course?.resourceNode?.id - if (exerciseAutoLaunch === 2) { + if (exerciseAutoLaunch === 2 && exerciseResourceNodeId) { sessionStorage.setItem(autoLaunchKey, "true") - window.location.href = `/main/exercise/exercise.php?cid=${courseId}` + sid - return false + return { + name: "ExerciseList", + params: { node: exerciseResourceNodeId }, + query: courseContextQuery, + } } else if (exerciseAutoLaunch === 1) { const exerciseId = await courseService.getAutoLaunchExerciseId(courseId, sessionId) - if (exerciseId) { + if (exerciseId && exerciseResourceNodeId) { sessionStorage.setItem(autoLaunchKey, "true") - window.location.href = `/main/exercise/overview.php?exerciseId=${exerciseId}&cid=${courseId}` + sid - return false + return { + name: "ExerciseOverview", + params: { node: exerciseResourceNodeId, exerciseId }, + query: courseContextQuery, + } } } @@ -443,6 +456,26 @@ const router = createRouter({ component: MyCourseList, meta: { requiresAuth: true }, }, + { + path: "exercise/pending-attempts", + name: "ExercisePendingAttempts", + component: () => import("../views/exercise/ExercisePendingAttemptsView.vue"), + meta: { + requiresAuth: true, + showBreadcrumb: true, + breadcrumb: "Pending attempts", + }, + }, + { + path: "exercise/global-report", + name: "ExerciseGlobalReport", + component: () => import("../views/exercise/ExerciseGlobalReportView.vue"), + meta: { + requiresAuth: true, + showBreadcrumb: true, + breadcrumb: "Exercises global report", + }, + }, ], }, { @@ -490,6 +523,7 @@ const router = createRouter({ links, forum, survey, + exercise, courseDescription, notebook, wiki, diff --git a/assets/vue/services/exerciseService.js b/assets/vue/services/exerciseService.js new file mode 100644 index 00000000000..0dcd21871ec --- /dev/null +++ b/assets/vue/services/exerciseService.js @@ -0,0 +1,451 @@ +import baseService from "./baseService" + +function cleanParams(params = {}) { + const query = {} + + for (const [key, value] of Object.entries(params)) { + if (value !== undefined && value !== null && String(value) !== "") { + query[key] = value + } + } + + return query +} + +function buildQueryString(params = {}) { + const query = new URLSearchParams() + + for (const [key, value] of Object.entries(cleanParams(params))) { + query.set(key, String(value)) + } + + const queryString = query.toString() + + return queryString ? `?${queryString}` : "" +} + +function exerciseRequestConfig(config = {}) { + return { + ...config, + } +} + + +function getLegacyBasePath() { + if (typeof window === "undefined") { + return "" + } + + const pathname = window.location?.pathname || "" + const markers = ["/resources/", "/courses/", "/search/", "/main/"] + + for (const marker of markers) { + const markerPosition = pathname.indexOf(marker) + + if (markerPosition > 0) { + return pathname.substring(0, markerPosition) + } + } + + return "" +} + +function buildLegacyExerciseAjaxUrl(params = {}) { + const query = new URLSearchParams() + + query.set("a", "browser_test") + + for (const [key, value] of Object.entries(cleanParams(params))) { + if (["cid", "sid", "gid"].includes(key)) { + query.set(key, String(value)) + } + } + + return `${getLegacyBasePath()}/main/inc/ajax/exercise.ajax.php?${query.toString()}` +} + +function buildBrowserCheckBody(params = {}, exerciseId, sleep = false) { + const body = new URLSearchParams() + + body.set("exe_id", "1") + body.set("exerciseId", String(exerciseId || 0)) + body.set("learnpath_id", String(params.learnpath_id || params.lp_id || 0)) + body.set("learnpath_item_id", String(params.learnpath_item_id || params.lp_item_id || 0)) + body.set("learnpath_item_view_id", String(params.learnpath_item_view_id || 0)) + body.set("reminder", "0") + body.set("type", "simple") + body.set("question_id", "23") + body.set("choice[23]", "45") + + if (sleep) { + body.set("sleep", "1") + } + + return body +} + +export default { + async getExerciseList(params = {}) { + return await baseService.get("/api/exercise/list", cleanParams(params), exerciseRequestConfig()) + }, + + async saveExerciseListAction(payload = {}, params = {}) { + const queryString = buildQueryString(params) + + return await baseService.post(`/api/exercise/list/action${queryString}`, payload, {}, exerciseRequestConfig()) + }, + + async getExerciseOverview(params = {}, exerciseId) { + return await baseService.get(`/api/exercise/overview/${exerciseId}`, cleanParams(params), exerciseRequestConfig()) + }, + + async checkExerciseBrowser(params = {}, exerciseId, sleep = false) { + const response = await fetch(buildLegacyExerciseAjaxUrl(params), { + method: "POST", + credentials: "same-origin", + headers: { + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", + "X-Requested-With": "XMLHttpRequest", + }, + body: buildBrowserCheckBody(params, exerciseId, sleep), + }) + + if (!response.ok) { + return false + } + + return (await response.text()).trim() === "ok" + }, + + async getExerciseCategories(categoryType, params = {}) { + return await baseService.get(`/api/exercise/categories/${categoryType}`, cleanParams(params), exerciseRequestConfig()) + }, + + async saveExerciseCategoryAction(categoryType, payload = {}, params = {}) { + const queryString = buildQueryString(params) + + return await baseService.post( + `/api/exercise/categories/${categoryType}/action${queryString}`, + payload, + {}, + exerciseRequestConfig(), + ) + }, + + async getExerciseConfiguration(params = {}, exerciseId = null) { + const endpoint = exerciseId ? `/api/exercise/configuration/${exerciseId}` : "/api/exercise/configuration" + + return await baseService.get(endpoint, cleanParams(params), exerciseRequestConfig()) + }, + + + async getExerciseQuestions(params = {}, exerciseId) { + return await baseService.get(`/api/exercise/questions/${exerciseId}`, cleanParams(params), exerciseRequestConfig()) + }, + + async getExerciseGlobalQuestionTypes(params = {}) { + return await baseService.get('/api/exercise/questions/global', cleanParams(params), exerciseRequestConfig()) + }, + + async getExerciseRuntime(params = {}, exerciseId) { + return await baseService.get(`/api/exercise/runtime/${exerciseId}`, cleanParams(params), exerciseRequestConfig()) + }, + + async startExerciseAttempt(payload = {}, params = {}, exerciseId) { + const queryString = buildQueryString(params) + + return await baseService.post( + `/api/exercise/runtime/${exerciseId}/attempt${queryString}`, + payload, + {}, + exerciseRequestConfig(), + ) + }, + + async saveExerciseRuntimeAnswer(payload = {}, params = {}, exerciseId, attemptId) { + const queryString = buildQueryString(params) + + return await baseService.post( + `/api/exercise/runtime/${exerciseId}/attempt/${attemptId}/answer${queryString}`, + payload, + {}, + exerciseRequestConfig(), + ) + }, + + + async uploadExerciseRuntimeAnswer(formData, params = {}, exerciseId, attemptId) { + const queryString = buildQueryString(params) + + return await baseService.postForm( + `/api/exercise/runtime/${exerciseId}/attempt/${attemptId}/upload-answer${queryString}`, + formData, + exerciseRequestConfig(), + ) + }, + + async finishExerciseRuntimeAttempt(payload = {}, params = {}, exerciseId, attemptId) { + const queryString = buildQueryString(params) + + return await baseService.post( + `/api/exercise/runtime/${exerciseId}/attempt/${attemptId}/finish${queryString}`, + payload, + {}, + exerciseRequestConfig(), + ) + }, + + async getExerciseRuntimeResult(params = {}, exerciseId, attemptId) { + return await baseService.get( + `/api/exercise/runtime/${exerciseId}/attempt/${attemptId}/result`, + cleanParams(params), + exerciseRequestConfig(), + ) + }, + + async getExerciseRuntimeReport(params = {}, exerciseId) { + return await baseService.get( + `/api/exercise/runtime/${exerciseId}/attempts`, + cleanParams(params), + exerciseRequestConfig(), + ) + }, + + async runExerciseRuntimeReportBulkAction(payload = {}, params = {}, exerciseId) { + const queryString = buildQueryString(params) + + return await baseService.post( + `/api/exercise/runtime/${exerciseId}/attempts/action${queryString}`, + payload, + {}, + exerciseRequestConfig(), + ) + }, + + async getExerciseQuestionStats(params = {}, exerciseId) { + return await baseService.get( + `/api/exercise/runtime/${exerciseId}/question-stats`, + cleanParams(params), + exerciseRequestConfig(), + ) + }, + + async getExerciseReportByQuestion(params = {}, exerciseId) { + return await baseService.get( + `/api/exercise/runtime/${exerciseId}/report-by-question`, + cleanParams(params), + exerciseRequestConfig(), + ) + }, + + async getExerciseLiveResults(params = {}, exerciseId) { + return await baseService.get( + `/api/exercise/runtime/${exerciseId}/live-results`, + cleanParams(params), + exerciseRequestConfig(), + ) + }, + + async deleteExerciseRuntimeAttempt(payload = {}, params = {}, exerciseId, attemptId) { + const queryString = buildQueryString(params) + + return await baseService.post( + `/api/exercise/runtime/${exerciseId}/attempt/${attemptId}/delete${queryString}`, + payload, + {}, + exerciseRequestConfig(), + ) + }, + + async closeExerciseRuntimeAttempt(payload = {}, params = {}, exerciseId, attemptId) { + const queryString = buildQueryString(params) + + return await baseService.post( + `/api/exercise/runtime/${exerciseId}/attempt/${attemptId}/close${queryString}`, + payload, + {}, + exerciseRequestConfig(), + ) + }, + + async recalculateExerciseRuntimeAttempt(payload = {}, params = {}, exerciseId, attemptId) { + const queryString = buildQueryString(params) + + return await baseService.post( + `/api/exercise/runtime/${exerciseId}/attempt/${attemptId}/recalculate${queryString}`, + payload, + {}, + exerciseRequestConfig(), + ) + }, + + async emailExerciseRuntimeAttempt(payload = {}, params = {}, exerciseId, attemptId) { + const queryString = buildQueryString(params) + + return await baseService.post( + `/api/exercise/runtime/${exerciseId}/attempt/${attemptId}/email${queryString}`, + payload, + {}, + exerciseRequestConfig(), + ) + }, + + async emailExerciseRuntimeReportAttempts(payload = {}, params = {}, exerciseId) { + const queryString = buildQueryString(params) + + return await baseService.post( + `/api/exercise/runtime/${exerciseId}/attempts/email${queryString}`, + payload, + {}, + exerciseRequestConfig(), + ) + }, + + buildExerciseRuntimeAttemptPdfUrl(params = {}, exerciseId, attemptId) { + return `/api/exercise/runtime/${exerciseId}/attempt/${attemptId}/pdf${buildQueryString(params)}` + }, + + async saveExerciseRuntimeCorrection(payload = {}, params = {}, exerciseId, attemptId) { + const queryString = buildQueryString(params) + + return await baseService.post( + `/api/exercise/runtime/${exerciseId}/attempt/${attemptId}/correction${queryString}`, + payload, + {}, + exerciseRequestConfig(), + ) + }, + + async getExerciseGlobalQuestionEditor(params = {}, questionType = null) { + const queryParams = { ...params } + + if (questionType !== null && questionType !== undefined) { + queryParams.type = questionType + } + + const endpoint = questionType !== null && questionType !== undefined + ? '/api/exercise/questions/global/editor' + : `/api/exercise/questions/global/editor/${params.questionId}` + + delete queryParams.questionId + + return await baseService.get(endpoint, cleanParams(queryParams), exerciseRequestConfig()) + }, + + async saveExerciseGlobalQuestion(payload, params = {}, questionId = null) { + const queryString = buildQueryString(params) + const endpoint = questionId + ? `/api/exercise/questions/global/editor/${questionId}${queryString}` + : `/api/exercise/questions/global/editor${queryString}` + + return await baseService.post(endpoint, payload, {}, exerciseRequestConfig()) + }, + + async getExerciseQuestionEditor(params = {}, exerciseId, questionId = null, questionType = null) { + const queryParams = { ...params } + + if (questionType !== null && questionType !== undefined) { + queryParams.type = questionType + } + + const endpoint = questionId + ? `/api/exercise/questions/${exerciseId}/editor/${questionId}` + : `/api/exercise/questions/${exerciseId}/editor` + + return await baseService.get(endpoint, cleanParams(queryParams), exerciseRequestConfig()) + }, + + async saveExerciseQuestion(payload, params = {}, exerciseId, questionId = null) { + const queryString = buildQueryString(params) + const endpoint = questionId + ? `/api/exercise/questions/${exerciseId}/editor/${questionId}${queryString}` + : `/api/exercise/questions/${exerciseId}/editor${queryString}` + + return await baseService.post(endpoint, payload, {}, exerciseRequestConfig()) + }, + + async saveExerciseQuestionAction(payload, params = {}, exerciseId) { + const queryString = buildQueryString(params) + + return await baseService.post( + `/api/exercise/questions/${exerciseId}/action${queryString}`, + payload, + {}, + exerciseRequestConfig(), + ) + }, + + async attachExerciseToLearningPath(payload = {}, params = {}, exerciseId) { + const queryString = buildQueryString(params) + + return await baseService.post( + `/api/exercise/questions/${exerciseId}/learning-path-item${queryString}`, + payload, + {}, + exerciseRequestConfig(), + ) + }, + + async getExerciseQuestionBank(params = {}, exerciseId = null) { + const endpoint = exerciseId ? `/api/exercise/questions/${exerciseId}/bank` : '/api/exercise/questions/bank' + + return await baseService.get(endpoint, cleanParams(params), exerciseRequestConfig()) + }, + + async saveExerciseQuestionBankAction(payload, params = {}, exerciseId = null) { + const queryString = buildQueryString(params) + const endpoint = exerciseId + ? `/api/exercise/questions/${exerciseId}/bank/action${queryString}` + : `/api/exercise/questions/bank/action${queryString}` + + return await baseService.post(endpoint, payload, {}, exerciseRequestConfig()) + }, + + async getExerciseAiAikenGenerator(params = {}) { + return await baseService.get("/api/exercise/ai-aiken-generator", cleanParams(params), exerciseRequestConfig()) + }, + + async generateExerciseAikenFromTopic(payload = {}) { + return await baseService.post("/ai/generate_aiken", payload, {}, exerciseRequestConfig()) + }, + + async generateExerciseAikenFromDocument(payload = {}) { + return await baseService.post("/ai/generate_aiken_from_document", payload, {}, exerciseRequestConfig()) + }, + + async getExerciseQuestionImport(importType, params = {}) { + return await baseService.get(`/api/exercise/import/${importType}`, cleanParams(params), exerciseRequestConfig()) + }, + + async importExerciseQuestions(importType, formData, params = {}) { + const queryString = buildQueryString(params) + + return await baseService.postForm(`/api/exercise/import/${importType}${queryString}`, formData, exerciseRequestConfig()) + }, + + async getExercisePendingAttempts(params = {}) { + return await baseService.get('/api/exercise/pending-attempts', cleanParams(params), exerciseRequestConfig()) + }, + + buildExercisePendingAttemptsCsvUrl(params = {}) { + return `/api/exercise/pending-attempts/export.csv${buildQueryString(params)}` + }, + + async getExerciseGlobalReport(params = {}) { + return await baseService.get('/api/exercise/global-report', cleanParams(params), exerciseRequestConfig()) + }, + + buildExerciseGlobalReportCsvUrl(params = {}) { + return `/api/exercise/global-report/export.csv${buildQueryString(params)}` + }, + + async saveExerciseConfiguration(payload, params = {}, exerciseId = null) { + const queryString = buildQueryString(params) + + if (exerciseId) { + return await baseService.put(`/api/exercise/configuration/${exerciseId}${queryString}`, payload, exerciseRequestConfig()) + } + + return await baseService.post(`/api/exercise/configuration${queryString}`, payload, {}, exerciseRequestConfig()) + }, +} diff --git a/assets/vue/views/course/CourseHome.vue b/assets/vue/views/course/CourseHome.vue index f962c563cf4..8b27548520a 100644 --- a/assets/vue/views/course/CourseHome.vue +++ b/assets/vue/views/course/CourseHome.vue @@ -294,6 +294,14 @@ const lpAutoLaunchMessage = computed(() => { const COURSE_TOOL_SKELETON_COUNT = 16 const TOOL_VISIBILITY_VISIBLE = 2 +function normalizeToolNavigation(tool) { + if (routerTools.includes(tool.title)) { + tool.to = tool.url + } + + return tool +} + function getToolVisibility(tool) { if (typeof tool?.visibility === "boolean") { return tool.visibility ? TOOL_VISIBILITY_VISIBLE : 0 @@ -395,11 +403,7 @@ async function loadCourseTools(showSkeleton = true) { const cTools = await courseService.loadCTools(course.value.id, session.value?.id) const normalizedTools = cTools.map((rawTool) => { - const tool = { ...rawTool } - - if (routerTools.includes(tool.title)) { - tool.to = tool.url - } + const tool = normalizeToolNavigation({ ...rawTool }) // Convenience flag for UI states (e.g. customize mode) tool.isEnabled = diff --git a/assets/vue/views/exercise/ExerciseAiAikenGeneratorView.vue b/assets/vue/views/exercise/ExerciseAiAikenGeneratorView.vue new file mode 100644 index 00000000000..bb2f895d3c3 --- /dev/null +++ b/assets/vue/views/exercise/ExerciseAiAikenGeneratorView.vue @@ -0,0 +1,693 @@ +
+ +
+ +
+ +
+

+ {{ t("AI Aiken generator") }} +

+

+ {{ t("Generate an Aiken quiz from a topic or from one course document.") }} +

+
+ + + +
+

{{ successMessage }}

+

+ {{ t("Imported questions") }}: {{ importedQuestionCount }} +

+
+ + +
+
+ +
+ {{ t("Loading") }}... +
+ +
+ {{ t(config.message || "No AI text providers configured.") }} +
+ +