From 7698fa673de840ee728837e80b31d0a778e0f51c Mon Sep 17 00:00:00 2001 From: Dani Palou Date: Tue, 9 Feb 2021 13:11:29 +0100 Subject: [PATCH] MOBILE-3651 quiz: Implement sync service and prefetch handler --- src/addons/calendar/services/calendar-sync.ts | 2 +- src/addons/messages/services/messages-sync.ts | 6 +- src/addons/mod/lesson/services/lesson-sync.ts | 2 +- .../mod/quiz/services/handlers/prefetch.ts | 659 ++++++++++++++++++ src/addons/mod/quiz/services/quiz-sync.ts | 513 ++++++++++++++ src/core/features/course/services/sync.ts | 10 +- .../question/services/question-delegate.ts | 2 +- src/core/services/sync.ts | 4 +- 8 files changed, 1185 insertions(+), 13 deletions(-) create mode 100644 src/addons/mod/quiz/services/handlers/prefetch.ts create mode 100644 src/addons/mod/quiz/services/quiz-sync.ts diff --git a/src/addons/calendar/services/calendar-sync.ts b/src/addons/calendar/services/calendar-sync.ts index eee65e310..9f9c80a36 100644 --- a/src/addons/calendar/services/calendar-sync.ts +++ b/src/addons/calendar/services/calendar-sync.ts @@ -53,7 +53,7 @@ export class AddonCalendarSyncProvider extends CoreSyncBaseProvider { - await this.syncOnSites('all calendar events', this.syncAllEventsFunc.bind(this, [force]), siteId); + await this.syncOnSites('all calendar events', this.syncAllEventsFunc.bind(this, force), siteId); } /** diff --git a/src/addons/messages/services/messages-sync.ts b/src/addons/messages/services/messages-sync.ts index be3a2b599..0e8893068 100644 --- a/src/addons/messages/services/messages-sync.ts +++ b/src/addons/messages/services/messages-sync.ts @@ -74,17 +74,17 @@ export class AddonMessagesSyncProvider extends CoreSyncBaseProvider { const syncFunctionLog = 'all discussions' + (onlyDeviceOffline ? ' (Only offline)' : ''); - return this.syncOnSites(syncFunctionLog, this.syncAllDiscussionsFunc.bind(this, [onlyDeviceOffline]), siteId); + return this.syncOnSites(syncFunctionLog, this.syncAllDiscussionsFunc.bind(this, onlyDeviceOffline), siteId); } /** * Get all messages pending to be sent in the site. * - * @param siteId Site ID to sync. If not defined, sync all sites. * @param onlyDeviceOffline True to only sync discussions that failed because device was offline. + * @param siteId Site ID to sync. If not defined, sync all sites. * @param Promise resolved if sync is successful, rejected if sync fails. */ - protected async syncAllDiscussionsFunc(siteId: string, onlyDeviceOffline = false): Promise { + protected async syncAllDiscussionsFunc(onlyDeviceOffline: boolean, siteId: string): Promise { const userIds: number[] = []; const conversationIds: number[] = []; const promises: Promise[] = []; diff --git a/src/addons/mod/lesson/services/lesson-sync.ts b/src/addons/mod/lesson/services/lesson-sync.ts index ea7c0a544..413118f71 100644 --- a/src/addons/mod/lesson/services/lesson-sync.ts +++ b/src/addons/mod/lesson/services/lesson-sync.ts @@ -253,7 +253,7 @@ export class AddonModLessonSyncProvider extends CoreCourseActivitySyncBaseProvid } // Sync finished, set sync time. - await CoreUtils.instance.ignoreErrors(this.setSyncTime(String(lessonId), siteId)); + await CoreUtils.instance.ignoreErrors(this.setSyncTime(lessonId, siteId)); // All done, return the result. return result; diff --git a/src/addons/mod/quiz/services/handlers/prefetch.ts b/src/addons/mod/quiz/services/handlers/prefetch.ts new file mode 100644 index 000000000..e138fe936 --- /dev/null +++ b/src/addons/mod/quiz/services/handlers/prefetch.ts @@ -0,0 +1,659 @@ +// (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 { CoreConstants } from '@/core/constants'; + +import { Injectable } from '@angular/core'; +import { CoreError } from '@classes/errors/error'; +import { CoreCourseActivityPrefetchHandlerBase } from '@features/course/classes/activity-prefetch-handler'; +import { CoreCourseAnyModuleData, CoreCourseCommonModWSOptions } from '@features/course/services/course'; +import { CoreQuestionHelper } from '@features/question/services/question-helper'; +import { CoreFilepool } from '@services/filepool'; +import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; +import { CoreTextUtils } from '@services/utils/text'; +import { CoreUtils } from '@services/utils/utils'; +import { CoreWSExternalFile } from '@services/ws'; +import { makeSingleton } from '@singletons'; +import { AddonModQuizAccessRuleDelegate } from '../access-rules-delegate'; +import { + AddonModQuiz, + AddonModQuizAttemptWSData, + AddonModQuizGetQuizAccessInformationWSResponse, + AddonModQuizProvider, + AddonModQuizQuizWSData, +} from '../quiz'; +import { AddonModQuizHelper } from '../quiz-helper'; +import { AddonModQuizSync, AddonModQuizSyncResult } from '../quiz-sync'; + +/** + * Handler to prefetch quizzes. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModQuizPrefetchHandlerService extends CoreCourseActivityPrefetchHandlerBase { + + name = 'AddonModQuiz'; + modName = 'quiz'; + component = AddonModQuizProvider.COMPONENT; + updatesNames = /^configuration$|^.*files$|^grades$|^gradeitems$|^questions$|^attempts$/; + + /** + * Download the module. + * + * @param module The module object returned by WS. + * @param courseId Course ID. + * @param dirPath Path of the directory where to store all the content files. + * @param single True if we're downloading a single module, false if we're downloading a whole section. + * @param canStart If true, start a new attempt if needed. + * @return Promise resolved when all content is downloaded. + */ + download( + module: CoreCourseAnyModuleData, + courseId: number, + dirPath?: string, + single?: boolean, + canStart: boolean = true, + ): Promise { + // Same implementation for download and prefetch. + return this.prefetch(module, courseId, single, dirPath, canStart); + } + + /** + * Get list of files. If not defined, we'll assume they're in module.contents. + * + * @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 list of files. + */ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async getFiles(module: CoreCourseAnyModuleData, courseId: number, single?: boolean): Promise { + try { + const quiz = await AddonModQuiz.instance.getQuiz(courseId, module.id); + + const files = this.getIntroFilesFromInstance(module, quiz); + + const attempts = await AddonModQuiz.instance.getUserAttempts(quiz.id, { + cmId: module.id, + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + }); + + const attemptFiles = await this.getAttemptsFeedbackFiles(quiz, attempts); + + return files.concat(attemptFiles); + } catch { + // Quiz not found, return empty list. + return []; + } + } + + /** + * Get the list of downloadable files on feedback attemptss. + * + * @param quiz Quiz. + * @param attempts Quiz user attempts. + * @param siteId Site ID. If not defined, current site. + * @return List of Files. + */ + protected async getAttemptsFeedbackFiles( + quiz: AddonModQuizQuizWSData, + attempts: AddonModQuizAttemptWSData[], + siteId?: string, + ): Promise { + const getInlineFiles = CoreSites.instance.getCurrentSite()?.isVersionGreaterEqualThan('3.2'); + let files: CoreWSExternalFile[] = []; + + await Promise.all(attempts.map(async (attempt) => { + if (!AddonModQuiz.instance.isAttemptFinished(attempt.state)) { + // Attempt not finished, no feedback files. + return; + } + + const attemptGrade = AddonModQuiz.instance.rescaleGrade(attempt.sumgrades, quiz, false); + if (typeof attemptGrade == 'undefined') { + return; + } + + const feedback = await AddonModQuiz.instance.getFeedbackForGrade(quiz.id, Number(attemptGrade), { + cmId: quiz.coursemodule, + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + siteId, + }); + + if (getInlineFiles && feedback.feedbackinlinefiles?.length) { + files = files.concat(feedback.feedbackinlinefiles); + } else if (feedback.feedbacktext && !getInlineFiles) { + files = files.concat( + CoreFilepool.instance.extractDownloadableFilesFromHtmlAsFakeFileObjects(feedback.feedbacktext), + ); + } + })); + + return files; + } + + /** + * Gather some preflight data for an attempt. This function will start a new attempt if needed. + * + * @param quiz Quiz. + * @param accessInfo Quiz access info returned by AddonModQuizProvider.getQuizAccessInformation. + * @param attempt Attempt to continue. Don't pass any value if the user needs to start a new attempt. + * @param askPreflight Whether it should ask for preflight data if needed. + * @param modalTitle Lang key of the title to set to preflight modal (e.g. 'addon.mod_quiz.startattempt'). + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with the preflight data. + */ + async getPreflightData( + quiz: AddonModQuizQuizWSData, + accessInfo: AddonModQuizGetQuizAccessInformationWSResponse, + attempt?: AddonModQuizAttemptWSData, + askPreflight?: boolean, + title?: string, + siteId?: string, + ): Promise> { + const preflightData: Record = {}; + + if (askPreflight) { + // We can ask preflight, check if it's needed and get the data. + await AddonModQuizHelper.instance.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 || []; + + await AddonModQuizAccessRuleDelegate.instance.getFixedPreflightData(rules, quiz, preflightData, attempt, true, siteId); + + if (!attempt) { + // We need to create a new attempt. + await AddonModQuiz.instance.startAttempt(quiz.id, preflightData, false, siteId); + } + } + + return preflightData; + } + + /** + * 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. + */ + invalidateContent(moduleId: number, courseId: number): Promise { + return AddonModQuiz.instance.invalidateContent(moduleId, courseId); + } + + /** + * 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 the calls required to check if a quiz is downloadable. + await Promise.all([ + AddonModQuiz.instance.invalidateQuizData(courseId), + AddonModQuiz.instance.invalidateUserAttemptsForUser(module.instance!), + ]); + } + + /** + * 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 { + if (CoreSites.instance.getCurrentSite()?.isOfflineDisabled()) { + // Don't allow downloading the quiz if offline is disabled to prevent wasting a lot of data when opening it. + return false; + } + + const siteId = CoreSites.instance.getCurrentSiteId(); + + const quiz = await AddonModQuiz.instance.getQuiz(courseId, module.id, { siteId }); + + if (quiz.allowofflineattempts !== 1 || quiz.hasquestions === 0) { + return false; + } + + // Not downloadable if we reached max attempts or the quiz has an unfinished attempt. + const attempts = await AddonModQuiz.instance.getUserAttempts(quiz.id, { + cmId: module.id, + siteId, + }); + + const isLastFinished = !attempts.length || AddonModQuiz.instance.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 A boolean, or a promise resolved with a boolean, indicating if the handler is enabled. + */ + async isEnabled(): Promise { + return AddonModQuiz.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. + * @param canStart If true, start a new attempt if needed. + * @return Promise resolved when done. + */ + async prefetch( + module: SyncedModule, + courseId?: number, + single?: boolean, + dirPath?: string, + canStart: boolean = true, + ): Promise { + if (module.attemptFinished) { + // Delete the value so it does not block anything if true. + delete module.attemptFinished; + + // Quiz got synced recently and an attempt has finished. Do not prefetch. + return; + } + + const siteId = CoreSites.instance.getCurrentSiteId(); + + return this.prefetchPackage(module, courseId, this.prefetchQuiz.bind(this, module, courseId, single, siteId, canStart)); + } + + /** + * Prefetch a quiz. + * + * @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 siteId Site ID. + * @param canStart If true, start a new attempt if needed. + * @return Promise resolved when done. + */ + protected async prefetchQuiz( + module: CoreCourseAnyModuleData, + courseId: number, + single: boolean, + siteId: string, + canStart: boolean, + ): Promise { + const commonOptions = { + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + siteId, + }; + const modOptions = { + cmId: module.id, + ...commonOptions, // Include all common options. + }; + + // Get quiz. + const quiz = await AddonModQuiz.instance.getQuiz(courseId, module.id, commonOptions); + + const introFiles = this.getIntroFilesFromInstance(module, quiz); + + // Prefetch some quiz data. + // eslint-disable-next-line prefer-const + let [quizAccessInfo, attempts, attemptAccessInfo] = await Promise.all([ + AddonModQuiz.instance.getQuizAccessInformation(quiz.id, modOptions), + AddonModQuiz.instance.getUserAttempts(quiz.id, modOptions), + AddonModQuiz.instance.getAttemptAccessInformation(quiz.id, 0, modOptions), + AddonModQuiz.instance.getQuizRequiredQtypes(quiz.id, modOptions), + CoreFilepool.instance.addFilesToQueue(siteId, introFiles, AddonModQuizProvider.COMPONENT, module.id), + ]); + + // Check if we need to start a new attempt. + let attempt: AddonModQuizAttemptWSData | undefined = attempts[attempts.length - 1]; + let preflightData: Record = {}; + let startAttempt = false; + + if (canStart || attempt) { + if (canStart && (!attempt || AddonModQuiz.instance.isAttemptFinished(attempt.state))) { + // Check if the user can attempt the quiz. + if (attemptAccessInfo.preventnewattemptreasons.length) { + throw new CoreError(CoreTextUtils.instance.buildMessage(attemptAccessInfo.preventnewattemptreasons)); + } + + startAttempt = true; + attempt = undefined; + } + + // Get the preflight data. This function will also start a new attempt if needed. + preflightData = await this.getPreflightData(quiz, quizAccessInfo, attempt, single, 'core.download', siteId); + } + + const promises: Promise[] = []; + + if (startAttempt) { + // Re-fetch user attempts since we created a new one. + promises.push(AddonModQuiz.instance.getUserAttempts(quiz.id, modOptions).then(async (atts) => { + attempts = atts; + + const attemptFiles = await this.getAttemptsFeedbackFiles(quiz, attempts, siteId); + + return CoreFilepool.instance.addFilesToQueue(siteId, attemptFiles, AddonModQuizProvider.COMPONENT, module.id); + })); + + // Update the download time to prevent detecting the new attempt as an update. + promises.push(CoreUtils.instance.ignoreErrors( + CoreFilepool.instance.updatePackageDownloadTime(siteId, AddonModQuizProvider.COMPONENT, module.id), + )); + } else { + // Use the already fetched attempts. + promises.push(this.getAttemptsFeedbackFiles(quiz, attempts, siteId).then((attemptFiles) => + CoreFilepool.instance.addFilesToQueue(siteId, attemptFiles, AddonModQuizProvider.COMPONENT, module.id))); + } + + // Fetch attempt related data. + promises.push(AddonModQuiz.instance.getCombinedReviewOptions(quiz.id, modOptions)); + promises.push(AddonModQuiz.instance.getUserBestGrade(quiz.id, modOptions)); + promises.push(this.prefetchGradeAndFeedback(quiz, modOptions, siteId)); + promises.push(AddonModQuiz.instance.getAttemptAccessInformation(quiz.id, 0, modOptions)); // Last attempt. + + await Promise.all(promises); + + // We have quiz data, now we'll get specific data for each attempt. + await Promise.all(attempts.map(async (attempt) => { + await this.prefetchAttempt(quiz, attempt, preflightData, siteId); + })); + + if (!canStart) { + // Nothing else to do. + return; + } + + // If there's nothing to send, mark the quiz as synchronized. + const hasData = await AddonModQuizSync.instance.hasDataToSync(quiz.id, siteId); + + if (!hasData) { + AddonModQuizSync.instance.setSyncTime(quiz.id, siteId); + } + } + + /** + * Prefetch all WS data for an attempt. + * + * @param quiz Quiz. + * @param attempt Attempt. + * @param preflightData Preflight required data (like password). + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the prefetch is finished. Data returned is not reliable. + */ + async prefetchAttempt( + quiz: AddonModQuizQuizWSData, + attempt: AddonModQuizAttemptWSData, + preflightData: Record, + siteId?: string, + ): Promise { + const pages = AddonModQuiz.instance.getPagesFromLayout(attempt.layout); + const isSequential = AddonModQuiz.instance.isNavigationSequential(quiz); + let promises: Promise[] = []; + + const modOptions: CoreCourseCommonModWSOptions = { + cmId: quiz.coursemodule, + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + siteId, + }; + + if (AddonModQuiz.instance.isAttemptFinished(attempt.state)) { + // Attempt is finished, get feedback and review data. + const attemptGrade = AddonModQuiz.instance.rescaleGrade(attempt.sumgrades, quiz, false); + if (typeof attemptGrade != 'undefined') { + promises.push(AddonModQuiz.instance.getFeedbackForGrade(quiz.id, Number(attemptGrade), modOptions)); + } + + // Get the review for each page. + pages.forEach((page) => { + promises.push(CoreUtils.instance.ignoreErrors(AddonModQuiz.instance.getAttemptReview(attempt.id, { + page, + ...modOptions, // Include all options. + }))); + }); + + // Get the review for all questions in same page. + promises.push(this.prefetchAttemptReviewFiles(quiz, attempt, modOptions, siteId)); + } else { + + // Attempt not finished, get data needed to continue the attempt. + promises.push(AddonModQuiz.instance.getAttemptAccessInformation(quiz.id, attempt.id, modOptions)); + promises.push(AddonModQuiz.instance.getAttemptSummary(attempt.id, preflightData, modOptions)); + + if (attempt.state == AddonModQuizProvider.ATTEMPT_IN_PROGRESS) { + // Get data for each page. + promises = promises.concat(pages.map(async (page) => { + if (isSequential && page < attempt.currentpage!) { + // Sequential quiz, cannot get pages before the current one. + return; + } + + const data = await AddonModQuiz.instance.getAttemptData(attempt.id, page, preflightData, modOptions); + + // Download the files inside the questions. + await Promise.all(data.questions.map(async (question) => { + await CoreQuestionHelper.instance.prefetchQuestionFiles( + question, + this.component, + quiz.coursemodule, + siteId, + attempt.uniqueid, + ); + })); + + })); + } + } + + await Promise.all(promises); + } + + /** + * Prefetch attempt review and its files. + * + * @param quiz Quiz. + * @param attempt Attempt. + * @param options Other options. + * @param siteId Site ID. + * @return Promise resolved when done. + */ + protected async prefetchAttemptReviewFiles( + quiz: AddonModQuizQuizWSData, + attempt: AddonModQuizAttemptWSData, + modOptions: CoreCourseCommonModWSOptions, + siteId?: string, + ): Promise { + // Get the review for all questions in same page. + const data = await CoreUtils.instance.ignoreErrors(AddonModQuiz.instance.getAttemptReview(attempt.id, { + page: -1, + ...modOptions, // Include all options. + })); + + if (!data) { + return; + } + // Download the files inside the questions. + await Promise.all(data.questions.map((question) => { + CoreQuestionHelper.instance.prefetchQuestionFiles( + question, + this.component, + quiz.coursemodule, + siteId, + attempt.uniqueid, + ); + })); + } + + /** + * Prefetch quiz grade and its feedback. + * + * @param quiz Quiz. + * @param modOptions Other options. + * @param siteId Site ID. + * @return Promise resolved when done. + */ + protected async prefetchGradeAndFeedback( + quiz: AddonModQuizQuizWSData, + modOptions: CoreCourseCommonModWSOptions, + siteId?: string, + ): Promise { + try { + const gradebookData = await AddonModQuiz.instance.getGradeFromGradebook(quiz.course, quiz.coursemodule, true, siteId); + + if (typeof gradebookData.graderaw != 'undefined') { + await AddonModQuiz.instance.getFeedbackForGrade(quiz.id, gradebookData.graderaw, modOptions); + } + } catch { + // Ignore errors. + } + } + + /** + * 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 quiz Quiz. + * @param askPreflight Whether it should ask for preflight data if needed. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async prefetchQuizAndLastAttempt(quiz: AddonModQuizQuizWSData, askPreflight?: boolean, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const modOptions = { + cmId: quiz.coursemodule, + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + siteId, + }; + + // Get quiz data. + const [quizAccessInfo, attempts] = await Promise.all([ + AddonModQuiz.instance.getQuizAccessInformation(quiz.id, modOptions), + AddonModQuiz.instance.getUserAttempts(quiz.id, modOptions), + AddonModQuiz.instance.getQuizRequiredQtypes(quiz.id, modOptions), + AddonModQuiz.instance.getCombinedReviewOptions(quiz.id, modOptions), + AddonModQuiz.instance.getUserBestGrade(quiz.id, modOptions), + this.prefetchGradeAndFeedback(quiz, modOptions, siteId), + AddonModQuiz.instance.getAttemptAccessInformation(quiz.id, 0, modOptions), // Last attempt. + ]); + + const lastAttempt = attempts[attempts.length - 1]; + let preflightData: Record = {}; + if (lastAttempt) { + // Get the preflight data. + preflightData = await this.getPreflightData(quiz, quizAccessInfo, lastAttempt, askPreflight, 'core.download', siteId); + + // Get data for last attempt. + await this.prefetchAttempt(quiz, lastAttempt, preflightData, siteId); + } + + // Prefetch finished, set the right status. + await this.setStatusAfterPrefetch(quiz, { + cmId: quiz.coursemodule, + attempts, + readingStrategy: CoreSitesReadingStrategy.PreferCache, + siteId, + }); + } + + /** + * Set the right status to a quiz after prefetching. + * If the last attempt is finished or there isn't one, set it as not downloaded to show download icon. + * + * @param quiz Quiz. + * @param options Other options. + * @return Promise resolved when done. + */ + async setStatusAfterPrefetch( + quiz: AddonModQuizQuizWSData, + options: AddonModQuizSetStatusAfterPrefetchOptions = {}, + ): Promise { + options.siteId = options.siteId || CoreSites.instance.getCurrentSiteId(); + + let attempts = options.attempts; + + if (!attempts) { + // Get the attempts. + attempts = await AddonModQuiz.instance.getUserAttempts(quiz.id, options); + } + + // Check the current status of the quiz. + const status = await CoreFilepool.instance.getPackageStatus(options.siteId, this.component, quiz.coursemodule); + + if (status === CoreConstants.NOT_DOWNLOADED) { + return; + } + + // 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 lastAttempt = attempts[attempts.length - 1]; + const isLastFinished = !lastAttempt || AddonModQuiz.instance.isAttemptFinished(lastAttempt.state); + const newStatus = isLastFinished ? CoreConstants.NOT_DOWNLOADED : CoreConstants.DOWNLOADED; + + await CoreFilepool.instance.storePackageStatus(options.siteId, newStatus, this.component, quiz.coursemodule); + } + + /** + * 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. + */ + async sync(module: SyncedModule, courseId: number, siteId?: string): Promise { + const quiz = await AddonModQuiz.instance.getQuiz(courseId, module.id, { siteId }); + + try { + const result = await AddonModQuizSync.instance.syncQuiz(quiz, false, siteId); + + module.attemptFinished = result.attemptFinished || false; + + return result; + } catch { + // Ignore errors. + module.attemptFinished = false; + } + } + +} + +export class AddonModQuizPrefetchHandler extends makeSingleton(AddonModQuizPrefetchHandlerService) {} + +/** + * Options to pass to setStatusAfterPrefetch. + */ +export type AddonModQuizSetStatusAfterPrefetchOptions = CoreCourseCommonModWSOptions & { + attempts?: AddonModQuizAttemptWSData[]; // List of attempts. If not provided, they will be calculated. +}; + +/** + * Module data with some calculated data. + */ +type SyncedModule = CoreCourseAnyModuleData & { + attemptFinished?: boolean; +}; diff --git a/src/addons/mod/quiz/services/quiz-sync.ts b/src/addons/mod/quiz/services/quiz-sync.ts new file mode 100644 index 000000000..061f0463b --- /dev/null +++ b/src/addons/mod/quiz/services/quiz-sync.ts @@ -0,0 +1,513 @@ +// (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 { CoreCourseActivitySyncBaseProvider } from '@features/course/classes/activity-sync'; +import { CoreCourse, CoreCourseAnyModuleData } from '@features/course/services/course'; +import { CoreCourseLogHelper } from '@features/course/services/log-helper'; +import { CoreCourseModulePrefetchDelegate } from '@features/course/services/module-prefetch-delegate'; +import { CoreQuestion, CoreQuestionQuestionParsed } from '@features/question/services/question'; +import { CoreQuestionDelegate } from '@features/question/services/question-delegate'; +import { CoreApp } from '@services/app'; +import { CoreSites, CoreSitesReadingStrategy } from '@services/sites'; +import { CoreSync } from '@services/sync'; +import { CoreUtils } from '@services/utils/utils'; +import { makeSingleton, Translate } from '@singletons'; +import { CoreEvents, CoreEventSiteData } from '@singletons/events'; +import { AddonModQuizAttemptDBRecord } from './database/quiz'; +import { AddonModQuizPrefetchHandler } from './handlers/prefetch'; +import { AddonModQuiz, AddonModQuizAttemptWSData, AddonModQuizProvider, AddonModQuizQuizWSData } from './quiz'; +import { AddonModQuizOffline, AddonModQuizQuestionsWithAnswers } from './quiz-offline'; + +/** + * Service to sync quizzes. + */ +@Injectable({ providedIn: 'root' }) +export class AddonModQuizSyncProvider extends CoreCourseActivitySyncBaseProvider { + + static readonly AUTO_SYNCED = 'addon_mod_quiz_autom_synced'; + + protected componentTranslate?: string; + + constructor() { + super('AddonModQuizSyncProvider'); + } + + /** + * Finish a sync process: remove offline data if needed, prefetch quiz data, set sync time and return the result. + * + * @param siteId Site ID. + * @param quiz Quiz. + * @param courseId Course ID. + * @param warnings List of warnings generated by the sync. + * @param options Other options. + * @return Promise resolved on success. + */ + protected async finishSync( + siteId: string, + quiz: AddonModQuizQuizWSData, + courseId: number, + warnings: string[], + options?: FinishSyncOptions, + ): Promise { + options = options || {}; + + // Invalidate the data for the quiz and attempt. + await CoreUtils.instance.ignoreErrors( + AddonModQuiz.instance.invalidateAllQuizData(quiz.id, courseId, options.attemptId, siteId), + ); + + if (options.removeAttempt && options.attemptId) { + const promises: Promise[] = []; + + promises.push(AddonModQuizOffline.instance.removeAttemptAndAnswers(options.attemptId, siteId)); + + if (options.onlineQuestions) { + for (const slot in options.onlineQuestions) { + promises.push(CoreQuestionDelegate.instance.deleteOfflineData( + options.onlineQuestions[slot], + AddonModQuizProvider.COMPONENT, + quiz.coursemodule, + siteId, + )); + } + } + + await Promise.all(promises); + } + + if (options.updated) { + try { + // Data has been sent. Update prefetched data. + const module = await CoreCourse.instance.getModuleBasicInfoByInstance(quiz.id, 'quiz', siteId); + + await this.prefetchAfterUpdateQuiz(module, quiz, courseId, undefined, siteId); + } catch { + // Ignore errors. + } + } + + await CoreUtils.instance.ignoreErrors(this.setSyncTime(quiz.id, siteId)); + + // Check if online attempt was finished because of the sync. + let attemptFinished = false; + if (options.onlineAttempt && !AddonModQuiz.instance.isAttemptFinished(options.onlineAttempt.state)) { + // Attempt wasn't finished at start. Check if it's finished now. + const attempts = await AddonModQuiz.instance.getUserAttempts(quiz.id, { cmId: quiz.coursemodule, siteId }); + + const attempt = attempts.find(attempt => attempt.id == options?.onlineAttempt?.id); + + attemptFinished = attempt ? AddonModQuiz.instance.isAttemptFinished(attempt.state) : false; + } + + return { warnings, attemptFinished, updated: !!options.updated || !!options.removeAttempt }; + } + + /** + * Check if a quiz has data to synchronize. + * + * @param quizId Quiz ID. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: whether it has data to sync. + */ + async hasDataToSync(quizId: number, siteId?: string): Promise { + try { + const attempts = await AddonModQuizOffline.instance.getQuizAttempts(quizId, siteId); + + return !!attempts.length; + } catch { + return false; + } + } + + /** + * Conveniece function to prefetch data after an update. + * + * @param module Module. + * @param quiz Quiz. + * @param courseId Course ID. + * @param regex If regex matches, don't download the data. Defaults to check files. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when done. + */ + async prefetchAfterUpdateQuiz( + module: CoreCourseAnyModuleData, + quiz: AddonModQuizQuizWSData, + courseId: number, + regex?: RegExp, + siteId?: string, + ): Promise { + regex = regex || /^.*files$/; + + let shouldDownload = false; + + // Get the module updates to check if the data was updated or not. + const result = await CoreCourseModulePrefetchDelegate.instance.getModuleUpdates(module, courseId, true, siteId); + + if (result?.updates?.length) { + // Only prefetch if files haven't changed. + shouldDownload = !result.updates.find((entry) => entry.name.match(regex!)); + + if (shouldDownload) { + await AddonModQuizPrefetchHandler.instance.download(module, courseId, undefined, false, false); + } + } + + // Prefetch finished or not needed, set the right status. + await AddonModQuizPrefetchHandler.instance.setStatusAfterPrefetch(quiz, { + cmId: module.id, + readingStrategy: shouldDownload ? CoreSitesReadingStrategy.PreferCache : undefined, + siteId, + }); + } + + /** + * Try to synchronize all the quizzes 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. + */ + syncAllQuizzes(siteId?: string, force?: boolean): Promise { + return this.syncOnSites('all quizzes', this.syncAllQuizzesFunc.bind(this, !!force), siteId); + } + + /** + * Sync all quizzes on a site. + * + * @param siteId Site ID to sync. + * @param force Wether to force sync not depending on last execution. + * @param Promise resolved if sync is successful, rejected if sync fails. + */ + protected async syncAllQuizzesFunc(siteId: string, force: boolean): Promise { + // Get all offline attempts. + const attempts = await AddonModQuizOffline.instance.getAllAttempts(siteId); + + const quizIds: Record = {}; // To prevent duplicates. + + // Sync all quizzes that haven't been synced for a while and that aren't attempted right now. + await Promise.all(attempts.map(async (attempt) => { + if (quizIds[attempt.quizid]) { + // Quiz already treated. + return; + } + quizIds[attempt.quizid] = true; + + if (CoreSync.instance.isBlocked(AddonModQuizProvider.COMPONENT, attempt.quizid, siteId)) { + return; + } + + // Quiz not blocked, try to synchronize it. + const quiz = await AddonModQuiz.instance.getQuizById(attempt.courseid, attempt.quizid, { siteId }); + + const data = await (force ? this.syncQuiz(quiz, false, siteId) : this.syncQuizIfNeeded(quiz, false, siteId)); + + if (data?.warnings?.length) { + // Store the warnings to show them when the user opens the quiz. + await this.setSyncWarnings(quiz.id, data.warnings, siteId); + } + + if (data) { + // Sync successful. Send event. + CoreEvents.trigger(AddonModQuizSyncProvider.AUTO_SYNCED, { + quizId: quiz.id, + attemptFinished: data.attemptFinished, + warnings: data.warnings, + }, siteId); + } + })); + } + + /** + * Sync a quiz only if a certain time has passed since the last time. + * + * @param quiz Quiz. + * @param askPreflight Whether we should ask for preflight data if needed. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved when the quiz is synced or if it doesn't need to be synced. + */ + async syncQuizIfNeeded( + quiz: AddonModQuizQuizWSData, + askPreflight?: boolean, + siteId?: string, + ): Promise { + const needed = await this.isSyncNeeded(quiz.id, siteId); + + if (needed) { + return this.syncQuiz(quiz, askPreflight, siteId); + } + } + + /** + * Try to synchronize a quiz. + * The promise returned will be resolved with an array with warnings if the synchronization is successful. + * + * @param quiz Quiz. + * @param askPreflight Whether we should ask for preflight data if needed. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved in success. + */ + syncQuiz(quiz: AddonModQuizQuizWSData, askPreflight?: boolean, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + if (this.isSyncing(quiz.id, siteId)) { + // There's already a sync ongoing for this quiz, return the promise. + return this.getOngoingSync(quiz.id, siteId)!; + } + + // Verify that quiz isn't blocked. + if (CoreSync.instance.isBlocked(AddonModQuizProvider.COMPONENT, quiz.id, siteId)) { + this.logger.debug('Cannot sync quiz ' + quiz.id + ' because it is blocked.'); + this.componentTranslate = this.componentTranslate || CoreCourse.instance.translateModuleName('quiz'); + + throw new CoreError(Translate.instance.instant('core.errorsyncblocked', { $a: this.componentTranslate })); + } + + return this.addOngoingSync(quiz.id, this.performSyncQuiz(quiz, askPreflight, siteId), siteId); + } + + /** + * Perform the quiz sync. + * + * @param quiz Quiz. + * @param askPreflight Whether we should ask for preflight data if needed. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved in success. + */ + async performSyncQuiz(quiz: AddonModQuizQuizWSData, askPreflight?: boolean, siteId?: string): Promise { + siteId = siteId || CoreSites.instance.getCurrentSiteId(); + + const warnings: string[] = []; + const courseId = quiz.course; + const modOptions = { + cmId: quiz.coursemodule, + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + siteId, + }; + + this.logger.debug('Try to sync quiz ' + quiz.id + ' in site ' + siteId); + + // Sync offline logs. + await CoreUtils.instance.ignoreErrors( + CoreCourseLogHelper.instance.syncActivity(AddonModQuizProvider.COMPONENT, quiz.id, siteId), + ); + + // Get all the offline attempts for the quiz. It should always be 0 or 1 attempt + const offlineAttempts = await AddonModQuizOffline.instance.getQuizAttempts(quiz.id, siteId); + + if (!offlineAttempts.length) { + // Nothing to sync, finish. + return this.finishSync(siteId, quiz, courseId, warnings); + } + + if (!CoreApp.instance.isOnline()) { + // Cannot sync in offline. + throw new CoreError(Translate.instance.instant('core.cannotconnect')); + } + + const offlineAttempt = offlineAttempts.pop()!; + + // Now get the list of online attempts to make sure this attempt exists and isn't finished. + const onlineAttempts = await AddonModQuiz.instance.getUserAttempts(quiz.id, modOptions); + + const lastAttemptId = onlineAttempts.length ? onlineAttempts[onlineAttempts.length - 1].id : undefined; + const onlineAttempt = onlineAttempts.find((attempt) => attempt.id == offlineAttempt.id); + + if (!onlineAttempt || AddonModQuiz.instance.isAttemptFinished(onlineAttempt.state)) { + // Attempt not found or it's finished in online. Discard it. + warnings.push(Translate.instance.instant('addon.mod_quiz.warningattemptfinished')); + + return this.finishSync(siteId, quiz, courseId, warnings, { + attemptId: offlineAttempt.id, + offlineAttempt, + onlineAttempt, + removeAttempt: true, + }); + } + + // Get the data stored in offline. + const answersList = await AddonModQuizOffline.instance.getAttemptAnswers(offlineAttempt.id, siteId); + + if (!answersList.length) { + // No answers stored, finish. + return this.finishSync(siteId, quiz, courseId, warnings, { + attemptId: lastAttemptId, + offlineAttempt, + onlineAttempt, + removeAttempt: true, + }); + } + + const offlineAnswers = CoreQuestion.instance.convertAnswersArrayToObject(answersList); + const offlineQuestions = AddonModQuizOffline.instance.classifyAnswersInQuestions(offlineAnswers); + + // We're going to need preflightData, get it. + const info = await AddonModQuiz.instance.getQuizAccessInformation(quiz.id, modOptions); + + const preflightData = await AddonModQuizPrefetchHandler.instance.getPreflightData( + quiz, + info, + onlineAttempt, + askPreflight, + 'core.settings.synchronization', + siteId, + ); + + // Now get the online questions data. + const onlineQuestions = await AddonModQuiz.instance.getAllQuestionsData(quiz, onlineAttempt, preflightData, { + pages: AddonModQuiz.instance.getPagesFromLayoutAndQuestions(onlineAttempt.layout || '', offlineQuestions), + readingStrategy: CoreSitesReadingStrategy.OnlyNetwork, + siteId, + }); + + // Validate questions, discarding the offline answers that can't be synchronized. + const discardedData = await this.validateQuestions(onlineAttempt.id, onlineQuestions, offlineQuestions, siteId); + + // Let questions prepare the data to send. + await Promise.all(Object.keys(offlineQuestions).map(async (slotString) => { + const slot = Number(slotString); + const onlineQuestion = onlineQuestions[slot]; + + await CoreQuestionDelegate.instance.prepareSyncData( + onlineQuestion, + offlineQuestions[slot].answers, + AddonModQuizProvider.COMPONENT, + quiz.coursemodule, + siteId, + ); + })); + + // Get the answers to send. + const answers = AddonModQuizOffline.instance.extractAnswersFromQuestions(offlineQuestions); + const finish = !!offlineAttempt.finished && !discardedData; + + if (discardedData) { + if (offlineAttempt.finished) { + warnings.push(Translate.instance.instant('addon.mod_quiz.warningdatadiscardedfromfinished')); + } else { + warnings.push(Translate.instance.instant('addon.mod_quiz.warningdatadiscarded')); + } + } + + // Send the answers. + await AddonModQuiz.instance.processAttempt(quiz, onlineAttempt, answers, preflightData, finish, false, false, siteId); + + if (!finish) { + // Answers sent, now set the current page. + // Don't pass the quiz instance because we don't want to trigger a Firebase event in this case. + await CoreUtils.instance.ignoreErrors(AddonModQuiz.instance.logViewAttempt( + onlineAttempt.id, + offlineAttempt.currentpage, + preflightData, + false, + undefined, + siteId, + )); + } + + // Data sent. Finish the sync. + return this.finishSync(siteId, quiz, courseId, warnings, { + attemptId: lastAttemptId, + offlineAttempt, + onlineAttempt, + removeAttempt: true, + updated: true, + onlineQuestions, + }); + } + + /** + * Validate questions, discarding the offline answers that can't be synchronized. + * + * @param attemptId Attempt ID. + * @param onlineQuestions Online questions + * @param offlineQuestions Offline questions. + * @param siteId Site ID. If not defined, current site. + * @return Promise resolved with boolean: true if some offline data was discarded, false otherwise. + */ + async validateQuestions( + attemptId: number, + onlineQuestions: Record, + offlineQuestions: AddonModQuizQuestionsWithAnswers, + siteId?: string, + ): Promise { + let discardedData = false; + + await Promise.all(Object.keys(offlineQuestions).map(async (slotString) => { + const slot = Number(slotString); + const offlineQuestion = offlineQuestions[slot]; + const onlineQuestion = onlineQuestions[slot]; + const offlineSequenceCheck = offlineQuestion.answers[':sequencecheck']; + + if (onlineQuestion) { + // We found the online data for the question, validate that the sequence check is ok. + if (!CoreQuestionDelegate.instance.validateSequenceCheck(onlineQuestion, offlineSequenceCheck)) { + // Sequence check is not valid, remove the offline data. + await AddonModQuizOffline.instance.removeQuestionAndAnswers(attemptId, slot, siteId); + + discardedData = true; + delete offlineQuestions[slot]; + } else { + // Sequence check is valid. Use the online one to prevent synchronization errors. + offlineQuestion.answers[':sequencecheck'] = String(onlineQuestion.sequencecheck); + } + } else { + // Online question not found, it can happen for 2 reasons: + // 1- It's a sequential quiz and the question is in a page already passed. + // 2- Quiz layout has changed (shouldn't happen since it's blocked if there are attempts). + await AddonModQuizOffline.instance.removeQuestionAndAnswers(attemptId, slot, siteId); + + discardedData = true; + delete offlineQuestions[slot]; + } + })); + + return discardedData; + } + +} + +export class AddonModQuizSync extends makeSingleton(AddonModQuizSyncProvider) {} + +/** + * Data returned by a quiz sync. + */ +export type AddonModQuizSyncResult = { + warnings: string[]; // List of warnings. + attemptFinished: boolean; // Whether an attempt was finished in the site due to the sync. + updated: boolean; +}; + +/** + * Options to pass to finish sync. + */ +type FinishSyncOptions = { + attemptId?: number; // Last attempt ID. + offlineAttempt?: AddonModQuizAttemptDBRecord; // Offline attempt synchronized, if any. + onlineAttempt?: AddonModQuizAttemptWSData; // Online data for the offline attempt. + removeAttempt?: boolean; // Whether the offline data should be removed. + updated?: boolean; // Whether the offline data should be removed. + onlineQuestions?: Record; // Online questions indexed by slot. +}; + +/** + * Data passed to AUTO_SYNCED event. + */ +export type AddonModQuizAutoSyncData = CoreEventSiteData & { + quizId: number; + attemptFinished: boolean; + warnings: string[]; +}; diff --git a/src/core/features/course/services/sync.ts b/src/core/features/course/services/sync.ts index 4cbcd2f0c..a23a0d5f4 100644 --- a/src/core/features/course/services/sync.ts +++ b/src/core/features/course/services/sync.ts @@ -50,17 +50,17 @@ export class CoreCourseSyncProvider extends CoreSyncBaseProvider { - return this.syncOnSites('courses', this.syncAllCoursesFunc.bind(this, siteId, force), siteId); + return this.syncOnSites('courses', this.syncAllCoursesFunc.bind(this, !!force), siteId); } /** * Sync all courses on a site. * - * @param siteId Site ID to sync. * @param force Wether the execution is forced (manual sync). + * @param siteId Site ID to sync. * @return Promise resolved if sync is successful, rejected if sync fails. */ - protected async syncAllCoursesFunc(siteId: string, force: boolean): Promise { + protected async syncAllCoursesFunc(force: boolean, siteId: string): Promise { await Promise.all([ CoreCourseLogHelper.instance.syncSite(siteId), this.syncCoursesCompletion(siteId, force), @@ -149,7 +149,7 @@ export class CoreCourseSyncProvider extends CoreSyncBaseProvider { const db = await CoreSites.instance.getSiteDb(siteId); - return await db.getRecord(SYNC_TABLE_NAME, { component: component, id: id }); + return await db.getRecord(SYNC_TABLE_NAME, { component: component, id: String(id) }); } /** @@ -121,7 +121,7 @@ export class CoreSyncProvider { const db = await CoreSites.instance.getSiteDb(siteId); data.component = component; - data.id = id + ''; + data.id = String(id); await db.insertRecord(SYNC_TABLE_NAME, data); }