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.
|
||||
*/
|
||||
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> {
|
||||
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<void> {
|
||||
protected async syncAllDiscussionsFunc(onlyDeviceOffline: boolean, siteId: string): Promise<void> {
|
||||
const userIds: number[] = [];
|
||||
const conversationIds: number[] = [];
|
||||
const promises: Promise<void>[] = [];
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* @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<void> {
|
||||
protected async syncAllCoursesFunc(force: boolean, siteId: string): Promise<void> {
|
||||
await Promise.all([
|
||||
CoreCourseLogHelper.instance.syncSite(siteId),
|
||||
this.syncCoursesCompletion(siteId, force),
|
||||
|
@ -149,7 +149,7 @@ export class CoreCourseSyncProvider extends CoreSyncBaseProvider<CoreCourseSyncR
|
|||
|
||||
if (!completions || !completions.length) {
|
||||
// Nothing to sync, set sync time.
|
||||
await this.setSyncTime(String(courseId), siteId);
|
||||
await this.setSyncTime(courseId, siteId);
|
||||
|
||||
// All done, return the data.
|
||||
return result;
|
||||
|
@ -233,7 +233,7 @@ export class CoreCourseSyncProvider extends CoreSyncBaseProvider<CoreCourseSyncR
|
|||
}
|
||||
|
||||
// Sync finished, set sync time.
|
||||
await this.setSyncTime(String(courseId), siteId);
|
||||
await this.setSyncTime(courseId, siteId);
|
||||
|
||||
// All done, return the data.
|
||||
return result;
|
||||
|
|
|
@ -443,7 +443,7 @@ export class CoreQuestionDelegateService extends CoreDelegate<CoreQuestionHandle
|
|||
* @param siteId Site ID. If not defined, current site.
|
||||
* @return If async, promise resolved when done.
|
||||
*/
|
||||
async prepareSyncData?(
|
||||
async prepareSyncData(
|
||||
question: CoreQuestionQuestionParsed,
|
||||
answers: CoreQuestionsAnswers,
|
||||
component: string,
|
||||
|
|
|
@ -100,7 +100,7 @@ export class CoreSyncProvider {
|
|||
async getSyncRecord(component: string, id: string | number, siteId?: string): Promise<CoreSyncRecord> {
|
||||
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);
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue