2
0
2019-04-01 10:57:53 +02:00

458 lines
21 KiB
TypeScript

// (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 { CoreCourseLogHelperProvider } from '@core/course/providers/log-helper';
import { CoreCourseModulePrefetchDelegate } from '@core/course/providers/module-prefetch-delegate';
import { CoreQuestionProvider } from '@core/question/providers/question';
import { CoreQuestionDelegate } from '@core/question/providers/delegate';
import { CoreCourseActivitySyncBaseProvider } from '@core/course/classes/activity-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 CoreCourseActivitySyncBaseProvider {
static AUTO_SYNCED = 'addon_mod_quiz_autom_synced';
protected componentTranslate: string;
constructor(loggerProvider: CoreLoggerProvider, sitesProvider: CoreSitesProvider, appProvider: CoreAppProvider,
syncProvider: CoreSyncProvider, textUtils: CoreTextUtilsProvider, translate: TranslateService,
private eventsProvider: CoreEventsProvider, timeUtils: CoreTimeUtilsProvider,
private quizProvider: AddonModQuizProvider, private quizOfflineProvider: AddonModQuizOfflineProvider,
protected prefetchHandler: AddonModQuizPrefetchHandler, private questionProvider: CoreQuestionProvider,
private questionDelegate: CoreQuestionDelegate, private logHelper: CoreCourseLogHelperProvider,
prefetchDelegate: CoreCourseModulePrefetchDelegate, private courseProvider: CoreCourseProvider) {
super('AddonModQuizSyncProvider', loggerProvider, sitesProvider, appProvider, syncProvider, textUtils, translate,
timeUtils, prefetchDelegate, prefetchHandler);
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<AddonModQuizSyncResult>} 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<AddonModQuizSyncResult> {
// 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.courseProvider.getModuleBasicInfoByInstance(quiz.id, 'quiz', siteId).then((module) => {
return this.prefetchAfterUpdateQuiz(module, quiz, courseId, undefined, siteId);
}).catch(() => {
// Ignore errors.
});
}
}).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<boolean>} Promise resolved with boolean: whether it has data to sync.
*/
hasDataToSync(quizId: number, siteId?: string): Promise<boolean> {
return this.quizOfflineProvider.getQuizAttempts(quizId, siteId).then((attempts) => {
return !!attempts.length;
}).catch(() => {
return false;
});
}
/**
* Conveniece function to prefetch data after an update.
*
* @param {any} module Module.
* @param {any} quiz Quiz.
* @param {number} courseId Course ID.
* @param {RegExp} [regex] If regex matches, don't download the data. Defaults to check files.
* @param {string} [siteId] Site ID. If not defined, current site.
* @return {Promise<any>} Promise resolved when done.
*/
prefetchAfterUpdateQuiz(module: any, quiz: any, courseId: number, regex?: RegExp, siteId?: string): Promise<any> {
regex = regex || /^.*files$/;
let shouldDownload;
// Get the module updates to check if the data was updated or not.
return this.prefetchDelegate.getModuleUpdates(module, courseId, true, siteId).then((result) => {
if (result && result.updates && result.updates.length > 0) {
// Only prefetch if files haven't changed.
shouldDownload = !result.updates.find((entry) => {
return entry.name.match(regex);
});
if (shouldDownload) {
return this.prefetchHandler.download(module, courseId, undefined, false, false);
}
}
}).then(() => {
// Prefetch finished or not needed, set the right status.
return this.prefetchHandler.setStatusAfterPrefetch(quiz, undefined, shouldDownload, false, siteId);
});
}
/**
* 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.
* @param {boolean} [force] Wether to force sync not depending on last execution.
* @return {Promise<any>} Promise resolved if sync is successful, rejected if sync fails.
*/
syncAllQuizzes(siteId?: string, force?: boolean): Promise<any> {
return this.syncOnSites('all quizzes', this.syncAllQuizzesFunc.bind(this), [force], siteId);
}
/**
* Sync all quizzes on a site.
*
* @param {string} siteId Site ID to sync.
* @param {boolean} [force] Wether to force sync not depending on last execution.
* @param {Promise<any>} Promise resolved if sync is successful, rejected if sync fails.
*/
protected syncAllQuizzesFunc(siteId?: string, force?: boolean): Promise<any> {
// 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, false, siteId).then((quiz) => {
const promise = force ? this.syncQuiz(quiz, false, siteId) : this.syncQuizIfNeeded(quiz, false, siteId);
return promise.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<any>} Promise resolved when the quiz is synced or if it doesn't need to be synced.
*/
syncQuizIfNeeded(quiz: any, askPreflight?: boolean, siteId?: string): Promise<any> {
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<AddonModQuizSyncResult>} Promise resolved in success.
*/
syncQuiz(quiz: any, askPreflight?: boolean, siteId?: string): Promise<AddonModQuizSyncResult> {
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);
// Sync offline logs.
syncPromise = this.logHelper.syncIfNeeded(AddonModQuizProvider.COMPONENT, quiz.id, siteId).catch(() => {
// Ignore errors.
}).then(() => {
// Get all the offline attempts for the quiz.
return 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<boolean>} Promise resolved with boolean: true if some offline data was discarded, false otherwise.
*/
validateQuestions(attemptId: number, onlineQuestions: any, offlineQuestions: any, siteId?: string): Promise<boolean> {
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;
});
}
}