// (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, CoreSitesCommonWSOptions } from '@services/sites'; import { CoreUtils } from '@services/utils/utils'; import { CoreCourseAnyCourseData, CoreCourses } from '@features/courses/services/courses'; import { CoreSite } from '@classes/sites/site'; import { CoreStatusWithWarningsWSResponse, CoreWSExternalWarning } from '@services/ws'; import { makeSingleton } from '@singletons'; import { CoreError } from '@classes/errors/error'; import { asyncObservable } from '@/core/utils/rxjs'; import { map } from 'rxjs/operators'; import { CoreSiteWSPreSets, WSObservable } from '@classes/sites/authenticated-site'; import { firstValueFrom } from 'rxjs'; const ROOT_CACHE_KEY = 'mmaCourseCompletion:'; /** * Service to handle course completion. */ @Injectable({ providedIn: 'root' }) export class AddonCourseCompletionProvider { protected logger: CoreLogger; constructor() { this.logger = CoreLogger.getInstance('AddonCourseCompletion'); } /** * Check whether completion is available in a certain site. * * @param site Site. If not defined, use current site. * @returns True if available. */ isCompletionEnabledInSite(site?: CoreSite): boolean { site = site || CoreSites.getCurrentSite(); return !!site && site.canUseAdvancedFeature('enablecompletion'); } /** * Check whether completion is available in a certain course. * * @param course Course. * @param site Site. If not defined, use current site. * @returns True if available. */ isCompletionEnabledInCourse(course: CoreCourseAnyCourseData, site?: CoreSite): boolean { if (!this.isCompletionEnabledInSite(site)) { return false; } return this.isCompletionEnabledInCourseObject(course); } /** * Check whether completion is enabled in a certain course object. * * @param course Course object. * @returns True if completion is enabled, false otherwise. */ protected isCompletionEnabledInCourseObject(course: CoreCourseAnyCourseData): boolean { // Undefined means it's not supported, so it's enabled by default. return course.enablecompletion !== false; } /** * Returns whether or not the user can mark a course as self completed. * It can if it's configured in the course and it hasn't been completed yet. * * @param userId User ID. * @param completion Course completion. * @returns True if user can mark course as self completed, false otherwise. */ canMarkSelfCompleted(userId: number, completion: AddonCourseCompletionCourseCompletionStatus): boolean { if (CoreSites.getCurrentSiteUserId() != userId) { return false; } let selfCompletionActive = false; let alreadyMarked = false; completion.completions.forEach((criteria) => { if (criteria.type === 1) { // Self completion criteria found. selfCompletionActive = true; alreadyMarked = criteria.complete; } }); return selfCompletionActive && !alreadyMarked; } /** * Get completed status text. The language code returned is meant to be translated. * * @param completion Course completion. * @returns Language code of the text to show. */ getCompletedStatusText(completion: AddonCourseCompletionCourseCompletionStatus): string { if (completion.completed) { return 'addon.coursecompletion.completed'; } // Let's calculate status. const hasStarted = completion.completions.some((criteria) => criteria.timecompleted || criteria.complete); if (hasStarted) { return 'addon.coursecompletion.inprogress'; } return 'addon.coursecompletion.notyetstarted'; } /** * Get course completion status for a certain course and user. * * @param courseId Course ID. * @param userId User ID. If not defined, use current user. * @param preSets Presets to use when calling the WebService. * @param siteId Site ID. If not defined, use current site. * @returns Promise to be resolved when the completion is retrieved. */ getCompletion( courseId: number, userId?: number, preSets: CoreSiteWSPreSets = {}, siteId?: string, ): Promise<AddonCourseCompletionCourseCompletionStatus> { return firstValueFrom(this.getCompletionObservable(courseId, { userId, preSets, siteId, })); } /** * Get course completion status for a certain course and user. * * @param courseId Course ID. * @param options Options. * @returns Observable returning the completion. */ getCompletionObservable( courseId: number, options: AddonCourseCompletionGetCompletionOptions = {}, ): WSObservable<AddonCourseCompletionCourseCompletionStatus> { return asyncObservable(async () => { const site = await CoreSites.getSite(options.siteId); const userId = options.userId || site.getUserId(); this.logger.debug('Get completion for course ' + courseId + ' and user ' + userId); const data: AddonCourseCompletionGetCourseCompletionStatusWSParams = { courseid: courseId, userid: userId, }; const preSets = { ...(options.preSets ?? {}), cacheKey: this.getCompletionCacheKey(courseId, userId), updateFrequency: CoreSite.FREQUENCY_SOMETIMES, cacheErrors: ['notenroled'], ...CoreSites.getReadingStrategyPreSets(options.readingStrategy), }; return site.readObservable<AddonCourseCompletionGetCourseCompletionStatusWSResponse>( 'core_completion_get_course_completion_status', data, preSets, ).pipe(map(result => result.completionstatus)); }); } /** * Get cache key for get completion WS calls. * * @param courseId Course ID. * @param userId User ID. * @returns Cache key. */ protected getCompletionCacheKey(courseId: number, userId: number): string { return ROOT_CACHE_KEY + 'view:' + courseId + ':' + userId; } /** * Invalidates view course completion WS call. * * @param courseId Course ID. * @param userId User ID. If not defined, use current user. * @param siteId Site ID. If not defined, use current site. * @returns Promise resolved when the list is invalidated. */ async invalidateCourseCompletion(courseId: number, userId?: number, siteId?: string): Promise<void> { const site = await CoreSites.getSite(siteId); userId = userId || site.getUserId(); await site.invalidateWsCacheForKey(this.getCompletionCacheKey(courseId, userId)); } /** * Returns whether or not the view course completion plugin is enabled for the current site. * * @returns True if plugin enabled, false otherwise. */ isPluginViewEnabled(): boolean { return CoreSites.isLoggedIn() && this.isCompletionEnabledInSite(); } /** * Returns whether or not the view course completion plugin is enabled for a certain course. * * @param courseId Course ID. * @param preferCache True if shouldn't call WS if data is cached, false otherwise. * @returns Promise resolved with true if plugin is enabled, rejected or resolved with false otherwise. */ async isPluginViewEnabledForCourse(courseId?: number, preferCache = true): Promise<boolean> { if (!courseId || !this.isCompletionEnabledInSite()) { return false; } const course = await CoreCourses.getUserCourse(courseId, preferCache); if (!course) { return true; } // Check completion is enabled in the course and it has criteria, to view completion. return this.isCompletionEnabledInCourseObject(course) && course.completionhascriteria !== false; } /** * Returns whether or not the view course completion plugin is enabled for a certain user. * * @param courseId Course ID. * @param userId User ID. If not defined, use current user. * @param siteId Site ID. If not defined, use current site. * @returns Promise resolved with true if plugin is enabled, rejected or resolved with false otherwise. */ async isPluginViewEnabledForUser(courseId: number, userId?: number, siteId?: string): Promise<boolean> { const site = await CoreSites.getSite(siteId); const currentUserId = site.getUserId(); // Check if user wants to view his own completion. try { if (!userId || userId === currentUserId) { // Viewing own completion. Get the course to check if it has completion criteria. const course = await CoreCourses.getUserCourse(courseId, true); // If the site is returning the completionhascriteria then the user can view his own completion. // We already checked the value in isPluginViewEnabledForCourse. if (course && course.completionhascriteria !== undefined) { return true; } } } catch { // Ignore errors. } // User not viewing own completion or the site doesn't tell us if the course has criteria. // The only way to know if completion can be viewed is to call the WS. // Disable emergency cache to be able to detect that the plugin has been disabled (WS will fail). const preSets: CoreSiteWSPreSets = { emergencyCache: false, }; try { await this.getCompletion(courseId, userId, preSets); return true; } catch (error) { if (CoreUtils.isWebServiceError(error)) { // The WS returned an error, plugin is not enabled. return false; } } try { // Not a WS error. Check if we have a cached value. preSets.omitExpires = true; await this.getCompletion(courseId, userId, preSets); return true; } catch { return false; } } /** * Mark a course as self completed. * * @param courseId Course ID. * @param siteId Site ID. If not defined, use current site. * @returns Promise resolved on success. */ async markCourseAsSelfCompleted(courseId: number, siteId?: string): Promise<void> { const site = await CoreSites.getSite(siteId); const params: AddonCourseCompletionMarkCourseSelfCompletedWSParams = { courseid: courseId, }; const response = await site.write<CoreStatusWithWarningsWSResponse>('core_completion_mark_course_self_completed', params); if (!response.status) { throw new CoreError('Cannot mark course as self completed'); } } } export const AddonCourseCompletion = makeSingleton(AddonCourseCompletionProvider); /** * Completion status returned by core_completion_get_course_completion_status. */ export type AddonCourseCompletionCourseCompletionStatus = { completed: boolean; // True if the course is complete, false otherwise. aggregation: number; // Aggregation method 1 means all, 2 means any. completions: { type: number; // Completion criteria type. title: string; // Completion criteria Title. status: string; // Completion status (Yes/No) a % or number. complete: boolean; // Completion status (true/false). timecompleted: number; // Timestamp for criteria completetion. details: { type: string; // Type description. criteria: string; // Criteria description. requirement: string; // Requirement description. status: string; // Status description, can be anything. }; // Details. }[]; }; /** * Params of core_completion_get_course_completion_status WS. */ export type AddonCourseCompletionGetCourseCompletionStatusWSParams = { courseid: number; // Course ID. userid: number; // User ID. }; /** * Data returned by core_completion_get_course_completion_status WS. */ export type AddonCourseCompletionGetCourseCompletionStatusWSResponse = { completionstatus: AddonCourseCompletionCourseCompletionStatus; // Course status. warnings?: CoreWSExternalWarning[]; }; /** * Params of core_completion_mark_course_self_completed WS. */ export type AddonCourseCompletionMarkCourseSelfCompletedWSParams = { courseid: number; // Course ID. }; /** * Options for getCompletionObservable. */ export type AddonCourseCompletionGetCompletionOptions = CoreSitesCommonWSOptions & { userId?: number; // Id of the user, default to current user. preSets?: CoreSiteWSPreSets; // Presets to use when calling the WebService. };