diff --git a/src/addons/mod/lesson/components/components.module.ts b/src/addons/mod/lesson/components/components.module.ts new file mode 100644 index 000000000..8999caf02 --- /dev/null +++ b/src/addons/mod/lesson/components/components.module.ts @@ -0,0 +1,39 @@ +// (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 { CommonModule } from '@angular/common'; +import { IonicModule } from '@ionic/angular'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CoreSharedModule } from '@/core/shared.module'; +import { AddonModLessonPasswordModalComponent } from './password-modal/password-modal'; + +@NgModule({ + declarations: [ + AddonModLessonPasswordModalComponent, + ], + imports: [ + CommonModule, + IonicModule, + TranslateModule.forChild(), + CoreSharedModule, + ], + providers: [ + ], + exports: [ + AddonModLessonPasswordModalComponent, + ], +}) +export class AddonModLessonComponentsModule {} diff --git a/src/addons/mod/lesson/components/password-modal/password-modal.html b/src/addons/mod/lesson/components/password-modal/password-modal.html new file mode 100644 index 000000000..ba0313e13 --- /dev/null +++ b/src/addons/mod/lesson/components/password-modal/password-modal.html @@ -0,0 +1,28 @@ + + + {{ 'core.login.password' | translate }} + + + + + + + + + +
+ + {{ 'addon.mod_lesson.enterpassword' | translate }} + + + + + + {{ 'addon.mod_lesson.continue' | translate }} + + + + +
+
diff --git a/src/addons/mod/lesson/components/password-modal/password-modal.ts b/src/addons/mod/lesson/components/password-modal/password-modal.ts new file mode 100644 index 000000000..746e525b2 --- /dev/null +++ b/src/addons/mod/lesson/components/password-modal/password-modal.ts @@ -0,0 +1,57 @@ +// (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 { Component, ViewChild, ElementRef } from '@angular/core'; + +import { CoreSites } from '@services/sites'; +import { CoreDomUtils } from '@services/utils/dom'; +import { ModalController } from '@singletons'; + + +/** + * Modal that asks the password for a lesson. + */ +@Component({ + selector: 'page-addon-mod-lesson-password-modal', + templateUrl: 'password-modal.html', +}) +export class AddonModLessonPasswordModalComponent { + + @ViewChild('passwordForm') formElement?: ElementRef; + + /** + * Send the password back. + * + * @param e Event. + * @param password The input element. + */ + submitPassword(e: Event, password: HTMLInputElement): void { + e.preventDefault(); + e.stopPropagation(); + + CoreDomUtils.instance.triggerFormSubmittedEvent(this.formElement, false, CoreSites.instance.getCurrentSiteId()); + + ModalController.instance.dismiss(password.value); + } + + /** + * Close modal. + */ + closeModal(): void { + CoreDomUtils.instance.triggerFormCancelledEvent(this.formElement, CoreSites.instance.getCurrentSiteId()); + + ModalController.instance.dismiss(); + } + +} diff --git a/src/addons/mod/lesson/lesson.module.ts b/src/addons/mod/lesson/lesson.module.ts index 83df48fce..9b0785854 100644 --- a/src/addons/mod/lesson/lesson.module.ts +++ b/src/addons/mod/lesson/lesson.module.ts @@ -12,13 +12,19 @@ // See the License for the specific language governing permissions and // limitations under the License. -import { NgModule } from '@angular/core'; +import { APP_INITIALIZER, NgModule } from '@angular/core'; +import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate'; +import { CoreCronDelegate } from '@services/cron'; import { CORE_SITE_SCHEMAS } from '@services/sites'; +import { AddonModLessonComponentsModule } from './components/components.module'; import { SITE_SCHEMA, OFFLINE_SITE_SCHEMA } from './services/database/lesson'; +import { AddonModLessonPrefetchHandler } from './services/handlers/prefetch'; +import { AddonModLessonSyncCronHandler } from './services/handlers/sync-cron'; @NgModule({ imports: [ + AddonModLessonComponentsModule, ], providers: [ { @@ -26,6 +32,15 @@ import { SITE_SCHEMA, OFFLINE_SITE_SCHEMA } from './services/database/lesson'; useValue: [SITE_SCHEMA, OFFLINE_SITE_SCHEMA], multi: true, }, + { + provide: APP_INITIALIZER, + multi: true, + deps: [], + useFactory: () => () => { + CoreCourseModulePrefetchDelegate.instance.registerHandler(AddonModLessonPrefetchHandler.instance); + CoreCronDelegate.instance.register(AddonModLessonSyncCronHandler.instance); + }, + }, ], }) export class AddonModLessonModule {} diff --git a/src/addons/mod/lesson/services/database/lesson.ts b/src/addons/mod/lesson/services/database/lesson.ts index ec4b6ec2b..843a690d6 100644 --- a/src/addons/mod/lesson/services/database/lesson.ts +++ b/src/addons/mod/lesson/services/database/lesson.ts @@ -15,7 +15,7 @@ import { CoreSiteSchema } from '@services/sites'; /** - * Database variables for AddonModLessonOfflineProvider. + * Database variables for AddonModLessonProvider. */ export const PASSWORD_TABLE_NAME = 'addon_mod_lesson_password'; export const SITE_SCHEMA: CoreSiteSchema = { @@ -145,6 +145,39 @@ export const OFFLINE_SITE_SCHEMA: CoreSiteSchema = { ], }; +/** + * Database variables for AddonModLessonSyncProvider. + */ +export const RETAKES_FINISHED_SYNC_TABLE_NAME = 'addon_mod_lesson_retakes_finished_sync'; +export const SYNC_SITE_SCHEMA: CoreSiteSchema = { + name: 'AddonModLessonSyncProvider', + version: 1, + tables: [ + { + name: RETAKES_FINISHED_SYNC_TABLE_NAME, + columns: [ + { + name: 'lessonid', + type: 'INTEGER', + primaryKey: true, + }, + { + name: 'retake', + type: 'INTEGER', + }, + { + name: 'pageid', + type: 'INTEGER', + }, + { + name: 'timefinished', + type: 'INTEGER', + }, + ], + }, + ], +}; + /** * Lesson retake data. */ @@ -183,3 +216,13 @@ export type AddonModLessonPageAttemptDBRecord = { answerid: number | null; useranswer: string | null; }; + +/** + * Data about a retake finished in sync. + */ +export type AddonModLessonRetakeFinishedInSyncDBRecord = { + lessonid: number; + retake: number; + pageid: number; + timefinished: number; +}; diff --git a/src/addons/mod/lesson/services/handlers/prefetch.ts b/src/addons/mod/lesson/services/handlers/prefetch.ts new file mode 100644 index 000000000..f81535a66 --- /dev/null +++ b/src/addons/mod/lesson/services/handlers/prefetch.ts @@ -0,0 +1,576 @@ +// (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 { CoreCanceledError } from '@classes/errors/cancelederror'; +import { CoreError } from '@classes/errors/error'; + +import { CoreCourseActivityPrefetchHandlerBase } from '@features/course/classes/activity-prefetch-handler'; +import { CoreCourse, CoreCourseCommonModWSOptions, CoreCourseAnyModuleData } from '@features/course/services/course'; +import { CoreFilepool } from '@services/filepool'; +import { CoreGroups } from '@services/groups'; +import { CoreFileSizeSum, CorePluginFileDelegate } from '@services/plugin-file-delegate'; +import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreWSExternalFile } from '@services/ws'; +import { makeSingleton, ModalController, Translate } from '@singletons'; +import { AddonModLessonPasswordModalComponent } from '../../components/password-modal/password-modal'; +import { + AddonModLesson, + AddonModLessonGetAccessInformationWSResponse, + AddonModLessonLessonWSData, + AddonModLessonPasswordOptions, + AddonModLessonProvider, +} from '../lesson'; +import { AddonModLessonSync, AddonModLessonSyncResult } from '../lesson-sync'; + +/** + * Handler to prefetch lessons. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModLessonPrefetchHandlerService extends CoreCourseActivityPrefetchHandlerBase { + + name = 'AddonModLesson'; + modName = 'lesson'; + component = AddonModLessonProvider.COMPONENT; + // Don't check timers to decrease positives. If a user performs some action it will be reflected in other items. + updatesNames = /^configuration$|^.*files$|^grades$|^gradeitems$|^pages$|^answers$|^questionattempts$|^pagesviewed$/; + + /** + * Ask password. + * + * @return Promise resolved with the password. + */ + protected async askUserPassword(): Promise { + // Create and show the modal. + const modal = await ModalController.instance.create({ + component: AddonModLessonPasswordModalComponent, + }); + + await modal.present(); + + const password = await modal.onWillDismiss(); + + if (typeof password != 'string') { + throw new CoreCanceledError(); + } + + return password; + } + + /** + * Get the download size of a module. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @param single True if we're downloading a single module, false if we're downloading a whole section. + * @return Promise resolved with the size. + */ + async getDownloadSize(module: CoreCourseAnyModuleData, courseId: number, single?: boolean): Promise { + const siteId = CoreSites.instance.getCurrentSiteId(); + + let lesson = await AddonModLesson.instance.getLesson(courseId, module.id, { siteId }); + + // Get the lesson password if it's needed. + const passwordData = await this.getLessonPassword(lesson.id, { + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + askPassword: single, + siteId, + }); + + lesson = passwordData.lesson || lesson; + + // Get intro files and media files. + let files = lesson.mediafiles || []; + files = files.concat(this.getIntroFilesFromInstance(module, lesson)); + + const result = await CorePluginFileDelegate.instance.getFilesDownloadSize(files); + + // Get the pages to calculate the size. + const pages = await AddonModLesson.instance.getPages(lesson.id, { + cmId: module.id, + password: passwordData.password, + siteId, + }); + + pages.forEach((page) => { + result.size += page.filessizetotal; + }); + + return result; + } + + /** + * Get the lesson password if needed. If not stored, it can ask the user to enter it. + * + * @param lessonId Lesson ID. + * @param options Other options. + * @return Promise resolved when done. + */ + async getLessonPassword( + lessonId: number, + options: AddonModLessonGetPasswordOptions = {}, + ): Promise { + + options.siteId = options.siteId || CoreSites.instance.getCurrentSiteId(); + + // Get access information to check if password is needed. + const accessInfo = await AddonModLesson.instance.getAccessInformation(lessonId, options); + + if (!accessInfo.preventaccessreasons.length) { + // Password not needed. + return { accessInfo }; + } + + const passwordNeeded = accessInfo.preventaccessreasons.length == 1 && + AddonModLesson.instance.isPasswordProtected(accessInfo); + + if (!passwordNeeded) { + // Lesson cannot be played, reject. + throw new CoreError(accessInfo.preventaccessreasons[0].message); + } + + // The lesson requires a password. Check if there is one in DB. + let password = await CoreUtils.instance.ignoreErrors(AddonModLesson.instance.getStoredPassword(lessonId)); + + if (password) { + try { + return this.validatePassword(lessonId, accessInfo, password, options); + } catch { + // Error validating it. + } + } + + // Ask for the password if allowed. + if (!options.askPassword) { + // Cannot ask for password, reject. + throw new CoreError(accessInfo.preventaccessreasons[0].message); + } + + password = await this.askUserPassword(); + + return this.validatePassword(lessonId, accessInfo, password, options); + } + + /** + * Invalidate the prefetched content. + * + * @param moduleId The module ID. + * @param courseId The course ID the module belongs to. + * @return Promise resolved when the data is invalidated. + */ + async invalidateContent(moduleId: number, courseId: number): Promise { + // Only invalidate the data that doesn't ignore cache when prefetching. + await Promise.all([ + AddonModLesson.instance.invalidateLessonData(courseId), + CoreCourse.instance.invalidateModule(moduleId), + CoreGroups.instance.invalidateActivityAllowedGroups(moduleId), + ]); + } + + /** + * Invalidate WS calls needed to determine module status. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @return Promise resolved when invalidated. + */ + async invalidateModule(module: CoreCourseAnyModuleData, courseId: number): Promise { + // Invalidate data to determine if module is downloadable. + const siteId = CoreSites.instance.getCurrentSiteId(); + + const lesson = await AddonModLesson.instance.getLesson(courseId, module.id, { + readingStrategy: CoreSitesReadingStrategy.PreferCache, + siteId, + }); + + await Promise.all([ + AddonModLesson.instance.invalidateLessonData(courseId, siteId), + AddonModLesson.instance.invalidateAccessInformation(lesson.id, siteId), + ]); + } + + /** + * Check if a module can be downloaded. If the function is not defined, we assume that all modules are downloadable. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @return Whether the module can be downloaded. The promise should never be rejected. + */ + async isDownloadable(module: CoreCourseAnyModuleData, courseId: number): Promise { + const siteId = CoreSites.instance.getCurrentSiteId(); + + const lesson = await AddonModLesson.instance.getLesson(courseId, module.id, { siteId }); + const accessInfo = await AddonModLesson.instance.getAccessInformation(lesson.id, { cmId: module.id, siteId }); + + // If it's a student and lesson isn't offline, it isn't downloadable. + if (!accessInfo.canviewreports && !AddonModLesson.instance.isLessonOffline(lesson)) { + return false; + } + + // It's downloadable if there are no prevent access reasons or there is just 1 and it's password. + return !accessInfo.preventaccessreasons.length || + (accessInfo.preventaccessreasons.length == 1 && AddonModLesson.instance.isPasswordProtected(accessInfo)); + } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return Promise resolved with a boolean indicating if the handler is enabled. + */ + isEnabled(): Promise { + return AddonModLesson.instance.isPluginEnabled(); + } + + /** + * Prefetch a module. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @param single True if we're downloading a single module, false if we're downloading a whole section. + * @param dirPath Path of the directory where to store all the content files. + * @return Promise resolved when done. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + prefetch(module: CoreCourseAnyModuleData, courseId?: number, single?: boolean, dirPath?: string): Promise { + return this.prefetchPackage(module, courseId, this.prefetchLesson.bind(this, module, courseId, single)); + } + + /** + * Prefetch a lesson. + * + * @param module Module. + * @param courseId Course ID the module belongs to. + * @param single True if we're downloading a single module, false if we're downloading a whole section. + * @return Promise resolved when done. + */ + protected async prefetchLesson(module: CoreCourseAnyModuleData, courseId?: number, single?: boolean): Promise { + const siteId = CoreSites.instance.getCurrentSiteId(); + courseId = courseId || module.course || 1; + + const commonOptions = { + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + siteId, + }; + const modOptions = { + cmId: module.id, + ...commonOptions, // Include all common options. + }; + + let lesson = await AddonModLesson.instance.getLesson(courseId, module.id, commonOptions); + + // Get the lesson password if it's needed. + const passwordData = await this.getLessonPassword(lesson.id, { + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + askPassword: single, + siteId, + }); + + lesson = passwordData.lesson || lesson; + let accessInfo = passwordData.accessInfo; + const password = passwordData.password; + + if (AddonModLesson.instance.isLessonOffline(lesson) && !AddonModLesson.instance.leftDuringTimed(accessInfo)) { + // The user didn't left during a timed session. Call launch retake to make sure there is a started retake. + accessInfo = await this.launchRetake(lesson.id, password, modOptions, siteId); + } + + const promises: Promise[] = []; + + // Download intro files and media files. + const files = (lesson.mediafiles || []).concat(this.getIntroFilesFromInstance(module, lesson)); + promises.push(CoreFilepool.instance.addFilesToQueue(siteId, files, this.component, module.id)); + + if (AddonModLesson.instance.isLessonOffline(lesson)) { + promises.push(this.prefetchPlayData(lesson, password, accessInfo.attemptscount, modOptions)); + } + + if (accessInfo.canviewreports) { + promises.push(this.prefetchGroupInfo(module.id, lesson.id, modOptions)); + promises.push(this.prefetchReportsData(module.id, lesson.id, modOptions)); + } + + await Promise.all(promises); + } + + /** + * Launch a retake and return the updated access information. + * + * @param lessonId Lesson ID. + * @param password Password (if needed). + * @param modOptions Options. + * @param siteId Site ID. + */ + protected async launchRetake( + lessonId: number, + password: string | undefined, + modOptions: CoreCourseCommonModWSOptions, + siteId: string, + ): Promise { + // The user didn't left during a timed session. Call launch retake to make sure there is a started retake. + await AddonModLesson.instance.launchRetake(lessonId, password, undefined, false, siteId); + + const results = await Promise.all([ + CoreUtils.instance.ignoreErrors(CoreFilepool.instance.updatePackageDownloadTime(siteId, this.component, module.id)), + AddonModLesson.instance.getAccessInformation(lessonId, modOptions), + ]); + + return results[1]; + } + + /** + * Prefetch data to play the lesson in offline. + * + * @param lesson Lesson. + * @param password Password (if needed). + * @param retake Retake to prefetch. + * @param options Options. + * @return Promise resolved when done. + */ + protected async prefetchPlayData( + lesson: AddonModLessonLessonWSData, + password: string | undefined, + retake: number, + modOptions: CoreCourseCommonModWSOptions, + ): Promise { + const passwordOptions = { + password, + ...modOptions, // Include all mod options. + }; + + await Promise.all([ + this.prefetchPagesData(lesson, passwordOptions), + // Prefetch user timers to be able to calculate timemodified in offline. + CoreUtils.instance.ignoreErrors(AddonModLesson.instance.getTimers(lesson.id, modOptions)), + // Prefetch viewed pages in last retake to calculate progress. + AddonModLesson.instance.getContentPagesViewedOnline(lesson.id, retake, modOptions), + // Prefetch question attempts in last retake for offline calculations. + AddonModLesson.instance.getQuestionsAttemptsOnline(lesson.id, retake, modOptions), + ]); + } + + /** + * Prefetch data related to pages. + * + * @param lesson Lesson. + * @param options Options. + * @return Promise resolved when done. + */ + protected async prefetchPagesData( + lesson: AddonModLessonLessonWSData, + options: AddonModLessonPasswordOptions, + ): Promise { + const pages = await AddonModLesson.instance.getPages(lesson.id, options); + + let hasRandomBranch = false; + + // Get the data for each page. + const promises = pages.map(async (data) => { + // Check if any page has a RANDOMBRANCH jump. + if (!hasRandomBranch) { + for (let i = 0; i < data.jumps.length; i++) { + if (data.jumps[i] == AddonModLessonProvider.LESSON_RANDOMBRANCH) { + hasRandomBranch = true; + break; + } + } + } + + // Get the page data. We don't pass accessInfo because we don't need to calculate the offline data. + const pageData = await AddonModLesson.instance.getPageData(lesson, data.page.id, { + includeContents: true, + includeOfflineData: false, + ...options, // Include all options. + }); + + // Download the page files. + let pageFiles = pageData.contentfiles || []; + + pageData.answers.forEach((answer) => { + pageFiles = pageFiles.concat(answer.answerfiles); + pageFiles = pageFiles.concat(answer.responsefiles); + }); + + await CoreFilepool.instance.addFilesToQueue(options.siteId!, pageFiles, this.component, module.id); + }); + + // Prefetch the list of possible jumps for offline navigation. Do it here because we know hasRandomBranch. + promises.push(this.prefetchPossibleJumps(lesson.id, hasRandomBranch, options)); + + await Promise.all(promises); + } + + /** + * Prefetch possible jumps. + * + * @param lessonId Lesson ID. + * @param hasRandomBranch Whether any page has a random branch jump. + * @param modOptions Options. + * @return Promise resolved when done. + */ + protected async prefetchPossibleJumps( + lessonId: number, + hasRandomBranch: boolean, + modOptions: CoreCourseCommonModWSOptions, + ): Promise { + try { + await AddonModLesson.instance.getPagesPossibleJumps(lessonId, modOptions); + } catch (error) { + if (hasRandomBranch) { + // The WebSevice probably failed because RANDOMBRANCH aren't supported if the user hasn't seen any page. + throw new CoreError(Translate.instance.instant('addon.mod_lesson.errorprefetchrandombranch')); + } + + throw error; + } + } + + /** + * Prefetch group info. + * + * @param moduleId Module ID. + * @param lessonId Lesson ID. + * @param modOptions Options. + * @return Promise resolved when done. + */ + protected async prefetchGroupInfo( + moduleId: number, + lessonId: number, + modOptions: CoreCourseCommonModWSOptions, + ): Promise { + const groupInfo = await CoreGroups.instance.getActivityGroupInfo(moduleId, false, undefined, modOptions.siteId, true); + + await Promise.all(groupInfo.groups?.map(async (group) => { + await AddonModLesson.instance.getRetakesOverview(lessonId, { + groupId: group.id, + ...modOptions, // Include all options. + }); + }) || []); + } + + /** + * Prefetch reports data. + * + * @param moduleId Module ID. + * @param lessonId Lesson ID. + * @param modOptions Options. + * @return Promise resolved when done. + */ + protected async prefetchReportsData( + moduleId: number, + lessonId: number, + modOptions: CoreCourseCommonModWSOptions, + ): Promise { + // Always get all participants, even if there are no groups. + const data = await AddonModLesson.instance.getRetakesOverview(lessonId, modOptions); + if (!data || !data.students) { + return; + } + + // Prefetch the last retake for each user. + await Promise.all(data.students.map(async (student) => { + const lastRetake = student.attempts?.[student.attempts.length - 1]; + if (!lastRetake) { + return; + } + + const attempt = await AddonModLesson.instance.getUserRetake(lessonId, lastRetake.try, { + userId: student.id, + ...modOptions, // Include all options. + }); + + if (!attempt?.answerpages) { + return; + } + + // Download embedded files in essays. + const files: CoreWSExternalFile[] = []; + attempt.answerpages.forEach((answerPage) => { + if (!answerPage.page || answerPage.page.qtype != AddonModLessonProvider.LESSON_PAGE_ESSAY) { + return; + } + + answerPage.answerdata?.answers?.forEach((answer) => { + files.push(...CoreFilepool.instance.extractDownloadableFilesFromHtmlAsFakeFileObjects(answer[0])); + }); + }); + + await CoreFilepool.instance.addFilesToQueue(modOptions.siteId!, files, this.component, moduleId); + })); + } + + /** + * Validate the password. + * + * @param lessonId Lesson ID. + * @param info Lesson access info. + * @param pwd Password to check. + * @param options Other options. + * @return Promise resolved when done. + */ + protected async validatePassword( + lessonId: number, + accessInfo: AddonModLessonGetAccessInformationWSResponse, + password: string, + options: CoreCourseCommonModWSOptions = {}, + ): Promise { + + options.siteId = options.siteId || CoreSites.instance.getCurrentSiteId(); + + const lesson = await AddonModLesson.instance.getLessonWithPassword(lessonId, { + password, + ...options, // Include all options. + }); + + // Password is ok, store it and return the data. + await AddonModLesson.instance.storePassword(lesson.id, password, options.siteId); + + return { + password, + lesson, + accessInfo, + }; + } + + /** + * Sync a module. + * + * @param module Module. + * @param courseId Course ID the module belongs to + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + sync(module: CoreCourseAnyModuleData, courseId: number, siteId?: string): Promise { + return AddonModLessonSync.instance.syncLesson(module.instance!, false, false, siteId); + } + +} + +export class AddonModLessonPrefetchHandler extends makeSingleton(AddonModLessonPrefetchHandlerService) {} + +/** + * Options to pass to get lesson password. + */ +export type AddonModLessonGetPasswordOptions = CoreCourseCommonModWSOptions & { + askPassword?: boolean; // True if we should ask for password if needed, false otherwise. +}; + +/** + * Result of getLessonPassword. + */ +export type AddonModLessonGetPasswordResult = { + password?: string; + lesson?: AddonModLessonLessonWSData; + accessInfo: AddonModLessonGetAccessInformationWSResponse; +}; diff --git a/src/addons/mod/lesson/services/handlers/sync-cron.ts b/src/addons/mod/lesson/services/handlers/sync-cron.ts new file mode 100644 index 000000000..c2dfce3ca --- /dev/null +++ b/src/addons/mod/lesson/services/handlers/sync-cron.ts @@ -0,0 +1,52 @@ +// (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 { CoreCronHandler } from '@services/cron'; +import { makeSingleton } from '@singletons'; +import { AddonModLessonSync } from '../lesson-sync'; + +/** + * Synchronization cron handler. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModLessonSyncCronHandlerService implements CoreCronHandler { + + name = 'AddonModLessonSyncCronHandler'; + + /** + * Execute the process. + * Receives the ID of the site affected, undefined for all sites. + * + * @param siteId ID of the site affected, undefined for all sites. + * @param force Wether the execution is forced (manual sync). + * @return Promise resolved when done, rejected if failure. + */ + execute(siteId?: string, force?: boolean): Promise { + return AddonModLessonSync.instance.syncAllLessons(siteId, force); + } + + /** + * Get the time between consecutive executions. + * + * @return Time between consecutive executions (in ms). + */ + getInterval(): number { + return AddonModLessonSync.instance.syncInterval; + } + +} + +export class AddonModLessonSyncCronHandler extends makeSingleton(AddonModLessonSyncCronHandlerService) {} diff --git a/src/addons/mod/lesson/services/lesson-sync.ts b/src/addons/mod/lesson/services/lesson-sync.ts new file mode 100644 index 000000000..48034d781 --- /dev/null +++ b/src/addons/mod/lesson/services/lesson-sync.ts @@ -0,0 +1,518 @@ +// (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 { CoreError } from '@classes/errors/error'; +import { CoreNetworkError } from '@classes/errors/network-error'; +import { CoreCourseActivitySyncBaseProvider } from '@features/course/classes/activity-sync'; +import { CoreCourse } from '@features/course/services/course'; +import { CoreCourseLogHelper } from '@features/course/services/log-helper'; +import { CoreApp } from '@services/app'; +import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; +import { CoreSync } from '@services/sync'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreTimeUtils } from '@services/utils/time'; +import { CoreUrlUtils } from '@services/utils/url'; +import { CoreUtils } from '@services/utils/utils'; +import { makeSingleton, Translate } from '@singletons'; +import { CoreEvents, CoreEventSiteData } from '@singletons/events'; +import { AddonModLessonRetakeFinishedInSyncDBRecord, RETAKES_FINISHED_SYNC_TABLE_NAME } from './database/lesson'; +import { AddonModLessonGetPasswordResult, AddonModLessonPrefetchHandler } from './handlers/prefetch'; +import { AddonModLesson, AddonModLessonLessonWSData, AddonModLessonProvider } from './lesson'; +import { AddonModLessonOffline, AddonModLessonPageAttemptRecord } from './lesson-offline'; + +/** + * Service to sync lesson. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModLessonSyncProvider extends CoreCourseActivitySyncBaseProvider { + + static readonly AUTO_SYNCED = 'addon_mod_lesson_autom_synced'; + + protected componentTranslate?: string; + + constructor() { + super('AddonModLessonSyncProvider'); + } + + /** + * Unmark a retake as finished in a synchronization. + * + * @param lessonId Lesson ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async deleteRetakeFinishedInSync(lessonId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + // Ignore errors, maybe there is none. + await CoreUtils.instance.ignoreErrors(site.getDb().deleteRecords(RETAKES_FINISHED_SYNC_TABLE_NAME, { lessonid: lessonId })); + } + + /** + * Get a retake finished in a synchronization for a certain lesson (if any). + * + * @param lessonId Lesson ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the retake entry (undefined if no retake). + */ + async getRetakeFinishedInSync( + lessonId: number, + siteId?: string, + ): Promise { + const site = await CoreSites.instance.getSite(siteId); + + return CoreUtils.instance.ignoreErrors(site.getDb().getRecord(RETAKES_FINISHED_SYNC_TABLE_NAME, { lessonid: lessonId })); + } + + /** + * Check if a lesson has data to synchronize. + * + * @param lessonId Lesson ID. + * @param retake Retake number. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: whether it has data to sync. + */ + async hasDataToSync(lessonId: number, retake: number, siteId?: string): Promise { + + const [hasAttempts, hasFinished] = await Promise.all([ + CoreUtils.instance.ignoreErrors(AddonModLessonOffline.instance.hasRetakeAttempts(lessonId, retake, siteId)), + CoreUtils.instance.ignoreErrors(AddonModLessonOffline.instance.hasFinishedRetake(lessonId, siteId)), + ]); + + return !!(hasAttempts || hasFinished); + } + + /** + * Mark a retake as finished in a synchronization. + * + * @param lessonId Lesson ID. + * @param retake The retake number. + * @param pageId The page ID to start reviewing from. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async setRetakeFinishedInSync(lessonId: number, retake: number, pageId: number, siteId?: string): Promise { + const site = await CoreSites.instance.getSite(siteId); + + await site.getDb().insertRecord(RETAKES_FINISHED_SYNC_TABLE_NAME, { + lessonid: lessonId, + retake: Number(retake), + pageid: Number(pageId), + timefinished: CoreTimeUtils.instance.timestamp(), + }); + } + + /** + * Try to synchronize all the lessons in a certain site or in all sites. + * + * @param siteId Site ID to sync. If not defined, sync all sites. + * @param force Wether to force sync not depending on last execution. + * @return Promise resolved if sync is successful, rejected if sync fails. + */ + syncAllLessons(siteId?: string, force?: boolean): Promise { + return this.syncOnSites('all lessons', this.syncAllLessonsFunc.bind(this, !!force), siteId); + } + + /** + * Sync all lessons on a site. + * + * @param force Wether to force sync not depending on last execution. + * @param siteId Site ID to sync. + * @param Promise resolved if sync is successful, rejected if sync fails. + */ + protected async syncAllLessonsFunc(force: boolean, siteId: string): Promise { + // Get all the lessons that have something to be synchronized. + const lessons = await AddonModLessonOffline.instance.getAllLessonsWithData(siteId); + + // Sync all lessons that need it. + await Promise.all(lessons.map(async (lesson) => { + const result = force ? + await this.syncLesson(lesson.id, false, false, siteId) : + await this.syncLessonIfNeeded(lesson.id, false, siteId); + + if (result?.updated) { + // Sync successful, send event. + CoreEvents.trigger(AddonModLessonSyncProvider.AUTO_SYNCED, { + lessonId: lesson.id, + warnings: result.warnings, + }, siteId); + } + })); + } + + /** + * Sync a lesson only if a certain time has passed since the last time. + * + * @param lessonId Lesson ID. + * @param askPreflight Whether we should ask for password if needed. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the lesson is synced or if it doesn't need to be synced. + */ + async syncLessonIfNeeded( + lessonId: number, + askPassword?: boolean, + siteId?: string, + ): Promise { + const needed = await this.isSyncNeeded(lessonId, siteId); + + if (needed) { + return this.syncLesson(lessonId, askPassword, false, siteId); + } + } + + /** + * Try to synchronize a lesson. + * + * @param lessonId Lesson ID. + * @param askPassword True if we should ask for password if needed, false otherwise. + * @param ignoreBlock True to ignore the sync block setting. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved in success. + */ + async syncLesson( + lessonId: number, + askPassword?: boolean, + ignoreBlock?: boolean, + siteId?: string, + ): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + this.componentTranslate = this.componentTranslate || CoreCourse.instance.translateModuleName('lesson'); + + let syncPromise = this.getOngoingSync(lessonId, siteId); + if (syncPromise) { + // There's already a sync ongoing for this lesson, return the promise. + return syncPromise; + } + + // Verify that lesson isn't blocked. + if (!ignoreBlock && CoreSync.instance.isBlocked(AddonModLessonProvider.COMPONENT, lessonId, siteId)) { + this.logger.debug('Cannot sync lesson ' + lessonId + ' because it is blocked.'); + + throw new CoreError(Translate.instance.instant('core.errorsyncblocked', { $a: this.componentTranslate })); + } + + this.logger.debug('Try to sync lesson ' + lessonId + ' in site ' + siteId); + + syncPromise = this.performSyncLesson(lessonId, askPassword, ignoreBlock, siteId); + + return this.addOngoingSync(lessonId, syncPromise, siteId); + } + + /** + * Try to synchronize a lesson. + * + * @param lessonId Lesson ID. + * @param askPassword True if we should ask for password if needed, false otherwise. + * @param ignoreBlock True to ignore the sync block setting. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved in success. + */ + protected async performSyncLesson( + lessonId: number, + askPassword?: boolean, + ignoreBlock?: boolean, + siteId?: string, + ): Promise { + // Sync offline logs. + await CoreUtils.instance.ignoreErrors( + CoreCourseLogHelper.instance.syncActivity(AddonModLessonProvider.COMPONENT, lessonId, siteId), + ); + + const result: AddonModLessonSyncResult = { + warnings: [], + updated: false, + }; + + // Try to synchronize the page attempts first. + const passwordData = await this.syncAttempts(lessonId, result, askPassword, siteId); + + // Now sync the retake. + await this.syncRetake(lessonId, result, passwordData, askPassword, ignoreBlock, siteId); + + if (result.updated && result.courseId) { + try { + // Data has been sent to server, update data. + const module = await CoreCourse.instance.getModuleBasicInfoByInstance(lessonId, 'lesson', siteId); + await this.prefetchAfterUpdate(AddonModLessonPrefetchHandler.instance, module, result.courseId, undefined, siteId); + } catch { + // Ignore errors. + } + } + + // Sync finished, set sync time. + await CoreUtils.instance.ignoreErrors(this.setSyncTime(String(lessonId), siteId)); + + // All done, return the result. + return result; + } + + /** + * Sync all page attempts. + * + * @param lessonId Lesson ID. + * @param result Sync result where to store the result. + * @param askPassword True if we should ask for password if needed, false otherwise. + * @param siteId Site ID. If not defined, current site. + */ + protected async syncAttempts( + lessonId: number, + result: AddonModLessonSyncResult, + askPassword?: boolean, + siteId?: string, + ): Promise { + let attempts = await AddonModLessonOffline.instance.getLessonAttempts(lessonId, siteId); + + if (!attempts.length) { + return; + } else if (!CoreApp.instance.isOnline()) { + // Cannot sync in offline. + throw new CoreNetworkError(); + } + + result.courseId = attempts[0].courseid; + const attemptsLength = attempts.length; + + // Get the info, access info and the lesson password if needed. + const lesson = await AddonModLesson.instance.getLessonById(result.courseId, lessonId, { siteId }); + + const passwordData = await AddonModLessonPrefetchHandler.instance.getLessonPassword(lessonId, { + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + askPassword, + siteId, + }); + + const promises: Promise[] = []; + passwordData.lesson = passwordData.lesson || lesson; + + // Filter the attempts, get only the ones that belong to the current retake. + attempts = attempts.filter((attempt) => { + if (attempt.retake == passwordData.accessInfo.attemptscount) { + return true; + } + + // Attempt doesn't belong to current retake, delete. + promises.push(CoreUtils.instance.ignoreErrors(AddonModLessonOffline.instance.deleteAttempt( + lesson.id, + attempt.retake, + attempt.pageid, + attempt.timemodified, + siteId, + ))); + + return false; + }); + + if (attempts.length != attemptsLength) { + // Some attempts won't be sent, add a warning. + result.warnings.push(Translate.instance.instant('core.warningofflinedatadeleted', { + component: this.componentTranslate, + name: lesson.name, + error: Translate.instance.instant('addon.mod_lesson.warningretakefinished'), + })); + } + + await Promise.all(promises); + + if (!attempts.length) { + return passwordData; + } + + // Send the attempts in the same order they were answered. + attempts.sort((a, b) => a.timemodified - b.timemodified); + + const promisesData = attempts.map((attempt) => ({ + function: this.sendAttempt.bind(this, lesson, passwordData.password, attempt, result, siteId), + blocking: true, + })); + + await CoreUtils.instance.executeOrderedPromises(promisesData); + + return passwordData; + } + + /** + * Send an attempt to the site and delete it afterwards. + * + * @param lesson Lesson. + * @param password Password (if any). + * @param attempt Attempt to send. + * @param result Result where to store the data. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + protected async sendAttempt( + lesson: AddonModLessonLessonWSData, + password: string, + attempt: AddonModLessonPageAttemptRecord, + result: AddonModLessonSyncResult, + siteId?: string, + ): Promise { + const retake = attempt.retake; + const pageId = attempt.pageid; + const timemodified = attempt.timemodified; + + try { + // Send the page data. + await AddonModLesson.instance.processPageOnline(lesson.id, attempt.pageid, attempt.data || {}, { + password, + siteId, + }); + + result.updated = true; + + await AddonModLessonOffline.instance.deleteAttempt(lesson.id, retake, pageId, timemodified, siteId); + } catch (error) { + if (!error || !CoreUtils.instance.isWebServiceError(error)) { + // Couldn't connect to server. + throw error; + } + + // The WebService has thrown an error, this means that the attempt cannot be submitted. Delete it. + result.updated = true; + + await AddonModLessonOffline.instance.deleteAttempt(lesson.id, retake, pageId, timemodified, siteId); + + // Attempt deleted, add a warning. + result.warnings.push(Translate.instance.instant('core.warningofflinedatadeleted', { + component: this.componentTranslate, + name: lesson.name, + error: CoreTextUtils.instance.getErrorMessageFromError(error), + })); + } + } + + /** + * Sync retake. + * + * @param lessonId Lesson ID. + * @param result Sync result where to store the result. + * @param passwordData Password data. If not provided it will be calculated. + * @param askPassword True if we should ask for password if needed, false otherwise. + * @param ignoreBlock True to ignore the sync block setting. + * @param siteId Site ID. If not defined, current site. + */ + protected async syncRetake( + lessonId: number, + result: AddonModLessonSyncResult, + passwordData?: AddonModLessonGetPasswordResult, + askPassword?: boolean, + ignoreBlock?: boolean, + siteId?: string, + ): Promise { + // Attempts sent or there was none. If there is a finished retake, send it. + const retake = await CoreUtils.instance.ignoreErrors(AddonModLessonOffline.instance.getRetake(lessonId, siteId)); + + if (!retake) { + // No retake to sync. + return; + } + + if (!retake.finished) { + // The retake isn't marked as finished, nothing to send. Delete the retake. + await AddonModLessonOffline.instance.deleteRetake(lessonId, siteId); + + return; + } else if (!CoreApp.instance.isOnline()) { + // Cannot sync in offline. + throw new CoreNetworkError(); + } + + result.courseId = retake.courseid || result.courseId; + + if (!passwordData?.lesson) { + // Retrieve the needed data. + const lesson = await AddonModLesson.instance.getLessonById(result.courseId!, lessonId, { siteId }); + passwordData = await AddonModLessonPrefetchHandler.instance.getLessonPassword(lessonId, { + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + askPassword, + siteId, + }); + + passwordData.lesson = passwordData.lesson || lesson; + } + + if (retake.retake != passwordData.accessInfo.attemptscount) { + // The retake changed, add a warning if it isn't there already. + if (!result.warnings.length) { + result.warnings.push(Translate.instance.instant('core.warningofflinedatadeleted', { + component: this.componentTranslate, + name: passwordData.lesson.name, + error: Translate.instance.instant('addon.mod_lesson.warningretakefinished'), + })); + } + + await AddonModLessonOffline.instance.deleteRetake(lessonId, siteId); + } + + try { + // All good, finish the retake. + const response = await AddonModLesson.instance.finishRetakeOnline(lessonId, { + password: passwordData.password, + siteId, + }); + + result.updated = true; + + // Mark the retake as finished in a sync if it can be reviewed. + if (!ignoreBlock && response.data?.reviewlesson) { + const params = CoreUrlUtils.instance.extractUrlParams( response.data.reviewlesson.value); + if (params.pageid) { + // The retake can be reviewed, mark it as finished. Don't block the user for this. + this.setRetakeFinishedInSync(lessonId, retake.retake, Number(params.pageid), siteId); + } + } + + await AddonModLessonOffline.instance.deleteRetake(lessonId, siteId); + } catch (error) { + if (!error || !CoreUtils.instance.isWebServiceError(error)) { + // Couldn't connect to server. + throw error; + } + + // The WebService has thrown an error, this means that responses cannot be submitted. Delete them. + result.updated = true; + + await AddonModLessonOffline.instance.deleteRetake(lessonId, siteId); + + // Retake deleted, add a warning. + result.warnings.push(Translate.instance.instant('core.warningofflinedatadeleted', { + component: this.componentTranslate, + name: passwordData.lesson.name, + error: CoreTextUtils.instance.getErrorMessageFromError(error), + })); + } + } + +} + +export class AddonModLessonSync extends makeSingleton(AddonModLessonSyncProvider) {} + +/** + * Data returned by a lesson sync. + */ +export type AddonModLessonSyncResult = { + warnings: string[]; // List of warnings. + updated: boolean; // Whether some data was sent to the server or offline data was updated. + courseId?: number; // Course the lesson belongs to (if known). +}; + +/** + * Data passed to AUTO_SYNCED event. + */ +export type AddonModLessonAutoSyncData = CoreEventSiteData & { + lessonId: number; + warnings: string[]; +};