diff --git a/src/core/constants.ts b/src/core/constants.ts index 087a16e77..b4bd8c42a 100644 --- a/src/core/constants.ts +++ b/src/core/constants.ts @@ -71,6 +71,12 @@ export class CoreConstants { static readonly OUTDATED = 'outdated'; static readonly NOT_DOWNLOADABLE = 'notdownloadable'; + static readonly DOWNLOADED_ICON = 'cloud-done'; + static readonly DOWNLOADING_ICON = 'spinner'; + static readonly NOT_DOWNLOADED_ICON = 'cloud-download'; + static readonly OUTDATED_ICON = 'fas-redo-alt'; + static readonly NOT_DOWNLOADABLE_ICON = ''; + // Constants from Moodle's resourcelib. static readonly RESOURCELIB_DISPLAY_AUTO = 0; // Try the best way. static readonly RESOURCELIB_DISPLAY_EMBED = 1; // Display using object tag. diff --git a/src/core/features/course/course.module.ts b/src/core/features/course/course.module.ts new file mode 100644 index 000000000..cd23709c2 --- /dev/null +++ b/src/core/features/course/course.module.ts @@ -0,0 +1,37 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { NgModule } from '@angular/core'; + +import { CORE_SITE_SCHEMAS } from '@/core/services/sites'; + +import { + SITE_SCHEMA as COURSE_SITE_SCHEMA, + OFFLINE_SITE_SCHEMA as COURSE_OFFLINE_SITE_SCHEMA, +} from './services/course-db'; + +@NgModule({ + providers: [ + { + provide: CORE_SITE_SCHEMAS, + useValue: [ + COURSE_SITE_SCHEMA, + COURSE_OFFLINE_SITE_SCHEMA, + ], + multi: true, + }, + ], +}) +export class CoreCourseModule { +} diff --git a/src/core/features/course/services/course-db.ts b/src/core/features/course/services/course-db.ts new file mode 100644 index 000000000..db0305c68 --- /dev/null +++ b/src/core/features/course/services/course-db.ts @@ -0,0 +1,111 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { CoreSiteSchema } from '@services/sites'; + +/** + * Database variables for CoreCourse service. + */ +export const COURSE_STATUS_TABLE = 'course_status'; +export const SITE_SCHEMA: CoreSiteSchema = { + name: 'CoreCourseProvider', + version: 1, + tables: [ + { + name: COURSE_STATUS_TABLE, + columns: [ + { + name: 'id', + type: 'INTEGER', + primaryKey: true, + }, + { + name: 'status', + type: 'TEXT', + notNull: true, + }, + { + name: 'previous', + type: 'TEXT', + }, + { + name: 'updated', + type: 'INTEGER', + }, + { + name: 'downloadTime', + type: 'INTEGER', + }, + { + name: 'previousDownloadTime', + type: 'INTEGER', + }, + ], + }, + ], +}; + +/** + * Database variables for CoreCourseOffline service. + */ +export const MANUAL_COMPLETION_TABLE = 'course_manual_completion'; +export const OFFLINE_SITE_SCHEMA: CoreSiteSchema = { + name: 'CoreCourseOfflineProvider', + version: 1, + tables: [ + { + name: MANUAL_COMPLETION_TABLE, + columns: [ + { + name: 'cmid', + type: 'INTEGER', + primaryKey: true, + }, + { + name: 'completed', + type: 'INTEGER', + }, + { + name: 'courseid', + type: 'INTEGER', + }, + { + name: 'coursename', + type: 'TEXT', + }, + { + name: 'timecompleted', + type: 'INTEGER', + }, + ], + }, + ], +}; + +export type CoreCourseStatusDBRecord = { + id: number; + status: string; + previous: string; + updated: number; + downloadTime: number; + previousDownloadTime: number; +}; + +export type CoreCourseManualCompletionDBRecord = { + cmid: number; + completed: number; + courseid: number; + coursename: string; + timecompleted: number; +}; diff --git a/src/core/features/course/services/course-offline.ts b/src/core/features/course/services/course-offline.ts new file mode 100644 index 000000000..0db107fe1 --- /dev/null +++ b/src/core/features/course/services/course-offline.ts @@ -0,0 +1,117 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { makeSingleton } from '@singletons/core.singletons'; +import { CoreSites } from '@services/sites'; +import { CoreCourseManualCompletionDBRecord, MANUAL_COMPLETION_TABLE } from './course-db'; +import { CoreStatusWithWarningsWSResponse } from '@services/ws'; + +/** + * Service to handle offline data for courses. + */ +@Injectable({ + providedIn: 'root', +}) +export class CoreCourseOfflineProvider { + + /** + * Delete a manual completion stored. + * + * @param cmId The module ID to remove the completion. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async deleteManualCompletion(cmId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.getDb().deleteRecords(MANUAL_COMPLETION_TABLE, { cmid: cmId }); + } + + /** + * Get all offline manual completions for a certain course. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the list of completions. + */ + async getAllManualCompletions(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return await site.getDb().getRecords(MANUAL_COMPLETION_TABLE); + } + + /** + * Get all offline manual completions for a certain course. + * + * @param courseId Course ID the module belongs to. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the list of completions. + */ + async getCourseManualCompletions(courseId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return await site.getDb().getRecords(MANUAL_COMPLETION_TABLE, { courseid: courseId }); + } + + /** + * Get the offline manual completion for a certain module. + * + * @param cmId The module ID to remove the completion. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the completion, rejected if failure or not found. + */ + async getManualCompletion(cmId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return await site.getDb().getRecord(MANUAL_COMPLETION_TABLE, { cmid: cmId }); + } + + /** + * Offline version for manually marking a module as completed. + * + * @param cmId The module ID to store the completion. + * @param completed Whether the module is completed or not. + * @param courseId Course ID the module belongs to. + * @param courseName Course name. Recommended, it is used to display a better warning message. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when completion is successfully stored. + */ + async markCompletedManually( + cmId: number, + completed: boolean, + courseId: number, + courseName?: string, + siteId?: string, + ): Promise { + + // Store the offline data. + const site = await CoreSites.instance.getSite(siteId); + const entry: CoreCourseManualCompletionDBRecord = { + cmid: cmId, + completed: completed ? 1 : 0, + courseid: courseId, + coursename: courseName || '', + timecompleted: Date.now(), + }; + await site.getDb().insertRecord(MANUAL_COMPLETION_TABLE, entry); + + return ({ + status: true, + offline: true, + }); + } + +} + +export class CoreCourseOffline extends makeSingleton(CoreCourseOfflineProvider) { } diff --git a/src/core/features/course/services/course.helper.ts b/src/core/features/course/services/course.helper.ts new file mode 100644 index 000000000..c9ebe1572 --- /dev/null +++ b/src/core/features/course/services/course.helper.ts @@ -0,0 +1,990 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { Params } from '@angular/router'; +import { CoreSites } from '@services/sites'; +import { CoreCourse, CoreCourseSection } from './course'; +import { CoreConstants } from '@/core/constants'; +import { CoreLogger } from '@singletons/logger'; +import { makeSingleton, Translate } from '@singletons/core.singletons'; +import { CoreFilepool } from '@services/filepool'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreUtils } from '@services/utils/utils'; +import { + CoreCourseBasicData, + CoreCourseGetCoursesData, + CoreCourses, + CoreCourseSearchedData, + CoreEnrolledCourseBasicData, + CoreEnrolledCourseData, +} from '@features/courses/services/courses'; +import { CoreEnrolledCourseDataWithExtraInfoAndOptions } from '@features/courses/services/courses.helper'; +import { CoreArray } from '@singletons/array'; +import { CoreLoginHelper, CoreLoginHelperProvider } from '@features/login/services/login.helper'; +import { CoreIonLoadingElement } from '@classes/ion-loading'; +import { CoreCourseOffline } from './course-offline'; + +/** + * Prefetch info of a module. + */ +export type CoreCourseModulePrefetchInfo = { + /** + * Downloaded size. + */ + size?: number; + + /** + * Downloadable size in a readable format. + */ + sizeReadable?: string; + + /** + * Module status. + */ + status?: string; + + /** + * Icon's name of the module status. + */ + statusIcon?: string; + + /** + * Time when the module was last downloaded. + */ + downloadTime?: number; + + /** + * Download time in a readable format. + */ + downloadTimeReadable?: string; +}; + +/** + * Progress of downloading a list of courses. + */ +export type CoreCourseCoursesProgress = { + /** + * Number of courses downloaded so far. + */ + count: number; + + /** + * Toal of courses to download. + */ + total: number; + + /** + * Whether the download has been successful so far. + */ + success: boolean; + + /** + * Last downloaded course. + */ + courseId?: number; +}; + +export type CorePrefetchStatusInfo = { + status: string; // Status of the prefetch. + statusTranslatable: string; // Status translatable string. + icon: string; // Icon based on the status. + loading: boolean; // If it's a loading status. + badge?: string; // Progress badge string if any. +}; + +/** + * Helper to gather some common course functions. + */ +@Injectable({ + providedIn: 'root', +}) +export class CoreCourseHelperProvider { + + protected courseDwnPromises: { [s: string]: { [id: number]: Promise } } = {}; + protected logger: CoreLogger; + + constructor() { + + this.logger = CoreLogger.getInstance('CoreCourseHelperProvider'); + } + + /** + * This function treats every module on the sections provided to load the handler data, treat completion + * and navigate to a module page if required. It also returns if sections has content. + * + * @param sections List of sections to treat modules. + * @param courseId Course ID of the modules. + * @param completionStatus List of completion status. + * @param courseName Course name. Recommended if completionStatus is supplied. + * @param forCoursePage Whether the data will be used to render the course page. + * @return Whether the sections have content. + */ + addHandlerDataForModules(): void { + // @todo params and logic + } + + /** + * Calculate the status of a section. + * + * @param section Section to calculate its status. It can't be "All sections". + * @param courseId Course ID the section belongs to. + * @param refresh True if it shouldn't use module status cache (slower). + * @param checkUpdates Whether to use the WS to check updates. Defaults to true. + * @return Promise resolved when the status is calculated. + */ + calculateSectionStatus(): void { + // @todo params and logic + } + + /** + * Calculate the status of a list of sections, setting attributes to determine the icons/data to be shown. + * + * @param sections Sections to calculate their status. + * @param courseId Course ID the sections belong to. + * @param refresh True if it shouldn't use module status cache (slower). + * @param checkUpdates Whether to use the WS to check updates. Defaults to true. + * @return Promise resolved when the states are calculated. + */ + calculateSectionsStatus(): void { + // @todo params and logic + } + + /** + * Show a confirm and prefetch a course. It will retrieve the sections and the course options if not provided. + * This function will set the icon to "spinner" when starting and it will also set it back to the initial icon if the + * user cancels. All the other updates of the icon should be made when CoreEvents.COURSE_STATUS_CHANGED is received. + * + * @param data An object where to store the course icon and title: "prefetchCourseIcon", "title" and "downloadSucceeded". + * @param course Course to prefetch. + * @param sections List of course sections. + * @param courseHandlers List of course handlers. + * @param menuHandlers List of course menu handlers. + * @return Promise resolved when the download finishes, rejected if an error occurs or the user cancels. + */ + confirmAndPrefetchCourse(): void { + // @todo params and logic + } + + /** + * Confirm and prefetches a list of courses. + * + * @param courses List of courses to download. + * @param onProgress Function to call everytime a course is downloaded. + * @return Resolved when downloaded, rejected if error or canceled. + */ + async confirmAndPrefetchCourses( + courses: CoreEnrolledCourseDataWithExtraInfoAndOptions[], + onProgress?: (data: CoreCourseCoursesProgress) => void, + ): Promise { + const siteId = CoreSites.instance.getCurrentSiteId(); + + // Confirm the download without checking size because it could take a while. + await CoreDomUtils.instance.showConfirm(Translate.instance.instant('core.areyousure')); + + const total = courses.length; + let count = 0; + + const promises = courses.map((course) => { + const subPromises: Promise[] = []; + let sections: CoreCourseSection[]; + let handlers: any; + let menuHandlers: any; + let success = true; + + // Get the sections and the handlers. + subPromises.push(CoreCourse.instance.getSections(course.id, false, true).then((courseSections) => { + sections = courseSections; + + return; + })); + + /** + * @todo + subPromises.push(this.courseOptionsDelegate.getHandlersToDisplay(this.injector, course).then((cHandlers: any) => { + handlers = cHandlers; + })); + subPromises.push(this.courseOptionsDelegate.getMenuHandlersToDisplay(this.injector, course).then((mHandlers: any) => { + menuHandlers = mHandlers; + })); + */ + + return Promise.all(subPromises).then(() => this.prefetchCourse(course, sections, handlers, menuHandlers, siteId)) + .catch((error) => { + success = false; + + throw error; + }).finally(() => { + // Course downloaded or failed, notify the progress. + count++; + if (onProgress) { + onProgress({ count: count, total: total, courseId: course.id, success: success }); + } + }); + }); + + if (onProgress) { + // Notify the start of the download. + onProgress({ count: 0, total: total, success: true }); + } + + return CoreUtils.instance.allPromises(promises); + } + + /** + * Show confirmation dialog and then remove a module files. + * + * @param module Module to remove the files. + * @param courseId Course ID the module belongs to. + * @param done Function to call when done. It will close the context menu. + * @return Promise resolved when done. + * @todo module type. + */ + async confirmAndRemoveFiles(module: any, courseId: number, done?: () => void): Promise { + let modal: CoreIonLoadingElement | undefined; + + try { + + await CoreDomUtils.instance.showDeleteConfirm('core.course.confirmdeletestoreddata'); + + modal = await CoreDomUtils.instance.showModalLoading(); + + await this.removeModuleStoredData(module, courseId); + + done && done(); + + } catch (error) { + if (error) { + CoreDomUtils.instance.showErrorModal(error); + } + } finally { + modal?.dismiss(); + } + } + + /** + * Calculate the size to download a section and show a confirm modal if needed. + * + * @param courseId Course ID the section belongs to. + * @param section Section. If not provided, all sections. + * @param sections List of sections. Used when downloading all the sections. + * @param alwaysConfirm True to show a confirm even if the size isn't high, false otherwise. + * @return Promise resolved if the user confirms or there's no need to confirm. + */ + confirmDownloadSizeSection(): void { + // @todo params and logic + } + + /** + * Helper function to prefetch a module, showing a confirmation modal if the size is big. + * This function is meant to be called from a context menu option. It will also modify some data like the prefetch icon. + * + * @param instance The component instance that has the context menu. It should have prefetchStatusIcon and isDestroyed. + * @param module Module to be prefetched + * @param courseId Course ID the module belongs to. + * @param done Function to call when done. It will close the context menu. + * @return Promise resolved when done. + */ + contextMenuPrefetch(): void { + // @todo params and logic + } + + /** + * Determine the status of a list of courses. + * + * @param courses Courses + * @return Promise resolved with the status. + */ + async determineCoursesStatus(courses: CoreCourseBasicData[]): Promise { + // Get the status of each course. + const promises: Promise[] = []; + const siteId = CoreSites.instance.getCurrentSiteId(); + + courses.forEach((course) => { + promises.push(CoreCourse.instance.getCourseStatus(course.id, siteId)); + }); + + const statuses = await Promise.all(promises); + + // Now determine the status of the whole list. + let status = statuses[0]; + const filepool = CoreFilepool.instance; + for (let i = 1; i < statuses.length; i++) { + status = filepool.determinePackagesStatus(status, statuses[i]); + } + + return status; + } + + /** + * Convenience function to open a module main file, downloading the package if needed. + * This is meant for modules like mod_resource. + * + * @param module The module to download. + * @param courseId The course ID of the module. + * @param component The component to link the files to. + * @param componentId An ID to use in conjunction with the component. + * @param files List of files of the module. If not provided, use module.contents. + * @param siteId The site ID. If not defined, current site. + * @return Resolved on success. + */ + downloadModuleAndOpenFile(): void { + // @todo params and logic + } + + /** + * Convenience function to download a module that has a main file and return the local file's path and other info. + * This is meant for modules like mod_resource. + * + * @param module The module to download. + * @param courseId The course ID of the module. + * @param component The component to link the files to. + * @param componentId An ID to use in conjunction with the component. + * @param files List of files of the module. If not provided, use module.contents. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved when done. + */ + downloadModuleWithMainFileIfNeeded(): void { + // @todo params and logic + } + + /** + * Convenience function to download a module that has a main file and return the local file's path and other info. + * This is meant for modules like mod_resource. + * + * @param module The module to download. + * @param courseId The course ID of the module. + * @param fixedUrl Main file's fixed URL. + * @param files List of files of the module. + * @param status The package status. + * @param component The component to link the files to. + * @param componentId An ID to use in conjunction with the component. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved when done. + */ + protected downloadModuleWithMainFile(): void { + // @todo params and logic + } + + /** + * Convenience function to download a module. + * + * @param module The module to download. + * @param courseId The course ID of the module. + * @param component The component to link the files to. + * @param componentId An ID to use in conjunction with the component. + * @param files List of files of the module. If not provided, use module.contents. + * @param siteId The site ID. If not defined, current site. + * @return Promise resolved when done. + */ + downloadModule(): void { + // @todo params and logic + } + + /** + * Fill the Context Menu for a certain module. + * + * @param instance The component instance that has the context menu. + * @param module Module to be prefetched + * @param courseId Course ID the module belongs to. + * @param invalidateCache Invalidates the cache first. + * @param component Component of the module. + * @return Promise resolved when done. + */ + fillContextMenu(): void { + // @todo params and logic + } + + /** + * Get a course. It will first check the user courses, and fallback to another WS if not enrolled. + * + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the course. + */ + async getCourse( + courseId: number, + siteId?: string, + ): Promise<{ enrolled: boolean; course: CoreEnrolledCourseData | CoreCourseSearchedData | CoreCourseGetCoursesData }> { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + let course: CoreEnrolledCourseData | CoreCourseSearchedData | CoreCourseGetCoursesData; + + // Try with enrolled courses first. + try { + course = await CoreCourses.instance.getUserCourse(courseId, false, siteId); + + return ({ enrolled: true, course: course }); + } catch { + // Not enrolled or an error happened. Try to use another WebService. + } + + const available = await CoreCourses.instance.isGetCoursesByFieldAvailableInSite(siteId); + + if (available) { + course = await CoreCourses.instance.getCourseByField('id', courseId, siteId); + } else { + course = await CoreCourses.instance.getCourse(courseId, siteId); + } + + return ({ enrolled: false, course: course }); + } + + /** + * Get a course, wait for any course format plugin to load, and open the course page. It basically chains the functions + * getCourse and openCourse. + * + * @param courseId Course ID. + * @param params Other params to pass to the course page. + * @param siteId Site ID. If not defined, current site. + */ + async getAndOpenCourse(courseId: number, params?: Params, siteId?: string): Promise { + const modal = await CoreDomUtils.instance.showModalLoading(); + + let course: CoreEnrolledCourseData | CoreCourseSearchedData | CoreCourseGetCoursesData | { id: number }; + + try { + const data = await this.getCourse(courseId, siteId); + + course = data.course; + } catch { + // Cannot get course, return a "fake". + course = { id: courseId }; + } + + modal?.dismiss(); + + return this.openCourse(course, params, siteId); + } + + /** + * Check if the course has a block with that name. + * + * @param courseId Course ID. + * @param name Block name to search. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with true if the block exists or false otherwise. + * @since 3.3 + */ + async hasABlockNamed(courseId: number, name: string, siteId?: string): Promise { + try { + const blocks = await CoreCourse.instance.getCourseBlocks(courseId, siteId); + + return blocks.some((block) => block.name == name); + } catch { + return false; + } + } + + /** + * Initialize the prefetch icon for selected courses. + * + * @param courses Courses array to get info from. + * @param prefetch Prefetch information. + * @param minCourses Min course to show icon. + * @return Resolved with the prefetch information updated when done. + */ + async initPrefetchCoursesIcons( + courses: CoreCourseBasicData[], + prefetch: CorePrefetchStatusInfo, + minCourses: number = 2, + ): Promise { + if (!courses || courses.length < minCourses) { + // Not enough courses. + prefetch.icon = ''; + + return prefetch; + } + + const status = await this.determineCoursesStatus(courses); + + prefetch = this.getCourseStatusIconAndTitleFromStatus(status); + + if (prefetch.loading) { + // It seems all courses are being downloaded, show a download button instead. + prefetch.icon = CoreConstants.NOT_DOWNLOADED_ICON; + } + + return prefetch; + } + + /** + * Load offline completion into a list of sections. + * This should be used in 3.6 sites or higher, where the course contents already include the completion. + * + * @param courseId The course to get the completion. + * @param sections List of sections of the course. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async loadOfflineCompletion(courseId: number, sections: any[], siteId?: string): Promise { + const offlineCompletions = await CoreCourseOffline.instance.getCourseManualCompletions(courseId, siteId); + + if (!offlineCompletions || !offlineCompletions.length) { + // No offline completion. + return; + } + + const totalOffline = offlineCompletions.length; + let loaded = 0; + const offlineCompletionsMap = CoreUtils.instance.arrayToObject(offlineCompletions, 'cmid'); + // Load the offline data in the modules. + for (let i = 0; i < sections.length; i++) { + const section = sections[i]; + if (!section.modules || !section.modules.length) { + // Section has no modules, ignore it. + continue; + } + + for (let j = 0; j < section.modules.length; j++) { + const module = section.modules[j]; + const offlineCompletion = offlineCompletionsMap[module.id]; + + if (offlineCompletion && typeof module.completiondata != 'undefined' && + offlineCompletion.timecompleted >= module.completiondata.timecompleted * 1000) { + // The module has offline completion. Load it. + module.completiondata.state = offlineCompletion.completed; + module.completiondata.offline = true; + + // If all completions have been loaded, stop. + loaded++; + if (loaded == totalOffline) { + break; + } + } + } + } + } + + /** + * Prefetch all the courses in the array. + * + * @param courses Courses array to prefetch. + * @param prefetch Prefetch information to be updated. + * @return Promise resolved when done. + */ + async prefetchCourses( + courses: CoreEnrolledCourseDataWithExtraInfoAndOptions[], + prefetch: CorePrefetchStatusInfo, + ): Promise { + prefetch.loading = true; + prefetch.icon = CoreConstants.DOWNLOADING_ICON; + prefetch.badge = ''; + + try { + await this.confirmAndPrefetchCourses(courses, (progress) => { + prefetch.badge = progress.count + ' / ' + progress.total; + }); + prefetch.icon = CoreConstants.OUTDATED_ICON; + } finally { + prefetch.loading = false; + prefetch.badge = ''; + } + } + + /** + * Get a course download promise (if any). + * + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Download promise, undefined if not found. + */ + getCourseDownloadPromise(courseId: number, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + return this.courseDwnPromises[siteId] && this.courseDwnPromises[siteId][courseId]; + } + + /** + * Get a course status icon and the langkey to use as a title. + * + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the icon name and the title key. + */ + async getCourseStatusIconAndTitle(courseId: number, siteId?: string): Promise { + const status = await CoreCourse.instance.getCourseStatus(courseId, siteId); + + return this.getCourseStatusIconAndTitleFromStatus(status); + } + + /** + * Get a course status icon and the langkey to use as a title from status. + * + * @param status Course status. + * @return Title and icon name. + */ + getCourseStatusIconAndTitleFromStatus(status: string): CorePrefetchStatusInfo { + const prefetchStatus: CorePrefetchStatusInfo = { + status: status, + icon: this.getPrefetchStatusIcon(status, false), + statusTranslatable: '', + loading: false, + }; + + if (status == CoreConstants.DOWNLOADED) { + // Always show refresh icon, we cannot know if there's anything new in course options. + prefetchStatus.statusTranslatable = 'core.course.refreshcourse'; + } else if (status == CoreConstants.DOWNLOADING) { + prefetchStatus.statusTranslatable = 'core.downloading'; + prefetchStatus.loading = true; + } else { + prefetchStatus.statusTranslatable = 'core.course.downloadcourse'; + } + + return prefetchStatus; + } + + /** + * Get the icon given the status and if trust the download status. + * + * @param status Status constant. + * @param trustDownload True to show download success, false to show an outdated status when downloaded. + * @return Icon name. + */ + getPrefetchStatusIcon(status: string, trustDownload: boolean = false): string { + if (status == CoreConstants.NOT_DOWNLOADED) { + return CoreConstants.NOT_DOWNLOADED_ICON; + } + if (status == CoreConstants.OUTDATED || (status == CoreConstants.DOWNLOADED && !trustDownload)) { + return CoreConstants.OUTDATED_ICON; + } + if (status == CoreConstants.DOWNLOADED && trustDownload) { + return CoreConstants.DOWNLOADED_ICON; + } + if (status == CoreConstants.DOWNLOADING) { + return CoreConstants.DOWNLOADING_ICON; + } + + return CoreConstants.DOWNLOADING_ICON; + } + + /** + * Get the course ID from a module instance ID, showing an error message if it can't be retrieved. + * + * @param id Instance ID. + * @param module Name of the module. E.g. 'glossary'. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the module's course ID. + * @todo module type. + */ + async getModuleCourseIdByInstance(id: number, module: any, siteId?: string): Promise { + try { + const cm = await CoreCourse.instance.getModuleBasicInfoByInstance(id, module, siteId); + + return cm.course; + } catch (error) { + CoreDomUtils.instance.showErrorModalDefault(error, 'core.course.errorgetmodule', true); + + throw error; + } + } + + /** + * Get prefetch info for a module. + * + * @param module Module to get the info from. + * @param courseId Course ID the section belongs to. + * @param invalidateCache Invalidates the cache first. + * @param component Component of the module. + * @return Promise resolved with the info. + */ + getModulePrefetchInfo(): void { + // @todo params and logic + } + + /** + * Get the download ID of a section. It's used to interact with CoreCourseModulePrefetchDelegate. + * + * @param section Section. + * @return Section download ID. + * @todo section type. + */ + getSectionDownloadId(section: any): string { + return 'Section-' + section.id; + } + + /** + * Navigate to a module using instance ID and module name. + * + * @param instanceId Activity instance ID. + * @param modName Module name of the activity. + * @param siteId Site ID. If not defined, current site. + * @param courseId Course ID. If not defined we'll try to retrieve it from the site. + * @param sectionId Section the module belongs to. If not defined we'll try to retrieve it from the site. + * @param useModNameToGetModule If true, the app will retrieve all modules of this type with a single WS call. This reduces the + * number of WS calls, but it isn't recommended for modules that can return a lot of contents. + * @param modParams Params to pass to the module + * @param navCtrl NavController for adding new pages to the current history. Optional for legacy support, but + * generates a warning if omitted. + * @return Promise resolved when done. + */ + navigateToModuleByInstance(): void { + // @todo params and logic + } + + /** + * Navigate to a module. + * + * @param moduleId Module's ID. + * @param siteId Site ID. If not defined, current site. + * @param courseId Course ID. If not defined we'll try to retrieve it from the site. + * @param sectionId Section the module belongs to. If not defined we'll try to retrieve it from the site. + * @param modName If set, the app will retrieve all modules of this type with a single WS call. This reduces the + * number of WS calls, but it isn't recommended for modules that can return a lot of contents. + * @param modParams Params to pass to the module + * @param navCtrl NavController for adding new pages to the current history. Optional for legacy support, but + * generates a warning if omitted. + * @return Promise resolved when done. + */ + navigateToModule(): void { + // @todo params and logic + } + + /** + * Open a module. + * + * @param navCtrl The NavController to use. + * @param module The module to open. + * @param courseId The course ID of the module. + * @param sectionId The section ID of the module. + * @param modParams Params to pass to the module + * @param True if module can be opened, false otherwise. + */ + openModule(): void { + // @todo params and logic + } + + /** + * Prefetch all the activities in a course and also the course addons. + * + * @param course The course to prefetch. + * @param sections List of course sections. + * @param courseHandlers List of course options handlers. + * @param courseMenuHandlers List of course menu handlers. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the download finishes. + */ + async prefetchCourse( + course: CoreEnrolledCourseDataWithExtraInfoAndOptions, + sections: CoreCourseSection[], + courseHandlers: any[], // @todo CoreCourseOptionsHandlerToDisplay[], + courseMenuHandlers: any[], // @todo CoreCourseOptionsMenuHandlerToDisplay[], + siteId?: string, + ): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + if (this.courseDwnPromises[siteId] && this.courseDwnPromises[siteId][course.id]) { + // There's already a download ongoing for this course, return the promise. + return this.courseDwnPromises[siteId][course.id]; + } else if (!this.courseDwnPromises[siteId]) { + this.courseDwnPromises[siteId] = {}; + } + + // First of all, mark the course as being downloaded. + this.courseDwnPromises[siteId][course.id] = CoreCourse.instance.setCourseStatus( + course.id, + CoreConstants.DOWNLOADING, + siteId, + ).then(async () => { + + const promises: Promise[] = []; + + // Prefetch all the sections. If the first section is "All sections", use it. Otherwise, use a fake "All sections". + /* + * @todo + let allSectionsSection = sections[0]; + if (sections[0].id != CoreCourseProvider.ALL_SECTIONS_ID) { + allSectionsSection = { id: CoreCourseProvider.ALL_SECTIONS_ID }; + } + promises.push(this.prefetchSection(allSectionsSection, course.id, sections)); + + // Prefetch course options. + courseHandlers.forEach((handler) => { + if (handler.prefetch) { + promises.push(handler.prefetch(course)); + } + }); + courseMenuHandlers.forEach((handler) => { + if (handler.prefetch) { + promises.push(handler.prefetch(course)); + } + });*/ + + // Prefetch other data needed to render the course. + if (CoreCourses.instance.isGetCoursesByFieldAvailable()) { + promises.push(CoreCourses.instance.getCoursesByField('id', course.id)); + } + + const sectionWithModules = sections.find((section) => section.modules && section.modules.length > 0); + if (!sectionWithModules || typeof sectionWithModules.modules[0].completion == 'undefined') { + promises.push(CoreCourse.instance.getActivitiesCompletionStatus(course.id)); + } + + // @todo promises.push(this.filterHelper.getFilters('course', course.id)); + + return CoreUtils.instance.allPromises(promises); + }).then(() => + // Download success, mark the course as downloaded. + CoreCourse.instance.setCourseStatus(course.id, CoreConstants.DOWNLOADED, siteId)).catch(async (error) => { + // Error, restore previous status. + await CoreCourse.instance.setCoursePreviousStatus(course.id, siteId); + + throw error; + }).finally(() => { + delete this.courseDwnPromises[siteId!][course.id]; + }); + + return this.courseDwnPromises[siteId][course.id]; + } + + /** + * Helper function to prefetch a module, showing a confirmation modal if the size is big + * and invalidating contents if refreshing. + * + * @param handler Prefetch handler to use. Must implement 'prefetch' and 'invalidateContent'. + * @param module Module to download. + * @param size Object containing size to download (in bytes) and a boolean to indicate if its totally calculated. + * @param courseId Course ID of the module. + * @param refresh True if refreshing, false otherwise. + * @return Promise resolved when downloaded. + */ + prefetchModule(): void { + // @todo params and logic + } + + /** + * Prefetch one section or all the sections. + * If the section is "All sections" it will prefetch all the sections. + * + * @param section Section. + * @param courseId Course ID the section belongs to. + * @param sections List of sections. Used when downloading all the sections. + * @return Promise resolved when the prefetch is finished. + */ + async prefetchSection(): Promise { + // @todo params and logic + } + + /** + * Prefetch a certain section if it needs to be prefetched. + * If the section is "All sections" it will be ignored. + * + * @param section Section to prefetch. + * @param courseId Course ID the section belongs to. + * @return Promise resolved when the section is prefetched. + */ + protected prefetchSingleSectionIfNeeded(): void { + // @todo params and logic + } + + /** + * Start or restore the prefetch of a section. + * If the section is "All sections" it will be ignored. + * + * @param section Section to download. + * @param result Result of CoreCourseModulePrefetchDelegate.getModulesStatus for this section. + * @param courseId Course ID the section belongs to. + * @return Promise resolved when the section has been prefetched. + */ + protected prefetchSingleSection(): void { + // @todo params and logic + } + + /** + * Check if a section has content. + * + * @param section Section to check. + * @return Whether the section has content. + * @todo section type. + */ + sectionHasContent(section: any): boolean { + if (section.hiddenbynumsections) { + return false; + } + + return (typeof section.availabilityinfo != 'undefined' && section.availabilityinfo != '') || + section.summary != '' || (section.modules && section.modules.length > 0); + } + + /** + * Wait for any course format plugin to load, and open the course page. + * + * If the plugin's promise is resolved, the course page will be opened. If it is rejected, they will see an error. + * If the promise for the plugin is still in progress when the user tries to open the course, a loader + * will be displayed until it is complete, before the course page is opened. If the promise is already complete, + * they will see the result immediately. + * + * @param course Course to open + * @param params Params to pass to the course page. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + openCourse(course: CoreEnrolledCourseBasicData | { id: number }, params?: Params, siteId?: string): Promise { + if (!siteId || siteId == CoreSites.instance.getCurrentSiteId()) { + // Current site, we can open the course. + return CoreCourse.instance.openCourse(course, params); + } else { + // We need to load the site first. + params = params || {}; + Object.assign(params, { course: course }); + + return CoreLoginHelper.instance.redirect(CoreLoginHelperProvider.OPEN_COURSE, params, siteId); + } + } + + /** + * Delete course files. + * + * @param courseId Course id. + * @return Promise to be resolved once the course files are deleted. + */ + async deleteCourseFiles(courseId: number): Promise { + const sections = await CoreCourse.instance.getSections(courseId); + const modules = CoreArray.flatten(sections.map((section) => section.modules)); + + await Promise.all( + modules.map((module) => this.removeModuleStoredData(module, courseId)), + ); + + await CoreCourse.instance.setCourseStatus(courseId, CoreConstants.NOT_DOWNLOADED); + } + + /** + * Remove module stored data. + * + * @param module Module to remove the files. + * @param courseId Course ID the module belongs to. + * @return Promise resolved when done. + */ + // @todo remove when done. + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async removeModuleStoredData(module: any, courseId: number): Promise { + const promises: Promise[] = []; + + // @todo + // promises.push(this.prefetchDelegate.removeModuleFiles(module, courseId)); + + // @todo + // const handler = this.prefetchDelegate.getPrefetchHandlerFor(module); + // if (handler) { + // promises.push(CoreSites.instance.getCurrentSite().deleteComponentFromCache(handler.component, module.id)); + // } + + await Promise.all(promises); + } + +} + +export class CoreCourseHelper extends makeSingleton(CoreCourseHelperProvider) {} diff --git a/src/core/features/course/services/course.ts b/src/core/features/course/services/course.ts new file mode 100644 index 000000000..3a167c60a --- /dev/null +++ b/src/core/features/course/services/course.ts @@ -0,0 +1,1505 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { Params } from '@angular/router'; + +import { CoreApp } from '@services/app'; +import { CoreEvents } from '@singletons/events'; +import { CoreLogger } from '@singletons/logger'; +import { CoreSitesCommonWSOptions, CoreSites } from '@services/sites'; +import { CoreTimeUtils } from '@services/utils/time'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreSiteWSPreSets, CoreSite } from '@classes/site'; +import { CoreConstants } from '@/core/constants'; +import { makeSingleton, Translate } from '@singletons/core.singletons'; +import { CoreStatusWithWarningsWSResponse, CoreWSExternalFile } from '@services/ws'; + +import { CoreCourseStatusDBRecord, COURSE_STATUS_TABLE } from './course-db'; +import { CoreCourseOffline } from './course-offline'; +import { CoreError } from '@classes/errors/error'; +import { + CoreCourses, + CoreCoursesProvider, +} from '../../courses/services/courses'; +import { CoreDomUtils } from '@services/utils/dom'; +import { CoreWSError } from '@classes/errors/wserror'; + +const ROOT_CACHE_KEY = 'mmCourse:'; + +/** + * Service that provides some features regarding a course. + */ +@Injectable({ + providedIn: 'root', +}) +export class CoreCourseProvider { + + static readonly ALL_SECTIONS_ID = -2; + static readonly STEALTH_MODULES_SECTION_ID = -1; + static readonly ACCESS_GUEST = 'courses_access_guest'; + static readonly ACCESS_DEFAULT = 'courses_access_default'; + static readonly ALL_COURSES_CLEARED = -1; + + static readonly COMPLETION_TRACKING_NONE = 0; + static readonly COMPLETION_TRACKING_MANUAL = 1; + static readonly COMPLETION_TRACKING_AUTOMATIC = 2; + + static readonly COMPLETION_INCOMPLETE = 0; + static readonly COMPLETION_COMPLETE = 1; + static readonly COMPLETION_COMPLETE_PASS = 2; + static readonly COMPLETION_COMPLETE_FAIL = 3; + + static readonly COMPONENT = 'CoreCourse'; + + protected readonly CORE_MODULES = [ + 'assign', 'assignment', 'book', 'chat', 'choice', 'data', 'database', 'date', 'external-tool', + 'feedback', 'file', 'folder', 'forum', 'glossary', 'ims', 'imscp', 'label', 'lesson', 'lti', 'page', 'quiz', + 'resource', 'scorm', 'survey', 'url', 'wiki', 'workshop', 'h5pactivity', + ]; + + protected logger: CoreLogger; + + constructor() { + // @todo + // protected courseFormatDelegate: CoreCourseFormatDelegate, + // protected sitePluginsProvider: CoreSitePluginsProvider, + // protected pushNotificationsProvider: CorePushNotificationsProvider, + this.logger = CoreLogger.getInstance('CoreCourseProvider'); + } + + /** + * Check if the get course blocks WS is available in current site. + * + * @param site Site to check. If not defined, current site. + * @return Whether it's available. + * @since 3.7 + */ + canGetCourseBlocks(site?: CoreSite): boolean { + site = site || CoreSites.instance.getCurrentSite(); + + return !!site && site.isVersionGreaterEqualThan('3.7') && site.wsAvailable('core_block_get_course_blocks'); + } + + /** + * Check whether the site supports requesting stealth modules. + * + * @param site Site. If not defined, current site. + * @return Whether the site supports requesting stealth modules. + * @since 3.4.6, 3.5.3, 3.6 + */ + canRequestStealthModules(site?: CoreSite): boolean { + site = site || CoreSites.instance.getCurrentSite(); + + return !!site && site.isVersionGreaterEqualThan(['3.4.6', '3.5.3']); + } + + /** + * Check if module completion could have changed. If it could have, trigger event. This function must be used, + * for example, after calling a "module_view" WS since it can change the module completion. + * + * @param courseId Course ID. + * @param completion Completion status of the module. + * @todo Add completion type. + */ + checkModuleCompletion(courseId: number, completion: any): void { + if (completion && completion.tracking === 2 && completion.state === 0) { + this.invalidateSections(courseId).finally(() => { + CoreEvents.trigger(CoreEvents.COMPLETION_MODULE_VIEWED, { courseId: courseId }); + }); + } + } + + /** + * Clear all courses status in a site. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when all status are cleared. + */ + async clearAllCoursesStatus(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + this.logger.debug('Clear all course status for site ' + site.id); + + await site.getDb().deleteRecords(COURSE_STATUS_TABLE); + this.triggerCourseStatusChanged(CoreCourseProvider.ALL_COURSES_CLEARED, CoreConstants.NOT_DOWNLOADED, site.id); + } + + /** + * Check if the current view in a NavController is a certain course initial page. + * + * @param navCtrl NavController. + * @param courseId Course ID. + * @return Whether the current view is a certain course. + */ + currentViewIsCourse(): boolean { + // @ todo add params and logic. + return false; + } + + /** + * Get completion status of all the activities in a course for a certain user. + * + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @param userId User ID. If not defined, current user. + * @param forceCache True if it should return cached data. Has priority over ignoreCache. + * @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down). + * @param includeOffline True if it should load offline data in the completion status. + * @return Promise resolved with the completion statuses: object where the key is module ID. + */ + async getActivitiesCompletionStatus( + courseId: number, + siteId?: string, + userId?: number, + forceCache: boolean = false, + ignoreCache: boolean = false, + includeOffline: boolean = true, + ): Promise> { + + const site = await CoreSites.instance.getSite(siteId); + userId = userId || site.getUserId(); + + this.logger.debug(`Getting completion status for user ${userId} in course ${courseId}`); + + const params: CoreCompletionGetActivitiesCompletionStatusWSParams = { + courseid: courseId, + userid: userId, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getActivitiesCompletionCacheKey(courseId, userId), + }; + + if (forceCache) { + preSets.omitExpires = true; + } else if (ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + + const data = await site.read( + 'core_completion_get_activities_completion_status', + params, + preSets, + ); + + if (!data || !data.statuses) { + throw Error('WS core_completion_get_activities_completion_status failed'); + } + + const completionStatus = CoreUtils.instance.arrayToObject(data.statuses, 'cmid'); + if (!includeOffline) { + return completionStatus; + } + + try { + // Now get the offline completion (if any). + const offlineCompletions = await CoreCourseOffline.instance.getCourseManualCompletions(courseId, site.id); + + offlineCompletions.forEach((offlineCompletion) => { + + if (offlineCompletion && typeof completionStatus[offlineCompletion.cmid] != 'undefined') { + const onlineCompletion = completionStatus[offlineCompletion.cmid]; + + // If the activity uses manual completion, override the value with the offline one. + if (onlineCompletion.tracking === 1) { + onlineCompletion.state = offlineCompletion.completed; + onlineCompletion.offline = true; + } + } + }); + + return completionStatus; + } catch { + // Ignore errors. + return completionStatus; + } + } + + /** + * Get cache key for activities completion WS calls. + * + * @param courseId Course ID. + * @param userId User ID. + * @return Cache key. + */ + protected getActivitiesCompletionCacheKey(courseId: number, userId: number): string { + return ROOT_CACHE_KEY + 'activitiescompletion:' + courseId + ':' + userId; + } + + /** + * Get course blocks. + * + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the list of blocks. + * @since 3.7 + */ + async getCourseBlocks(courseId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + const params: CoreBlockGetCourseBlocksWSParams = { + courseid: courseId, + returncontents: true, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getCourseBlocksCacheKey(courseId), + updateFrequency: CoreSite.FREQUENCY_RARELY, + }; + const result = await site.read('core_block_get_course_blocks', params, preSets); + + return result.blocks || []; + } + + /** + * Get cache key for course blocks WS calls. + * + * @param courseId Course ID. + * @return Cache key. + */ + protected getCourseBlocksCacheKey(courseId: number): string { + return ROOT_CACHE_KEY + 'courseblocks:' + courseId; + } + + /** + * Get the data stored for a course. + * + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the data. + */ + async getCourseStatusData(courseId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + const entry: CoreCourseStatusDBRecord = await site.getDb().getRecord(COURSE_STATUS_TABLE, { id: courseId }); + if (!entry) { + throw Error('No entry found on course status table'); + } + + return entry; + } + + /** + * Get a course status. + * + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the status. + */ + async getCourseStatus(courseId: number, siteId?: string): Promise { + try { + const entry = await this.getCourseStatusData(courseId, siteId); + + return entry.status || CoreConstants.NOT_DOWNLOADED; + } catch { + return CoreConstants.NOT_DOWNLOADED; + } + } + + /** + * Obtain ids of downloaded courses. + * + * @param siteId Site id. + * @return Resolves with an array containing downloaded course ids. + */ + async getDownloadedCourseIds(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + const entries: CoreCourseStatusDBRecord[] = await site.getDb().getRecordsList( + COURSE_STATUS_TABLE, + 'status', + [ + CoreConstants.DOWNLOADED, + CoreConstants.DOWNLOADING, + CoreConstants.OUTDATED, + ], + ); + + return entries.map((entry) => entry.id); + } + + /** + * Get a module from Moodle. + * + * @param moduleId The module ID. + * @param courseId The course ID. Recommended to speed up the process and minimize data usage. + * @param sectionId The section ID. + * @param preferCache True if shouldn't call WS if data is cached, false otherwise. + * @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down). + * @param siteId Site ID. If not defined, current site. + * @param modName If set, the app will retrieve all modules of this type with a single WS call. This reduces the + * number of WS calls, but it isn't recommended for modules that can return a lot of contents. + * @return Promise resolved with the module. + */ + async getModule( + moduleId: number, + courseId?: number, + sectionId?: number, + preferCache: boolean = false, + ignoreCache: boolean = false, + siteId?: string, + modName?: string, + ): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + // Helper function to do the WS request without processing the result. + const doRequest = async ( + site: CoreSite, + moduleId: number, + modName: string | undefined, + includeStealth: boolean, + preferCache: boolean, + ): Promise => { + const params: CoreCourseGetContentsParams = { + courseid: courseId!, + options: [], + }; + const preSets: CoreSiteWSPreSets = { + omitExpires: preferCache, + updateFrequency: CoreSite.FREQUENCY_RARELY, + }; + + if (includeStealth) { + params.options!.push({ + name: 'includestealthmodules', + value: true, + }); + } + + // If modName is set, retrieve all modules of that type. Otherwise get only the module. + if (modName) { + params.options!.push({ + name: 'modname', + value: modName, + }); + preSets.cacheKey = this.getModuleByModNameCacheKey(modName); + } else { + params.options!.push({ + name: 'cmid', + value: moduleId, + }); + preSets.cacheKey = this.getModuleCacheKey(moduleId); + } + + if (!preferCache && ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + try { + const sections: CoreCourseSection[] = await site.read('core_course_get_contents', params, preSets); + + return sections; + } catch { + // The module might still be cached by a request with different parameters. + if (!ignoreCache && !CoreApp.instance.isOnline()) { + if (includeStealth) { + // Older versions didn't include the includestealthmodules option. + return doRequest(site, moduleId, modName, false, true); + } else if (modName) { + // Falback to the request for the given moduleId only. + return doRequest(site, moduleId, undefined, this.canRequestStealthModules(site), true); + } + } + + throw Error('WS core_course_get_contents failed, cache ignored'); + } + }; + + if (!courseId) { + // No courseId passed, try to retrieve it. + const module = await this.getModuleBasicInfo(moduleId, siteId); + courseId = module.course; + } + + let sections: CoreCourseSection[]; + try { + const site = await CoreSites.instance.getSite(siteId); + // We have courseId, we can use core_course_get_contents for compatibility. + this.logger.debug(`Getting module ${moduleId} in course ${courseId}`); + + sections = await doRequest(site, moduleId, modName, this.canRequestStealthModules(site), preferCache); + } catch { + // Error getting the module. Try to get all contents (without filtering by module). + const preSets: CoreSiteWSPreSets = { + omitExpires: preferCache, + }; + + if (!preferCache && ignoreCache) { + preSets.getFromCache = false; + preSets.emergencyCache = false; + } + + sections = await this.getSections(courseId, false, false, preSets, siteId); + } + + let foundModule: CoreCourseModule | undefined; + + const foundSection = sections.some((section) => { + if (sectionId != null && + !isNaN(sectionId) && + section.id != CoreCourseProvider.STEALTH_MODULES_SECTION_ID && + sectionId != section.id + ) { + return false; + } + + foundModule = section.modules.find((module) => module.id == moduleId); + + return !!foundModule; + }); + + if (foundSection && foundModule) { + foundModule.course = courseId; + + return foundModule; + } + + throw Error('Module not found'); + } + + /** + * Gets a module basic info by module ID. + * + * @param moduleId Module ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the module's info. + */ + async getModuleBasicInfo(moduleId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + const params: CoreCourseGetCourseModuleWSParams = { + cmid: moduleId, + }; + const preSets = { + cacheKey: this.getModuleCacheKey(moduleId), + updateFrequency: CoreSite.FREQUENCY_RARELY, + }; + const response = await site.read('core_course_get_course_module', params, preSets); + + if (response.warnings && response.warnings.length) { + throw new CoreWSError(response.warnings[0]); + } else if (response.cm) { + return response.cm; + } + + throw Error('WS core_course_get_course_module failed.'); + } + + /** + * Gets a module basic grade info by module ID. + * + * If the user does not have permision to manage the activity false is returned. + * + * @param moduleId Module ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the module's grade info. + */ + async getModuleBasicGradeInfo(moduleId: number, siteId?: string): Promise { + const info = await this.getModuleBasicInfo(moduleId, siteId); + + const grade: CoreCourseModuleGradeInfo = { + advancedgrading: info.advancedgrading, + grade: info.grade, + gradecat: info.gradecat, + gradepass: info.gradepass, + outcomes: info.outcomes, + scale: info.scale, + }; + + if ( + typeof grade.grade != 'undefined' || + typeof grade.advancedgrading != 'undefined' || + typeof grade.outcomes != 'undefined' + ) { + return grade; + } + + return false; + } + + /** + * Gets a module basic info by instance. + * + * @param id Instance ID. + * @param module Name of the module. E.g. 'glossary'. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the module's info. + */ + async getModuleBasicInfoByInstance(id: number, module: string, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + const params: CoreCourseGetCourseModuleByInstanceWSParams = { + instance: id, + module: module, + }; + const preSets = { + cacheKey: this.getModuleBasicInfoByInstanceCacheKey(id, module), + updateFrequency: CoreSite.FREQUENCY_RARELY, + }; + const response: CoreCourseGetCourseModuleWSResponse = + await site.read('core_course_get_course_module_by_instance', params, preSets); + + if (response.warnings && response.warnings.length) { + throw new CoreWSError(response.warnings[0]); + } else if (response.cm) { + return response.cm; + } + + throw Error('WS core_course_get_course_module_by_instance failed'); + } + + /** + * Get cache key for get module by instance WS calls. + * + * @param id Instance ID. + * @param module Name of the module. E.g. 'glossary'. + * @return Cache key. + */ + protected getModuleBasicInfoByInstanceCacheKey(id: number, module: string): string { + return ROOT_CACHE_KEY + 'moduleByInstance:' + module + ':' + id; + } + + /** + * Get cache key for module WS calls. + * + * @param moduleId Module ID. + * @return Cache key. + */ + protected getModuleCacheKey(moduleId: number): string { + return ROOT_CACHE_KEY + 'module:' + moduleId; + } + + /** + * Get cache key for module by modname WS calls. + * + * @param modName Name of the module. + * @return Cache key. + */ + protected getModuleByModNameCacheKey(modName: string): string { + return ROOT_CACHE_KEY + 'module:modName:' + modName; + } + + /** + * Returns the source to a module icon. + * + * @param moduleName The module name. + * @param modicon The mod icon string to use in case we are not using a core activity. + * @return The IMG src. + */ + getModuleIconSrc(moduleName: string, modicon?: string): string { + // @TODO: Check modicon url theme to apply other theme icons. + + // Use default icon on core themes. + if (this.CORE_MODULES.indexOf(moduleName) < 0) { + if (modicon) { + return modicon; + } + + moduleName = 'external-tool'; + } + + return 'assets/img/mod/' + moduleName + '.svg'; + } + + /** + * Get the section ID a module belongs to. + * + * @param moduleId The module ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the section ID. + */ + async getModuleSectionId(moduleId: number, siteId?: string): Promise { + // Try to get the section using getModuleBasicInfo. + const module = await this.getModuleBasicInfo(moduleId, siteId); + + return module.section; + } + + /** + * Return a specific section. + * + * @param courseId The course ID. + * @param sectionId The section ID. + * @param excludeModules Do not return modules, return only the sections structure. + * @param excludeContents Do not return module contents (i.e: files inside a resource). + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the section. + */ + async getSection( + courseId: number, + sectionId: number, + excludeModules?: boolean, + excludeContents?: boolean, + siteId?: string, + ): Promise { + + if (sectionId < 0) { + throw new CoreError('Invalid section ID'); + } + + const sections = await this.getSections(courseId, excludeModules, excludeContents, undefined, siteId); + const section = sections.find((section) => section.id == sectionId); + + if (section) { + return section; + } + + throw new CoreError('Unknown section'); + } + + /** + * Get the course sections. + * + * @param courseId The course ID. + * @param excludeModules Do not return modules, return only the sections structure. + * @param excludeContents Do not return module contents (i.e: files inside a resource). + * @param preSets Presets to use. + * @param siteId Site ID. If not defined, current site. + * @param includeStealthModules Whether to include stealth modules. Defaults to true. + * @return The reject contains the error message, else contains the sections. + */ + async getSections( + courseId: number, + excludeModules: boolean = false, + excludeContents: boolean = false, + preSets?: CoreSiteWSPreSets, + siteId?: string, + includeStealthModules: boolean = true, + ): Promise { + + const site = await CoreSites.instance.getSite(siteId); + preSets = preSets || {}; + preSets.cacheKey = this.getSectionsCacheKey(courseId); + preSets.updateFrequency = preSets.updateFrequency || CoreSite.FREQUENCY_RARELY; + + const params: CoreCourseGetContentsParams = { + courseid: courseId, + options: [ + { + name: 'excludemodules', + value: excludeModules, + }, + { + name: 'excludecontents', + value: excludeContents, + }, + ], + }; + if (this.canRequestStealthModules(site)) { + params.options!.push({ + name: 'includestealthmodules', + value: includeStealthModules, + }); + } + + let sections: CoreCourseSection[]; + try { + sections = await site.read('core_course_get_contents', params, preSets); + } catch { + // Error getting the data, it could fail because we added a new parameter and the call isn't cached. + // Retry without the new parameter and forcing cache. + preSets.omitExpires = true; + params.options!.splice(-1, 1); + sections = await site.read('core_course_get_contents', params, preSets); + } + + const siteHomeId = site.getSiteHomeId(); + let showSections = true; + if (courseId == siteHomeId) { + const storedNumSections = site.getStoredConfig('numsections'); + showSections = typeof storedNumSections != 'undefined' && !!storedNumSections; + } + + if (typeof showSections != 'undefined' && !showSections && sections.length > 0) { + // Get only the last section (Main menu block section). + sections.pop(); + } + + return sections; + } + + /** + * Get cache key for section WS call. + * + * @param courseId Course ID. + * @return Cache key. + */ + protected getSectionsCacheKey(courseId: number): string { + return ROOT_CACHE_KEY + 'sections:' + courseId; + } + + /** + * Given a list of sections, returns the list of modules in the sections. + * + * @param sections Sections. + * @return Modules. + */ + getSectionsModules(sections: CoreCourseSection[]): CoreCourseModule[] { + if (!sections || !sections.length) { + return []; + } + + return sections.reduce((previous: CoreCourseModule[], section) => previous.concat(section.modules || []), []); + } + + /** + * Invalidates course blocks WS call. + * + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateCourseBlocks(courseId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getCourseBlocksCacheKey(courseId)); + } + + /** + * Invalidates module WS call. + * + * @param moduleId Module ID. + * @param siteId Site ID. If not defined, current site. + * @param modName Module name. E.g. 'label', 'url', ... + * @return Promise resolved when the data is invalidated. + */ + async invalidateModule(moduleId: number, siteId?: string, modName?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + const promises: Promise[] = []; + if (modName) { + promises.push(site.invalidateWsCacheForKey(this.getModuleByModNameCacheKey(modName))); + } + promises.push(site.invalidateWsCacheForKey(this.getModuleCacheKey(moduleId))); + + await Promise.all(promises); + } + + /** + * Invalidates module WS call. + * + * @param id Instance ID. + * @param module Name of the module. E.g. 'glossary'. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateModuleByInstance(id: number, module: string, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getModuleBasicInfoByInstanceCacheKey(id, module)); + } + + /** + * Invalidates sections WS call. + * + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @param userId User ID. If not defined, current user. + * @return Promise resolved when the data is invalidated. + */ + async invalidateSections(courseId: number, siteId?: string, userId?: number): Promise { + const site = await CoreSites.instance.getSite(siteId); + const promises: Promise[] = []; + const siteHomeId = site.getSiteHomeId(); + userId = userId || site.getUserId(); + promises.push(site.invalidateWsCacheForKey(this.getSectionsCacheKey(courseId))); + promises.push(site.invalidateWsCacheForKey(this.getActivitiesCompletionCacheKey(courseId, userId))); + if (courseId == siteHomeId) { + promises.push(site.invalidateConfig()); + } + + await Promise.all(promises); + } + + /** + * Load module contents into module.contents if they aren't loaded already. + * + * @param module Module to load the contents. + * @param courseId The course ID. Recommended to speed up the process and minimize data usage. + * @param sectionId The section ID. + * @param preferCache True if shouldn't call WS if data is cached, false otherwise. + * @param ignoreCache True if it should ignore cached data (it will always fail in offline or server down). + * @param siteId Site ID. If not defined, current site. + * @param modName If set, the app will retrieve all modules of this type with a single WS call. This reduces the + * number of WS calls, but it isn't recommended for modules that can return a lot of contents. + * @return Promise resolved when loaded. + */ + async loadModuleContents( + module: CoreCourseModule & CoreCourseModuleBasicInfo, + courseId?: number, + sectionId?: number, + preferCache?: boolean, + ignoreCache?: boolean, + siteId?: string, + modName?: string, + ): Promise { + + if (!ignoreCache && module.contents && module.contents.length) { + // Already loaded. + return; + } + + const mod = await this.getModule(module.id, courseId, sectionId, preferCache, ignoreCache, siteId, modName); + module.contents = mod.contents; + } + + /** + * Report a course and section as being viewed. + * + * @param courseId Course ID. + * @param sectionNumber Section number. + * @param siteId Site ID. If not defined, current site. + * @param name Name of the course. + * @return Promise resolved when the WS call is successful. + * @todo use logHelper. Remove eslint disable when done. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async logView(courseId: number, sectionNumber?: number, siteId?: string, name?: string): Promise { + const params: CoreCourseViewCourseWSParams = { + courseid: courseId, + }; + const wsName = 'core_course_view_course'; + + if (typeof sectionNumber != 'undefined') { + params.sectionnumber = sectionNumber; + } + + const site = await CoreSites.instance.getSite(siteId); + // @todo + // this.pushNotificationsProvider.logViewEvent(courseId, name, 'course', wsName, { sectionnumber: sectionNumber }, siteId); + const response: CoreStatusWithWarningsWSResponse = await site.write(wsName, params); + + if (!response.status) { + throw Error('WS core_course_view_course failed.'); + } else { + CoreEvents.trigger(CoreCoursesProvider.EVENT_MY_COURSES_UPDATED, { + courseId: courseId, + action: CoreCoursesProvider.ACTION_VIEW, + }, site.getId()); + } + } + + /** + * Offline version for manually marking a module as completed. + * + * @param cmId The module ID. + * @param completed Whether the module is completed or not. + * @param courseId Course ID the module belongs to. + * @param courseName Course name. Recommended, it is used to display a better warning message. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when completion is successfully sent or stored. + */ + async markCompletedManually( + cmId: number, + completed: boolean, + courseId: number, + courseName?: string, + siteId?: string, + ): Promise { + + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + // Convenience function to store a completion to be synchronized later. + const storeOffline = (): Promise => + CoreCourseOffline.instance.markCompletedManually(cmId, completed, courseId, courseName, siteId); + + // The offline function requires a courseId and it could be missing because it's a calculated field. + if (!CoreApp.instance.isOnline() && courseId) { + // App is offline, store the action. + return storeOffline(); + } + + // Try to send it to server. + try { + const result = await this.markCompletedManuallyOnline(cmId, completed, siteId); + + // Data sent to server, if there is some offline data delete it now. + try { + await CoreCourseOffline.instance.deleteManualCompletion(cmId, siteId); + } catch { + // Ignore errors, shouldn't happen. + } + + return result; + } catch (error) { + if (CoreUtils.instance.isWebServiceError(error) || !courseId) { + // The WebService has thrown an error, this means that responses cannot be submitted. + throw error; + } else { + // Couldn't connect to server, store it offline. + return storeOffline(); + } + } + } + + /** + * Offline version for manually marking a module as completed. + * + * @param cmId The module ID. + * @param completed Whether the module is completed or not. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when completion is successfully sent. + */ + async markCompletedManuallyOnline( + cmId: number, + completed: boolean, + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + const params: CoreCompletionUpdateActivityCompletionStatusManuallyWSParams = { + cmid: cmId, + completed: completed, + }; + + return site.write('core_completion_update_activity_completion_status_manually', params); + } + + /** + * Check if a module has a view page. E.g. labels don't have a view page. + * + * @param module The module object. + * @return Whether the module has a view page. + */ + moduleHasView(module: CoreCourseModuleSummary | CoreCourseModule): boolean { + return !!module.url; + } + + /** + * Wait for any course format plugin to load, and open the course page. + * + * If the plugin's promise is resolved, the course page will be opened. If it is rejected, they will see an error. + * If the promise for the plugin is still in progress when the user tries to open the course, a loader + * will be displayed until it is complete, before the course page is opened. If the promise is already complete, + * they will see the result immediately. + * + * This function must be in here instead of course helper to prevent circular dependencies. + * + * @param course Course to open + * @param params Other params to pass to the course page. + * @return Promise resolved when done. + */ + async openCourse( + course: { id: number ; format?: string }, + params?: Params, // eslint-disable-line @typescript-eslint/no-unused-vars + ): Promise { + // @todo const loading = await CoreDomUtils.instance.showModalLoading(); + + // Wait for site plugins to be fetched. + // @todo await this.sitePluginsProvider.waitFetchPlugins(); + + if (typeof course.format == 'undefined') { + // This block can be replaced by a call to CourseHelper.getCourse(), but it is circular dependant. + const coursesProvider = CoreCourses.instance; + try { + course = await coursesProvider.getUserCourse(course.id, true); + } catch (error) { + // Not enrolled or an error happened. Try to use another WebService. + const available = coursesProvider.isGetCoursesByFieldAvailableInSite(); + try { + if (available) { + course = await coursesProvider.getCourseByField('id', course.id); + } else { + course = await coursesProvider.getCourse(course.id); + } + } catch (error) { + // Ignore errors. + } + } + } + + /* @todo + if (!this.sitePluginsProvider.sitePluginPromiseExists('format_' + course.format)) { + // No custom format plugin. We don't need to wait for anything. + await this.courseFormatDelegate.openCourse(course, params); + loading.dismiss(); + + return; + } */ + + // This course uses a custom format plugin, wait for the format plugin to finish loading. + try { + /* @todo await this.sitePluginsProvider.sitePluginLoaded('format_' + course.format); + // The format loaded successfully, but the handlers wont be registered until all site plugins have loaded. + if (this.sitePluginsProvider.sitePluginsFinishedLoading) { + return this.courseFormatDelegate.openCourse(course, params); + }*/ + + // Wait for plugins to be loaded. + const deferred = CoreUtils.instance.promiseDefer(); + + const observer = CoreEvents.on(CoreEvents.SITE_PLUGINS_LOADED, () => { + observer && observer.off(); + + /* @todo this.courseFormatDelegate.openCourse(course, params).then((response) => { + deferred.resolve(response); + }).catch((error) => { + deferred.reject(error); + });*/ + }); + + return deferred.promise; + } catch (error) { + // The site plugin failed to load. The user needs to restart the app to try loading it again. + const message = Translate.instance.instant('core.courses.errorloadplugins'); + const reload = Translate.instance.instant('core.courses.reload'); + const ignore = Translate.instance.instant('core.courses.ignore'); + + await CoreDomUtils.instance.showConfirm(message, '', reload, ignore); + window.location.reload(); + } + } + + /** + * Select a certain tab in the course. Please use currentViewIsCourse() first to verify user is viewing the course. + * + * @param name Name of the tab. If not provided, course contents. + * @param params Other params. + */ + selectCourseTab(name?: string, params?: Params): void { + params = params || {}; + params.name = name || ''; + + CoreEvents.trigger(CoreEvents.SELECT_COURSE_TAB, params); + } + + /** + * Change the course status, setting it to the previous status. + * + * @param courseId Course ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the status is changed. Resolve param: new status. + */ + async setCoursePreviousStatus(courseId: number, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + this.logger.debug(`Set previous status for course ${courseId} in site ${siteId}`); + + const site = await CoreSites.instance.getSite(siteId); + const db = site.getDb(); + const entry = await this.getCourseStatusData(courseId, siteId); + + this.logger.debug(`Set previous status '${entry.status}' for course ${courseId}`); + + const newData = { + id: courseId, + status: entry.previous || CoreConstants.NOT_DOWNLOADED, + updated: Date.now(), + // Going back from downloading to previous status, restore previous download time. + downloadTime: entry.status == CoreConstants.DOWNLOADING ? entry.previousDownloadTime : entry.downloadTime, + }; + + await db.updateRecords(COURSE_STATUS_TABLE, newData, { id: courseId }); + // Success updating, trigger event. + this.triggerCourseStatusChanged(courseId, newData.status, siteId); + + return newData.status; + } + + /** + * Store course status. + * + * @param courseId Course ID. + * @param status New course status. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the status is stored. + */ + async setCourseStatus(courseId: number, status: string, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + this.logger.debug(`Set status '${status}' for course ${courseId} in site ${siteId}`); + + const site = await CoreSites.instance.getSite(siteId); + let downloadTime = 0; + let previousDownloadTime = 0; + let previousStatus = ''; + + if (status == CoreConstants.DOWNLOADING) { + // Set download time if course is now downloading. + downloadTime = CoreTimeUtils.instance.timestamp(); + } + + try { + const entry = await this.getCourseStatusData(courseId, siteId); + if (typeof downloadTime == 'undefined') { + // Keep previous download time. + downloadTime = entry.downloadTime; + previousDownloadTime = entry.previousDownloadTime; + } else { + // The downloadTime will be updated, store current time as previous. + previousDownloadTime = entry.downloadTime; + } + previousStatus = entry.status; + } catch { + // New entry. + } + + if (previousStatus != status) { + // Status has changed, update it. + const data: CoreCourseStatusDBRecord = { + id: courseId, + status: status, + previous: previousStatus, + updated: new Date().getTime(), + downloadTime: downloadTime, + previousDownloadTime: previousDownloadTime, + }; + + await site.getDb().insertRecord(COURSE_STATUS_TABLE, data); + } + + // Success inserting, trigger event. + this.triggerCourseStatusChanged(courseId, status, siteId); + } + + /** + * Translate a module name to current language. + * + * @param moduleName The module name. + * @return Translated name. + */ + translateModuleName(moduleName: string): string { + if (this.CORE_MODULES.indexOf(moduleName) < 0) { + moduleName = 'external-tool'; + } + + const langKey = 'core.mod_' + moduleName; + const translated = Translate.instance.instant(langKey); + + return translated !== langKey ? translated : moduleName; + } + + /** + * Trigger COURSE_STATUS_CHANGED with the right data. + * + * @param courseId Course ID. + * @param status New course status. + * @param siteId Site ID. If not defined, current site. + */ + protected triggerCourseStatusChanged(courseId: number, status: string, siteId?: string): void { + CoreEvents.trigger(CoreEvents.COURSE_STATUS_CHANGED, { + courseId: courseId, + status: status, + }, siteId); + } + +} + +/** + * Common options used by modules when calling a WS through CoreSite. + */ +export type CoreCourseCommonModWSOptions = CoreSitesCommonWSOptions & { + cmId?: number; // Module ID. +}; + +/** + * Data returned by course_summary_exporter. + */ +export type CoreCourseSummary = { + id: number; // Id. + fullname: string; // Fullname. + shortname: string; // Shortname. + idnumber: string; // Idnumber. + summary: string; // @since 3.3. Summary. + summaryformat: number; // @since 3.3. Summary format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + startdate: number; // @since 3.3. Startdate. + enddate: number; // @since 3.3. Enddate. + visible: boolean; // @since 3.8. Visible. + fullnamedisplay: string; // @since 3.3. Fullnamedisplay. + viewurl: string; // Viewurl. + courseimage: string; // @since 3.6. Courseimage. + progress?: number; // @since 3.6. Progress. + hasprogress: boolean; // @since 3.6. Hasprogress. + isfavourite: boolean; // @since 3.6. Isfavourite. + hidden: boolean; // @since 3.6. Hidden. + timeaccess?: number; // @since 3.6. Timeaccess. + showshortname: boolean; // @since 3.6. Showshortname. + coursecategory: string; // @since 3.7. Coursecategory. +}; + +/** + * Data returned by course_module_summary_exporter. + */ +export type CoreCourseModuleSummary = { + id: number; // Id. + name: string; // Name. + url?: string; // Url. + iconurl: string; // Iconurl. +}; + +/** + * Params of core_completion_get_activities_completion_status WS. + */ +type CoreCompletionGetActivitiesCompletionStatusWSParams = { + courseid: number; // Course ID. + userid: number; // User ID. +}; + +/** + * Data returned by core_completion_get_activities_completion_status WS. + */ +export type CoreCourseCompletionActivityStatusWSResponse = { + statuses: CoreCourseCompletionActivityStatus[]; // List of activities status. + warnings?: CoreStatusWithWarningsWSResponse[]; +}; + +/** + * Activity status. + */ +export type CoreCourseCompletionActivityStatus = { + cmid: number; // Comment ID. + modname: string; // Activity module name. + instance: number; // Instance ID. + state: number; // Completion state value: 0 means incomplete, 1 complete, 2 complete pass, 3 complete fail. + timecompleted: number; // Timestamp for completed activity. + tracking: number; // Type of tracking: 0 means none, 1 manual, 2 automatic. + overrideby?: number; // The user id who has overriden the status, or null. + valueused?: boolean; // Whether the completion status affects the availability of another activity. + offline?: boolean; // Whether the completions is offline and not yet synced. +}; + +/** + * Params of core_block_get_course_blocks WS. + */ +type CoreBlockGetCourseBlocksWSParams = { + courseid: number; // Course id. + returncontents?: boolean; // Whether to return the block contents. +}; + +/** + * Data returned by core_block_get_course_blocks WS. + */ +export type CoreCourseBlocksWSResponse = { + blocks: CoreCourseBlock[]; // List of blocks in the course. + warnings?: CoreStatusWithWarningsWSResponse[]; +}; + +/** + * Block data type. + */ +export type CoreCourseBlock = { + instanceid: number; // Block instance id. + name: string; // Block name. + region: string; // Block region. + positionid: number; // Position id. + collapsible: boolean; // Whether the block is collapsible. + dockable: boolean; // Whether the block is dockable. + weight?: number; // Used to order blocks within a region. + visible?: boolean; // Whether the block is visible. + contents?: { + title: string; // Block title. + content: string; // Block contents. + contentformat: number; // Content format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + footer: string; // Block footer. + files: CoreWSExternalFile[]; + }; // Block contents (if required). + configs?: { // Block instance and plugin configuration settings. + name: string; // Name. + value: string; // JSON encoded representation of the config value. + type: string; // Type (instance or plugin). + }[]; +}; + +/** + * Params of core_course_get_contents WS. + */ +export type CoreCourseGetContentsParams = { + courseid: number; // Course id. + options?: { // Options, used since Moodle 2.9. + /** + * The expected keys (value format) are: + * + * excludemodules (bool) Do not return modules, return only the sections structure + * excludecontents (bool) Do not return module contents (i.e: files inside a resource) + * includestealthmodules (bool) Return stealth modules for students in a special + * section (with id -1) + * sectionid (int) Return only this section + * sectionnumber (int) Return only this section with number (order) + * cmid (int) Return only this module information (among the whole sections structure) + * modname (string) Return only modules with this name "label, forum, etc..." + * modid (int) Return only the module with this id (to be used with modname. + */ + name: string; + value: string | number | boolean; // The value of the option, this param is personaly validated in the external function. + }[]; +}; + +/** + * Data returned by core_course_get_contents WS. + */ +export type CoreCourseSection = { + id: number; // Section ID. + name: string; // Section name. + visible?: number; // Is the section visible. + summary: string; // Section description. + summaryformat: number; // Summary format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + section?: number; // Section number inside the course. + hiddenbynumsections?: number; // Whether is a section hidden in the course format. + uservisible?: boolean; // Is the section visible for the user?. + availabilityinfo?: string; // Availability information. + modules: CoreCourseModule[]; +}; + +/** + * Params of core_course_get_course_module WS. + */ +type CoreCourseGetCourseModuleWSParams = { + cmid: number; // The course module id. +}; + +/** + * Params of core_course_get_course_module_by_instance WS. + */ +type CoreCourseGetCourseModuleByInstanceWSParams = { + module: string; // The module name. + instance: number; // The module instance id. +}; + +/** + * Data returned by core_course_get_course_module and core_course_get_course_module_by_instance WS. + */ +export type CoreCourseGetCourseModuleWSResponse = { + cm: CoreCourseModuleBasicInfo; + warnings?: CoreStatusWithWarningsWSResponse[]; +}; + + +/** + * Course module type. + */ +export type CoreCourseModule = { // List of module. + id: number; // Activity id. + course?: number; // The course id. + url?: string; // Activity url. + name: string; // Activity module name. + instance?: number; // Instance id. + contextid?: number; // Activity context id. + description?: string; // Activity description. + visible?: number; // Is the module visible. + uservisible?: boolean; // Is the module visible for the user?. + availabilityinfo?: string; // Availability information. + visibleoncoursepage?: number; // Is the module visible on course page. + modicon: string; // Activity icon url. + modname: string; // Activity module type. + modplural: string; // Activity module plural name. + availability?: string; // Module availability settings. + indent: number; // Number of identation in the site. + onclick?: string; // Onclick action. + afterlink?: string; // After link info to be displayed. + customdata?: string; // Custom data (JSON encoded). + noviewlink?: boolean; // Whether the module has no view page. + completion?: number; // Type of completion tracking: 0 means none, 1 manual, 2 automatic. + completiondata?: { // Module completion data. + state: number; // Completion state value: 0 means incomplete, 1 complete, 2 complete pass, 3 complete fail. + timecompleted: number; // Timestamp for completion status. + overrideby: number; // The user id who has overriden the status. + valueused?: boolean; // Whether the completion status affects the availability of another activity. + }; + contents: { + type: string; // A file or a folder or external link. + filename: string; // Filename. + filepath: string; // Filepath. + filesize: number; // Filesize. + fileurl?: string; // Downloadable file url. + content?: string; // Raw content, will be used when type is content. + timecreated: number; // Time created. + timemodified: number; // Time modified. + sortorder: number; // Content sort order. + mimetype?: string; // File mime type. + isexternalfile?: boolean; // Whether is an external file. + repositorytype?: string; // The repository type for external files. + userid: number; // User who added this content to moodle. + author: string; // Content owner. + license: string; // Content license. + tags?: { // Tags. + id: number; // Tag id. + name: string; // Tag name. + rawname: string; // The raw, unnormalised name for the tag as entered by users. + isstandard: boolean; // Whether this tag is standard. + tagcollid: number; // Tag collection id. + taginstanceid: number; // Tag instance id. + taginstancecontextid: number; // Context the tag instance belongs to. + itemid: number; // Id of the record tagged. + ordering: number; // Tag ordering. + flag: number; // Whether the tag is flagged as inappropriate. + }[]; + }[]; + contentsinfo?: { // Contents summary information. + filescount: number; // Total number of files. + filessize: number; // Total files size. + lastmodified: number; // Last time files were modified. + mimetypes: string[]; // Files mime types. + repositorytype?: string; // The repository type for the main file. + }; +}; + +/** + * Course module basic info type. + */ +export type CoreCourseModuleGradeInfo = { + grade?: number; // Grade (max value or scale id). + scale?: string; // Scale items (if used). + gradepass?: string; // Grade to pass (float). + gradecat?: number; // Grade category. + advancedgrading?: { // Advanced grading settings. + area: string; // Gradable area name. + method: string; // Grading method. + }[]; + outcomes?: { // Outcomes information. + id: string; // Outcome id. + name: string; // Outcome full name. + scale: string; // Scale items. + }[]; +}; + +/** + * Course module basic info type. + */ +export type CoreCourseModuleBasicInfo = CoreCourseModuleGradeInfo & { + id: number; // The course module id. + course: number; // The course id. + module: number; // The module type id. + name: string; // The activity name. + modname: string; // The module component name (forum, assign, etc..). + instance: number; // The activity instance id. + section: number; // The module section id. + sectionnum: number; // The module section number. + groupmode: number; // Group mode. + groupingid: number; // Grouping id. + completion: number; // If completion is enabled. + idnumber?: string; // Module id number. + added?: number; // Time added. + score?: number; // Score. + indent?: number; // Indentation. + visible?: number; // If visible. + visibleoncoursepage?: number; // If visible on course page. + visibleold?: number; // Visible old. + completiongradeitemnumber?: number; // Completion grade item. + completionview?: number; // Completion view setting. + completionexpected?: number; // Completion time expected. + showdescription?: number; // If the description is showed. + availability?: string; // Availability settings. +}; + +/** + * Params of core_course_view_course WS. + */ +type CoreCourseViewCourseWSParams = { + courseid: number; // Id of the course. + sectionnumber?: number; // Section number. +}; + +/** + * Params of core_completion_update_activity_completion_status_manually WS. + */ +type CoreCompletionUpdateActivityCompletionStatusManuallyWSParams = { + cmid: number; // Course module id. + completed: boolean; // Activity completed or not. +}; + +export class CoreCourse extends makeSingleton(CoreCourseProvider) {} diff --git a/src/core/features/courses/services/courses.helper.ts b/src/core/features/courses/services/courses.helper.ts new file mode 100644 index 000000000..0cef4e4f0 --- /dev/null +++ b/src/core/features/courses/services/courses.helper.ts @@ -0,0 +1,181 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +// import { PopoverController } from '@ionic/angular'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreSites } from '@services/sites'; +import { CoreCourses, CoreCourseSearchedData, CoreCourseUserAdminOrNavOptionIndexed, CoreEnrolledCourseData } from './courses'; +import { makeSingleton } from '@singletons/core.singletons'; +// import { AddonCourseCompletionProvider } from '@addon/coursecompletion/providers/coursecompletion'; +// import { CoreCoursePickerMenuPopoverComponent } from '@components/course-picker-menu/course-picker-menu-popover'; + +/** + * Helper to gather some common courses functions. + */ +@Injectable({ + providedIn: 'root', +}) +export class CoreCoursesHelperProvider { + + /** + * Get the courses to display the course picker popover. If a courseId is specified, it will also return its categoryId. + * + * @param courseId Course ID to get the category. + * @return Promise resolved with the list of courses and the category. + */ + async getCoursesForPopover(): Promise { + // @todo params and logic + } + + /** + * Given a course object returned by core_enrol_get_users_courses and another one returned by core_course_get_courses_by_field, + * load some extra data to the first one. + * + * @param course Course returned by core_enrol_get_users_courses. + * @param courseByField Course returned by core_course_get_courses_by_field. + * @param addCategoryName Whether add category name or not. + */ + loadCourseExtraInfo( + course: CoreEnrolledCourseDataWithExtraInfo, + courseByField: CoreCourseSearchedData, + addCategoryName: boolean = false, + ): void { + if (courseByField) { + course.displayname = courseByField.displayname; + course.categoryname = addCategoryName ? courseByField.categoryname : undefined; + + if (courseByField.overviewfiles && courseByField.overviewfiles[0]) { + course.courseImage = courseByField.overviewfiles[0].fileurl; + } else { + delete course.courseImage; + } + } else { + delete course.displayname; + delete course.courseImage; + } + } + + /** + * Given a list of courses returned by core_enrol_get_users_courses, load some extra data using the WebService + * core_course_get_courses_by_field if available. + * + * @param courses List of courses. + * @param loadCategoryNames Whether load category names or not. + * @return Promise resolved when done. + */ + async loadCoursesExtraInfo(courses: CoreEnrolledCourseDataWithExtraInfo[], loadCategoryNames: boolean = false): Promise { + if (!courses.length ) { + // No courses or cannot get the data, stop. + return; + } + + let coursesInfo = {}; + let courseInfoAvailable = false; + + const site = CoreSites.instance.getCurrentSite(); + const promises: Promise[] = []; + const colors: (string | undefined)[] = []; + + if (site?.isVersionGreaterEqualThan('3.8')) { + promises.push(site.getConfig().then((configs) => { + for (let x = 0; x < 10; x++) { + colors[x] = configs['core_admin_coursecolor' + (x + 1)] || undefined; + } + + return; + }).catch(() => { + // Ignore errors. + })); + } + + if (CoreCourses.instance.isGetCoursesByFieldAvailable() && (loadCategoryNames || + (typeof courses[0].overviewfiles == 'undefined' && typeof courses[0].displayname == 'undefined'))) { + const courseIds = courses.map((course) => course.id).join(','); + + courseInfoAvailable = true; + + // Get the extra data for the courses. + promises.push(CoreCourses.instance.getCoursesByField('ids', courseIds).then((coursesInfos) => { + coursesInfo = CoreUtils.instance.arrayToObject(coursesInfos, 'id'); + + return; + })); + } + + await Promise.all(promises); + + courses.forEach((course) => { + this.loadCourseExtraInfo(course, courseInfoAvailable ? coursesInfo[course.id] : course, loadCategoryNames); + + if (!course.courseImage) { + course.colorNumber = course.id % 10; + course.color = colors.length ? colors[course.colorNumber] : undefined; + } + }); + } + + /** + * Get user courses with admin and nav options. + * + * @param sort Sort courses after get them. If sort is not defined it won't be sorted. + * @param slice Slice results to get the X first one. If slice > 0 it will be done after sorting. + * @param filter Filter using some field. + * @param loadCategoryNames Whether load category names or not. + * @return Courses filled with options. + */ + async getUserCoursesWithOptions(): Promise { + // @todo params and logic + } + + /** + * Show a context menu to select a course, and return the courseId and categoryId of the selected course (-1 for all courses). + * Returns an empty object if popover closed without picking a course. + * + * @param event Click event. + * @param courses List of courses, from CoreCoursesHelperProvider.getCoursesForPopover. + * @param courseId The course to select at start. + * @return Promise resolved with the course ID and category ID. + */ + async selectCourse(): Promise { + // @todo params and logic + } + +} + +export class CoreCoursesHelper extends makeSingleton(CoreCoursesHelperProvider) { } + +/** + * Enrolled course data with extra rendering info. + */ +export type CoreEnrolledCourseDataWithExtraInfo = CoreEnrolledCourseData & { + colorNumber?: number; // Color index number. + color?: string; // Color RGB. + courseImage?: string; // Course thumbnail. + categoryname?: string; // Category name, +}; + +/** + * Enrolled course data with admin and navigation option availability. + */ +export type CoreEnrolledCourseDataWithOptions = CoreEnrolledCourseData & { + navOptions?: CoreCourseUserAdminOrNavOptionIndexed; + admOptions?: CoreCourseUserAdminOrNavOptionIndexed; +}; + +/** + * Enrolled course data with admin and navigation option availability and extra rendering info. + */ +export type CoreEnrolledCourseDataWithExtraInfoAndOptions = CoreEnrolledCourseDataWithExtraInfo & CoreEnrolledCourseDataWithOptions; + diff --git a/src/core/features/courses/services/courses.ts b/src/core/features/courses/services/courses.ts new file mode 100644 index 000000000..4c205ddb4 --- /dev/null +++ b/src/core/features/courses/services/courses.ts @@ -0,0 +1,1592 @@ +// (C) Copyright 2015 Moodle Pty Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import { Injectable } from '@angular/core'; +import { CoreLogger } from '@singletons/logger'; +import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; +import { CoreSite, CoreSiteWSPreSets } from '@classes/site'; +import { makeSingleton } from '@singletons/core.singletons'; +import { CoreStatusWithWarningsWSResponse, CoreWarningsWSResponse, CoreWSExternalFile, CoreWSExternalWarning } from '@services/ws'; +import { CoreEvents } from '@singletons/events'; +import { CoreWSError } from '@classes/errors/wserror'; + +const ROOT_CACHE_KEY = 'mmCourses:'; + +/** + * Service that provides some features regarding lists of courses and categories. + */ +@Injectable({ + providedIn: 'root', +}) +export class CoreCoursesProvider { + + static readonly SEARCH_PER_PAGE = 20; + static readonly ENROL_INVALID_KEY = 'CoreCoursesEnrolInvalidKey'; + static readonly EVENT_MY_COURSES_CHANGED = 'courses_my_courses_changed'; // User course list changed while app is running. + // A course was hidden/favourite, or user enroled in a course. + static readonly EVENT_MY_COURSES_UPDATED = 'courses_my_courses_updated'; + static readonly EVENT_MY_COURSES_REFRESHED = 'courses_my_courses_refreshed'; + static readonly EVENT_DASHBOARD_DOWNLOAD_ENABLED_CHANGED = 'dashboard_download_enabled_changed'; + + // Actions for event EVENT_MY_COURSES_UPDATED. + static readonly ACTION_ENROL = 'enrol'; // User enrolled in a course. + static readonly ACTION_STATE_CHANGED = 'state_changed'; // Course state changed (hidden, favourite). + static readonly ACTION_VIEW = 'view'; // Course viewed. + + // Possible states changed. + static readonly STATE_HIDDEN = 'hidden'; + static readonly STATE_FAVOURITE = 'favourite'; + + protected logger: CoreLogger; + protected userCoursesIds: { [id: number]: boolean } = {}; // Use an object to make it faster to search. + + constructor() { + this.logger = CoreLogger.getInstance('CoreCoursesProvider'); + } + + /** + * Whether current site supports getting course options. + * + * @return Whether current site supports getting course options. + */ + canGetAdminAndNavOptions(): boolean { + return CoreSites.instance.wsAvailableInCurrentSite('core_course_get_user_navigation_options') && + CoreSites.instance.wsAvailableInCurrentSite('core_course_get_user_administration_options'); + } + + /** + * Get categories. They can be filtered by id. + * + * @param categoryId Category ID to get. + * @param addSubcategories If it should add subcategories to the list. + * @param siteId Site to get the courses from. If not defined, use current site. + * @return Promise resolved with the categories. + */ + async getCategories( + categoryId: number, + addSubcategories: boolean = false, + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + + // Get parent when id is the root category. + const criteriaKey = categoryId == 0 ? 'parent' : 'id'; + const params: CoreCourseGetCategoriesWSParams = { + criteria: [ + { + key: criteriaKey, + value: categoryId, + }, + ], + addsubcategories: addSubcategories, + }; + + const preSets = { + cacheKey: this.getCategoriesCacheKey(categoryId, addSubcategories), + updateFrequency: CoreSite.FREQUENCY_RARELY, + }; + + return site.read('core_course_get_categories', params, preSets); + } + + /** + * Get cache key for get categories methods WS call. + * + * @param categoryId Category ID to get. + * @param addSubcategories If add subcategories to the list. + * @return Cache key. + */ + protected getCategoriesCacheKey(categoryId: number, addSubcategories?: boolean): string { + return ROOT_CACHE_KEY + 'categories:' + categoryId + ':' + !!addSubcategories; + } + + /** + * Given a list of course IDs to get course admin and nav options, return the list of courseIds to use. + * + * @param courseIds Course IDs. + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved with the list of course IDs. + */ + protected async getCourseIdsForAdminAndNavOptions(courseIds: number[], siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const siteHomeId = site.getSiteHomeId(); + if (courseIds.length == 1) { + // Only 1 course, check if it belongs to the user courses. If so, use all user courses. + return this.getCourseIdsIfEnrolled(courseIds[0], siteId); + } else { + if (courseIds.length > 1 && courseIds.indexOf(siteHomeId) == -1) { + courseIds.push(siteHomeId); + } + + // Sort the course IDs. + courseIds.sort((a, b) => b - a); + + return courseIds; + } + } + + /** + * Given a course ID, if user is enrolled in the course it will return the IDs of all enrolled courses and site home. + * Return only the course ID otherwise. + * + * @param courseIds Course IDs. + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved with the list of course IDs. + */ + async getCourseIdsIfEnrolled(courseId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + const siteHomeId = site.getSiteHomeId(); + + try { + // Check if user is enrolled in the course. + const courses = await this.getUserCourses(true, siteId); + let useAllCourses = false; + + if (courseId == siteHomeId) { + // It's site home, use all courses. + useAllCourses = true; + } else { + useAllCourses = !!courses.find((course) => course.id == courseId); + } + + if (useAllCourses) { + // User is enrolled, return all the courses. + const courseIds = courses.map((course) => course.id); + + // Always add the site home ID. + courseIds.push(siteHomeId); + + // Sort the course IDs. + courseIds.sort((a, b) => b - a); + + return courseIds; + } + } catch { + // Ignore errors. + } + + return [courseId]; + } + + /** + * Check if download a whole course is disabled in a certain site. + * + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved with true if disabled, rejected or resolved with false otherwise. + */ + async isDownloadCourseDisabled(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return this.isDownloadCoursesDisabledInSite(site); + } + + /** + * Check if download a whole course is disabled in a certain site. + * + * @param site Site. If not defined, use current site. + * @return Whether it's disabled. + */ + isDownloadCourseDisabledInSite(site?: CoreSite): boolean { + site = site || CoreSites.instance.getCurrentSite(); + + return !site || site.isOfflineDisabled() || site.isFeatureDisabled('NoDelegate_CoreCourseDownload'); + } + + /** + * Check if download all courses is disabled in a certain site. + * + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved with true if disabled, rejected or resolved with false otherwise. + */ + async isDownloadCoursesDisabled(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return this.isDownloadCoursesDisabledInSite(site); + } + + /** + * Check if download all courses is disabled in a certain site. + * + * @param site Site. If not defined, use current site. + * @return Whether it's disabled. + */ + isDownloadCoursesDisabledInSite(site?: CoreSite): boolean { + site = site || CoreSites.instance.getCurrentSite(); + + return !site || site.isOfflineDisabled() || site.isFeatureDisabled('NoDelegate_CoreCoursesDownload'); + } + + /** + * Check if My Courses is disabled in a certain site. + * + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved with true if disabled, rejected or resolved with false otherwise. + */ + async isMyCoursesDisabled(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return this.isMyCoursesDisabledInSite(site); + } + + /** + * Check if My Courses is disabled in a certain site. + * + * @param site Site. If not defined, use current site. + * @return Whether it's disabled. + */ + isMyCoursesDisabledInSite(site?: CoreSite): boolean { + site = site || CoreSites.instance.getCurrentSite(); + + return !site || site.isFeatureDisabled('CoreMainMenuDelegate_CoreCourses'); + } + + /** + * Check if Search Courses is disabled in a certain site. + * + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved with true if disabled, rejected or resolved with false otherwise. + */ + async isSearchCoursesDisabled(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return this.isSearchCoursesDisabledInSite(site); + } + + /** + * Check if Search Courses is disabled in a certain site. + * + * @param site Site. If not defined, use current site. + * @return Whether it's disabled. + */ + isSearchCoursesDisabledInSite(site?: CoreSite): boolean { + site = site || CoreSites.instance.getCurrentSite(); + + return !site || site.isFeatureDisabled('CoreCourseOptionsDelegate_search'); + } + + /** + * Get course. + * + * @param id ID of the course to get. + * @param siteId Site to get the courses from. If not defined, use current site. + * @return Promise resolved with the course. + */ + async getCourse(id: number, siteId?: string): Promise { + const courses = await this.getCourses([id], siteId); + + if (courses && courses.length > 0) { + return courses[0]; + } + + throw Error('Course not found on core_course_get_courses'); + } + + /** + * Get the enrolment methods from a course. + * + * @param id ID of the course. + * @param siteId Site ID. If not defined, use current site. + * @return Promise resolved with the methods. + */ + async getCourseEnrolmentMethods(id: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const params: CoreEnrolGetCourseEnrolmentMethodsWSParams = { + courseid: id, + }; + const preSets = { + cacheKey: this.getCourseEnrolmentMethodsCacheKey(id), + updateFrequency: CoreSite.FREQUENCY_RARELY, + }; + + return site.read('core_enrol_get_course_enrolment_methods', params, preSets); + } + + /** + * Get cache key for get course enrolment methods WS call. + * + * @param id Course ID. + * @return Cache key. + */ + protected getCourseEnrolmentMethodsCacheKey(id: number): string { + return ROOT_CACHE_KEY + 'enrolmentmethods:' + id; + } + + /** + * Get info from a course guest enrolment method. + * + * @param instanceId Guest instance ID. + * @param siteId Site ID. If not defined, use current site. + * @return Promise resolved when the info is retrieved. + */ + async getCourseGuestEnrolmentInfo(instanceId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + const params: EnrolGuestGetInstanceInfoWSParams = { + instanceid: instanceId, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getCourseGuestEnrolmentInfoCacheKey(instanceId), + updateFrequency: CoreSite.FREQUENCY_RARELY, + }; + const response = await site.read('enrol_guest_get_instance_info', params, preSets); + + return response.instanceinfo; + } + + /** + * Get cache key for get course guest enrolment methods WS call. + * + * @param instanceId Guest instance ID. + * @return Cache key. + */ + protected getCourseGuestEnrolmentInfoCacheKey(instanceId: number): string { + return ROOT_CACHE_KEY + 'guestinfo:' + instanceId; + } + + /** + * Get courses. + * Warning: if the user doesn't have permissions to view some of the courses passed the WS call will fail. + * The user must be able to view ALL the courses passed. + * + * @param ids List of IDs of the courses to get. + * @param siteId Site to get the courses from. If not defined, use current site. + * @return Promise resolved with the courses. + */ + async getCourses(ids: number[], siteId?: string): Promise { + if (!Array.isArray(ids)) { + throw Error('ids parameter should be an array'); + } + + if (ids.length === 0) { + return []; + } + + const site = await CoreSites.instance.getSite(siteId); + + const params: CoreCourseGetCoursesWSParams = { + options: { + ids: ids, + }, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getCoursesCacheKey(ids), + updateFrequency: CoreSite.FREQUENCY_RARELY, + }; + + return site.read('core_course_get_courses', params, preSets); + } + + /** + * Get cache key for get courses WS call. + * + * @param ids Courses IDs. + * @return Cache key. + */ + protected getCoursesCacheKey(ids: number[]): string { + return ROOT_CACHE_KEY + 'course:' + JSON.stringify(ids); + } + + /** + * This function is meant to decrease WS calls. + * When requesting a single course that belongs to enrolled courses, request all enrolled courses because + * the WS call is probably cached. + * + * @param field The field to search. + * @param value The value to match. + * @param siteId Site ID. If not defined, use current site. + * @return Promise resolved with the field and value to use. + */ + protected async fixCoursesByFieldParams( + field: string = '', + value: number | string = '', + siteId?: string, + ): Promise<{ field: string; value: number | string }> { + + if (field == 'id' || field == 'ids') { + let courseIds: number[]; + if (typeof value == 'string') { + courseIds = value.split(',').map((id) => parseInt(id, 10)); + } else { + courseIds = [value]; + } + + // Use the same optimization as in get admin and nav options. This will return the course IDs to use. + courseIds = await this.getCourseIdsForAdminAndNavOptions(courseIds, siteId); + + if (courseIds.length > 1) { + return { field: 'ids', value: courseIds.join(',') }; + } else { + return { field: 'id', value: Number(courseIds[0]) }; + } + } else { + // Nothing to do. + return { field: field, value: value }; + } + } + + /** + * Get the first course returned by getCoursesByField. + * + * @param field The field to search. Can be left empty for all courses or: + * id: course id. + * ids: comma separated course ids. + * shortname: course short name. + * idnumber: course id number. + * category: category id the course belongs to. + * @param value The value to match. + * @param siteId Site ID. If not defined, use current site. + * @return Promise resolved with the first course. + * @since 3.2 + */ + async getCourseByField(field?: string, value?: string | number, siteId?: string): Promise { + const courses = await this.getCoursesByField(field, value, siteId); + + if (courses && courses.length > 0) { + return courses[0]; + } + + throw Error('Course not found on core_course_get_courses_by_field'); + } + + /** + * Get courses. They can be filtered by field. + * + * @param field The field to search. Can be left empty for all courses or: + * id: course id. + * ids: comma separated course ids. + * shortname: course short name. + * idnumber: course id number. + * category: category id the course belongs to. + * @param value The value to match. + * @param siteId Site ID. If not defined, use current site. + * @return Promise resolved with the courses. + * @since 3.2 + */ + async getCoursesByField( + field: string = '', + value: string | number = '', + siteId?: string, + ): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const originalValue = value; + + const site = await CoreSites.instance.getSite(siteId); + + const fieldParams = await this.fixCoursesByFieldParams(field, value, siteId); + + const hasChanged = fieldParams.field != field || fieldParams.value != value; + field = fieldParams.field; + value = fieldParams.value; + const data: CoreCourseGetCoursesByFieldWSParams = { + field: field, + value: field ? value : '', + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getCoursesByFieldCacheKey(field, value), + updateFrequency: CoreSite.FREQUENCY_RARELY, + }; + + const response = await site.read('core_course_get_courses_by_field', data, preSets); + if (!response.courses) { + throw Error('WS core_course_get_courses_by_field failed'); + } + + if (field == 'ids' && hasChanged) { + // The list of courses requestes was changed to optimize it. + // Return only the ones that were being requested. + const courseIds = String(originalValue).split(',').map((id) => parseInt(id, 10)); + + // Only courses from the original selection. + response.courses = response.courses.filter((course) => courseIds.indexOf(course.id) >= 0); + } + + // Courses will be sorted using sortorder if avalaible. + return response.courses.sort((a, b) => { + if (typeof a.sortorder == 'undefined' && typeof b.sortorder == 'undefined') { + return b.id - a.id; + } + + if (typeof a.sortorder == 'undefined') { + return 1; + } + + if (typeof b.sortorder == 'undefined') { + return -1; + } + + return a.sortorder - b.sortorder; + }); + } + + /** + * Get cache key for get courses WS call. + * + * @param field The field to search. + * @param value The value to match. + * @return Cache key. + */ + protected getCoursesByFieldCacheKey(field: string = '', value: string | number = ''): string { + return ROOT_CACHE_KEY + 'coursesbyfield:' + field + ':' + value; + } + + /** + * Get courses matching the given custom field. Only works in online. + * + * @param customFieldName Custom field name. + * @param customFieldValue Custom field value. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the list of courses. + * @since 3.8 + */ + async getEnrolledCoursesByCustomField( + customFieldName: string, + customFieldValue: string, + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + const params: CoreCourseGetEnrolledCoursesByTimelineClassificationWSParams = { + classification: 'customfield', + customfieldname: customFieldName, + customfieldvalue: customFieldValue, + }; + const preSets: CoreSiteWSPreSets = { + getFromCache: false, + }; + const courses = await site.read( + 'core_course_get_enrolled_courses_by_timeline_classification', + params, + preSets, + ); + if (courses.courses) { + return courses.courses; + } + + throw Error('WS core_course_get_enrolled_courses_by_timeline_classification failed'); + } + + /** + * Check if get courses by field WS is available in a certain site. + * + * @param site Site to check. + * @return Whether get courses by field is available. + * @since 3.2 + */ + isGetCoursesByFieldAvailable(site?: CoreSite): boolean { + site = site || CoreSites.instance.getCurrentSite(); + + return !!site && site.wsAvailable('core_course_get_courses_by_field'); + } + + /** + * Check if get courses by field WS is available in a certain site, by site ID. + * + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: whether get courses by field is available. + * @since 3.2 + */ + async isGetCoursesByFieldAvailableInSite(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return this.isGetCoursesByFieldAvailable(site); + } + + /** + * Get the navigation and administration options for the given courses. + * + * @param courseIds IDs of courses to get. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the options for each course. + */ + async getCoursesAdminAndNavOptions( + courseIds: number[], + siteId?: string, + ): Promise<{ + navOptions: CoreCourseUserAdminOrNavOptionCourseIndexed; + admOptions: CoreCourseUserAdminOrNavOptionCourseIndexed; + }> { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + // Get the list of courseIds to use based on the param. + courseIds = await this.getCourseIdsForAdminAndNavOptions(courseIds, siteId); + + let navOptions: CoreCourseUserAdminOrNavOptionCourseIndexed; + let admOptions: CoreCourseUserAdminOrNavOptionCourseIndexed; + + // Get user navigation and administration options. + try { + navOptions = await this.getUserNavigationOptions(courseIds, siteId); + } catch { + // Couldn't get it, return empty options. + navOptions = {}; + } + + try { + admOptions = await this.getUserAdministrationOptions(courseIds, siteId); + } catch { + // Couldn't get it, return empty options. + admOptions = {}; + } + + return ({ navOptions: navOptions, admOptions: admOptions }); + } + + /** + * Get the common part of the cache keys for user administration options WS calls. + * + * @return Cache key. + */ + protected getUserAdministrationOptionsCommonCacheKey(): string { + return ROOT_CACHE_KEY + 'administrationOptions:'; + } + + /** + * Get cache key for get user administration options WS call. + * + * @param courseIds IDs of courses to get. + * @return Cache key. + */ + protected getUserAdministrationOptionsCacheKey(courseIds: number[]): string { + return this.getUserAdministrationOptionsCommonCacheKey() + courseIds.join(','); + } + + /** + * Get user administration options for a set of courses. + * + * @param courseIds IDs of courses to get. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with administration options for each course. + */ + async getUserAdministrationOptions(courseIds: number[], siteId?: string): Promise { + if (!courseIds || courseIds.length == 0) { + return {}; + } + + const site = await CoreSites.instance.getSite(siteId); + + const params: CoreCourseGetUserAdminOrNavOptionsWSParams = { + courseids: courseIds, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getUserAdministrationOptionsCacheKey(courseIds), + updateFrequency: CoreSite.FREQUENCY_RARELY, + }; + + const response: CoreCourseGetUserAdminOrNavOptionsWSResponse = + await site.read('core_course_get_user_administration_options', params, preSets); + + // Format returned data. + return this.formatUserAdminOrNavOptions(response.courses); + } + + /** + * Get the common part of the cache keys for user navigation options WS calls. + * + * @param courseIds IDs of courses to get. + * @return Cache key. + */ + protected getUserNavigationOptionsCommonCacheKey(): string { + return ROOT_CACHE_KEY + 'navigationOptions:'; + } + + /** + * Get cache key for get user navigation options WS call. + * + * @return Cache key. + */ + protected getUserNavigationOptionsCacheKey(courseIds: number[]): string { + return this.getUserNavigationOptionsCommonCacheKey() + courseIds.join(','); + } + + /** + * Get user navigation options for a set of courses. + * + * @param courseIds IDs of courses to get. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with navigation options for each course. + */ + async getUserNavigationOptions(courseIds: number[], siteId?: string): Promise { + if (!courseIds || courseIds.length == 0) { + return {}; + } + + const site = await CoreSites.instance.getSite(siteId); + + const params: CoreCourseGetUserAdminOrNavOptionsWSParams = { + courseids: courseIds, + }; + const preSets: CoreSiteWSPreSets = { + cacheKey: this.getUserNavigationOptionsCacheKey(courseIds), + updateFrequency: CoreSite.FREQUENCY_RARELY, + }; + + const response: CoreCourseGetUserAdminOrNavOptionsWSResponse = + await site.read('core_course_get_user_navigation_options', params, preSets); + + // Format returned data. + return this.formatUserAdminOrNavOptions(response.courses); + } + + /** + * Format user navigation or administration options. + * + * @param courses Navigation or administration options for each course. + * @return Formatted options. + */ + protected formatUserAdminOrNavOptions(courses: CoreCourseUserAdminOrNavOption[]): CoreCourseUserAdminOrNavOptionCourseIndexed { + const result = {}; + + courses.forEach((course) => { + const options = {}; + + if (course.options) { + course.options.forEach((option) => { + options[option.name] = option.available; + }); + } + + result[course.id] = options; + }); + + return result; + } + + /** + * Get a course the user is enrolled in. This function relies on getUserCourses. + * preferCache=true will try to speed up the response, but the data returned might not be updated. + * + * @param id ID of the course to get. + * @param preferCache True if shouldn't call WS if data is cached, false otherwise. + * @param siteId Site to get the courses from. If not defined, use current site. + * @return Promise resolved with the course. + */ + async getUserCourse(id: number, preferCache?: boolean, siteId?: string): Promise { + if (!id) { + throw Error('Invalid id parameter on getUserCourse'); + } + + const courses = await this.getUserCourses(preferCache, siteId); + + const course = courses.find((course) => course.id == id); + + if (course) { + return course; + } + + throw Error('Course not found on core_enrol_get_users_courses'); + } + + /** + * Get user courses. + * + * @param preferCache True if shouldn't call WS if data is cached, false otherwise. + * @param siteId Site to get the courses from. If not defined, use current site. + * @return Promise resolved with the courses. + */ + async getUserCourses( + preferCache: boolean = false, + siteId?: string, + strategy?: CoreSitesReadingStrategy, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const userId = site.getUserId(); + const wsParams: CoreEnrolGetUsersCoursesWSParams = { + userid: userId, + }; + const strategyPreSets = strategy + ? CoreSites.instance.getReadingStrategyPreSets(strategy) + : { omitExpires: !!preferCache }; + + const preSets = { + cacheKey: this.getUserCoursesCacheKey(), + getCacheUsingCacheKey: true, + updateFrequency: CoreSite.FREQUENCY_RARELY, + ...strategyPreSets, + }; + + if (site.isVersionGreaterEqualThan('3.7')) { + wsParams.returnusercount = false; + } + + const courses = await site.read('core_enrol_get_users_courses', wsParams, preSets); + + if (this.userCoursesIds) { + // Check if the list of courses has changed. + const added: number[] = []; + const removed: number[] = []; + const previousIds = Object.keys(this.userCoursesIds); + const currentIds = {}; // Use an object to make it faster to search. + + courses.forEach((course) => { + // Move category field to categoryid on a course. + course.categoryid = course.category; + delete course.category; + + currentIds[course.id] = true; + + if (!this.userCoursesIds[course.id]) { + // Course added. + added.push(course.id); + } + }); + + if (courses.length - added.length != previousIds.length) { + // A course was removed, check which one. + previousIds.forEach((id) => { + if (!currentIds[id]) { + // Course removed. + removed.push(Number(id)); + } + }); + } + + if (added.length || removed.length) { + // At least 1 course was added or removed, trigger the event. + CoreEvents.trigger(CoreCoursesProvider.EVENT_MY_COURSES_CHANGED, { + added: added, + removed: removed, + }, site.getId()); + } + + this.userCoursesIds = currentIds; + } else { + this.userCoursesIds = {}; + + // Store the list of courses. + courses.forEach((course) => { + // Move category field to categoryid on a course. + course.categoryid = course.category; + delete course.category; + + this.userCoursesIds[course.id] = true; + }); + } + + return courses; + } + + /** + * Get cache key for get user courses WS call. + * + * @return Cache key. + */ + protected getUserCoursesCacheKey(): string { + return ROOT_CACHE_KEY + 'usercourses'; + } + + /** + * Invalidates get categories WS call. + * + * @param categoryId Category ID to get. + * @param addSubcategories If it should add subcategories to the list. + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateCategories(categoryId: number, addSubcategories?: boolean, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getCategoriesCacheKey(categoryId, addSubcategories)); + } + + /** + * Invalidates get course WS call. + * + * @param id Course ID. + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved when the data is invalidated. + */ + invalidateCourse(id: number, siteId?: string): Promise { + return this.invalidateCourses([id], siteId); + } + + /** + * Invalidates get course enrolment methods WS call. + * + * @param id Course ID. + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateCourseEnrolmentMethods(id: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getCourseEnrolmentMethodsCacheKey(id)); + } + + /** + * Invalidates get course guest enrolment info WS call. + * + * @param instanceId Guest instance ID. + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateCourseGuestEnrolmentInfo(instanceId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getCourseGuestEnrolmentInfoCacheKey(instanceId)); + } + + /** + * Invalidates the navigation and administration options for the given courses. + * + * @param courseIds IDs of courses to get. + * @param siteId Site ID to invalidate. If not defined, use current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateCoursesAdminAndNavOptions(courseIds: number[], siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const ids = await this.getCourseIdsForAdminAndNavOptions(courseIds, siteId); + + const promises: Promise[] = []; + promises.push(this.invalidateUserAdministrationOptionsForCourses(ids, siteId)); + promises.push(this.invalidateUserNavigationOptionsForCourses(ids, siteId)); + + await Promise.all(promises); + } + + /** + * Invalidates get courses WS call. + * + * @param ids Courses IDs. + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateCourses(ids: number[], siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getCoursesCacheKey(ids)); + } + + /** + * Invalidates get courses by field WS call. + * + * @param field See getCoursesByField for info. + * @param value The value to match. + * @param siteId Site Id. If not defined, use current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateCoursesByField(field: string = '', value: number | string = '', siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const result = await this.fixCoursesByFieldParams(field, value, siteId); + field = result.field; + value = result.value; + + const site = await CoreSites.instance.getSite(siteId); + + return site.invalidateWsCacheForKey(this.getCoursesByFieldCacheKey(field, value)); + } + + /** + * Invalidates all user administration options. + * + * @param siteId Site ID to invalidate. If not defined, use current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateUserAdministrationOptions(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getUserAdministrationOptionsCommonCacheKey()); + } + + /** + * Invalidates user administration options for certain courses. + * + * @param courseIds IDs of courses. + * @param siteId Site ID to invalidate. If not defined, use current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateUserAdministrationOptionsForCourses(courseIds: number[], siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getUserAdministrationOptionsCacheKey(courseIds)); + } + + /** + * Invalidates get user courses WS call. + * + * @param siteId Site ID to invalidate. If not defined, use current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateUserCourses(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getUserCoursesCacheKey()); + } + + /** + * Invalidates all user navigation options. + * + * @param siteId Site ID to invalidate. If not defined, use current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateUserNavigationOptions(siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKeyStartingWith(this.getUserNavigationOptionsCommonCacheKey()); + } + + /** + * Invalidates user navigation options for certain courses. + * + * @param courseIds IDs of courses. + * @param siteId Site ID to invalidate. If not defined, use current site. + * @return Promise resolved when the data is invalidated. + */ + async invalidateUserNavigationOptionsForCourses(courseIds: number[], siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.invalidateWsCacheForKey(this.getUserNavigationOptionsCacheKey(courseIds)); + } + + /** + * Check if WS to retrieve guest enrolment data is available. + * + * @return Whether guest WS is available. + * @since 3.1 + * @deprecated Will always return true since it's available since 3.1. + */ + isGuestWSAvailable(): boolean { + return true; + } + + /** + * Search courses. + * + * @param text Text to search. + * @param page Page to get. + * @param perPage Number of courses per page. Defaults to CoreCoursesProvider.SEARCH_PER_PAGE. + * @param siteId Site ID. If not defined, use current site. + * @return Promise resolved with the courses and the total of matches. + */ + async search( + text: string, + page: number = 0, + perPage: number = CoreCoursesProvider.SEARCH_PER_PAGE, + siteId?: string, + ): Promise<{ total: number; courses: CoreCourseBasicSearchedData[] }> { + const site = await CoreSites.instance.getSite(siteId); + const params: CoreCourseSearchCoursesWSParams = { + criterianame: 'search', + criteriavalue: text, + page: page, + perpage: perPage, + }; + const preSets: CoreSiteWSPreSets = { + getFromCache: false, + }; + + const response = await site.read('core_course_search_courses', params, preSets); + + return ({ total: response.total, courses: response.courses }); + } + + /** + * Self enrol current user in a certain course. + * + * @param courseId Course ID. + * @param password Password to use. + * @param instanceId Enrol instance ID. + * @param siteId Site ID. If not defined, use current site. + * @return Promise resolved if the user is enrolled. If the password is invalid, the promise is rejected + * with an object with errorcode = CoreCoursesProvider.ENROL_INVALID_KEY. + */ + async selfEnrol(courseId: number, password: string = '', instanceId?: number, siteId?: string): Promise { + + const site = await CoreSites.instance.getSite(siteId); + + const params: EnrolSelfEnrolUserWSParams = { + courseid: courseId, + password: password, + }; + if (instanceId) { + params.instanceid = instanceId; + } + + const response = await site.write('enrol_self_enrol_user', params); + + if (!response) { + throw Error('WS enrol_self_enrol_user failed'); + } + + if (response.status) { + return true; + } + + if (response.warnings && response.warnings.length) { + // Invalid password warnings. + const warning = response.warnings.find((warning) => + warning.warningcode == '2' || warning.warningcode == '3' || warning.warningcode == '4'); + + if (warning) { + throw new CoreWSError({ errorcode: CoreCoursesProvider.ENROL_INVALID_KEY, message: warning.message }); + } else { + throw new CoreWSError(response.warnings[0]); + } + } + + throw Error('WS enrol_self_enrol_user failed without warnings'); + } + + /** + * Set favourite property on a course. + * + * @param courseId Course ID. + * @param favourite If favourite or unfavourite. + * @param siteId Site ID. If not defined, use current site. + * @return Promise resolved when done. + */ + async setFavouriteCourse(courseId: number, favourite: boolean, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + const params: CoreCourseSetFavouriteCoursesWSParams = { + courses: [ + { + id: courseId, + favourite: favourite, + }, + ], + }; + + return site.write('core_course_set_favourite_courses', params); + } + +} + +export class CoreCourses extends makeSingleton(CoreCoursesProvider) {} + +/** + * Data sent to the EVENT_MY_COURSES_UPDATED. + * + * @todo course type. + */ +export type CoreCoursesMyCoursesUpdatedEventData = { + action: string; // Action performed. + courseId?: number; // Course ID affected (if any). + course?: any; // Course affected (if any). + state?: string; // Only for ACTION_STATE_CHANGED. The state that changed (hidden, favourite). + value?: boolean; // The new value for the state changed. +}; + +/** + * Params of core_enrol_get_users_courses WS. + */ +type CoreEnrolGetUsersCoursesWSParams = { + userid: number; // User id. + returnusercount?: boolean; // Include count of enrolled users for each course? This can add several seconds to the response + // time if a user is on several large courses, so set this to false if the value will not be used to improve performance. +}; + +/** + * Data returned by core_enrol_get_users_courses WS. + */ +type CoreEnrolGetUsersCoursesWSResponse = (CoreEnrolledCourseData & { + category?: number; // Course category id. +})[]; + +/** + * Basic data obtained form any course. + */ +export type CoreCourseBasicData = { + id: number; // Course id. + fullname: string; // Course full name. + displayname?: string; // Course display name. + shortname: string; // Course short name. + summary: string; // Summary. + summaryformat: number; // Summary format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + categoryid?: number; // Course category id. +}; + +/** + * Basic data obtained from a course when the user is enrolled. + */ +export type CoreEnrolledCourseBasicData = CoreCourseBasicData & { + idnumber?: string; // Id number of course. + visible?: number; // 1 means visible, 0 means not yet visible course. + format?: string; // Course format: weeks, topics, social, site. + showgrades?: boolean; // True if grades are shown, otherwise false. + lang?: string; // Forced course language. + enablecompletion?: boolean; // True if completion is enabled, otherwise false. + startdate?: number; // Timestamp when the course start. + enddate?: number; // Timestamp when the course end. +}; + +/** + * Course Data model received when the user is enrolled. + */ +export type CoreEnrolledCourseData = CoreEnrolledCourseBasicData & { + enrolledusercount?: number; // Number of enrolled users in this course. + completionhascriteria?: boolean; // If completion criteria is set. + completionusertracked?: boolean; // If the user is completion tracked. + progress?: number; // Progress percentage. + completed?: boolean; // Whether the course is completed. + marker?: number; // Course section marker. + lastaccess?: number; // Last access to the course (timestamp). + isfavourite?: boolean; // If the user marked this course a favourite. + hidden?: boolean; // If the user hide the course from the dashboard. + overviewfiles?: CoreWSExternalFile[]; +}; + +/** + * Basic course data received on search. + */ +export type CoreCourseBasicSearchedData = CoreCourseBasicData & { + categoryid: number; // Category id. + categoryname: string; // Category name. + sortorder?: number; // Sort order in the category. + summaryfiles?: CoreWSExternalFile[]; + overviewfiles: CoreWSExternalFile[]; + contacts: { // Contact users. + id: number; // Contact user id. + fullname: string; // Contact user fullname. + }[]; + enrollmentmethods: string[]; // Enrollment methods list. + customfields?: CoreCourseCustomField[]; // Custom fields and associated values. +}; + +export type CoreCourseSearchedData = CoreCourseBasicSearchedData & { + idnumber?: string; // Id number. + format?: string; // Course format: weeks, topics, social, site,.. + showgrades?: number; // 1 if grades are shown, otherwise 0. + newsitems?: number; // Number of recent items appearing on the course page. + startdate?: number; // Timestamp when the course start. + enddate?: number; // Timestamp when the course end. + maxbytes?: number; // Largest size of file that can be uploaded into. + showreports?: number; // Are activity report shown (yes = 1, no =0). + visible?: number; // 1: available to student, 0:not available. + groupmode?: number; // No group, separate, visible. + groupmodeforce?: number; // 1: yes, 0: no. + defaultgroupingid?: number; // Default grouping id. + enablecompletion?: number; // Completion enabled? 1: yes 0: no. + completionnotify?: number; // 1: yes 0: no. + lang?: string; // Forced course language. + theme?: string; // Fame of the forced theme. + marker?: number; // Current course marker. + legacyfiles?: number; // If legacy files are enabled. + calendartype?: string; // Calendar type. + timecreated?: number; // Time when the course was created. + timemodified?: number; // Last time the course was updated. + requested?: number; // If is a requested course. + cacherev?: number; // Cache revision number. + filters?: { // Course filters. + filter: string; // Filter plugin name. + localstate: number; // Filter state: 1 for on, -1 for off, 0 if inherit. + inheritedstate: number; // 1 or 0 to use when localstate is set to inherit. + }[]; + courseformatoptions?: CoreCourseFormatOption[]; // Additional options for particular course format. +}; + +export type CoreCourseGetCoursesData = CoreEnrolledCourseBasicData & { + categoryid: number; // Category id. + categorysortorder?: number; // Sort order into the category. + newsitems?: number; // Number of recent items appearing on the course page. + /** + * Number of weeks/topics. + * + * @deprecated use courseformatoptions. + */ + numsections?: number; + maxbytes?: number; // Largest size of file that can be uploaded into the course. + showreports?: number; // Are activity report shown (yes = 1, no =0). + /** + * How the hidden sections in the course are displayed to students. + * + * @deprecated use courseformatoptions. + */ + hiddensections?: number; + groupmode?: number; // No group, separate, visible. + groupmodeforce?: number; // 1: yes, 0: no. + defaultgroupingid?: number; // Default grouping id. + timecreated?: number; // Timestamp when the course have been created. + timemodified?: number; // Timestamp when the course have been modified. + completionnotify?: number; // 1: yes 0: no. + forcetheme?: string; // Name of the force theme. + courseformatoptions?: CoreCourseFormatOption[]; // Additional options for particular course format. + customfields?: CoreCourseCustomField[]; // Custom fields and associated values. +}; + +/** + * Course custom fields and associated values. + */ +export type CoreCourseCustomField = { + name: string; // The name of the custom field. + shortname: string; // The shortname of the custom field. + type: string; // The type of the custom field - text, checkbox... + valueraw: string; // The raw value of the custom field. + value: string; // The value of the custom field. +}; + +/** + * Additional options for particular course format. + */ +export type CoreCourseFormatOption = { + name: string; // Course format option name. + value: string; // Course format option value. +}; + +/** + * Indexed course format options. + */ +export type CoreCourseFormatOptionsIndexed = { + [name: string]: string; +}; + +/** + * Params of core_course_get_courses_by_field WS. + */ +type CoreCourseGetCoursesByFieldWSParams = { + /** + * The field to search can be left empty for all courses or: + * id: course id + * ids: comma separated course ids + * shortname: course short name + * idnumber: course id number + * category: category id the course belongs to. + */ + field?: string; + value?: string | number; // The value to match. +}; + +/** + * Data returned by core_course_get_courses_by_field WS. + */ +export type CoreCourseGetCoursesByFieldWSResponse = { + courses: CoreCourseSearchedData[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of core_course_search_courses WS. + */ +type CoreCourseSearchCoursesWSParams = { + criterianame: string; // Criteria name (search, modulelist (only admins), blocklist (only admins), tagid). + criteriavalue: string; // Criteria value. + page?: number; // Page number (0 based). + perpage?: number; // Items per page. + requiredcapabilities?: string[]; // Optional list of required capabilities (used to filter the list). + limittoenrolled?: boolean; // Limit to enrolled courses. + onlywithcompletion?: boolean; // Limit to courses where completion is enabled. +}; + +/** + * Data returned by core_course_search_courses WS. + */ +export type CoreCourseSearchCoursesWSResponse = { + total: number; // Total course count. + courses: CoreCourseBasicSearchedData[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Params of core_course_get_courses WS. + */ +type CoreCourseGetCoursesWSParams = { + options?: { + ids?: number[]; // List of course id. If empty return all courses except front page course. + }; // Options - operator OR is used. +}; + +/** + * Data returned by core_course_get_courses WS. + */ +export type CoreCourseGetCoursesWSResponse = CoreCourseGetCoursesData[]; + +/** + * Course type exported in CoreCourseGetEnrolledCoursesByTimelineClassificationWSResponse; + */ +export type CoreCourseGetEnrolledCoursesByTimelineClassification = CoreCourseBasicData & { // Course. + idnumber: string; // Idnumber. + startdate: number; // Startdate. + enddate: number; // Enddate. + visible: boolean; // Visible. + fullnamedisplay: string; // Fullnamedisplay. + viewurl: string; // Viewurl. + courseimage: string; // Courseimage. + progress?: number; // Progress. + hasprogress: boolean; // Hasprogress. + isfavourite: boolean; // Isfavourite. + hidden: boolean; // Hidden. + timeaccess?: number; // Timeaccess. + showshortname: boolean; // Showshortname. + coursecategory: string; // Coursecategory. +}; + +/** + * Params of core_course_get_enrolled_courses_by_timeline_classification WS. + */ +type CoreCourseGetEnrolledCoursesByTimelineClassificationWSParams = { + classification: string; // Future, inprogress, or past. + limit?: number; // Result set limit. + offset?: number; // Result set offset. + sort?: string; // Sort string. + customfieldname?: string; // Used when classification = customfield. + customfieldvalue?: string; // Used when classification = customfield. +}; + +/** + * Data returned by core_course_get_enrolled_courses_by_timeline_classification WS. + */ +export type CoreCourseGetEnrolledCoursesByTimelineClassificationWSResponse = { + courses: CoreCourseGetEnrolledCoursesByTimelineClassification[]; + nextoffset: number; // Offset for the next request. +}; + +/** + * Params of core_course_get_categories WS. + */ +type CoreCourseGetCategoriesWSParams = { + criteria?: { // Criteria. + /** + * The category column to search, expected keys (value format) are: + * "id" (int) the category id, + * "ids" (string) category ids separated by commas, + * "name" (string) the category name, + * "parent" (int) the parent category id, + * "idnumber" (string) category idnumber - user must have 'moodle/category:manage' to search on idnumber, + * "visible" (int) whether the returned categories must be visible or hidden. + * If the key is not passed, then the function return all categories that the user can see.. + */ + key: string; + value: string | number; // The value to match. + }[]; + addsubcategories?: boolean; // Return the sub categories infos (1 - default) otherwise only the category info (0). +}; + +/** + * Data returned by core_course_get_categories WS. + */ +export type CoreCourseGetCategoriesWSResponse = CoreCategoryData[]; + +/** + * Category data model. + */ +export type CoreCategoryData = { + id: number; // Category id. + name: string; // Category name. + idnumber?: string; // Category id number. + description: string; // Category description. + descriptionformat: number; // Description format (1 = HTML, 0 = MOODLE, 2 = PLAIN or 4 = MARKDOWN). + parent: number; // Parent category id. + sortorder: number; // Category sorting order. + coursecount: number; // Number of courses in this category. + visible?: number; // 1: available, 0:not available. + visibleold?: number; // 1: available, 0:not available. + timemodified?: number; // Timestamp. + depth: number; // Category depth. + path: string; // Category path. + theme?: string; // Category theme. +}; + +/** + * Params of core_course_get_user_navigation_options and core_course_get_user_administration_options WS. + */ +type CoreCourseGetUserAdminOrNavOptionsWSParams = { + courseids: number[]; +}; + +/** + * Data returned by core_course_get_user_navigation_options and core_course_get_user_administration_options WS. + */ +export type CoreCourseGetUserAdminOrNavOptionsWSResponse = { + courses: CoreCourseUserAdminOrNavOption[]; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Admin or navigation option data. + */ +export type CoreCourseUserAdminOrNavOption = { + id: number; // Course id. + options: { + name: string; // Option name. + available: boolean; // Whether the option is available or not. + }[]; +}; + +/** + * Indexed administration or navigation course options. + */ +export type CoreCourseUserAdminOrNavOptionCourseIndexed = { + [id: number]: CoreCourseUserAdminOrNavOptionIndexed; +}; + +/** + * Indexed administration or navigation options. + */ +export type CoreCourseUserAdminOrNavOptionIndexed = { + [name: string]: // Option name. + boolean; // Whether the option is available or not. +}; + +/** + * Params of core_enrol_get_course_enrolment_methods WS. + */ +type CoreEnrolGetCourseEnrolmentMethodsWSParams = { + courseid: number; // Course id. +}; + +/** + * Course enrolment method. + */ +export type CoreCourseEnrolmentMethod = { + id: number; // Id of course enrolment instance. + courseid: number; // Id of course. + type: string; // Type of enrolment plugin. + name: string; // Name of enrolment plugin. + status: string; // Status of enrolment plugin. + wsfunction?: string; // Webservice function to get more information. +}; + +/** + * Params of enrol_guest_get_instance_info WS. + */ +type EnrolGuestGetInstanceInfoWSParams = { + instanceid: number; // Instance id of guest enrolment plugin. +}; + +/** + * Data returned by enrol_guest_get_instance_info WS. + */ +export type EnrolGuestGetInstanceInfoWSResponse = { + instanceinfo: CoreCourseEnrolmentGuestMethod; + warnings?: CoreWSExternalWarning[]; +}; + +/** + * Course guest enrolment method. + */ +export type CoreCourseEnrolmentGuestMethod = CoreCourseEnrolmentMethod & { + passwordrequired: boolean; // Is a password required?. +}; + +/** + * Params of enrol_self_enrol_user WS. + */ +type EnrolSelfEnrolUserWSParams = { + courseid: number; // Id of the course. + password?: string; // Enrolment key. + instanceid?: number; // Instance id of self enrolment plugin. +}; + +/** + * Params of core_course_set_favourite_courses WS. + */ +type CoreCourseSetFavouriteCoursesWSParams = { + courses: { + id: number; // Course ID. + favourite: boolean; // Favourite status. + }[]; +}; diff --git a/src/core/features/features.module.ts b/src/core/features/features.module.ts index ff3633baf..e81e4d728 100644 --- a/src/core/features/features.module.ts +++ b/src/core/features/features.module.ts @@ -14,6 +14,7 @@ import { NgModule } from '@angular/core'; +import { CoreCourseModule } from './course/course.module'; import { CoreCoursesModule } from './courses/courses.module'; import { CoreEmulatorModule } from './emulator/emulator.module'; import { CoreFileUploaderInitModule } from './fileuploader/fileuploader-init.module'; @@ -24,6 +25,7 @@ import { CoreSettingsInitModule } from './settings/settings-init.module'; imports: [ CoreEmulatorModule, CoreLoginModule, + CoreCourseModule, CoreCoursesModule, CoreSettingsInitModule, CoreFileUploaderInitModule, diff --git a/src/core/features/settings/services/settings.helper.ts b/src/core/features/settings/services/settings.helper.ts index 6f9022b6b..54deb5a30 100644 --- a/src/core/features/settings/services/settings.helper.ts +++ b/src/core/features/settings/services/settings.helper.ts @@ -24,7 +24,7 @@ import { CoreConstants } from '@/core/constants'; import { CoreConfig } from '@services/config'; // import { CoreFilterProvider } from '@features/filter/providers/filter'; import { CoreDomUtils } from '@services/utils/dom'; -// import { CoreCourseProvider } from '@features/course/providers/course'; +import { CoreCourse } from '@features/course/services/course'; import { makeSingleton, Translate } from '@singletons/core.singletons'; import { CoreError } from '@classes/errors/error'; @@ -58,7 +58,6 @@ export class CoreSettingsHelperProvider { constructor() { // protected filterProvider: CoreFilterProvider, - // protected courseProvider: CoreCourseProvider, if (!CoreConstants.CONFIG.forceColorScheme) { // Update color scheme when a user enters or leaves a site, or when the site info is updated. @@ -116,7 +115,7 @@ export class CoreSettingsHelperProvider { promises.push(site.deleteFolder().then(() => { filepoolService.clearAllPackagesStatus(siteId); filepoolService.clearFilepool(siteId); - // this.courseProvider.clearAllCoursesStatus(siteId); + CoreCourse.instance.clearAllCoursesStatus(siteId); siteInfo.spaceUsage = 0; diff --git a/src/core/services/ws.ts b/src/core/services/ws.ts index cd7b37868..967b48947 100644 --- a/src/core/services/ws.ts +++ b/src/core/services/ws.ts @@ -990,6 +990,13 @@ export type CoreStatusWithWarningsWSResponse = { warnings?: CoreWSExternalWarning[]; }; +/** + * Special response structure of many webservices that contains only warnings. + */ +export type CoreWarningsWSResponse = { + warnings?: CoreWSExternalWarning[]; +}; + /** * Structure of files returned by WS. */ diff --git a/src/core/singletons/events.ts b/src/core/singletons/events.ts index eeb8f554c..e58dc9740 100644 --- a/src/core/singletons/events.ts +++ b/src/core/singletons/events.ts @@ -228,3 +228,11 @@ export type CoreEventLoadPageMainMenuData = { redirectPage: string; redirectParams?: Params; }; + +/** + * Data passed to COURSE_STATUS_CHANGED event. + */ +export type CoreEventCourseStatusChanged = { + courseId: number; // Course Id. + status: string; +};