// (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 { CoreCourseProvider } from '@core/course/providers/course'; import { CoreQuestionProvider } from '@core/question/providers/question'; import { CoreQuestionDelegate } from '@core/question/providers/delegate'; import { CoreSyncBaseProvider } from '@classes/base-sync'; import { AddonModQuizProvider } from './quiz'; import { AddonModQuizOfflineProvider } from './quiz-offline'; import { AddonModQuizPrefetchHandler } from './prefetch-handler'; /** * Data returned by a quiz sync. */ export interface AddonModQuizSyncResult { /** * List of warnings. * @type {string[]} */ warnings: string[]; /** * Whether an attempt was finished in the site due to the sync, * @type {boolean} */ attemptFinished: boolean; } /** * Service to sync quizzes. */ @Injectable() export class AddonModQuizSyncProvider extends CoreSyncBaseProvider { static AUTO_SYNCED = 'addon_mod_quiz_autom_synced'; protected componentTranslate: string; constructor(loggerProvider: CoreLoggerProvider, sitesProvider: CoreSitesProvider, appProvider: CoreAppProvider, syncProvider: CoreSyncProvider, textUtils: CoreTextUtilsProvider, translate: TranslateService, courseProvider: CoreCourseProvider, private eventsProvider: CoreEventsProvider, timeUtils: CoreTimeUtilsProvider, private quizProvider: AddonModQuizProvider, private quizOfflineProvider: AddonModQuizOfflineProvider, private prefetchHandler: AddonModQuizPrefetchHandler, private questionProvider: CoreQuestionProvider, private questionDelegate: CoreQuestionDelegate) { super('AddonModQuizSyncProvider', loggerProvider, sitesProvider, appProvider, syncProvider, textUtils, translate, timeUtils); this.componentTranslate = courseProvider.translateModuleName('quiz'); } /** * Finish a sync process: remove offline data if needed, prefetch quiz data, set sync time and return the result. * * @param {string} siteId Site ID. * @param {any} quiz Quiz. * @param {number} courseId Course ID. * @param {string[]} warnings List of warnings generated by the sync. * @param {number} [attemptId] Last attempt ID. * @param {any} [offlineAttempt] Offline attempt synchronized, if any. * @param {any} [onlineAttempt] Online data for the offline attempt. * @param {boolean} [removeAttempt] Whether the offline data should be removed. * @param {boolean} [updated] Whether some data was sent to the site. * @return {Promise} Promise resolved on success. */ protected finishSync(siteId: string, quiz: any, courseId: number, warnings: string[], attemptId?: number, offlineAttempt?: any, onlineAttempt?: any, removeAttempt?: boolean, updated?: boolean): Promise { // Invalidate the data for the quiz and attempt. return this.quizProvider.invalidateAllQuizData(quiz.id, courseId, attemptId, siteId).catch(() => { // Ignore errors. }).then(() => { if (removeAttempt && attemptId) { return this.quizOfflineProvider.removeAttemptAndAnswers(attemptId, siteId); } }).then(() => { if (updated) { // Data has been sent. Update prefetched data. return this.prefetchHandler.prefetchQuizAndLastAttempt(quiz, false, siteId); } }).then(() => { return this.setSyncTime(quiz.id, siteId).catch(() => { // Ignore errors. }); }).then(() => { // Check if online attempt was finished because of the sync. if (onlineAttempt && !this.quizProvider.isAttemptFinished(onlineAttempt.state)) { // Attempt wasn't finished at start. Check if it's finished now. return this.quizProvider.getUserAttempts(quiz.id, 'all', true, false, false, siteId).then((attempts) => { // Search the attempt. for (const i in attempts) { const attempt = attempts[i]; if (attempt.id == onlineAttempt.id) { return this.quizProvider.isAttemptFinished(attempt.state); } } return false; }); } return false; }).then((attemptFinished) => { return { warnings: warnings, attemptFinished: attemptFinished }; }); } /** * Check if a quiz has data to synchronize. * * @param {number} quizId Quiz ID. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved with boolean: whether it has data to sync. */ hasDataToSync(quizId: number, siteId?: string): Promise { return this.quizOfflineProvider.getQuizAttempts(quizId, siteId).then((attempts) => { return !!attempts.length; }).catch(() => { return false; }); } /** * Try to synchronize all the quizzes 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. */ syncAllQuizzes(siteId?: string): Promise { return this.syncOnSites('all quizzes', this.syncAllQuizzesFunc.bind(this), [], siteId); } /** * Sync all quizzes 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 syncAllQuizzesFunc(siteId?: string): Promise { // Get all offline attempts. return this.quizOfflineProvider.getAllAttempts(siteId).then((attempts) => { const quizzes = [], ids = [], // To prevent duplicates. promises = []; // Get the IDs of all the quizzes that have something to be synced. attempts.forEach((attempt) => { if (ids.indexOf(attempt.quizid) == -1) { ids.push(attempt.quizid); quizzes.push({ id: attempt.quizid, courseid: attempt.courseid }); } }); // Sync all quizzes that haven't been synced for a while and that aren't attempted right now. quizzes.forEach((quiz) => { if (!this.syncProvider.isBlocked(AddonModQuizProvider.COMPONENT, quiz.id, siteId)) { // Quiz not blocked, try to synchronize it. promises.push(this.quizProvider.getQuizById(quiz.courseid, quiz.id, false, siteId).then((quiz) => { return this.syncQuizIfNeeded(quiz, false, siteId).then((data) => { if (data && data.warnings && data.warnings.length) { // Store the warnings to show them when the user opens the quiz. return this.setSyncWarnings(quiz.id, data.warnings, siteId).then(() => { return data; }); } return data; }).then((data) => { if (typeof data != 'undefined') { // Sync successful. Send event. this.eventsProvider.trigger(AddonModQuizSyncProvider.AUTO_SYNCED, { quizId: quiz.id, attemptFinished: data.attemptFinished, warnings: data.warnings }, siteId); } }); })); } }); return Promise.all(promises); }); } /** * Sync a quiz only if a certain time has passed since the last time. * * @param {any} quiz Quiz. * @param {boolean} [askPreflight] Whether we should ask for preflight data if needed. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved when the quiz is synced or if it doesn't need to be synced. */ syncQuizIfNeeded(quiz: any, askPreflight?: boolean, siteId?: string): Promise { return this.isSyncNeeded(quiz.id, siteId).then((needed) => { 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 {any} quiz Quiz. * @param {boolean} [askPreflight] Whether we should ask for preflight data if needed. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved in success. */ syncQuiz(quiz: any, askPreflight?: boolean, siteId?: string): Promise { siteId = siteId || this.sitesProvider.getCurrentSiteId(); const warnings = [], courseId = quiz.course; let syncPromise, preflightData; 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 (this.syncProvider.isBlocked(AddonModQuizProvider.COMPONENT, quiz.id, siteId)) { this.logger.debug('Cannot sync quiz ' + quiz.id + ' because it is blocked.'); return Promise.reject(this.translate.instant('core.errorsyncblocked', {$a: this.componentTranslate})); } this.logger.debug('Try to sync quiz ' + quiz.id + ' in site ' + siteId); // Get all the offline attempts for the quiz. syncPromise = this.quizOfflineProvider.getQuizAttempts(quiz.id, siteId).then((attempts) => { // Should return 0 or 1 attempt. if (!attempts.length) { return this.finishSync(siteId, quiz, courseId, warnings); } const offlineAttempt = attempts.pop(); // Now get the list of online attempts to make sure this attempt exists and isn't finished. return this.quizProvider.getUserAttempts(quiz.id, 'all', true, false, true, siteId).then((attempts) => { const lastAttemptId = attempts.length ? attempts[attempts.length - 1].id : undefined; let onlineAttempt; // Search the attempt we retrieved from offline. for (const i in attempts) { const attempt = attempts[i]; if (attempt.id == offlineAttempt.id) { onlineAttempt = attempt; break; } } if (!onlineAttempt || this.quizProvider.isAttemptFinished(onlineAttempt.state)) { // Attempt not found or it's finished in online. Discard it. warnings.push(this.translate.instant('addon.mod_quiz.warningattemptfinished')); return this.finishSync(siteId, quiz, courseId, warnings, offlineAttempt.id, offlineAttempt, onlineAttempt, true); } // Get the data stored in offline. return this.quizOfflineProvider.getAttemptAnswers(offlineAttempt.id, siteId).then((answersList) => { if (!answersList.length) { // No answers stored, finish. return this.finishSync(siteId, quiz, courseId, warnings, lastAttemptId, offlineAttempt, onlineAttempt, true); } const answers = this.questionProvider.convertAnswersArrayToObject(answersList), offlineQuestions = this.quizOfflineProvider.classifyAnswersInQuestions(answers); let finish; // We're going to need preflightData, get it. return this.quizProvider.getQuizAccessInformation(quiz.id, false, true, siteId).then((info) => { return this.prefetchHandler.getPreflightData(quiz, info, onlineAttempt, askPreflight, 'core.settings.synchronization', siteId); }).then((data) => { preflightData = data; // Now get the online questions data. const pages = this.quizProvider.getPagesFromLayoutAndQuestions(onlineAttempt.layout, offlineQuestions); return this.quizProvider.getAllQuestionsData(quiz, onlineAttempt, preflightData, pages, false, true, siteId); }).then((onlineQuestions) => { // Validate questions, discarding the offline answers that can't be synchronized. return this.validateQuestions(onlineAttempt.id, onlineQuestions, offlineQuestions, siteId); }).then((discardedData) => { // Get the answers to send. const answers = this.quizOfflineProvider.extractAnswersFromQuestions(offlineQuestions); finish = offlineAttempt.finished && !discardedData; if (discardedData) { if (offlineAttempt.finished) { warnings.push(this.translate.instant('addon.mod_quiz.warningdatadiscardedfromfinished')); } else { warnings.push(this.translate.instant('addon.mod_quiz.warningdatadiscarded')); } } return this.quizProvider.processAttempt(quiz, onlineAttempt, answers, preflightData, finish, false, false, siteId); }).then(() => { // Answers sent, now set the current page if the attempt isn't finished. if (!finish) { return this.quizProvider.logViewAttempt(onlineAttempt.id, offlineAttempt.currentpage, preflightData, false).catch(() => { // Ignore errors. }); } }).then(() => { // Data sent. Finish the sync. return this.finishSync(siteId, quiz, courseId, warnings, lastAttemptId, offlineAttempt, onlineAttempt, true, true); }); }); }); }); return this.addOngoingSync(quiz.id, syncPromise, siteId); } /** * Validate questions, discarding the offline answers that can't be synchronized. * * @param {number} attemptId Attempt ID. * @param {any} onlineQuestions Online questions * @param {any} offlineQuestions Offline questions. * @param {string} [siteId] Site ID. If not defined, current site. * @return {Promise} Promise resolved with boolean: true if some offline data was discarded, false otherwise. */ validateQuestions(attemptId: number, onlineQuestions: any, offlineQuestions: any, siteId?: string): Promise { const promises = []; let discardedData = false; for (const slot in offlineQuestions) { const offlineQuestion = offlineQuestions[slot], onlineQuestion = onlineQuestions[slot], offlineSequenceCheck = offlineQuestion.answers[':sequencecheck']; if (onlineQuestion) { // We found the online data for the question, validate that the sequence check is ok. if (!this.questionDelegate.validateSequenceCheck(onlineQuestion, offlineSequenceCheck)) { // Sequence check is not valid, remove the offline data. discardedData = true; promises.push(this.quizOfflineProvider.removeQuestionAndAnswers(attemptId, Number(slot), siteId)); delete offlineQuestions[slot]; } else { // Sequence check is valid. Use the online one to prevent synchronization errors. offlineQuestion.answers[':sequencecheck'] = 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). discardedData = true; promises.push(this.quizOfflineProvider.removeQuestionAndAnswers(attemptId, Number(slot), siteId)); delete offlineQuestions[slot]; } } return Promise.all(promises).then(() => { return discardedData; }); } }