// (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'; import { CoreStatusWithWarningsWSResponse, CoreWSExternalFile } from '@services/ws'; import { CoreCourseStatusDBRecord, COURSE_STATUS_TABLE } from './database/course'; import { CoreCourseOffline } from './course-offline'; import { CoreError } from '@classes/errors/error'; import { CoreCourseAnyCourseData, CoreCoursesMyCoursesUpdatedEventData, CoreCoursesProvider, } from '../../courses/services/courses'; import { CoreDomUtils } from '@services/utils/dom'; import { CoreWSError } from '@classes/errors/wserror'; import { CorePushNotifications } from '@features/pushnotifications/services/pushnotifications'; import { CoreCourseHelper, CoreCourseModuleCompletionDataFormatted } from './course-helper'; import { CoreCourseFormatDelegate } from './format-delegate'; 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() { 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: CoreCourseModuleCompletionDataFormatted): 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. */ // eslint-disable-next-line @typescript-eslint/no-unused-vars currentViewIsCourse(navCtrl: any, courseId: number): boolean { // @todo implement 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: CoreCourseModuleData | 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[]): CoreCourseModuleData[] { if (!sections || !sections.length) { return []; } return sections.reduce((previous: CoreCourseModuleData[], 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: CoreCourseModuleData & 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. */ 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); CorePushNotifications.instance.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 | CoreCourseModuleData): 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: CoreCourseAnyCourseData | { id: number }, params?: Params): Promise { // @todo const loading = await CoreDomUtils.instance.showModalLoading(); // Wait for site plugins to be fetched. // @todo await this.sitePluginsProvider.waitFetchPlugins(); if (!('format' in course) || typeof course.format == 'undefined') { const result = await CoreCourseHelper.instance.getCourse(course.id); course = result.course; } /* @todo if (!this.sitePluginsProvider.sitePluginPromiseExists('format_' + course.format)) { // No custom format plugin. We don't need to wait for anything. await CoreCourseFormatDelegate.instance.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 CoreCourseFormatDelegate.instance.openCourse(course, params); }*/ // Wait for plugins to be loaded. const deferred = CoreUtils.instance.promiseDefer(); const observer = CoreEvents.on(CoreEvents.SITE_PLUGINS_LOADED, () => { observer?.off(); CoreCourseFormatDelegate.instance.openCourse( course, params) .then(deferred.resolve).catch(deferred.reject); }); 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). }[]; configsRecord?: Record; }; /** * 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: CoreCourseModuleData[]; }; /** * 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 CoreCourseModuleData = { 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?: CoreCourseModuleCompletionData; // Module completion data. contents: CoreCourseModuleContentFile[]; 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. }; }; /** * Module completion data. */ export type CoreCourseModuleCompletionData = { 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. }; export type CoreCourseModuleContentFile = { type: string; // A file or a folder or external link. filename: string; // Filename. filepath: string; // Filepath. filesize: number; // Filesize. fileurl?: string; // Downloadable file url. url?: string; // @deprecated. Use fileurl instead. 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. }[]; }; /** * 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) {}