diff --git a/src/addon/mod/lesson/pages/password-modal/password-modal.html b/src/addon/mod/lesson/pages/password-modal/password-modal.html new file mode 100644 index 000000000..dd55a244a --- /dev/null +++ b/src/addon/mod/lesson/pages/password-modal/password-modal.html @@ -0,0 +1,26 @@ + + + {{ 'core.login.password' | translate }} + + + + + + +
+ + + {{ 'addon.mod_lesson.enterpassword' | translate }} + + + + + + +
+
diff --git a/src/addon/mod/lesson/pages/password-modal/password-modal.module.ts b/src/addon/mod/lesson/pages/password-modal/password-modal.module.ts new file mode 100644 index 000000000..5aebe2d78 --- /dev/null +++ b/src/addon/mod/lesson/pages/password-modal/password-modal.module.ts @@ -0,0 +1,31 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { IonicPageModule } from 'ionic-angular'; +import { AddonModLessonPasswordModalPage } from './password-modal'; +import { TranslateModule } from '@ngx-translate/core'; +import { CoreComponentsModule } from '@components/components.module'; + +@NgModule({ + declarations: [ + AddonModLessonPasswordModalPage + ], + imports: [ + CoreComponentsModule, + IonicPageModule.forChild(AddonModLessonPasswordModalPage), + TranslateModule.forChild() + ] +}) +export class AddonModLessonPasswordModalPageModule {} diff --git a/src/addon/mod/lesson/pages/password-modal/password-modal.ts b/src/addon/mod/lesson/pages/password-modal/password-modal.ts new file mode 100644 index 000000000..d9c116c8f --- /dev/null +++ b/src/addon/mod/lesson/pages/password-modal/password-modal.ts @@ -0,0 +1,43 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 } from '@angular/core'; +import { IonicPage, ViewController } from 'ionic-angular'; + +/** + * Modal that asks the password for a lesson. + */ +@IonicPage({ segment: 'addon-mod-lesson-password-modal' }) +@Component({ + selector: 'page-addon-mod-lesson-password-modal', + templateUrl: 'password-modal.html', +}) +export class AddonModLessonPasswordModalPage { + + constructor(protected viewCtrl: ViewController) { } + + /** + * Send the password back. + */ + submitPassword(password: HTMLInputElement): void { + this.viewCtrl.dismiss(password.value); + } + + /** + * Close modal. + */ + closeModal(): void { + this.viewCtrl.dismiss(); + } +} diff --git a/src/addon/mod/lesson/providers/lesson-sync.ts b/src/addon/mod/lesson/providers/lesson-sync.ts new file mode 100644 index 000000000..e2cd2841c --- /dev/null +++ b/src/addon/mod/lesson/providers/lesson-sync.ts @@ -0,0 +1,498 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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 { TranslateService } from '@ngx-translate/core'; +import { CoreAppProvider } from '@providers/app'; +import { CoreEventsProvider } from '@providers/events'; +import { CoreLoggerProvider } from '@providers/logger'; +import { CoreSitesProvider } from '@providers/sites'; +import { CoreSyncProvider } from '@providers/sync'; +import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreTimeUtilsProvider } from '@providers/utils/time'; +import { CoreUrlUtilsProvider } from '@providers/utils/url'; +import { CoreUtilsProvider } from '@providers/utils/utils'; +import { CoreCourseProvider } from '@core/course/providers/course'; +import { CoreSyncBaseProvider } from '@classes/base-sync'; +import { AddonModLessonProvider } from './lesson'; +import { AddonModLessonOfflineProvider } from './lesson-offline'; +import { AddonModLessonPrefetchHandler } from './prefetch-handler'; + +/** + * Data returned by a lesson sync. + */ +export interface AddonModLessonSyncResult { + /** + * List of warnings. + * @type {string[]} + */ + warnings: string[]; + + /** + * Whether some data was sent to the server or offline data was updated. + * @type {boolean} + */ + updated: boolean; +} + +/** + * Service to sync lesson. + */ +@Injectable() +export class AddonModLessonSyncProvider extends CoreSyncBaseProvider { + + static AUTO_SYNCED = 'addon_mod_lesson_autom_synced'; + static SYNC_TIME = 300000; + + protected componentTranslate: string; + + // Variables for database. + protected RETAKES_FINISHED_TABLE = 'addon_mod_lesson_retakes_finished_sync'; + protected tablesSchema = { + name: this.RETAKES_FINISHED_TABLE, + columns: [ + { + name: 'lessonId', + type: 'INTEGER', + primaryKey: true + }, + { + name: 'retake', + type: 'INTEGER' + }, + { + name: 'pageId', + type: 'INTEGER' + }, + { + name: 'timefinished', + type: 'INTEGER' + } + ] + }; + + constructor(loggerProvider: CoreLoggerProvider, sitesProvider: CoreSitesProvider, appProvider: CoreAppProvider, + syncProvider: CoreSyncProvider, textUtils: CoreTextUtilsProvider, translate: TranslateService, + courseProvider: CoreCourseProvider, private eventsProvider: CoreEventsProvider, + private lessonProvider: AddonModLessonProvider, private lessonOfflineProvider: AddonModLessonOfflineProvider, + private prefetchHandler: AddonModLessonPrefetchHandler, private timeUtils: CoreTimeUtilsProvider, + private utils: CoreUtilsProvider, private urlUtils: CoreUrlUtilsProvider) { + + super('AddonModLessonSyncProvider', loggerProvider, sitesProvider, appProvider, syncProvider, textUtils, translate); + + this.componentTranslate = courseProvider.translateModuleName('lesson'); + + this.sitesProvider.createTableFromSchema(this.tablesSchema); + } + + /** + * Unmark a retake as finished in a synchronization. + * + * @param {number} lessonId Lesson ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + deleteRetakeFinishedInSync(lessonId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().deleteRecords(this.RETAKES_FINISHED_TABLE, {lessonId}); + }).catch(() => { + // Ignore errors, maybe there is none. + }); + } + + /** + * Get a retake finished in a synchronization for a certain lesson (if any). + * + * @param {number} lessonId Lesson ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the retake entry (undefined if no retake). + */ + getRetakeFinishedInSync(lessonId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().getRecord(this.RETAKES_FINISHED_TABLE, {lessonId}); + }).catch(() => { + // Ignore errors, return undefined. + }); + } + + /** + * Check if a lesson has data to synchronize. + * + * @param {number} lessonId Lesson ID. + * @param {number} retake Retake number. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with boolean: whether it has data to sync. + */ + hasDataToSync(lessonId: number, retake: number, siteId?: string): Promise { + const promises = []; + let hasDataToSync = false; + + promises.push(this.lessonOfflineProvider.hasRetakeAttempts(lessonId, retake, siteId).then((hasAttempts) => { + hasDataToSync = hasDataToSync || hasAttempts; + }).catch(() => { + // Ignore errors. + })); + + promises.push(this.lessonOfflineProvider.hasFinishedRetake(lessonId, siteId).then((hasFinished) => { + hasDataToSync = hasDataToSync || hasFinished; + })); + + return Promise.all(promises).then(() => { + return hasDataToSync; + }); + } + + /** + * Mark a retake as finished in a synchronization. + * + * @param {number} lessonId Lesson ID. + * @param {number} retake The retake number. + * @param {number} pageId The page ID to start reviewing from. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + setRetakeFinishedInSync(lessonId: number, retake: number, pageId: number, siteId?: string): Promise { + return this.sitesProvider.getSite(siteId).then((site) => { + return site.getDb().insertRecord(this.RETAKES_FINISHED_TABLE, { + lessonId: lessonId, + retake: Number(retake), + pageId: Number(pageId), + timefinished: this.timeUtils.timestamp() + }); + }); + } + + /** + * Try to synchronize all the lessons in a certain site or in all sites. + * + * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @return {Promise} Promise resolved if sync is successful, rejected if sync fails. + */ + syncAllLessons(siteId?: string): Promise { + return this.syncOnSites('all lessons', this.syncAllLessonsFunc.bind(this), [], siteId); + } + + /** + * Sync all lessons on a site. + * + * @param {string} [siteId] Site ID to sync. If not defined, sync all sites. + * @param {Promise} Promise resolved if sync is successful, rejected if sync fails. + */ + protected syncAllLessonsFunc(siteId?: string): Promise { + // Get all the lessons that have something to be synchronized. + return this.lessonOfflineProvider.getAllLessonsWithData(siteId).then((lessons) => { + // Sync all lessons that haven't been synced for a while. + const promises = []; + + lessons.forEach((lesson) => { + promises.push(this.syncLessonIfNeeded(lesson.id, false, siteId).then((result) => { + if (result && result.updated) { + // Sync successful, send event. + this.eventsProvider.trigger(AddonModLessonSyncProvider.AUTO_SYNCED, { + lessonId: lesson.id, + warnings: result.warnings + }, siteId); + } + })); + }); + + return Promise.all(promises); + }); + } + + /** + * Sync a lesson only if a certain time has passed since the last time. + * + * @param {any} lessonId Lesson ID. + * @param {boolean} [askPreflight] Whether we should ask for password if needed. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the lesson is synced or if it doesn't need to be synced. + */ + syncLessonIfNeeded(lessonId: number, askPassword?: boolean, siteId?: string): Promise { + return this.isSyncNeeded(lessonId, siteId).then((needed) => { + if (needed) { + return this.syncLesson(lessonId, askPassword, false, siteId); + } + }); + } + + /** + * Try to synchronize a lesson. + * + * @param {number} lessonId Lesson ID. + * @param {boolean} askPassword True if we should ask for password if needed, false otherwise. + * @param {boolean} ignoreBlock True to ignore the sync block setting. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved in success. + */ + syncLesson(lessonId: number, askPassword?: boolean, ignoreBlock?: boolean, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const result: AddonModLessonSyncResult = { + warnings: [], + updated: false + }; + let syncPromise, + lesson, + courseId, + password, + accessInfo; + + if (this.isSyncing(lessonId, siteId)) { + // There's already a sync ongoing for this lesson, return the promise. + return this.getOngoingSync(lessonId, siteId); + } + + // Verify that lesson isn't blocked. + if (!ignoreBlock && this.syncProvider.isBlocked(AddonModLessonProvider.COMPONENT, lessonId, siteId)) { + this.logger.debug('Cannot sync lesson ' + lessonId + ' because it is blocked.'); + + return Promise.reject(this.translate.instant('core.errorsyncblocked', {$a: this.componentTranslate})); + } + + this.logger.debug('Try to sync lesson ' + lessonId + ' in site ' + siteId); + + // Try to synchronize the attempts first. + syncPromise = this.lessonOfflineProvider.getLessonAttempts(lessonId, siteId).then((attempts) => { + if (!attempts.length) { + return; + } else if (!this.appProvider.isOnline()) { + // Cannot sync in offline. + return Promise.reject(null); + } + + courseId = attempts[0].courseid; + + // Get the info, access info and the lesson password if needed. + return this.lessonProvider.getLessonById(courseId, lessonId, false, siteId).then((lessonData) => { + lesson = lessonData; + + return this.prefetchHandler.getLessonPassword(lessonId, false, true, askPassword, siteId); + }).then((data) => { + const attemptsLength = attempts.length, + promises = []; + + accessInfo = data.accessInfo; + password = data.password; + lesson = data.lesson || lesson; + + // Filter the attempts, get only the ones that belong to the current retake. + attempts = attempts.filter((attempt) => { + if (attempt.retake != accessInfo.attemptscount) { + // Attempt doesn't belong to current retake, delete. + promises.push(this.lessonOfflineProvider.deleteAttempt(lesson.id, attempt.retake, attempt.pageid, + attempt.timemodified, siteId).catch(() => { + // Ignore errors. + })); + + return false; + } + + return true; + }); + + if (attempts.length != attemptsLength) { + // Some attempts won't be sent, add a warning. + result.warnings.push(this.translate.instant('core.warningofflinedatadeleted', { + component: this.componentTranslate, + name: lesson.name, + error: this.translate.instant('addon.mod_lesson.warningretakefinished') + })); + } + + return Promise.all(promises); + }).then(() => { + if (!attempts.length) { + return; + } + + // Send the attempts in the same order they were answered. + attempts.sort((a, b) => { + return a.timemodified - b.timemodified; + }); + + attempts = attempts.map((attempt) => { + return { + func: this.sendAttempt.bind(this), + params: [lesson, password, attempt, result, siteId], + blocking: true + }; + }); + + return this.utils.executeOrderedPromises(attempts); + }); + }).then(() => { + // Attempts sent or there was none. If there is a finished retake, send it. + return this.lessonOfflineProvider.getRetake(lessonId, siteId).then((retake) => { + if (!retake.finished) { + // The retake isn't marked as finished, nothing to send. Delete the retake. + return this.lessonOfflineProvider.deleteRetake(lessonId, siteId); + } else if (!this.appProvider.isOnline()) { + // Cannot sync in offline. + return Promise.reject(null); + } + + let promise; + + courseId = retake.courseid || courseId; + + if (lesson) { + // Data already retrieved when syncing attempts. + promise = Promise.resolve(); + } else { + promise = this.lessonProvider.getLessonById(courseId, lessonId, false, siteId).then((lessonData) => { + lesson = lessonData; + + return this.prefetchHandler.getLessonPassword(lessonId, false, true, askPassword, siteId); + }).then((data) => { + accessInfo = data.accessInfo; + password = data.password; + lesson = data.lesson || lesson; + }); + } + + return promise.then(() => { + if (retake.retake != accessInfo.attemptscount) { + // The retake changed, add a warning if it isn't there already. + if (!result.warnings.length) { + result.warnings.push(this.translate.instant('core.warningofflinedatadeleted', { + component: this.componentTranslate, + name: lesson.name, + error: this.translate.instant('addon.mod_lesson.warningretakefinished') + })); + } + + return this.lessonOfflineProvider.deleteRetake(lessonId, siteId); + } + + // All good, finish the retake. + return this.lessonProvider.finishRetakeOnline(lessonId, password, false, false, siteId).then((response) => { + result.updated = true; + + if (!ignoreBlock) { + // Mark the retake as finished in a sync if it can be reviewed. + if (response.data && response.data.reviewlesson) { + const params = this.urlUtils.extractUrlParams(response.data.reviewlesson.value); + if (params && params.pageid) { + // The retake can be reviewed, mark it as finished. Don't block the user for this. + this.setRetakeFinishedInSync(lessonId, retake.retake, params.pageid, siteId); + } + } + } + + return this.lessonOfflineProvider.deleteRetake(lessonId, siteId); + }).catch((error) => { + if (error && this.utils.isWebServiceError(error)) { + // The WebService has thrown an error, this means that responses cannot be submitted. Delete them. + result.updated = true; + + return this.lessonOfflineProvider.deleteRetake(lessonId, siteId).then(() => { + // Retake deleted, add a warning. + result.warnings.push(this.translate.instant('core.warningofflinedatadeleted', { + component: this.componentTranslate, + name: lesson.name, + error: error + })); + }); + } else { + // Couldn't connect to server, reject. + return Promise.reject(error); + } + }); + }); + }, () => { + // No retake stored, nothing to do. + }); + }).then(() => { + if (result.updated && courseId) { + // Data has been sent to server. Now invalidate the WS calls. + const promises = []; + + promises.push(this.lessonProvider.invalidateAccessInformation(lessonId, siteId)); + promises.push(this.lessonProvider.invalidateContentPagesViewed(lessonId, siteId)); + promises.push(this.lessonProvider.invalidateQuestionsAttempts(lessonId, siteId)); + promises.push(this.lessonProvider.invalidatePagesPossibleJumps(lessonId, siteId)); + promises.push(this.lessonProvider.invalidateTimers(lessonId, siteId)); + + return this.utils.allPromises(promises).catch(() => { + // Ignore errors. + }).then(() => { + // Sync successful, update some data that might have been modified. + return this.lessonProvider.getAccessInformation(lessonId, false, false, siteId).then((info) => { + const promises = [], + retake = info.attemptscount; + + promises.push(this.lessonProvider.getContentPagesViewedOnline(lessonId, retake, false, false, siteId)); + promises.push(this.lessonProvider.getQuestionsAttemptsOnline(lessonId, retake, false, undefined, false, + false, siteId)); + + return Promise.all(promises); + }).catch(() => { + // Ignore errors. + }); + }); + } + }).then(() => { + // Sync finished, set sync time. + return this.setSyncTime(lessonId, siteId).catch(() => { + // Ignore errors. + }); + }).then(() => { + // All done, return the result. + return result; + }); + + return this.addOngoingSync(lessonId, syncPromise, siteId); + } + + /** + * Send an attempt to the site and delete it afterwards. + * + * @param {any} lesson Lesson. + * @param {string} password Password (if any). + * @param {any} attempt Attempt to send. + * @param {AddonModLessonSyncResult} result Result where to store the data. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + protected sendAttempt(lesson: any, password: string, attempt: any, result: AddonModLessonSyncResult, siteId?: string) + : Promise { + + return this.lessonProvider.processPageOnline(lesson.id, attempt.pageid, attempt.data, password, false, siteId).then(() => { + result.updated = true; + + return this.lessonOfflineProvider.deleteAttempt(lesson.id, attempt.retake, attempt.pageid, attempt.timemodified, + siteId); + }).catch((error) => { + if (error && this.utils.isWebServiceError(error)) { + // The WebService has thrown an error, this means that the attempt cannot be submitted. Delete it. + result.updated = true; + + return this.lessonOfflineProvider.deleteAttempt(lesson.id, attempt.retake, attempt.pageid, attempt.timemodified, + siteId).then(() => { + + // Attempt deleted, add a warning. + result.warnings.push(this.translate.instant('core.warningofflinedatadeleted', { + component: this.componentTranslate, + name: lesson.name, + error: error + })); + }); + } else { + // Couldn't connect to server, reject. + return Promise.reject(error); + } + }); + } +} diff --git a/src/addon/mod/lesson/providers/prefetch-handler.ts b/src/addon/mod/lesson/providers/prefetch-handler.ts new file mode 100644 index 000000000..4a1dc83c9 --- /dev/null +++ b/src/addon/mod/lesson/providers/prefetch-handler.ts @@ -0,0 +1,449 @@ +// (C) Copyright 2015 Martin Dougiamas +// +// 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, Injector } from '@angular/core'; +import { ModalController } from 'ionic-angular'; +import { CoreGroupsProvider } from '@providers/groups'; +import { CoreCourseModulePrefetchHandlerBase } from '@core/course/classes/module-prefetch-handler'; +import { AddonModLessonProvider } from './lesson'; + +/** + * Handler to prefetch lessons. + */ +@Injectable() +export class AddonModLessonPrefetchHandler extends CoreCourseModulePrefetchHandlerBase { + 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$/; + + constructor(protected injector: Injector, protected modalCtrl: ModalController, protected groupsProvider: CoreGroupsProvider, + protected lessonProvider: AddonModLessonProvider) { + super(injector); + } + + /** + * Ask password. + * + * @param {any} info Lesson access info. + * @return {Promise} Promise resolved with the password. + */ + protected askUserPassword(info: any): Promise { + // Create and show the modal. + const modal = this.modalCtrl.create('AddonModLessonPasswordModalPage'); + + modal.present(); + + // Wait for modal to be dismissed. + return new Promise((resolve, reject): void => { + modal.onDidDismiss((password) => { + if (typeof password != 'undefined') { + resolve(password); + } else { + reject(this.domUtils.createCanceledError()); + } + }); + }); + } + + /** + * Download the module. + * + * @param {any} module The module object returned by WS. + * @param {number} courseId Course ID. + * @param {string} [dirPath] Path of the directory where to store all the content files. @see downloadOrPrefetch. + * @return {Promise} Promise resolved when all content is downloaded. + */ + download(module: any, courseId: number, dirPath?: string): Promise { + // Same implementation for download and prefetch. + return this.prefetch(module, courseId, false, dirPath); + } + + /** + * Get the download size of a module. + * + * @param {any} module Module. + * @param {Number} courseId Course ID the module belongs to. + * @param {boolean} [single] True if we're downloading a single module, false if we're downloading a whole section. + * @return {Promise<{size: number, total: boolean}>} Promise resolved with the size and a boolean indicating if it was able + * to calculate the total size. + */ + getDownloadSize(module: any, courseId: any, single?: boolean): Promise<{ size: number, total: boolean }> { + const siteId = this.sitesProvider.getCurrentSiteId(); + let lesson, + password, + result; + + return this.lessonProvider.getLesson(courseId, module.id, false, siteId).then((lessonData) => { + lesson = lessonData; + + // Get the lesson password if it's needed. + return this.getLessonPassword(lesson.id, false, true, single, siteId); + }).then((data) => { + password = data.password; + lesson = data.lesson || lesson; + + // Get intro files and media files. + let files = lesson.mediafiles || []; + files = files.concat(this.getIntroFilesFromInstance(module, lesson)); + + result = this.utils.sumFileSizes(files); + + // Get the pages to calculate the size. + return this.lessonProvider.getPages(lesson.id, password, false, false, siteId); + }).then((pages) => { + pages.forEach((page) => { + result.size += page.filessizetotal; + }); + + return result; + }); + } + + /** + * Get list of files. If not defined, we'll assume they're in module.contents. + * + * @param {any} module Module. + * @param {Number} courseId Course ID the module belongs to. + * @param {boolean} [single] True if we're downloading a single module, false if we're downloading a whole section. + * @return {Promise} Promise resolved with the list of files. + */ + getFiles(module: any, courseId: number, single?: boolean): Promise { + return Promise.resolve([]); + } + + /** + * Get the lesson password if needed. If not stored, it can ask the user to enter it. + * + * @param {number} lessonId Lesson ID. + * @param {boolean} [forceCache] Whether it should return cached data. Has priority over ignoreCache. + * @param {boolean} [ignoreCache] Whether it should ignore cached data (it will always fail in offline or server down). + * @param {boolean} [askPassword] True if we should ask for password if needed, false otherwise. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise<{password?: string, lesson?: any, accessInfo: any}>} Promise resolved when done. + */ + getLessonPassword(lessonId: number, forceCache?: boolean, ignoreCache?: boolean, askPassword?: boolean, siteId?: string) + : Promise<{password?: string, lesson?: any, accessInfo: any}> { + + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + // Get access information to check if password is needed. + return this.lessonProvider.getAccessInformation(lessonId, forceCache, ignoreCache, siteId).then((info): any => { + if (info.preventaccessreasons && info.preventaccessreasons.length) { + const passwordNeeded = info.preventaccessreasons.length == 1 && this.lessonProvider.isPasswordProtected(info); + if (passwordNeeded) { + + // The lesson requires a password. Check if there is one in DB. + return this.lessonProvider.getStoredPassword(lessonId).catch(() => { + // No password found. + }).then((password) => { + if (password) { + return this.validatePassword(lessonId, info, password, forceCache, ignoreCache, siteId); + } else { + return Promise.reject(null); + } + }).catch(() => { + // No password or error validating it. Ask for it if allowed. + if (askPassword) { + return this.askUserPassword(info).then((password) => { + return this.validatePassword(lessonId, info, password, forceCache, ignoreCache, siteId); + }); + } + + // Cannot ask for password, reject. + return Promise.reject(info.preventaccessreasons[0].message); + }); + } else { + // Lesson cannot be played, reject. + return Promise.reject(info.preventaccessreasons[0].message); + } + } + + // Password not needed. + return { accessInfo: info }; + }); + } + + /** + * Invalidate the prefetched content. + * + * @param {number} moduleId The module ID. + * @param {number} courseId The course ID the module belongs to. + * @return {Promise} Promise resolved when the data is invalidated. + */ + invalidateContent(moduleId: number, courseId: number): Promise { + // Only invalidate the data that doesn't ignore cache when prefetching. + const promises = []; + + promises.push(this.lessonProvider.invalidateLessonData(courseId)); + promises.push(this.courseProvider.invalidateModule(moduleId)); + promises.push(this.groupsProvider.invalidateActivityAllowedGroups(moduleId)); + + return Promise.all(promises); + } + + /** + * Invalidate WS calls needed to determine module status. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @return {Promise} Promise resolved when invalidated. + */ + invalidateModule(module: any, courseId: number): Promise { + const siteId = this.sitesProvider.getCurrentSiteId(); + + // Invalidate data to determine if module is downloadable. + return this.lessonProvider.getLesson(courseId, module.id, false, siteId).then((lesson) => { + const promises = []; + + promises.push(this.lessonProvider.invalidateLessonData(courseId, siteId)); + promises.push(this.lessonProvider.invalidateAccessInformation(lesson.id, siteId)); + + return Promise.all(promises); + }); + } + + /** + * Check if a module can be downloaded. If the function is not defined, we assume that all modules are downloadable. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @return {boolean|Promise} Whether the module can be downloaded. The promise should never be rejected. + */ + isDownloadable(module: any, courseId: number): boolean | Promise { + const siteId = this.sitesProvider.getCurrentSiteId(); + + return this.lessonProvider.getLesson(courseId, module.id, false, siteId).then((lesson) => { + if (!this.lessonProvider.isLessonOffline(lesson)) { + return false; + } + + // Check if there is any prevent access reason. + return this.lessonProvider.getAccessInformation(lesson.id, false, false, siteId).then((info) => { + // It's downloadable if there are no prevent access reasons or there is just 1 and it's password. + return !info.preventaccessreasons || !info.preventaccessreasons.length || + (info.preventaccessreasons.length == 1 && this.lessonProvider.isPasswordProtected(info)); + }); + }); + } + + /** + * Whether or not the handler is enabled on a site level. + * + * @return {boolean|Promise} A boolean, or a promise resolved with a boolean, indicating if the handler is enabled. + */ + isEnabled(): boolean | Promise { + return this.lessonProvider.isPluginEnabled(); + } + + /** + * Prefetch a module. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @param {boolean} [single] True if we're downloading a single module, false if we're downloading a whole section. + * @param {string} [dirPath] Path of the directory where to store all the content files. @see downloadOrPrefetch. + * @return {Promise} Promise resolved when done. + */ + prefetch(module: any, courseId?: number, single?: boolean, dirPath?: string): Promise { + return this.prefetchPackage(module, courseId, single, this.prefetchLesson.bind(this)); + } + + /** + * Prefetch a lesson. + * + * @param {any} module Module. + * @param {number} courseId Course ID the module belongs to. + * @param {boolean} single True if we're downloading a single module, false if we're downloading a whole section. + * @param {String} siteId Site ID. + * @return {Promise} Promise resolved when done. + */ + protected prefetchLesson(module: any, courseId: number, single: boolean, siteId: string): Promise { + let lesson, + password, + accessInfo; + + return this.lessonProvider.getLesson(courseId, module.id, false, siteId).then((lessonData) => { + lesson = lessonData; + + // Get the lesson password if it's needed. + return this.getLessonPassword(lesson.id, false, true, single, siteId); + }).then((data) => { + password = data.password; + lesson = data.lesson || lesson; + accessInfo = data.accessInfo; + + if (!this.lessonProvider.leftDuringTimed(accessInfo)) { + // The user didn't left during a timed session. Call launch retake to make sure there is a started retake. + return this.lessonProvider.launchRetake(lesson.id, password, undefined, false, siteId).then(() => { + const promises = []; + + // New data generated, update the download time and refresh the access info. + promises.push(this.filepoolProvider.updatePackageDownloadTime(siteId, this.component, module.id).catch(() => { + // Ignore errors. + })); + + promises.push(this.lessonProvider.getAccessInformation(lesson.id, false, true, siteId).then((info) => { + accessInfo = info; + })); + + return Promise.all(promises); + }); + } + }).then(() => { + const promises = [], + retake = accessInfo.attemptscount; + + // Download intro files and media files. + let files = lesson.mediafiles || []; + files = files.concat(this.getIntroFilesFromInstance(module, lesson)); + + promises.push(this.filepoolProvider.addFilesToQueue(siteId, files, this.component, module.id)); + + // Get the list of pages. + promises.push(this.lessonProvider.getPages(lesson.id, password, false, true, siteId).then((pages) => { + const subPromises = []; + let hasRandomBranch = false; + + // Get the data for each page. + pages.forEach((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. + subPromises.push(this.lessonProvider.getPageData(lesson, data.page.id, password, false, true, false, + true, undefined, undefined, siteId).then((pageData) => { + + // Download the page files. + let pageFiles = pageData.contentfiles || []; + + pageData.answers.forEach((answer) => { + if (answer.answerfiles && answer.answerfiles.length) { + pageFiles = pageFiles.concat(answer.answerfiles); + } + if (answer.responsefiles && answer.responsefiles.length) { + pageFiles = pageFiles.concat(answer.responsefiles); + } + }); + + return this.filepoolProvider.addFilesToQueue(siteId, pageFiles, this.component, module.id); + })); + }); + + // Prefetch the list of possible jumps for offline navigation. Do it here because we know hasRandomBranch. + subPromises.push(this.lessonProvider.getPagesPossibleJumps(lesson.id, false, true, siteId).catch((error) => { + if (hasRandomBranch) { + // The WebSevice probably failed because RANDOMBRANCH aren't supported if the user hasn't seen any page. + return Promise.reject(this.translate.instant('addon.mod_lesson.errorprefetchrandombranch')); + } else { + return Promise.reject(error); + } + })); + + return Promise.all(subPromises); + })); + + // Prefetch user timers to be able to calculate timemodified in offline. + promises.push(this.lessonProvider.getTimers(lesson.id, false, true, siteId).catch(() => { + // Ignore errors. + })); + + // Prefetch viewed pages in last retake to calculate progress. + promises.push(this.lessonProvider.getContentPagesViewedOnline(lesson.id, retake, false, true, siteId)); + + // Prefetch question attempts in last retake for offline calculations. + promises.push(this.lessonProvider.getQuestionsAttemptsOnline(lesson.id, retake, false, undefined, false, true, siteId)); + + // Get module info to be able to handle links. + promises.push(this.courseProvider.getModuleBasicInfo(module.id, siteId)); + + if (accessInfo.canviewreports) { + // Prefetch reports data. + promises.push(this.groupsProvider.getActivityAllowedGroupsIfEnabled(module.id, undefined, siteId).then((groups) => { + const subPromises = []; + + groups.forEach((group) => { + subPromises.push(this.lessonProvider.getRetakesOverview(lesson.id, group.id, false, true, siteId)); + }); + + // Always get group 0, even if there are no groups. + subPromises.push(this.lessonProvider.getRetakesOverview(lesson.id, 0, false, true, siteId).then((data) => { + if (!data || !data.students) { + return; + } + + // Prefetch the last retake for each user. + const retakePromises = []; + + data.students.forEach((student) => { + if (!student.attempts || !student.attempts.length) { + return; + } + + const lastRetake = student.attempts[student.attempts.length - 1]; + if (!lastRetake) { + return; + } + + retakePromises.push(this.lessonProvider.getUserRetake(lesson.id, lastRetake.try, student.id, false, + true, siteId)); + }); + + return Promise.all(retakePromises); + })); + + return Promise.all(subPromises); + })); + } + + return Promise.all(promises); + }); + } + + /** + * Validate the password. + * + * @param {number} lessonId Lesson ID. + * @param {any} info Lesson access info. + * @param {string} pwd Password to check. + * @param {boolean} [forceCache] Whether it should return cached data. Has priority over ignoreCache. + * @param {boolean} [ignoreCache] Whether it should ignore cached data (it will always fail in offline or server down). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise<{password: string, lesson: any, accessInfo: any}>} Promise resolved when done. + */ + protected validatePassword(lessonId: number, info: any, pwd: string, forceCache?: boolean, ignoreCache?: boolean, + siteId?: string): Promise<{password: string, lesson: any, accessInfo: any}> { + + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + return this.lessonProvider.getLessonWithPassword(lessonId, pwd, true, forceCache, ignoreCache, siteId).then((lesson) => { + // Password is ok, store it and return the data. + return this.lessonProvider.storePassword(lesson.id, pwd, siteId).then(() => { + return { + password: pwd, + lesson: lesson, + accessInfo: info + }; + }); + }); + } +} diff --git a/src/core/login/pages/reconnect/reconnect.html b/src/core/login/pages/reconnect/reconnect.html index 74350213e..f2b454762 100644 --- a/src/core/login/pages/reconnect/reconnect.html +++ b/src/core/login/pages/reconnect/reconnect.html @@ -33,7 +33,7 @@
- +