// (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 { 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. * @returns Promise resolved when done. */ async deleteRetakeFinishedInSync(lessonId: number, siteId?: string): Promise { 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. * @returns Promise resolved with the retake entry (undefined if no retake). */ async getRetakeFinishedInSync( lessonId: number, siteId?: string, ): Promise { 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. * @returns Promise resolved with boolean: whether it has data to sync. */ async hasDataToSync(lessonId: number, retake: number, siteId?: string): Promise { 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. * @returns Promise resolved when done. */ async setRetakeFinishedInSync(lessonId: number, retake: number, pageId: number, siteId?: string): Promise { const site = await CoreSites.getSite(siteId); await site.getDb().insertRecord(RETAKES_FINISHED_SYNC_TABLE_NAME, { 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. * @returns Promise resolved if sync is successful, rejected if sync fails. */ syncAllLessons(siteId?: string, force = false): Promise { return this.syncOnSites('all lessons', (siteId) => this.syncAllLessonsFunc(!!force, siteId), siteId); } /** * Sync all lessons on a site. * * @param force Wether to force sync not depending on last execution. * @param siteId Site ID to sync. * @returns Promise resolved if sync is successful, rejected if sync fails. */ protected async syncAllLessonsFunc(force: boolean, siteId: string): Promise { // 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 askPassword Whether we should ask for password if needed. * @param siteId Site ID. If not defined, current site. * @returns 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 { 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. * @returns Promise resolved in success. */ async syncLesson( lessonId: number, askPassword = false, ignoreBlock = false, siteId?: string, ): Promise { 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. * @returns Promise resolved in success. */ protected async performSyncLesson( lessonId: number, askPassword = false, ignoreBlock = false, siteId?: string, ): Promise { // 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. * @returns Password result if any. */ protected async syncAttempts( lessonId: number, result: AddonModLessonSyncResult, askPassword = false, siteId?: string, ): Promise { 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[] = []; 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(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. * @returns Promise resolved when done. */ protected async sendAttempt( lesson: AddonModLessonLessonWSData, password: string, attempt: AddonModLessonPageAttemptRecord, result: AddonModLessonSyncResult, siteId?: string, ): Promise { 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 { // 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( 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[]; };