MOBILE-3651 quiz: Implement sync service and prefetch handler
parent
596ef954ba
commit
7698fa673d
|
@ -53,7 +53,7 @@ export class AddonCalendarSyncProvider extends CoreSyncBaseProvider<AddonCalenda
|
||||||
* @return Promise resolved if sync is successful, rejected if sync fails.
|
* @return Promise resolved if sync is successful, rejected if sync fails.
|
||||||
*/
|
*/
|
||||||
async syncAllEvents(siteId?: string, force = false): Promise<void> {
|
async syncAllEvents(siteId?: string, force = false): Promise<void> {
|
||||||
await this.syncOnSites('all calendar events', this.syncAllEventsFunc.bind(this, [force]), siteId);
|
await this.syncOnSites('all calendar events', this.syncAllEventsFunc.bind(this, force), siteId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -74,17 +74,17 @@ export class AddonMessagesSyncProvider extends CoreSyncBaseProvider<AddonMessage
|
||||||
syncAllDiscussions(siteId?: string, onlyDeviceOffline: boolean = false): Promise<void> {
|
syncAllDiscussions(siteId?: string, onlyDeviceOffline: boolean = false): Promise<void> {
|
||||||
const syncFunctionLog = 'all discussions' + (onlyDeviceOffline ? ' (Only offline)' : '');
|
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.
|
* 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 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.
|
* @param Promise resolved if sync is successful, rejected if sync fails.
|
||||||
*/
|
*/
|
||||||
protected async syncAllDiscussionsFunc(siteId: string, onlyDeviceOffline = false): Promise<void> {
|
protected async syncAllDiscussionsFunc(onlyDeviceOffline: boolean, siteId: string): Promise<void> {
|
||||||
const userIds: number[] = [];
|
const userIds: number[] = [];
|
||||||
const conversationIds: number[] = [];
|
const conversationIds: number[] = [];
|
||||||
const promises: Promise<void>[] = [];
|
const promises: Promise<void>[] = [];
|
||||||
|
|
|
@ -253,7 +253,7 @@ export class AddonModLessonSyncProvider extends CoreCourseActivitySyncBaseProvid
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync finished, set sync time.
|
// 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.
|
// All done, return the result.
|
||||||
return result;
|
return result;
|
||||||
|
|
|
@ -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<void> {
|
||||||
|
// 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<CoreWSExternalFile[]> {
|
||||||
|
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<CoreWSExternalFile[]> {
|
||||||
|
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<Record<string, string>> {
|
||||||
|
const preflightData: Record<string, string> = {};
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
// 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<boolean> {
|
||||||
|
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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<string, string> = {};
|
||||||
|
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<unknown>[] = [];
|
||||||
|
|
||||||
|
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<string, string>,
|
||||||
|
siteId?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const pages = AddonModQuiz.instance.getPagesFromLayout(attempt.layout);
|
||||||
|
const isSequential = AddonModQuiz.instance.isNavigationSequential(quiz);
|
||||||
|
let promises: Promise<unknown>[] = [];
|
||||||
|
|
||||||
|
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<void> {
|
||||||
|
// 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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<string, string> = {};
|
||||||
|
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<void> {
|
||||||
|
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<AddonModQuizSyncResult | undefined> {
|
||||||
|
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;
|
||||||
|
};
|
|
@ -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<AddonModQuizSyncResult> {
|
||||||
|
|
||||||
|
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<AddonModQuizSyncResult> {
|
||||||
|
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<unknown>[] = [];
|
||||||
|
|
||||||
|
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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
// Get all offline attempts.
|
||||||
|
const attempts = await AddonModQuizOffline.instance.getAllAttempts(siteId);
|
||||||
|
|
||||||
|
const quizIds: Record<number, boolean> = {}; // 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<AddonModQuizAutoSyncData>(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<AddonModQuizSyncResult | undefined> {
|
||||||
|
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<AddonModQuizSyncResult> {
|
||||||
|
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<AddonModQuizSyncResult> {
|
||||||
|
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<number, CoreQuestionQuestionParsed>,
|
||||||
|
offlineQuestions: AddonModQuizQuestionsWithAnswers,
|
||||||
|
siteId?: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
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 = <string> 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<number, CoreQuestionQuestionParsed>; // Online questions indexed by slot.
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Data passed to AUTO_SYNCED event.
|
||||||
|
*/
|
||||||
|
export type AddonModQuizAutoSyncData = CoreEventSiteData & {
|
||||||
|
quizId: number;
|
||||||
|
attemptFinished: boolean;
|
||||||
|
warnings: string[];
|
||||||
|
};
|
|
@ -50,17 +50,17 @@ export class CoreCourseSyncProvider extends CoreSyncBaseProvider<CoreCourseSyncR
|
||||||
* @return Promise resolved if sync is successful, rejected if sync fails.
|
* @return Promise resolved if sync is successful, rejected if sync fails.
|
||||||
*/
|
*/
|
||||||
syncAllCourses(siteId?: string, force?: boolean): Promise<void> {
|
syncAllCourses(siteId?: string, force?: boolean): Promise<void> {
|
||||||
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.
|
* Sync all courses on a site.
|
||||||
*
|
*
|
||||||
* @param siteId Site ID to sync.
|
|
||||||
* @param force Wether the execution is forced (manual 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.
|
* @return Promise resolved if sync is successful, rejected if sync fails.
|
||||||
*/
|
*/
|
||||||
protected async syncAllCoursesFunc(siteId: string, force: boolean): Promise<void> {
|
protected async syncAllCoursesFunc(force: boolean, siteId: string): Promise<void> {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
CoreCourseLogHelper.instance.syncSite(siteId),
|
CoreCourseLogHelper.instance.syncSite(siteId),
|
||||||
this.syncCoursesCompletion(siteId, force),
|
this.syncCoursesCompletion(siteId, force),
|
||||||
|
@ -149,7 +149,7 @@ export class CoreCourseSyncProvider extends CoreSyncBaseProvider<CoreCourseSyncR
|
||||||
|
|
||||||
if (!completions || !completions.length) {
|
if (!completions || !completions.length) {
|
||||||
// Nothing to sync, set sync time.
|
// Nothing to sync, set sync time.
|
||||||
await this.setSyncTime(String(courseId), siteId);
|
await this.setSyncTime(courseId, siteId);
|
||||||
|
|
||||||
// All done, return the data.
|
// All done, return the data.
|
||||||
return result;
|
return result;
|
||||||
|
@ -233,7 +233,7 @@ export class CoreCourseSyncProvider extends CoreSyncBaseProvider<CoreCourseSyncR
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sync finished, set sync time.
|
// Sync finished, set sync time.
|
||||||
await this.setSyncTime(String(courseId), siteId);
|
await this.setSyncTime(courseId, siteId);
|
||||||
|
|
||||||
// All done, return the data.
|
// All done, return the data.
|
||||||
return result;
|
return result;
|
||||||
|
|
|
@ -443,7 +443,7 @@ export class CoreQuestionDelegateService extends CoreDelegate<CoreQuestionHandle
|
||||||
* @param siteId Site ID. If not defined, current site.
|
* @param siteId Site ID. If not defined, current site.
|
||||||
* @return If async, promise resolved when done.
|
* @return If async, promise resolved when done.
|
||||||
*/
|
*/
|
||||||
async prepareSyncData?(
|
async prepareSyncData(
|
||||||
question: CoreQuestionQuestionParsed,
|
question: CoreQuestionQuestionParsed,
|
||||||
answers: CoreQuestionsAnswers,
|
answers: CoreQuestionsAnswers,
|
||||||
component: string,
|
component: string,
|
||||||
|
|
|
@ -100,7 +100,7 @@ export class CoreSyncProvider {
|
||||||
async getSyncRecord(component: string, id: string | number, siteId?: string): Promise<CoreSyncRecord> {
|
async getSyncRecord(component: string, id: string | number, siteId?: string): Promise<CoreSyncRecord> {
|
||||||
const db = await CoreSites.instance.getSiteDb(siteId);
|
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);
|
const db = await CoreSites.instance.getSiteDb(siteId);
|
||||||
|
|
||||||
data.component = component;
|
data.component = component;
|
||||||
data.id = id + '';
|
data.id = String(id);
|
||||||
|
|
||||||
await db.insertRecord(SYNC_TABLE_NAME, data);
|
await db.insertRecord(SYNC_TABLE_NAME, data);
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue