MOBILE-3651 quiz: Implement sync service and prefetch handler

main
Dani Palou 2021-02-09 13:11:29 +01:00
parent 596ef954ba
commit 7698fa673d
8 changed files with 1185 additions and 13 deletions

View File

@ -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);
}
/**

View File

@ -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>[] = [];

View File

@ -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;

View File

@ -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;
};

View File

@ -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[];
};

View File

@ -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;

View File

@ -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,

View File

@ -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);
}