From 32c66f222f9a7c36307ba8ec6375e1ad2f8dc44f Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 27 Mar 2018 10:31:45 +0200 Subject: [PATCH] MOBILE-2348 quiz: Implement prefetch handler --- .../mod/quiz/providers/prefetch-handler.ts | 448 ++++++++++++++++++ src/addon/mod/quiz/quiz.module.ts | 21 +- src/core/question/providers/helper.ts | 42 +- src/core/question/providers/question.ts | 2 + 4 files changed, 510 insertions(+), 3 deletions(-) create mode 100644 src/addon/mod/quiz/providers/prefetch-handler.ts diff --git a/src/addon/mod/quiz/providers/prefetch-handler.ts b/src/addon/mod/quiz/providers/prefetch-handler.ts new file mode 100644 index 000000000..7c9739d45 --- /dev/null +++ b/src/addon/mod/quiz/providers/prefetch-handler.ts @@ -0,0 +1,448 @@ +// (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 { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreQuestionHelperProvider } from '@core/question/providers/helper'; +import { CoreCourseModulePrefetchHandlerBase } from '@core/course/classes/module-prefetch-handler'; +import { AddonModQuizProvider } from './quiz'; +import { AddonModQuizHelperProvider } from './helper'; +import { AddonModQuizAccessRuleDelegate } from './access-rules-delegate'; +import { AddonModQuizSyncProvider } from './quiz-sync'; +import { CoreConstants } from '@core/constants'; + +/** + * Handler to prefetch quizzes. + */ +@Injectable() +export class AddonModQuizPrefetchHandler extends CoreCourseModulePrefetchHandlerBase { + name = 'AddonModQuiz'; + modName = 'quiz'; + component = AddonModQuizProvider.COMPONENT; + updatesNames = /^configuration$|^.*files$|^grades$|^gradeitems$|^questions$|^attempts$/; + + protected syncProvider: AddonModQuizSyncProvider; // It will be injected later to prevent circular dependencies. + + constructor(protected injector: Injector, protected quizProvider: AddonModQuizProvider, + protected textUtils: CoreTextUtilsProvider, protected quizHelper: AddonModQuizHelperProvider, + protected accessRuleDelegate: AddonModQuizAccessRuleDelegate, protected questionHelper: CoreQuestionHelperProvider) { + super(injector); + } + + /** + * 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 or 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 }> { + return Promise.resolve({ + size: -1, + total: false + }); + } + + /** + * 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([]); + } + + /** + * Gather some preflight data for an attempt. This function will start a new attempt if needed. + * + * @param {any} quiz Quiz. + * @param {any} accessInfo Quiz access info returned by AddonModQuizProvider.getQuizAccessInformation. + * @param {any} [attempt] Attempt to continue. Don't pass any value if the user needs to start a new attempt. + * @param {boolean} [askPreflight] Whether it should ask for preflight data if needed. + * @param {string} [modalTitle] Lang key of the title to set to preflight modal (e.g. 'addon.mod_quiz.startattempt'). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved with the preflight data. + */ + getPreflightData(quiz: any, accessInfo: any, attempt?: any, askPreflight?: boolean, title?: string, siteId?: string) + : Promise { + const preflightData = {}; + let promise; + + if (askPreflight) { + // We can ask preflight, check if it's needed and get the data. + promise = this.quizHelper.getAndCheckPreflightData( + quiz, accessInfo, preflightData, attempt, false, true, title, siteId); + } else { + // Get some fixed preflight data from access rules (data that doesn't require user interaction). + const rules = accessInfo.activerulenames; + + promise = this.accessRuleDelegate.getFixedPreflightData(rules, quiz, preflightData, attempt, true, siteId).then(() => { + if (!attempt) { + // We need to create a new attempt. + return this.quizProvider.startAttempt(quiz.id, preflightData, false, siteId); + } + }); + } + + return promise.then(() => { + return preflightData; + }); + } + + /** + * 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 { + return this.quizProvider.invalidateContent(moduleId, courseId); + } + + /** + * 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 { + // Invalidate the calls required to check if a quiz is downloadable. + const promises = []; + + promises.push(this.quizProvider.invalidateQuizData(courseId)); + promises.push(this.quizProvider.invalidateUserAttemptsForUser(module.instance)); + + 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.quizProvider.getQuiz(courseId, module.id, false, siteId).then((quiz) => { + if (quiz.allowofflineattempts !== 1 || quiz.hasquestions === 0) { + return false; + } + + // Not downloadable if we reached max attempts or the quiz has an unfinished attempt. + return this.quizProvider.getUserAttempts(quiz.id, undefined, true, false, false, siteId).then((attempts) => { + const isLastFinished = !attempts.length || this.quizProvider.isAttemptFinished(attempts[attempts.length - 1].state); + + return quiz.attempts === 0 || quiz.attempts > attempts.length || !isLastFinished; + }); + }); + } + + /** + * 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.quizProvider.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.prefetchQuiz.bind(this)); + } + + /** + * Prefetch a quiz. + * + * @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 prefetchQuiz(module: any, courseId: number, single: boolean, siteId: string): Promise { + let attempts: any[], + startAttempt = false, + quiz, + quizAccessInfo, + attemptAccessInfo, + preflightData; + + // Get quiz. + return this.quizProvider.getQuiz(courseId, module.id, false, siteId).then((quizData) => { + quiz = quizData; + + const promises = [], + introFiles = this.getIntroFilesFromInstance(module, quiz); + + // Prefetch some quiz data. + promises.push(this.quizProvider.getQuizAccessInformation(quiz.id, false, true, siteId).then((info) => { + quizAccessInfo = info; + })); + promises.push(this.quizProvider.getQuizRequiredQtypes(quiz.id, true, siteId)); + promises.push(this.quizProvider.getUserAttempts(quiz.id, 'all', true, false, true, siteId).then((atts) => { + attempts = atts; + })); + promises.push(this.quizProvider.getAttemptAccessInformation(quiz.id, 0, false, true, siteId).then((info) => { + attemptAccessInfo = info; + })); + + promises.push(this.filepoolProvider.addFilesToQueue(siteId, introFiles, AddonModQuizProvider.COMPONENT, module.id)); + + return Promise.all(promises); + }).then(() => { + // Check if we need to start a new attempt. + const attempt = attempts[attempts.length - 1]; + if (!attempt || this.quizProvider.isAttemptFinished(attempt.state)) { + // Check if the user can attempt the quiz. + if (attemptAccessInfo.preventnewattemptreasons.length) { + return Promise.reject(this.textUtils.buildMessage(attemptAccessInfo.preventnewattemptreasons)); + } + + startAttempt = true; + } + + // Get the preflight data. This function will also start a new attempt if needed. + return this.getPreflightData(quiz, quizAccessInfo, attempt, single, 'core.download', siteId); + + }).then((data) => { + preflightData = data; + + const promises = []; + + if (startAttempt) { + // Re-fetch user attempts since we created a new one. + promises.push(this.quizProvider.getUserAttempts(quiz.id, 'all', true, false, true, siteId).then((atts) => { + attempts = atts; + })); + + // Update the download time to prevent detecting the new attempt as an update. + promises.push(this.filepoolProvider.updatePackageDownloadTime(siteId, AddonModQuizProvider.COMPONENT, module.id) + .catch(() => { + // Ignore errors. + })); + } + + // Fetch attempt related data. + promises.push(this.quizProvider.getCombinedReviewOptions(quiz.id, true, siteId)); + promises.push(this.quizProvider.getUserBestGrade(quiz.id, true, siteId)); + promises.push(this.quizProvider.getGradeFromGradebook(courseId, module.id, true, siteId).then((gradebookData) => { + if (typeof gradebookData.graderaw != 'undefined') { + return this.quizProvider.getFeedbackForGrade(quiz.id, gradebookData.graderaw, true, siteId); + } + }).catch(() => { + // Ignore errors. + })); + promises.push(this.quizProvider.getAttemptAccessInformation(quiz.id, 0, false, true, siteId)); // Last attempt. + + return Promise.all(promises); + }).then(() => { + // We have quiz data, now we'll get specific data for each attempt. + const promises = []; + + attempts.forEach((attempt) => { + promises.push(this.prefetchAttempt(quiz, attempt, preflightData, siteId)); + }); + + return Promise.all(promises); + }).then(() => { + // If there's nothing to send, mark the quiz as synchronized. + // We don't return the promises because it should be fast and we don't want to block the user for this. + if (!this.syncProvider) { + this.syncProvider = this.injector.get(AddonModQuizSyncProvider); + } + + this.syncProvider.hasDataToSync(quiz.id, siteId).then((hasData) => { + if (!hasData) { + this.syncProvider.setSyncTime(quiz.id, siteId); + } + }); + }); + } + + /** + * Prefetch all WS data for an attempt. + * + * @param {any} quiz Quiz. + * @param {any} attempt Attempt. + * @param {any} preflightData Preflight required data (like password). + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when the prefetch is finished. Data returned is not reliable. + */ + prefetchAttempt(quiz: any, attempt: any, preflightData: any, siteId?: string): Promise { + const pages = this.quizProvider.getPagesFromLayout(attempt.layout), + promises = [], + isSequential = this.quizProvider.isNavigationSequential(quiz); + + if (this.quizProvider.isAttemptFinished(attempt.state)) { + // Attempt is finished, get feedback and review data. + + const attemptGrade = this.quizProvider.rescaleGrade(attempt.sumgrades, quiz, false); + if (typeof attemptGrade != 'undefined') { + promises.push(this.quizProvider.getFeedbackForGrade(quiz.id, Number(attemptGrade), true, siteId)); + } + + // Get the review for each page. + pages.forEach((page) => { + promises.push(this.quizProvider.getAttemptReview(attempt.id, page, true, siteId).catch(() => { + // Ignore failures, maybe the user can't review the attempt. + })); + }); + + // Get the review for all questions in same page. + promises.push(this.quizProvider.getAttemptReview(attempt.id, -1, true, siteId).then((data) => { + // Download the files inside the questions. + const questionPromises = []; + + data.questions.forEach((question) => { + questionPromises.push(this.questionHelper.prefetchQuestionFiles( + question, this.component, quiz.coursemodule, siteId)); + }); + + return Promise.all(questionPromises); + }, () => { + // Ignore failures, maybe the user can't review the attempt. + })); + } else { + + // Attempt not finished, get data needed to continue the attempt. + promises.push(this.quizProvider.getAttemptAccessInformation(quiz.id, attempt.id, false, true, siteId)); + promises.push(this.quizProvider.getAttemptSummary(attempt.id, preflightData, false, true, false, siteId)); + + if (attempt.state == AddonModQuizProvider.ATTEMPT_IN_PROGRESS) { + // Get data for each page. + pages.forEach((page) => { + if (isSequential && page < attempt.currentpage) { + // Sequential quiz, cannot get pages before the current one. + return; + } + + promises.push(this.quizProvider.getAttemptData(attempt.id, page, preflightData, false, true, siteId) + .then((data) => { + // Download the files inside the questions. + const questionPromises = []; + + data.questions.forEach((question) => { + questionPromises.push(this.questionHelper.prefetchQuestionFiles( + question, this.component, quiz.coursemodule, siteId)); + }); + + return Promise.all(questionPromises); + })); + }); + } + } + + return Promise.all(promises); + } + + /** + * Prefetches some data for a quiz and its last attempt. + * This function will NOT start a new attempt, it only reads data for the quiz and the last attempt. + * + * @param {any} quiz Quiz. + * @param {boolean} [askPreflight] Whether it should ask for preflight data if needed. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when done. + */ + prefetchQuizAndLastAttempt(quiz: any, askPreflight?: boolean, siteId?: string): Promise { + siteId = siteId || this.sitesProvider.getCurrentSiteId(); + + const promises = []; + let attempts, + quizAccessInfo, + preflightData, + lastAttempt; + + // Get quiz data. + promises.push(this.quizProvider.getQuizAccessInformation(quiz.id, false, true, siteId).then((info) => { + quizAccessInfo = info; + })); + promises.push(this.quizProvider.getQuizRequiredQtypes(quiz.id, true, siteId)); + promises.push(this.quizProvider.getCombinedReviewOptions(quiz.id, true, siteId)); + promises.push(this.quizProvider.getUserBestGrade(quiz.id, true, siteId)); + promises.push(this.quizProvider.getUserAttempts(quiz.id, 'all', true, false, true, siteId).then((atts) => { + attempts = atts; + })); + promises.push(this.quizProvider.getGradeFromGradebook(quiz.course, quiz.coursemodule, true, siteId) + .then((gradebookData) => { + if (typeof gradebookData.grade != 'undefined') { + return this.quizProvider.getFeedbackForGrade(quiz.id, gradebookData.graderaw, true, siteId); + } + })); + promises.push(this.quizProvider.getAttemptAccessInformation(quiz.id, 0, false, true, siteId)); // Last attempt. + + return Promise.all(promises).then(() => { + lastAttempt = attempts[attempts.length - 1]; + if (!lastAttempt) { + // No need to get attempt data, we don't need preflight data. + return; + } + + // Get the preflight data. + return this.getPreflightData(quiz, quizAccessInfo, lastAttempt, askPreflight, 'core.download', siteId); + + }).then((data) => { + preflightData = data; + + if (lastAttempt) { + // Get data for last attempt. + return this.prefetchAttempt(quiz, lastAttempt, preflightData, siteId); + } + }).then(() => { + // Prefetch finished, get current status to determine if we need to change it. + + return this.filepoolProvider.getPackageStatus(siteId, this.component, quiz.coursemodule); + }).then((status) => { + if (status !== CoreConstants.NOT_DOWNLOADED) { + // Quiz was downloaded, set the new status. + // If no attempts or last is finished we'll mark it as not downloaded to show download icon. + const isLastFinished = !lastAttempt || this.quizProvider.isAttemptFinished(lastAttempt.state), + newStatus = isLastFinished ? CoreConstants.NOT_DOWNLOADED : CoreConstants.DOWNLOADED; + + return this.filepoolProvider.storePackageStatus(siteId, newStatus, this.component, quiz.coursemodule); + } + }); + } +} diff --git a/src/addon/mod/quiz/quiz.module.ts b/src/addon/mod/quiz/quiz.module.ts index 48861aa2b..a4ea1920d 100644 --- a/src/addon/mod/quiz/quiz.module.ts +++ b/src/addon/mod/quiz/quiz.module.ts @@ -13,9 +13,15 @@ // limitations under the License. import { NgModule } from '@angular/core'; +import { CoreCronDelegate } from '@providers/cron'; +import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate'; import { AddonModQuizAccessRuleDelegate } from './providers/access-rules-delegate'; import { AddonModQuizProvider } from './providers/quiz'; import { AddonModQuizOfflineProvider } from './providers/quiz-offline'; +import { AddonModQuizHelperProvider } from './providers/helper'; +import { AddonModQuizSyncProvider } from './providers/quiz-sync'; +import { AddonModQuizPrefetchHandler } from './providers/prefetch-handler'; +import { AddonModQuizSyncCronHandler } from './providers/sync-cron-handler'; @NgModule({ declarations: [ @@ -25,7 +31,18 @@ import { AddonModQuizOfflineProvider } from './providers/quiz-offline'; providers: [ AddonModQuizAccessRuleDelegate, AddonModQuizProvider, - AddonModQuizOfflineProvider + AddonModQuizOfflineProvider, + AddonModQuizHelperProvider, + AddonModQuizSyncProvider, + AddonModQuizPrefetchHandler, + AddonModQuizSyncCronHandler ] }) -export class AddonModQuizModule { } +export class AddonModQuizModule { + constructor(prefetchDelegate: CoreCourseModulePrefetchDelegate, prefetchHandler: AddonModQuizPrefetchHandler, + cronDelegate: CoreCronDelegate, syncHandler: AddonModQuizSyncCronHandler) { + + prefetchDelegate.registerHandler(prefetchHandler); + cronDelegate.register(syncHandler); + } +} diff --git a/src/core/question/providers/helper.ts b/src/core/question/providers/helper.ts index f6d317dd2..38d575562 100644 --- a/src/core/question/providers/helper.ts +++ b/src/core/question/providers/helper.ts @@ -14,9 +14,11 @@ import { Injectable, EventEmitter } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; +import { CoreFilepoolProvider } from '@providers/filepool'; import { CoreSitesProvider } from '@providers/sites'; import { CoreDomUtilsProvider } from '@providers/utils/dom'; import { CoreTextUtilsProvider } from '@providers/utils/text'; +import { CoreUrlUtilsProvider } from '@providers/utils/url'; import { CoreQuestionProvider } from './question'; /** @@ -29,7 +31,8 @@ export class CoreQuestionHelperProvider { constructor(private domUtils: CoreDomUtilsProvider, private textUtils: CoreTextUtilsProvider, private questionProvider: CoreQuestionProvider, private sitesProvider: CoreSitesProvider, - private translate: TranslateService) { } + private translate: TranslateService, private urlUtils: CoreUrlUtilsProvider, + private filepoolProvider: CoreFilepoolProvider) { } /** * Add a behaviour button to the question's "behaviourButtons" property. @@ -428,6 +431,43 @@ export class CoreQuestionHelperProvider { question.html = form.innerHTML; } + /** + * Prefetch the files in a question HTML. + * + * @param {any} question Question. + * @param {string} [component] The component to link the files to. If not defined, question component. + * @param {string|number} [componentId] An ID to use in conjunction with the component. If not defined, question ID. + * @param {string} [siteId] Site ID. If not defined, current site. + * @return {Promise} Promise resolved when all the files have been downloaded. + */ + prefetchQuestionFiles(question: any, component?: string, componentId?: string | number, siteId?: string): Promise { + const urls = this.domUtils.extractDownloadableFilesFromHtml(question.html); + + if (!component) { + component = CoreQuestionProvider.COMPONENT; + componentId = question.id; + } + + return this.sitesProvider.getSite(siteId).then((site) => { + const promises = []; + + urls.forEach((url) => { + if (!site.canDownloadFiles() && this.urlUtils.isPluginFileUrl(url)) { + return; + } + + if (url.indexOf('theme/image.php') > -1 && url.indexOf('flagged') > -1) { + // Ignore flag images. + return; + } + + promises.push(this.filepoolProvider.addToQueueByUrl(siteId, url, component, componentId)); + }); + + return Promise.all(promises); + }); + } + /** * Replace Moodle's correct/incorrect classes with the Mobile ones. * diff --git a/src/core/question/providers/question.ts b/src/core/question/providers/question.ts index 9dd4aa95a..9ca9c749a 100644 --- a/src/core/question/providers/question.ts +++ b/src/core/question/providers/question.ts @@ -58,6 +58,8 @@ export interface CoreQuestionState { */ @Injectable() export class CoreQuestionProvider { + static COMPONENT = 'mmQuestion'; + // Variables for database. protected QUESTION_TABLE = 'questions'; protected QUESTION_ANSWERS_TABLE = 'question_answers';