510 lines
19 KiB
TypeScript
Raw Normal View History

// (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 { CoreSyncBlockedError } from '@classes/base-sync';
import { CoreNetworkError } from '@classes/errors/network-error';
import { CoreCourseActivitySyncBaseProvider } from '@features/course/classes/activity-sync';
import { CoreCourse } from '@features/course/services/course';
import { CoreCourseLogHelper } from '@features/course/services/log-helper';
import { CoreNetwork } from '@services/network';
import { CoreSites, CoreSitesReadingStrategy } from '@services/sites';
import { CoreSync } from '@services/sync';
import { CoreTimeUtils } from '@services/utils/time';
import { CoreUrlUtils } from '@services/utils/url';
import { CoreUtils } from '@services/utils/utils';
import { makeSingleton, Translate } from '@singletons';
import { CoreEvents } from '@singletons/events';
import { AddonModLessonRetakeFinishedInSyncDBRecord, RETAKES_FINISHED_SYNC_TABLE_NAME } from './database/lesson';
import { AddonModLessonGetPasswordResult, AddonModLessonPrefetchHandler } from './handlers/prefetch';
import { AddonModLesson, AddonModLessonLessonWSData, AddonModLessonProvider } from './lesson';
import { AddonModLessonOffline, AddonModLessonPageAttemptRecord } from './lesson-offline';
/**
* Service to sync lesson.
*/
@Injectable({ providedIn: 'root' })
export class AddonModLessonSyncProvider extends CoreCourseActivitySyncBaseProvider<AddonModLessonSyncResult> {
static readonly AUTO_SYNCED = 'addon_mod_lesson_autom_synced';
protected componentTranslatableString = 'lesson';
constructor() {
super('AddonModLessonSyncProvider');
}
/**
* Unmark a retake as finished in a synchronization.
*
* @param lessonId Lesson ID.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when done.
*/
async deleteRetakeFinishedInSync(lessonId: number, siteId?: string): Promise<void> {
const site = await CoreSites.getSite(siteId);
// Ignore errors, maybe there is none.
await CoreUtils.ignoreErrors(site.getDb().deleteRecords(RETAKES_FINISHED_SYNC_TABLE_NAME, { lessonid: lessonId }));
}
/**
* Get a retake finished in a synchronization for a certain lesson (if any).
*
* @param lessonId Lesson ID.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with the retake entry (undefined if no retake).
*/
async getRetakeFinishedInSync(
lessonId: number,
siteId?: string,
): Promise<AddonModLessonRetakeFinishedInSyncDBRecord | undefined> {
const site = await CoreSites.getSite(siteId);
return CoreUtils.ignoreErrors(site.getDb().getRecord(RETAKES_FINISHED_SYNC_TABLE_NAME, { lessonid: lessonId }));
}
/**
* Check if a lesson has data to synchronize.
*
* @param lessonId Lesson ID.
* @param retake Retake number.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved with boolean: whether it has data to sync.
*/
async hasDataToSync(lessonId: number, retake: number, siteId?: string): Promise<boolean> {
const [hasAttempts, hasFinished] = await Promise.all([
CoreUtils.ignoreErrors(AddonModLessonOffline.hasRetakeAttempts(lessonId, retake, siteId)),
CoreUtils.ignoreErrors(AddonModLessonOffline.hasFinishedRetake(lessonId, siteId)),
]);
return !!(hasAttempts || hasFinished);
}
/**
* Mark a retake as finished in a synchronization.
*
* @param lessonId Lesson ID.
* @param retake The retake number.
* @param pageId The page ID to start reviewing from.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when done.
*/
async setRetakeFinishedInSync(lessonId: number, retake: number, pageId: number, siteId?: string): Promise<void> {
const site = await CoreSites.getSite(siteId);
await site.getDb().insertRecord(RETAKES_FINISHED_SYNC_TABLE_NAME, <AddonModLessonRetakeFinishedInSyncDBRecord> {
lessonid: lessonId,
retake: Number(retake),
pageid: Number(pageId),
timefinished: CoreTimeUtils.timestamp(),
});
}
/**
* Try to synchronize all the lessons 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.
*/
syncAllLessons(siteId?: string, force = false): Promise<void> {
return this.syncOnSites('all lessons', this.syncAllLessonsFunc.bind(this, !!force), siteId);
}
/**
* Sync all lessons on a site.
*
* @param force Wether to force sync not depending on last execution.
* @param siteId Site ID to sync.
* @param Promise resolved if sync is successful, rejected if sync fails.
*/
protected async syncAllLessonsFunc(force: boolean, siteId: string): Promise<void> {
// Get all the lessons that have something to be synchronized.
const lessons = await AddonModLessonOffline.getAllLessonsWithData(siteId);
// Sync all lessons that need it.
await Promise.all(lessons.map(async (lesson) => {
const result = force ?
await this.syncLesson(lesson.id, false, false, siteId) :
await this.syncLessonIfNeeded(lesson.id, false, siteId);
if (result?.updated) {
// Sync successful, send event.
CoreEvents.trigger(AddonModLessonSyncProvider.AUTO_SYNCED, {
lessonId: lesson.id,
warnings: result.warnings,
}, siteId);
}
}));
}
/**
* Sync a lesson only if a certain time has passed since the last time.
*
* @param lessonId Lesson ID.
* @param askPreflight Whether we should ask for password if needed.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when the lesson is synced or if it doesn't need to be synced.
*/
async syncLessonIfNeeded(
lessonId: number,
askPassword = false,
siteId?: string,
): Promise<AddonModLessonSyncResult | undefined> {
const needed = await this.isSyncNeeded(lessonId, siteId);
if (needed) {
return this.syncLesson(lessonId, askPassword, false, siteId);
}
}
/**
* Try to synchronize a lesson.
*
* @param lessonId Lesson ID.
* @param askPassword True if we should ask for password if needed, false otherwise.
* @param ignoreBlock True to ignore the sync block setting.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved in success.
*/
async syncLesson(
lessonId: number,
askPassword = false,
ignoreBlock = false,
siteId?: string,
): Promise<AddonModLessonSyncResult> {
siteId = siteId || CoreSites.getCurrentSiteId();
let syncPromise = this.getOngoingSync(lessonId, siteId);
if (syncPromise) {
// There's already a sync ongoing for this lesson, return the promise.
return syncPromise;
}
// Verify that lesson isn't blocked.
if (!ignoreBlock && CoreSync.isBlocked(AddonModLessonProvider.COMPONENT, lessonId, siteId)) {
this.logger.debug('Cannot sync lesson ' + lessonId + ' because it is blocked.');
throw new CoreSyncBlockedError(Translate.instant('core.errorsyncblocked', { $a: this.componentTranslate }));
}
this.logger.debug('Try to sync lesson ' + lessonId + ' in site ' + siteId);
syncPromise = this.performSyncLesson(lessonId, askPassword, ignoreBlock, siteId);
return this.addOngoingSync(lessonId, syncPromise, siteId);
}
/**
* Try to synchronize a lesson.
*
* @param lessonId Lesson ID.
* @param askPassword True if we should ask for password if needed, false otherwise.
* @param ignoreBlock True to ignore the sync block setting.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved in success.
*/
protected async performSyncLesson(
lessonId: number,
askPassword = false,
ignoreBlock = false,
siteId?: string,
): Promise<AddonModLessonSyncResult> {
// Sync offline logs.
await CoreUtils.ignoreErrors(
CoreCourseLogHelper.syncActivity(AddonModLessonProvider.COMPONENT, lessonId, siteId),
);
const result: AddonModLessonSyncResult = {
warnings: [],
updated: false,
};
// Try to synchronize the page attempts first.
const passwordData = await this.syncAttempts(lessonId, result, askPassword, siteId);
// Now sync the retake.
await this.syncRetake(lessonId, result, passwordData, askPassword, ignoreBlock, siteId);
if (result.updated && result.courseId) {
try {
// Data has been sent to server, update data.
const module = await CoreCourse.getModuleBasicInfoByInstance(lessonId, 'lesson', { siteId });
await this.prefetchAfterUpdate(AddonModLessonPrefetchHandler.instance, module, result.courseId, undefined, siteId);
} catch {
// Ignore errors.
}
}
// Sync finished, set sync time.
await CoreUtils.ignoreErrors(this.setSyncTime(lessonId, siteId));
// All done, return the result.
return result;
}
/**
* Sync all page attempts.
*
* @param lessonId Lesson ID.
* @param result Sync result where to store the result.
* @param askPassword True if we should ask for password if needed, false otherwise.
* @param siteId Site ID. If not defined, current site.
*/
protected async syncAttempts(
lessonId: number,
result: AddonModLessonSyncResult,
askPassword = false,
siteId?: string,
): Promise<AddonModLessonGetPasswordResult | undefined> {
let attempts = await AddonModLessonOffline.getLessonAttempts(lessonId, siteId);
if (!attempts.length) {
return;
} else if (!CoreNetwork.isOnline()) {
// Cannot sync in offline.
throw new CoreNetworkError();
}
result.courseId = attempts[0].courseid;
const attemptsLength = attempts.length;
// Get the info, access info and the lesson password if needed.
const lesson = await AddonModLesson.getLessonById(result.courseId, lessonId, { siteId });
const passwordData = await AddonModLessonPrefetchHandler.getLessonPassword(lessonId, {
readingStrategy: CoreSitesReadingStrategy.ONLY_NETWORK,
askPassword,
siteId,
});
const promises: Promise<void>[] = [];
passwordData.lesson = passwordData.lesson || lesson;
// Filter the attempts, get only the ones that belong to the current retake.
attempts = attempts.filter((attempt) => {
if (attempt.retake == passwordData.accessInfo.attemptscount) {
return true;
}
// Attempt doesn't belong to current retake, delete.
promises.push(CoreUtils.ignoreErrors(AddonModLessonOffline.deleteAttempt(
lesson.id,
attempt.retake,
attempt.pageid,
attempt.timemodified,
siteId,
)));
return false;
});
if (attempts.length != attemptsLength) {
// Some attempts won't be sent, add a warning.
this.addOfflineDataDeletedWarning(
result.warnings,
lesson.name,
Translate.instant('addon.mod_lesson.warningretakefinished'),
);
}
await Promise.all(promises);
if (!attempts.length) {
return passwordData;
}
// Send the attempts in the same order they were answered.
attempts.sort((a, b) => a.timemodified - b.timemodified);
const promisesData = attempts.map((attempt) => ({
function: this.sendAttempt.bind(this, lesson, passwordData.password, attempt, result, siteId),
blocking: true,
}));
await CoreUtils.executeOrderedPromises(promisesData);
return passwordData;
}
/**
* Send an attempt to the site and delete it afterwards.
*
* @param lesson Lesson.
* @param password Password (if any).
* @param attempt Attempt to send.
* @param result Result where to store the data.
* @param siteId Site ID. If not defined, current site.
* @return Promise resolved when done.
*/
protected async sendAttempt(
lesson: AddonModLessonLessonWSData,
password: string,
attempt: AddonModLessonPageAttemptRecord,
result: AddonModLessonSyncResult,
siteId?: string,
): Promise<void> {
const retake = attempt.retake;
const pageId = attempt.pageid;
const timemodified = attempt.timemodified;
try {
// Send the page data.
await AddonModLesson.processPageOnline(lesson.id, attempt.pageid, attempt.data || {}, {
password,
siteId,
});
result.updated = true;
await AddonModLessonOffline.deleteAttempt(lesson.id, retake, pageId, timemodified, siteId);
} catch (error) {
if (!error || !CoreUtils.isWebServiceError(error)) {
// Couldn't connect to server.
throw error;
}
// The WebService has thrown an error, this means that the attempt cannot be submitted. Delete it.
result.updated = true;
await AddonModLessonOffline.deleteAttempt(lesson.id, retake, pageId, timemodified, siteId);
// Attempt deleted, add a warning.
this.addOfflineDataDeletedWarning(result.warnings, lesson.name, error);
}
}
/**
* Sync retake.
*
* @param lessonId Lesson ID.
* @param result Sync result where to store the result.
* @param passwordData Password data. If not provided it will be calculated.
* @param askPassword True if we should ask for password if needed, false otherwise.
* @param ignoreBlock True to ignore the sync block setting.
* @param siteId Site ID. If not defined, current site.
*/
protected async syncRetake(
lessonId: number,
result: AddonModLessonSyncResult,
passwordData?: AddonModLessonGetPasswordResult,
askPassword = false,
ignoreBlock = false,
siteId?: string,
): Promise<void> {
// Attempts sent or there was none. If there is a finished retake, send it.
const retake = await CoreUtils.ignoreErrors(AddonModLessonOffline.getRetake(lessonId, siteId));
if (!retake) {
// No retake to sync.
return;
}
if (!retake.finished) {
// The retake isn't marked as finished, nothing to send. Delete the retake.
await AddonModLessonOffline.deleteRetake(lessonId, siteId);
return;
} else if (!CoreNetwork.isOnline()) {
// Cannot sync in offline.
throw new CoreNetworkError();
}
result.courseId = retake.courseid || result.courseId;
if (!passwordData?.lesson) {
// Retrieve the needed data.
const lesson = await AddonModLesson.getLessonById(result.courseId!, lessonId, { siteId });
passwordData = await AddonModLessonPrefetchHandler.getLessonPassword(lessonId, {
readingStrategy: CoreSitesReadingStrategy.ONLY_NETWORK,
askPassword,
siteId,
});
passwordData.lesson = passwordData.lesson || lesson;
}
if (retake.retake != passwordData.accessInfo.attemptscount) {
// The retake changed, add a warning if it isn't there already.
if (!result.warnings.length) {
this.addOfflineDataDeletedWarning(
result.warnings,
passwordData.lesson.name,
Translate.instant('addon.mod_lesson.warningretakefinished'),
);
}
await AddonModLessonOffline.deleteRetake(lessonId, siteId);
}
try {
// All good, finish the retake.
const response = await AddonModLesson.finishRetakeOnline(lessonId, {
password: passwordData.password,
siteId,
});
result.updated = true;
// Mark the retake as finished in a sync if it can be reviewed.
if (!ignoreBlock && response.data?.reviewlesson) {
const params = CoreUrlUtils.extractUrlParams(<string> response.data.reviewlesson.value);
if (params.pageid) {
// The retake can be reviewed, mark it as finished. Don't block the user for this.
this.setRetakeFinishedInSync(lessonId, retake.retake, Number(params.pageid), siteId);
}
}
await AddonModLessonOffline.deleteRetake(lessonId, siteId);
} catch (error) {
if (!error || !CoreUtils.isWebServiceError(error)) {
// Couldn't connect to server.
throw error;
}
// The WebService has thrown an error, this means that responses cannot be submitted. Delete them.
result.updated = true;
await AddonModLessonOffline.deleteRetake(lessonId, siteId);
// Retake deleted, add a warning.
this.addOfflineDataDeletedWarning(result.warnings, passwordData.lesson.name, error);
}
}
}
export const AddonModLessonSync = makeSingleton(AddonModLessonSyncProvider);
/**
* Data returned by a lesson sync.
*/
export type AddonModLessonSyncResult = {
warnings: string[]; // List of warnings.
updated: boolean; // Whether some data was sent to the server or offline data was updated.
courseId?: number; // Course the lesson belongs to (if known).
};
/**
* Data passed to AUTO_SYNCED event.
*/
export type AddonModLessonAutoSyncData = {
lessonId: number;
warnings: string[];
};