380 lines
14 KiB
TypeScript

// (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.
};